Infinite scroll, polling, and the route handler
The read side goes live. The seeded first page still paints instantly, as you wired it last lesson; what’s new is everything after that. A “Load older” button pages in earlier comments from a real HTTP endpoint, and a background poll refreshes the head of the thread every ten seconds, so a coworker’s comment shows up on its own and the poll goes quiet the moment you switch tabs. Most of the change is behavioral and shows up in the network tab, but the settled thread looks like this:
Your mission
Section titled “Your mission”Client reads travel through a public route handler, GET /api/invoices/[id]/comments, so a future mobile or third-party client can reach the same data; the Server Component’s prefetch keeps reading the store directly through last lesson’s server-only listCommentsPage.
Two read functions, one wire shape.
They stay separate because the client fetcher must never import the store, getSession, or queries.ts: any transitive reach into server-only code fails next build once a Client Component pulls it in.
Wrap the handler in authedRoute so the tenancy boundary holds at the read seam, and parse both the handler’s and the fetcher’s payload through the same Zod schema so a drifted response fails loudly instead of rendering broken UI.
On the client, useInfiniteQuery reads cursor pages newest-first, caps retained pages at ten to bound a chat-style thread’s memory, and polls on a ten-second refetchInterval with refetchIntervalInBackground: false so the browser pauses while the tab is hidden.
Ten seconds is deliberate: faster floods the connection pool and burns mobile battery, slower feels stale.
Keep “Load older” an explicit button, not an IntersectionObserver that auto-loads on scroll, which suits an endless feed but not a thread a user might scroll past by accident.
Surface the poll’s in-flight state with an isFetching chip, visually distinct from the per-page “Load older” spinner.
Posting stays unwired this lesson: the thread is read-only until the next lesson. And “live” here means polling, not WebSockets or Server-Sent Events; polling is all a comment thread needs.
GET /api/invoices/[id]/comments requests visible in the network tab; the first-paint data does not.Coding time
Section titled “Coding time”Implement against the brief and the lesson’s tests, then open the walkthrough below.
Reference solution and walkthrough
Three files, in data-flow order: the client fetcher that builds the request, the route handler that answers it, then the thread component that drives both the paging and the poll.
The client-safe fetcher
Section titled “The client-safe fetcher”The thread imports this module to read pages, so it imports no server-only code, only the shared schema.
Build the URL against window.location.origin, add cursor as a search param only when there is one, fetch with the cookie attached, throw on a non-ok response, then parse the { data } envelope through commentsPageSchema.
// The CLIENT-safe fetcher. `comment-thread.tsx` imports this module, so it must// never import `getSession`, the store, or `queries.ts` — any transitive// `server-only` reach fails `next build` from a Client Component. The server// prefetch reads the store directly in the page, not through this module.
import { type CommentsPage, commentsPageSchema } from '@/lib/comments/schema';
export type FetchCommentsArgs = { invoiceId: string; cursor: string | null;};
export const fetchCommentsPage = async ({ invoiceId, cursor,}: FetchCommentsArgs): Promise<CommentsPage> => { const url = new URL( `/api/invoices/${invoiceId}/comments`, window.location.origin, ); if (cursor) { url.searchParams.set('cursor', cursor); }
const res = await fetch(url, { credentials: 'same-origin' }); if (!res.ok) { throw new Error(`Failed to load comments (${res.status})`); }
const json = await res.json(); return commentsPageSchema.parse(json.data);};Two lines do the real work.
The throw new Error on !res.ok lets a 500 or 403 reach the query as an error instead of resolving to an empty success; without it, a refused read renders as a thread with no comments.
And because commentsPageSchema is a strictObject, an unexpected field throws on parse, so wire-shape drift fails loudly on the client exactly as it would on the server.
The schema lives in src/lib/comments/schema.ts and is imported by the handler, the fetcher, and next lesson’s action, so the wire shape changes in one place.
The route handler
Section titled “The route handler”This is the public read seam.
authedRoute takes the minimum role, the query schema, and the handler, and hands your function a parsed query plus a ctx carrying the session, the org, and the route params.
The whole tenancy story is one line: scope listCommentsPage to ctx.orgId.
import { authedRoute } from '@/lib/authed-route';import { listCommentsPage } from '@/lib/comments/queries';import { commentsPageSchema, commentsQuerySchema } from '@/lib/comments/schema';
// The public read seam the client fetcher hits. Tenancy falls out of scoping// the read to `ctx.orgId`: a cross-org `invoiceId` yields an empty page, so no// foreign rows leak. `listCommentsPage` already projects off the server-only// `orgId` column, so the strict `commentsPageSchema.parse` matches.export const GET = authedRoute('member', commentsQuerySchema, (query, ctx) => { const page = listCommentsPage({ orgId: ctx.orgId, invoiceId: ctx.params.id, cursor: query.cursor ?? null, pageSize: 20, }); return Response.json({ data: commentsPageSchema.parse(page) });});authedRoute checks roleAtLeast('member', ...) before your handler runs, so a caller below member gets a 403 Problem Details and never reaches the read.
Because the read is scoped to ctx.orgId and not the invoiceId alone, a request for another org’s invoice matches no rows and returns an empty page: no 404, no error, the correct answer to “show me a resource that, as far as you’re concerned, doesn’t exist.”
That tenancy check sits here at the read seam alongside the same scoping in the data layer and the cache tags, so no single layer is the only thing between one tenant and another’s data.
The thread, in full
Section titled “The thread, in full”The page seeds the cache on first paint; from here on the thread owns the live read: the cursor paging, the poll, the “Load older” control, and the poll indicator. Step through the four load-bearing parts.
'use client';
import { type InfiniteData, useInfiniteQuery, useMutation, useQueryClient,} from '@tanstack/react-query';import { Loader2Icon } from 'lucide-react';import { useState } from 'react';import { CommentForm } from '@/app/(app)/invoices/[id]/comment-form';import { addCommentAction } from '@/lib/comments/actions';import { fetchCommentsPage } from '@/lib/comments/fetcher';import { commentKeys } from '@/lib/comments/keys';import type { Comment, CommentsPage } from '@/lib/comments/schema';
export type Session = { userId: string; userName: string };
export const CommentThread = ({ invoiceId, session,}: { invoiceId: string; session: Session;}) => { const queryClient = useQueryClient(); const [body, setBody] = useState('');
// The cache is seeded by the page's SSR `prefetchInfiniteQuery` under the same // key, so `data` is populated on first paint with no loading state. From then // on the client fetcher hits the route handler: 10s polling (paused on a // hidden tab) and "Load older" cursor paging, capped at `maxPages: 10`. const { data, isError, isFetching, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchCommentsPage({ invoiceId, cursor: pageParam }), initialPageParam: null as string | null, getNextPageParam: (last) => last.nextCursor ?? undefined, getPreviousPageParam: (first) => first.prevCursor ?? undefined, refetchInterval: 10_000, refetchIntervalInBackground: false, maxPages: 10, });
// The cache-update optimistic add. The mandatory step order: // cancelQueries → snapshot whole query data → setQueryData page-0 prepend // → onError restore → onSettled invalidate. // `onSettled.invalidateQueries` refetches, flipping the `optimistic-<uuid>` // row to its real server id. `updateTag` inside the action handles the Server // Component cache; this `invalidateQueries` handles the client cache — the two // halves of the two-system invalidation. const mutation = useMutation({ mutationFn: async (text: string) => { const result = await addCommentAction({ invoiceId, body: text }); if (!result.ok) { throw new Error(result.error.userMessage); } return result.data; }, onMutate: async (text) => { await queryClient.cancelQueries({ queryKey: commentKeys.lists(invoiceId), });
const snapshot = queryClient.getQueryData<InfiniteData<CommentsPage>>( commentKeys.lists(invoiceId), );
const optimistic: Comment = { id: `optimistic-${crypto.randomUUID()}`, invoiceId, authorId: session.userId, authorName: session.userName, body: text, createdAt: new Date().toISOString(), };
queryClient.setQueryData<InfiniteData<CommentsPage>>( commentKeys.lists(invoiceId), (old) => { if (!old) { return old; } const [firstPage, ...restPages] = old.pages; const headPage: CommentsPage = { comments: [optimistic, ...(firstPage?.comments ?? [])], nextCursor: firstPage?.nextCursor ?? null, prevCursor: firstPage?.prevCursor ?? null, }; return { ...old, pages: [headPage, ...restPages] }; }, );
return { snapshot }; }, onError: (_error, _text, context) => { if (context?.snapshot) { queryClient.setQueryData( commentKeys.lists(invoiceId), context.snapshot, ); } }, onSuccess: () => { setBody(''); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId), }); }, });
const comments = data?.pages.flatMap((page) => page.comments) ?? []; const postError = mutation.isError && mutation.error instanceof Error ? mutation.error.message : null;
return ( <div className="space-y-3"> <div className="flex h-5 items-center justify-end"> {isFetching ? ( <span data-testid="poll-indicator" className="flex items-center gap-1 text-xs text-muted-foreground" > <Loader2Icon className="size-3 animate-spin" /> Updating… </span> ) : null} </div>
<CommentForm body={body} onBodyChange={setBody} onPost={(text) => mutation.mutate(text)} isPending={mutation.isPending} error={postError} />
{isError ? ( <p data-testid="thread-error" className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive" > Couldn’t load comments. Retrying… </p> ) : null}
<div data-testid="comment-thread" className="space-y-3"> {comments.map((comment) => ( <article key={comment.id} data-testid="comment-row" data-comment-id={comment.id} className="rounded-md border px-3 py-2 text-sm" > <div className="font-medium">{comment.authorName}</div> <p className="text-muted-foreground">{comment.body}</p> </article> ))} </div>
<button type="button" data-testid="load-older" onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} className="flex w-full items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm text-muted-foreground disabled:opacity-60" > {isFetchingNextPage ? ( <Loader2Icon className="size-4 animate-spin" /> ) : hasNextPage ? ( 'Load older' ) : ( 'End of thread' )} </button> </div> );};The query config. queryKey is commentKeys.lists(invoiceId) — the exact key the page prefetched, so the hydrated first page is read on mount with no fetch. The queryFn calls the client fetcher, which hits the route handler; initialPageParam: null MUST match the prefetch’s value or the first render fetches cold despite the hydration boundary.
'use client';
import { type InfiniteData, useInfiniteQuery, useMutation, useQueryClient,} from '@tanstack/react-query';import { Loader2Icon } from 'lucide-react';import { useState } from 'react';import { CommentForm } from '@/app/(app)/invoices/[id]/comment-form';import { addCommentAction } from '@/lib/comments/actions';import { fetchCommentsPage } from '@/lib/comments/fetcher';import { commentKeys } from '@/lib/comments/keys';import type { Comment, CommentsPage } from '@/lib/comments/schema';
export type Session = { userId: string; userName: string };
export const CommentThread = ({ invoiceId, session,}: { invoiceId: string; session: Session;}) => { const queryClient = useQueryClient(); const [body, setBody] = useState('');
// The cache is seeded by the page's SSR `prefetchInfiniteQuery` under the same // key, so `data` is populated on first paint with no loading state. From then // on the client fetcher hits the route handler: 10s polling (paused on a // hidden tab) and "Load older" cursor paging, capped at `maxPages: 10`. const { data, isError, isFetching, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchCommentsPage({ invoiceId, cursor: pageParam }), initialPageParam: null as string | null, getNextPageParam: (last) => last.nextCursor ?? undefined, getPreviousPageParam: (first) => first.prevCursor ?? undefined, refetchInterval: 10_000, refetchIntervalInBackground: false, maxPages: 10, });
// The cache-update optimistic add. The mandatory step order: // cancelQueries → snapshot whole query data → setQueryData page-0 prepend // → onError restore → onSettled invalidate. // `onSettled.invalidateQueries` refetches, flipping the `optimistic-<uuid>` // row to its real server id. `updateTag` inside the action handles the Server // Component cache; this `invalidateQueries` handles the client cache — the two // halves of the two-system invalidation. const mutation = useMutation({ mutationFn: async (text: string) => { const result = await addCommentAction({ invoiceId, body: text }); if (!result.ok) { throw new Error(result.error.userMessage); } return result.data; }, onMutate: async (text) => { await queryClient.cancelQueries({ queryKey: commentKeys.lists(invoiceId), });
const snapshot = queryClient.getQueryData<InfiniteData<CommentsPage>>( commentKeys.lists(invoiceId), );
const optimistic: Comment = { id: `optimistic-${crypto.randomUUID()}`, invoiceId, authorId: session.userId, authorName: session.userName, body: text, createdAt: new Date().toISOString(), };
queryClient.setQueryData<InfiniteData<CommentsPage>>( commentKeys.lists(invoiceId), (old) => { if (!old) { return old; } const [firstPage, ...restPages] = old.pages; const headPage: CommentsPage = { comments: [optimistic, ...(firstPage?.comments ?? [])], nextCursor: firstPage?.nextCursor ?? null, prevCursor: firstPage?.prevCursor ?? null, }; return { ...old, pages: [headPage, ...restPages] }; }, );
return { snapshot }; }, onError: (_error, _text, context) => { if (context?.snapshot) { queryClient.setQueryData( commentKeys.lists(invoiceId), context.snapshot, ); } }, onSuccess: () => { setBody(''); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId), }); }, });
const comments = data?.pages.flatMap((page) => page.comments) ?? []; const postError = mutation.isError && mutation.error instanceof Error ? mutation.error.message : null;
return ( <div className="space-y-3"> <div className="flex h-5 items-center justify-end"> {isFetching ? ( <span data-testid="poll-indicator" className="flex items-center gap-1 text-xs text-muted-foreground" > <Loader2Icon className="size-3 animate-spin" /> Updating… </span> ) : null} </div>
<CommentForm body={body} onBodyChange={setBody} onPost={(text) => mutation.mutate(text)} isPending={mutation.isPending} error={postError} />
{isError ? ( <p data-testid="thread-error" className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive" > Couldn’t load comments. Retrying… </p> ) : null}
<div data-testid="comment-thread" className="space-y-3"> {comments.map((comment) => ( <article key={comment.id} data-testid="comment-row" data-comment-id={comment.id} className="rounded-md border px-3 py-2 text-sm" > <div className="font-medium">{comment.authorName}</div> <p className="text-muted-foreground">{comment.body}</p> </article> ))} </div>
<button type="button" data-testid="load-older" onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} className="flex w-full items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm text-muted-foreground disabled:opacity-60" > {isFetchingNextPage ? ( <Loader2Icon className="size-4 animate-spin" /> ) : hasNextPage ? ( 'Load older' ) : ( 'End of thread' )} </button> </div> );};Cursor paging and the cap. getNextPageParam reads last.nextCursor for “Load older”; getPreviousPageParam reads first.prevCursor and is mandatory whenever maxPages is set (the TanStack Query chapter), so a page the cap drops can re-fetch on scroll-back. maxPages: 10 bounds retained memory for a chat-style thread.
'use client';
import { type InfiniteData, useInfiniteQuery, useMutation, useQueryClient,} from '@tanstack/react-query';import { Loader2Icon } from 'lucide-react';import { useState } from 'react';import { CommentForm } from '@/app/(app)/invoices/[id]/comment-form';import { addCommentAction } from '@/lib/comments/actions';import { fetchCommentsPage } from '@/lib/comments/fetcher';import { commentKeys } from '@/lib/comments/keys';import type { Comment, CommentsPage } from '@/lib/comments/schema';
export type Session = { userId: string; userName: string };
export const CommentThread = ({ invoiceId, session,}: { invoiceId: string; session: Session;}) => { const queryClient = useQueryClient(); const [body, setBody] = useState('');
// The cache is seeded by the page's SSR `prefetchInfiniteQuery` under the same // key, so `data` is populated on first paint with no loading state. From then // on the client fetcher hits the route handler: 10s polling (paused on a // hidden tab) and "Load older" cursor paging, capped at `maxPages: 10`. const { data, isError, isFetching, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchCommentsPage({ invoiceId, cursor: pageParam }), initialPageParam: null as string | null, getNextPageParam: (last) => last.nextCursor ?? undefined, getPreviousPageParam: (first) => first.prevCursor ?? undefined, refetchInterval: 10_000, refetchIntervalInBackground: false, maxPages: 10, });
// The cache-update optimistic add. The mandatory step order: // cancelQueries → snapshot whole query data → setQueryData page-0 prepend // → onError restore → onSettled invalidate. // `onSettled.invalidateQueries` refetches, flipping the `optimistic-<uuid>` // row to its real server id. `updateTag` inside the action handles the Server // Component cache; this `invalidateQueries` handles the client cache — the two // halves of the two-system invalidation. const mutation = useMutation({ mutationFn: async (text: string) => { const result = await addCommentAction({ invoiceId, body: text }); if (!result.ok) { throw new Error(result.error.userMessage); } return result.data; }, onMutate: async (text) => { await queryClient.cancelQueries({ queryKey: commentKeys.lists(invoiceId), });
const snapshot = queryClient.getQueryData<InfiniteData<CommentsPage>>( commentKeys.lists(invoiceId), );
const optimistic: Comment = { id: `optimistic-${crypto.randomUUID()}`, invoiceId, authorId: session.userId, authorName: session.userName, body: text, createdAt: new Date().toISOString(), };
queryClient.setQueryData<InfiniteData<CommentsPage>>( commentKeys.lists(invoiceId), (old) => { if (!old) { return old; } const [firstPage, ...restPages] = old.pages; const headPage: CommentsPage = { comments: [optimistic, ...(firstPage?.comments ?? [])], nextCursor: firstPage?.nextCursor ?? null, prevCursor: firstPage?.prevCursor ?? null, }; return { ...old, pages: [headPage, ...restPages] }; }, );
return { snapshot }; }, onError: (_error, _text, context) => { if (context?.snapshot) { queryClient.setQueryData( commentKeys.lists(invoiceId), context.snapshot, ); } }, onSuccess: () => { setBody(''); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId), }); }, });
const comments = data?.pages.flatMap((page) => page.comments) ?? []; const postError = mutation.isError && mutation.error instanceof Error ? mutation.error.message : null;
return ( <div className="space-y-3"> <div className="flex h-5 items-center justify-end"> {isFetching ? ( <span data-testid="poll-indicator" className="flex items-center gap-1 text-xs text-muted-foreground" > <Loader2Icon className="size-3 animate-spin" /> Updating… </span> ) : null} </div>
<CommentForm body={body} onBodyChange={setBody} onPost={(text) => mutation.mutate(text)} isPending={mutation.isPending} error={postError} />
{isError ? ( <p data-testid="thread-error" className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive" > Couldn’t load comments. Retrying… </p> ) : null}
<div data-testid="comment-thread" className="space-y-3"> {comments.map((comment) => ( <article key={comment.id} data-testid="comment-row" data-comment-id={comment.id} className="rounded-md border px-3 py-2 text-sm" > <div className="font-medium">{comment.authorName}</div> <p className="text-muted-foreground">{comment.body}</p> </article> ))} </div>
<button type="button" data-testid="load-older" onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} className="flex w-full items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm text-muted-foreground disabled:opacity-60" > {isFetchingNextPage ? ( <Loader2Icon className="size-4 animate-spin" /> ) : hasNextPage ? ( 'Load older' ) : ( 'End of thread' )} </button> </div> );};The poll. refetchInterval: 10_000 re-reads the head every ten seconds; refetchIntervalInBackground: false lets the framework’s hidden-tab check pause the poll when the tab loses focus and resume it on return.
'use client';
import { type InfiniteData, useInfiniteQuery, useMutation, useQueryClient,} from '@tanstack/react-query';import { Loader2Icon } from 'lucide-react';import { useState } from 'react';import { CommentForm } from '@/app/(app)/invoices/[id]/comment-form';import { addCommentAction } from '@/lib/comments/actions';import { fetchCommentsPage } from '@/lib/comments/fetcher';import { commentKeys } from '@/lib/comments/keys';import type { Comment, CommentsPage } from '@/lib/comments/schema';
export type Session = { userId: string; userName: string };
export const CommentThread = ({ invoiceId, session,}: { invoiceId: string; session: Session;}) => { const queryClient = useQueryClient(); const [body, setBody] = useState('');
// The cache is seeded by the page's SSR `prefetchInfiniteQuery` under the same // key, so `data` is populated on first paint with no loading state. From then // on the client fetcher hits the route handler: 10s polling (paused on a // hidden tab) and "Load older" cursor paging, capped at `maxPages: 10`. const { data, isError, isFetching, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: commentKeys.lists(invoiceId), queryFn: ({ pageParam }) => fetchCommentsPage({ invoiceId, cursor: pageParam }), initialPageParam: null as string | null, getNextPageParam: (last) => last.nextCursor ?? undefined, getPreviousPageParam: (first) => first.prevCursor ?? undefined, refetchInterval: 10_000, refetchIntervalInBackground: false, maxPages: 10, });
// The cache-update optimistic add. The mandatory step order: // cancelQueries → snapshot whole query data → setQueryData page-0 prepend // → onError restore → onSettled invalidate. // `onSettled.invalidateQueries` refetches, flipping the `optimistic-<uuid>` // row to its real server id. `updateTag` inside the action handles the Server // Component cache; this `invalidateQueries` handles the client cache — the two // halves of the two-system invalidation. const mutation = useMutation({ mutationFn: async (text: string) => { const result = await addCommentAction({ invoiceId, body: text }); if (!result.ok) { throw new Error(result.error.userMessage); } return result.data; }, onMutate: async (text) => { await queryClient.cancelQueries({ queryKey: commentKeys.lists(invoiceId), });
const snapshot = queryClient.getQueryData<InfiniteData<CommentsPage>>( commentKeys.lists(invoiceId), );
const optimistic: Comment = { id: `optimistic-${crypto.randomUUID()}`, invoiceId, authorId: session.userId, authorName: session.userName, body: text, createdAt: new Date().toISOString(), };
queryClient.setQueryData<InfiniteData<CommentsPage>>( commentKeys.lists(invoiceId), (old) => { if (!old) { return old; } const [firstPage, ...restPages] = old.pages; const headPage: CommentsPage = { comments: [optimistic, ...(firstPage?.comments ?? [])], nextCursor: firstPage?.nextCursor ?? null, prevCursor: firstPage?.prevCursor ?? null, }; return { ...old, pages: [headPage, ...restPages] }; }, );
return { snapshot }; }, onError: (_error, _text, context) => { if (context?.snapshot) { queryClient.setQueryData( commentKeys.lists(invoiceId), context.snapshot, ); } }, onSuccess: () => { setBody(''); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: commentKeys.lists(invoiceId), }); }, });
const comments = data?.pages.flatMap((page) => page.comments) ?? []; const postError = mutation.isError && mutation.error instanceof Error ? mutation.error.message : null;
return ( <div className="space-y-3"> <div className="flex h-5 items-center justify-end"> {isFetching ? ( <span data-testid="poll-indicator" className="flex items-center gap-1 text-xs text-muted-foreground" > <Loader2Icon className="size-3 animate-spin" /> Updating… </span> ) : null} </div>
<CommentForm body={body} onBodyChange={setBody} onPost={(text) => mutation.mutate(text)} isPending={mutation.isPending} error={postError} />
{isError ? ( <p data-testid="thread-error" className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive" > Couldn’t load comments. Retrying… </p> ) : null}
<div data-testid="comment-thread" className="space-y-3"> {comments.map((comment) => ( <article key={comment.id} data-testid="comment-row" data-comment-id={comment.id} className="rounded-md border px-3 py-2 text-sm" > <div className="font-medium">{comment.authorName}</div> <p className="text-muted-foreground">{comment.body}</p> </article> ))} </div>
<button type="button" data-testid="load-older" onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} className="flex w-full items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm text-muted-foreground disabled:opacity-60" > {isFetchingNextPage ? ( <Loader2Icon className="size-4 animate-spin" /> ) : hasNextPage ? ( 'Load older' ) : ( 'End of thread' )} </button> </div> );};The render shape. The poll-indicator “Updating…” chip keys off isFetching (any read in flight, including a background poll), distinct from the “Load older” spinner below, which keys off isFetchingNextPage (a paging fetch only). data?.pages.flatMap renders every retained page newest-first; thread-error is gated on isError; and the control reads “Load older” while pages remain, “End of thread” once hasNextPage is false.
The useMutation block sits in the file because read and write share one component, but it’s inert this lesson: the form is unwired, so nothing calls mutation.mutate yet.
Leave it for next lesson’s optimistic post, and read only the query, the render, and the two controls for now.
A few decisions deserve a sentence each.
The two read paths stay split on purpose. Unifying the prefetch and the client read into one function would force it to import the store, dragging server-only code into the client bundle and failing the build, and would erase the HTTP contract a future mobile or third-party client depends on.
maxPages: 10 is the chat-thread choice. An unbounded useInfiniteQuery retains every loaded page until the tab closes, so the cap drops the oldest page as new ones load; a feed you only scroll forward through can leave it unbounded. The cap is also why getPreviousPageParam is mandatory: when the user scrolls back to a dropped page, the query needs a backward cursor to re-fetch it.
refetchIntervalInBackground: false is the battery line. The framework’s document.hidden check pauses the poll on a hidden tab and resumes it on focus, so no GET fires for a thread nobody is looking at. The inspector’s “Open thread with polling OFF” link (?poll=off) demonstrates that pause by hand.
The hidden-tab pause is the one requirement the tests can’t reach, since document.hidden and focus behavior aren’t deterministic in the node runner, so it’s on the manual checklist below. The data-testid strings the thread renders, poll-indicator, comment-thread, comment-row (carrying data-comment-id), load-older, and thread-error, are the contract the tests select against, so keep them exact.
The useInfiniteQuery reference for getNextPageParam, the bi-directional getPreviousPageParam, and the maxPages cap you set here.
The GET handler, dynamic-segment params, and query-param parsing the public read seam is built on.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 3The suite drives both seams in a node env.
It calls your GET handler and asserts the response: twenty rows for an in-org read, an empty page for a cross-org invoiceId, a freshly inserted coworker comment leading the head page, and a member-level caller admitted by the role gate.
It runs the fetcher against a stubbed fetch, asserting the URL shape with the cursor as a search param, that a clean body parses, and that both a non-ok response and a drifted body (an extra field the strictObject rejects) throw so the query enters its error state.
The maxPages cap and the thread-error paint only happen on a live fetch, so the suite reads the thread source instead to confirm maxPages: 10, its required getPreviousPageParam, and a thread-error element gated on isError.
The runner can’t open a browser, so confirm the rest by hand with the network tab and the React Query devtools open:
/invoices/inv-0001); first paint shows twenty seeded comments instantly. Click “Load older” — the network tab shows GET /api/invoices/[id]/comments?cursor=..., the response renders below the existing rows, the head stays put. Devtools shows data.pages.length growing; click again and the next page appends.data.pages.length caps at ten — the oldest retained page drops while the head page is unchanged. With 240 seeded comments (twelve pages at pageSize: 20), “End of thread” is only reachable after the cap has dropped earlier pages.GET .../comments fires, the new row appears at the top, and the comment audit tail shows the insert — no manual refresh.GET .../comments requests fire while it’s hidden. Switch back; a poll fires within ten seconds.fetch('/api/invoices/<a-globex-invoice-id>/comments'). The handler scopes to the acting org and returns an empty page — no foreign rows leak.strictObject schema rejects it on parse, the client surfaces the error state (data-testid="thread-error"), and reverting recovers the thread on the next poll.The read seam is live: the thread pages and polls against a real public endpoint, and a coworker’s comment arrives on its own within the poll window. The last lesson wires the write side: an optimistic post through a Server Action that shows the row the instant you submit, rolls back cleanly if the server rejects it, and invalidates both the Server Component cache and the TanStack cache.