Pagination (take four)

❗ This is not a tutorial ❗

Continuing from yesterday… I think ChatGPT got me pointed in the right direction.

Why do this at all if LLMs can write adequate code and blog posts? The short answer is that they can’t, yet. The longer answer follows from LLM’s lack of understanding and common sense.

(Edit: ChatGPT’s only as good as the questions you ask it. I found a better solution in a WPGraphQL blog post: https://www.wpgraphql.com/2020/03/26/forward-and-backward-pagination-with-wpgraphql. Oddly, figuring it out didn’t require seeing a code example, I just had to get the concepts straight in my head. The solution is outlined here: https://zalgorithm.com/pagination-take-five-success/.)

Link the navigation to blog.archive.$cursor.tsx

I’ll start by checking out a new branch:

$ git checkout -b link_based_pagination_child_route_fix

Rename blog.archive.$page:

$ mv app/routes/blog.archive.\$page.tsx app/routes/blog.archive.\$cursor.tsx 

I want to start with an empty route instead of editing the code I’ve copied to blog.archive.$cursor. I’ll make a copy of that file for reference, then delete the blog.archive.$cursor.tsx file’s existing code:

$ cp app/routes/blog.archive.\$cursor.tsx app/routes/blog.archiveBak.\$cursor.tsx
// app/routes/blog.archive.$cursor.tsx

import { useRouteError } from "@remix-run/react";

export default function BlogArchiveCursor() {
  return (
    <div>
      <h2>Blog Archive Cursor route</h2>
      <p>
        This page will load a batch of posts, starting from a given `cursor`
        value.
      </p>
    </div>
  );
}

export function ErrorBoundary() {
  const error = useRouteError();

  const errorMessage = error instanceof Error ? error.message : "Unknown error";
  return (
    <div className="mx-auto max-w-3xl px-5 py-4 my-10 bg-red-200 border-2 border-red-700 rounded">
      <h1>App Error</h1>
      <pre className="break-all">{errorMessage}</pre>
    </div>
  );
}

Update the links in blog.archive.tsx to point to the $cursor route:

// app/routes/blog.archive.tsx

...
      {pages.map((page: Page) => (
        <Link
          prefetch="intent"
          to={`?cursor=${page.lastCursor}`}
          className="px-3 py-2 mx-3 hover:underline text-sky-700"
          key={page.pageNumber}
        >
          {page.pageNumber + 1}
        </Link>
      ))}
...

With those changes, the links work as expected, but the new blog.archive.$cursor page is not being loaded:

This works:

// app/routes/blog.archive.tsx

...
      {pages.map((page: Page) => (
        <Link
          prefetch="intent"
          to={`any_string_will_do/?cursor=${page.lastCursor}`}
          className="px-3 py-2 mx-3 hover:underline text-sky-700"
          key={page.pageNumber}
        >
          {page.pageNumber + 1}
        </Link>
      ))}
...

but that gets me back to where I was yesterday. I’m wanting to use the value of the cursor to select a batch of posts, then have the posts rendered in the blog.archive.tsx Outlet.

Something just came up: https://remix.run/docs/en/main/guides/data-loading#data-reloads

When multiple nested routes are rendering and the search params change, all the routes will be reloaded (instead of just the new or changed routes). This is because search params are a cross-cutting concern and could affect any loader. If you would like to prevent some of your routes from reloading in this scenario, use shouldRevalidate.

Remix docs

I’ll deal with that later.

Getting back to issue with routes, there’s an easy solution: https://github.com/remix-run/remix/discussions/3464 – just rename blog.archive.$cursor.tsx to blog.archive._index.tsx:

// app/routes/blog.archive._index.tsx

import { json, LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { useRouteError } from "@remix-run/react";

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const cursor = url.searchParams.get("cursor") || "";

  console.log(`cursor: ${cursor}`);

  return json({ cursor: cursor });
};

export default function BlogArchiveCursor() {
  const { cursor } = useLoaderData<typeof loader>();
  return (
    <div>
      <h2>Blog Posts for cursor {cursor}</h2>
      <p>
        This page will load a batch of posts, starting from a given `cursor`
        value.
      </p>
    </div>
  );
}

export function ErrorBoundary() {
  const error = useRouteError();

  const errorMessage = error instanceof Error ? error.message : "Unknown error";
  return (
    <div className="mx-auto max-w-3xl px-5 py-4 my-10 bg-red-200 border-2 border-red-700 rounded">
      <h1>App Error</h1>
      <pre className="break-all">{errorMessage}</pre>
    </div>
  );
}

That’s a win for helpful people on the internet, a loss for ChatGPT 🙂

Loading post batches on blog.archive._index.tsx

I was expecting it to be harder. This is the code I wrote yesterday, with a small change to set a default value for the cursor param:

// app/routes/blog.archive._index.tsx

import { json, LoaderFunctionArgs } from "@remix-run/node";
import { useRouteError, useLoaderData } from "@remix-run/react";

import { createApolloClient } from "lib/createApolloClient";
import { ARCHIVE_POSTS_QUERY } from "~/models/wp_queries";
import type { PostConnectionEdge } from "~/graphql/__generated__/graphql";

import PostExcerptCard from "~/components/PostExcerptCard";

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { searchParams } = new URL(request.url);
  const cursor = searchParams.get("cursor") || "";

  const client = createApolloClient();
  const response = await client.query({
    query: ARCHIVE_POSTS_QUERY,
    variables: {
      after: cursor,
    },
  });

  if (response.errors || !response?.data?.posts?.edges) {
    throw new Error("An error was returned loading the posts");
  }

  const postEdges = response.data.posts.edges;

  return json({ postEdges: postEdges });
};

