UNPKG

@rsc-kit/mcp

Version:

An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.

370 lines (290 loc) • 15.7 kB
# Partial prerendering > Storing what every visitor sees the same, and rendering the rest per request. A page usually has two halves: markup that is the same for everybody, and data that is not. The build stores the first and renders the second per request, so the browser paints immediately and fills in as the data arrives. There is nothing to switch on, and no mode to pick. Every route goes through the same probe, and this is one of the things it can find. ## How the build decides ```text Build time → the page is rendered with a budget → anything still waiting when the budget expires is postponed → React has already flushed what did not need the request: layouts, static markup, and the Suspense fallbacks standing in for the rest → that markup is stored as the route's shell, and beside it, where it stopped Request time → the shell is served straight from disk — fast, no render → the render is picked up from where it stopped, against data that exists now → only the unfinished boundaries are written, onto the same response → a script React emits beside each one moves it into place as the HTML parses ``` **Postponed, not merely abandoned.** The difference matters. Aborting a render gives you the bytes that flushed and nothing else — React has no record of where it got to, so the holes can only be filled later by the browser. Postponing keeps that record, which is what lets the boundaries be finished at the origin and arrive with the document. So the content is in the first response. It appears without waiting for the app bundle or for hydration — on a slow connection, the difference between a spinner and a page — and it is in the HTML a crawler reads. This is not the same as working with scripting off: with JavaScript disabled the fallbacks stay. If anything goes wrong — the engine cannot resume, or the render fails — the shell is served on its own and the client fills the boundaries when it hydrates, exactly as it did before any of this existed. A slower hole, never a wrong page. A page is rendered **once per visit**. Hydrating needs the page as a payload as well as HTML, and the render's payload streams into the document beside the HTML — a slow region's with its HTML — so the browser never asks for it separately. The page's queries run once, its backend is called once, and the tree the browser hydrates against is the one its HTML came from. A page the build stored whole fetches its stored payload, which costs no render. The shell is stored per route **pattern**, not per url. `/posts/[slug]` has one shell serving every slug, because everything that varies by slug is behind a boundary the request fills. That includes the metadata: a route with a `generateMetadata` streams it under a boundary of its own, so the shell has a hole where the title goes and the request fills it with the page's real title. Nothing is looked up before the shell is sent. A route with only static `metadata` has it in the shell. ## A shell and the server that finishes it are one build A replay matches a slot by component **name** — `displayName`, then `name` — so anything that renames a component between the build and the server breaks every slot below it. A bundler run over the build's output does exactly that when it merges module scopes: two different components of one name is ordinary (a wrapper named after the primitive it wraps), and the merge renames one of them. The build pins every component's name as it is in the build's output, as a `displayName` written into the server bundle. Whatever a later bundler calls the binding, React reads the name the shell recorded — which is why a page compiled into a single binary is resumed like any other, with no flag. A postponed state names the components React has to find when it replays, and a name is whatever that build's minifier assigned. Resumed against a different bundle, the same component answers to something else: ```text Expected the resume to render <J> in this slot but instead it rendered <J2>. ``` Two names for one component is the fingerprint of two builds. Every shell is stamped with the build that froze it, and a host that finds a shell from another build serves it but renders the page whole instead of resuming it, with a line in the log saying so. How they drift apart in practice: a Docker layer, a CI cache or a volume that keeps a previous prerender output while the server bundle is rebuilt. Build both together, or clear the output directory between builds. ## A shell for a pattern is one shape A route with params that listed no urls is frozen **once**, for the pattern — the build has no url, so every param is a placeholder and anything that reads one postpones. What resumes it renders the tree the shell froze and fills the holes with the real values. So the shape above a boundary cannot depend on a param: ```tsx // Refuses to resume: two different components in one slot const { id } = await params return id === 'new' ? <CreateForm /> : <Record /> ``` React replays the postponed tree by position and type. A different component in a slot ends the replay: ```text Expected the resume to render <Record> in this slot but instead it rendered <CreateForm>. The tree doesn't match so React will fallback to client rendering. ``` Every hole then falls back to client rendering, which arrives as a page whose buttons do nothing. The engine holds up its end: the resume is given the placeholders the shell was frozen with, and the real params separately, so a component that reads them is inside a hole by construction. What an app must not do is branch on a param **above** its boundary. Put the branch inside the `<Suspense>`, or give the route a `generateStaticParams` so each url is frozen as itself. ## What makes a page dynamic Anything the build cannot know: - **`await params`** — the url, for a route that has not listed its urls - **`usePathname()`** in a client component, for the same route, answers `""` in that shell: one shell serves every url it matches, so no link is active and a breadcrumb is empty until hydration, when the hook moves to the browser's url on its own. The payload such a document boots from agrees with the shell, so there is no mismatch to repair. Where the url is known — a listed url, a client navigation — the hook answers as always. - **`await searchParams`** — the query string - **`await headers()` / `await cookies()`** — the request - **a host call**, on a host that has one - **slow work of your own** that outlasts the budget All of them behave the same way: the read suspends, the nearest fallback above it goes into the shell, and the real value arrives per request. ## Where to put the boundaries Only content inside a `<Suspense>` boundary can stream. Content above every boundary has to finish before anything paints — which is the difference between a route the build can store and one it refuses. ```tsx title="src/app/posts/[slug]/page.tsx" import { Suspense } from 'react'; import { findPost } from '../../../data'; async function Body({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const post = await findPost(slug); return <article><h1>{post.title}</h1><p>{post.body}</p></article>; } export default function PostPage({ params }: { params: Promise<{ slug: string }> }) { return ( <Suspense fallback={<p>Loading…</p>}> <Body params={params} /> </Suspense> ); } ``` `Body` is still waiting when the budget expires, so the shell holds the fallback. At request time the slug resolves and React replaces it. ### A fallback that has painted stays up for 300 ms This is React's rule, not this package's. Once a fallback has been on screen, React holds back the content that replaces it until 300 ms after that paint, even when the content arrived sooner. It stops a page from flickering between placeholder and content on a slow connection. A stored shell and its holes come in one response, the holes a moment after the shell. When the data is fast, it arrives after the shell has painted but inside those 300 ms, so the fallback shows for the full 300 ms anyway. On an installed app that is a flash of placeholders on every launch. Work with it rather than around it: - **Put a boundary only where the wait is real.** Data that is always back in a few milliseconds does not need a fallback. Rendered directly, it is in the first paint. - **Make the fallback the shape of the content.** Use the same card, the same size and the same colour with the numbers left blank. Then 300 ms of it reads as the page, not as a flash, and nothing moves when the data lands. - **Leave out `animate-pulse` for anything that is usually gone quickly.** A pulse is what makes a short fallback look like a flash. ## The default: a page that paints, and slots that wait Write the page as a synchronous component. Everything it knows without asking — the title, the copy, the card frames, a button that needs nothing — is in the stored shell. Each read lives in its own async component, under its own `<Suspense>`, with a skeleton the shape of what it stands for: ```tsx title="src/app/integrations/shopify/page.tsx" import { Suspense } from 'react'; import { ConnectButtonSlot } from './connect-button-slot'; import { ConnectionSlot } from './connection-slot'; import { ConnectionsSkeleton } from '../connections-skeleton'; export default function ShopifyPage() { return ( <Card> <CardHeader> {/* Fixed copy: in the shell, painted at once. */} <CardTitle>Shopify</CardTitle> <CardDescription>Connect your store so your agent can look up orders.</CardDescription> {/* Nothing to show until it knows: no fallback. */} <Suspense> <ConnectButtonSlot /> </Suspense> </CardHeader> <CardContent> <Suspense fallback={<ConnectionsSkeleton rows={1} />}> <ConnectionSlot /> </Suspense> </CardContent> </Card> ); } ``` ```tsx title="src/app/integrations/shopify/connection-slot.tsx" export async function ConnectionSlot() { const agent = await currentAgent(); // its own read, its own check const connection = await shopifyConnection(agent.id); return connection ? <ConnectionRow connection={connection} /> : <EmptyState />; } ``` What that buys: - **The visitor sees the page, not a placeholder of it.** The shell is the real heading and copy, and only the parts that wait show a skeleton. - **Each part arrives when it is ready.** A fast read is not held back by a slow one beside it; each boundary fills on its own. - **The skeleton can be exact.** It stands for one known thing — a list of one row, a button — so nothing moves when the content lands. - **A slot is self-contained.** It does its own read and its own permission check, so it can move to another page, or be revalidated as a [section](/guides/sections), without the page around it. ## When loading.tsx is the wrong tool `loading.tsx` wraps the whole page in one boundary. It is a fit for a page that is one read and nothing else, such as a detail page whose skeleton really is the page. Elsewhere it costs more than it saves: - **It hides what the page already knows.** The heading, the copy and the frames wait with the data, so the shell is a guess at the page rather than the page. - **The slowest read decides for all of them.** Everything under the one boundary paints when the last of it is ready. - **A status decided inside it is not the response's.** A page under `loading.tsx` that calls `notFound()` or `redirect()`, or reads a backend that answers 404, may already have sent its shell with a 200. Whether a page exists for this visitor is a check for [`middleware.ts`](/guides/authorization), which runs before anything is sent. - **It does not cover its layout.** It sits below the layout beside it, so a layout that waits — on its params, on the session — needs a `<Suspense>` of its own inside the layout. - **At the root it catches mistakes.** A root `loading.tsx` wraps every page, so a page waiting in the wrong place is stored behind the app-wide fallback, and the build can only warn. See below. ## loading.tsx, when it fits Rather than wrapping a page by hand, put a `loading.tsx` beside it. The build wraps the page in `<Suspense fallback={<Loading />}>` for you, and the page can await at its top level: ```tsx title="src/app/posts/[slug]/loading.tsx" export default function Loading() { return <div className="h-6 w-50 animate-pulse rounded-lg bg-zinc-800" />; } ``` ```tsx title="src/app/posts/[slug]/page.tsx" export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; return <h1>{(await findPost(slug)).title}</h1>; } ``` ### It is hierarchical Like `layout.tsx`, the nearest `loading.tsx` to the page wins, and several in one chain stack as nested boundaries: ```text src/app/ layout.tsx loading.tsx fallback for everything without a closer one docs/ loading.tsx fallback for /docs/* [slug]/ page.tsx uses docs/loading.tsx dashboard/ page.tsx uses app/loading.tsx ``` ### A root loading.tsx catches everything, including mistakes It wraps every page, so a page that waits above any boundary of its own is still caught and still stored — and the build has nothing to refuse. It looks exactly like a page whose boundary is in the right place. So the build says so: ```text ◐ /locale ⚠ nothing painted without the root loading.tsx — the fallback the whole app shares is standing in for this page. Put a boundary where the waiting is. ``` Worked out by rendering the route a second time without the root fallback: a page with a boundary of its own paints immediately, and a page leaning on the root paints nothing. Only routes with no closer `loading.tsx` are asked, so it costs nothing for the rest. It is a warning rather than an error because the page does work. What it costs is shared: every page in the app shows the same fallback while this one waits, and the fallback cannot say anything about what is loading. Moving the boundary to where the waiting is fixes both. ## When nothing can be frozen If nothing was flushed before the page blocked, there is no shell to store — and the build says so rather than filing the route under a category: ```text Some routes could not be prerendered: /dashboard — reaches for the host before anything can paint, so there is no shell to store Each one reads request data — params, headers, cookies, or the host — above every Suspense boundary, so nothing can paint without it. Put the part that waits inside <Suspense>, or add a loading.tsx beside the page, so there is something to store while the rest arrives. ``` Fixed the same way every time, and there is no way to declare it away. A route the build cannot store has its boundary in the wrong place; moving the boundary is the fix. ## If the build hangs `RSC_PPR_TIMEOUT_MS` sets it; the default is 2 seconds. Raising it lets slow pages be stored whole; lowering it pushes more of them to shells. The budget is per page, but pages do not wait in line for it. A page that has rendered what it can and is only waiting gives its place to the next, so a site of hundreds of shells costs about one budget, not one each. A page whose data resolves inside the budget is stored **whole**, even one that called `fetch()`. If a page must reflect the world at request time, what makes it so is reading the request — not a flag.