❗ 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 🙂