export default function ArchiveBlogPage() {
  const { postEdges } = useLoaderData<typeof loader>();

  return (
    <div className="px-6 mx-auto max-w-screen-lg">
      <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
        {postEdges.map((edge: PostConnectionEdge) => (
          <PostExcerptCard
            title={edge.node?.title}
            date={edge.node?.date}
            featuredImage={edge.node?.featuredImage?.node?.sourceUrl}
            excerpt={edge.node?.excerpt}
            authorName={edge.node?.author?.node?.name}
            slug={edge.node?.slug}
            key={edge.node.id}
          />
        ))}
      </div>
    </div>
  );
}

export function ErrorBoundary() {
  const error = useRouteError();

  const errorMessage = error instanceof Error ? error.message : "Unknown error";
  return (
    <div className="mx-auto max-w-3xl px-5 py-4 my-10 bg-red-200 border-2 border-red-700 rounded">
      <h1>App Error</h1>
      <pre className="break-all">{errorMessage}</pre>
    </div>
  );
}

I’ll merge this into the main branch:

$ git add .
$ git commit -m "Load posts on blog.archive._index route"
$ git checkout main
$ git merge main link_based_pagination_child_route_fix

Prevent the ARCHIVE_CURSOR_QUERY from being re-run

Related to the data loading issue that I linked to above, the app should only run the query to return the cursor data from WordPress if the cursor data has not yet been rendered in the page’s navigation element.

There’s some risk with trying to control this behaviour:

When you use this feature you risk your UI getting out of sync with your server. Use with caution!

https://remix.run/docs/en/main/route/should-revalidate

The Ignoring search params section is relevant to my case. The recommendation is to think of this in terms of the data that the loader cares about. The blog.archive._index.tsx route needs to know the value of the cursor that’s set in the navigation element. I’ll revisit this later, but for now I’ll go with this approach:

// app/routes/blog.archive.tsx

...
import type { ShouldRevalidateFunction } from "@remix-run/react";
...

// If `cursor` isn't set, re-run the loader function.
// todo: look into this some more. The risk is that it could prevent the navigation UI from rendering.
export const shouldRevalidate: ShouldRevalidateFunction = ({
  currentUrl,
  defaultShouldRevalidate,
}) => {
  const { searchParams } = new URL(currentUrl);
  const cursor = searchParams.get("cursor");

  if (cursor) {
    return false;
  }

  return defaultShouldRevalidate;
};

Push the project to a GitHub repo

Better late than never. The project is here: https://github.com/scossar/wp-remix.

Improve the pagination UI

Pagination is working, but the user interface doesn’t give much indication about what’s going on. I’ll start by adding a “current page” indicator.

Hmm… the shouldRevalidate function that I added in the last section is working to prevent an unnecessary API call to WordPress, but it’s also preventing the UI from knowing about the current page.

I’ve been messing around with this for a while. I created a Paginator component to move the pagination logic off the blog.archive.tsx route:

// app/components/Paginator.tsx

import { Link } from "@remix-run/react";
import { Page } from "~/types/Page";

interface PaginatorProps {
  // todo: set a type!
  pages: Page[];
  currentPage: number;
}

export default function Paginator({ pages, currentPage }: PaginatorProps) {
  return (
    <div>
      {pages.map((page: any) => (
        <Link
          prefetch="intent"
          to={`?page=${page.pageNumber}&cursor=${page.lastCursor}`}
          key={page.pageNumber}
          className={`mx-4 p-3 text-sky-700 font-semibold hover:bg-sky-200 ${
            currentPage === page.pageNumber ? "underline" : ""
          }`}
        >
          {page.pageNumber + 1}
        </Link>
      ))}
    </div>
  );
}

