Whole-tree page hydration, page loaders, and the bootstrap payload
An opt-in, per-page alternative to islands:
Purpose
An opt-in, per-page alternative to islands: the page's whole React subtree hydrates as one hydrateRoot over #root. Function props and context shared across composed components (upstream shadcn/ui's <DialogTrigger> + <DialogContent>) work because nothing crosses a JSON boundary except the page's own top-level props.
Islands stay the default. A route without hydrate ships zero JS (or only its island entry) exactly as before.
Public surface
// app/routes/dashboard/[id].tsx
export const hydrate = true; // literal boolean, read statically at build
export async function loader(req: Request, ctx: PattiesContext) {
return { user: await db.user(ctx.params.id) }; // or return a Response
}
export const serialize = (d) => ({ ...d, at: d.at.toISOString() }); // server, optional
export const deserialize = (j) => ({ ...j, at: new Date(j.at) }); // client, optional
export default function Dashboard({ id, data }) { /* … */ }
// patties.config.ts
render: { hydrate: "off" | "pages" } // default "off"; "pages" ⇒ opt-out via `hydrate = false`
patties/client exports hydratePage(Page, { root?, bootstrap?, deserialize? }) for hand-written entries, plus bootPage, navigate, prefetch, Link (framework/29) and resetPageRuntime.
Behavior
- Build.
scanHydratedPagesreads each page source withBun.Transpiler.scan(no module evaluation). A non-literalhydrateexport is a build error. For each hydrated route it writespatties-gen/page-<n>-<slug>.ts→bootPage("<pattern>", { Page, deserialize? }). - Islands entry + every page entry go through one
Bun.build(splitting: true) so React and shared components land in common chunks.process.env.NODE_ENVis defined from the build mode (React's production build ships in production — this also fixed island bundles). - The
patties:strip-server-exportsplugin rewrites hydrated page modules withBun.Transpiler({ exports: { eliminate: ["loader", "serialize"] }, trimUnusedImports: true }), so server-only imports never reach the browser. Bare imports the transpiler leaves behind are dropped unless authored. manifest.pages[bunPattern]holds the page chunk URL; presence there is what marks a route hydrated at request time.BuildResult.clientBytesreports each route's entry + static-import closure;patties buildprints it.- Render.
loader(req, ctx)runs for any page that exports it; its result is thedataprop (a returnedResponseshort-circuits). Hydrated pages also emit<script type="application/json" id="__patties_bootstrap">with{ params, data? }(afterserialize) outside#root, and their module script is the page chunk instead of the island entry.Vary: x-patties-nav. - Client.
bootPagehydrates once per document with{ ...params, data: deserialize(data) }— byte-for-byte the server's props.
Precedence with islands
Whole-tree wins: a hydrated page loads only its page chunk; <Island> markers inside it render and hydrate as part of the tree, so they are redundant. patties doctor warns (hydrate-islands) and fails on a non-literal export.
Constraints
- Build-time discovery holds: the server bundle still inlines the route table; no runtime scan (
tests/integration/hydrate.test.ts). - SSR must be deterministic for hydrated pages (use
useId, notMath.random). - Works on
bunandedgetargets;adapter.bun.compileembeds page chunks through the existing client-chunk path.
Tests
tests/build/page-entry.test.ts, tests/integration/hydrate.test.ts (bun + edge), tests/client/page-runtime.test.tsx (happy-dom: no mismatch, context + function props, soft navigation), tests/cli/doctor-hydrate.test.ts.