The component’s pages and currentPage props are set in the blog.archive.tsx loader function:

// app/routes/blog.archive.tsx

...
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { searchParams } = new URL(request.url);
  const currentPage = Number(searchParams.get("page")) || 0;
  const client = createApolloClient();
  const response = await client.query({
    query: ARCHIVE_CURSORS_QUERY,
    variables: {
      after: "",
    },
  });

  if (response.errors || !response?.data?.posts?.edges) {
    throw new Error("Unable to load post details.");
  }

  const chunkSize = Number(process.env?.ARCHIVE_CHUNK_SIZE) || 15;
  const cursorEdges = response.data.posts.edges;
  const pages = cursorEdges.reduce(
    (
      acc: { lastCursor: Maybe<string | null>; pageNumber: number }[],
      edge: RootQueryToPostConnectionEdge,
      index: number
    ) => {
      if ((index + 1) % chunkSize === 0) {
        acc.push({
          lastCursor: edge.cursor,
          pageNumber: acc.length + 1,
        });
      }
      return acc;
    },
    []
  );
  // handle the case of the chunk size being a multiple of the total number of posts.
  if (cursorEdges.length % chunkSize === 0) {
    pages.pop();
  }

  // add a first page object (lastCursor: "")
  pages.unshift({
    lastCursor: "",
    pageNumber: 0,
  });

  return json({ pages: pages, currentPage: currentPage });
};
...

The changes above allow the Paginator component to know the value of the currentPage so that it can highlight the current page in the UI.

The problem is that I’d like to avoid running the blog.archive.tsx loader every time a navigation element is clicked. That can be achieved with:

// app/routes/blog.archive.tsx

...
// the logic's a bit off, but that's not
// the main concern at the moment:

export const shouldRevalidate: ShouldRevalidateFunction = ({
  currentUrl,
  defaultShouldRevalidate,
}) => {
  const { searchParams } = new URL(currentUrl);
  const cursor = searchParams.get("cursor");

  if (cursor) {
    return false;
  }

  return defaultShouldRevalidate;
};

Unfortunately, this prevents the currentPageNumber from updating.

I’ll comment out the above function for now. Pagination is working. That was today’s goal.

Adding “previous” and “next” buttons to the pagination UI

This is a good start:

// app/components/Paginator.tsx

import { Link } from "@remix-run/react";
import { Page } from "~/types/Page";

interface PaginatorProps {
  pages: Page[];
  currentPage: number;
}

export default function Paginator({ pages, currentPage }: PaginatorProps) {
  const previousCursor = currentPage > 0 ? pages[currentPage].lastCursor : null;
  const nextCursor =
    currentPage < pages.length - 1 ? pages[currentPage + 1].lastCursor : null;
  return (
    <div>
      {previousCursor ? (
        <Link
          to={`?page=${currentPage - 1}&cursor=${previousCursor}`}
          className="mr-1 my-3 py-3 text-sky-700 text-sm font-semibold hover:bg-sky-100 rounded"
        >
          Previous
        </Link>
      ) : (
        <div className="inline-block mr-1 my-3 py-3 text-slate-500 font-light text-sm">
          Previous
        </div>
      )}
      {pages.map((page: Page) => (
        <Link
          prefetch="intent"
          to={`?page=${page.pageNumber}&cursor=${page.lastCursor}`}
          key={page.pageNumber}
          className={`mr-1 my-3 p-2 text-sky-700 font-semibold hover:bg-sky-100 rounded text-sm ${
            currentPage === page.pageNumber ? "bg-sky-200" : ""
          }`}
        >
          {page.pageNumber + 1}
        </Link>
      ))}
      {nextCursor ? (
        <Link
          to={`?page=${currentPage + 1}&cursor=${nextCursor}`}
          className="ml-1 my-3 pl-3 text-sky-700 font-semibold hover:bg-sky-100 rounded"
        >
          Next
        </Link>
      ) : (
        <div className="inline-block mr-1 my-3 py-3 text-slate-500 font-light text-sm">
          Next
        </div>
      )}
    </div>
  );
}

I pushed it to the live site: https://hello.zalgorithm.com/blog/archive. It works! The code needs to be updated so that the pagination link to the currently selected page is disabled:

Otherwise, clicking the link to the current page causes the page to be re-rendered.

It looks like there’s going to be a “Pagination (take five)” post. It’s worth getting this right. The unnecessary requests to the blog.archive.tsx loader function are bugging me 🙂