You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Implementation plan for layout persistence (#639). PR 1 (segmentMap migration) has landed as #737. PR 2a (client primitives) is implemented on branch feat/layout-persistence-pr-2a. See roadmap comment for the full PR breakdown and status.
Problem
The current RSC pipeline is monolithic: the server builds one complete React tree (root layout through all nested layouts down to the page) and passes it to renderToReadableStream() as a single ReactNode. On client-side navigation, createFromFetch() deserializes it into one ReactNode and replaces the entire React tree. This causes every layout to remount on navigation, replaying CSS animations and losing client component state.
Solution
Adopt Waku's flat keyed map architecture. Instead of one nested tree, the server returns a Record<string, ReactNode> where each layout, page, template, parallel slot, and route wiring are separate entries. On navigation, new entries are shallow-merged into existing ones. Old entries not in the new response persist. This preserves layouts across navigations.
Scope: Phase 1 only — flat map + merge. The skip header optimization (X-Vinext-Router-Skip) is deferred to a follow-up. The server always renders all entries; the client merges and preserves old layout entries.
Core Principle
Layout entries = static per-segment identity (persists across navigations). Route wiring = dynamic per-navigation structure (always fresh). mergeElementsPromise = the persistence mechanism (shallow spread preserves old entries).
Everything that changes on navigation lives in the wiring. Everything that persists lives in the entries.
Flat Map Payload Structure
The server produces a Record<string, ReactNode> with the following entry taxonomy:
Entry ID conventions: "tree path" is the layout's position in the filesystem route tree, including route groups (e.g., /, /(dashboard), /(dashboard)/blog). This prevents collisions when different route groups define different layouts at the same visible URL. "Route path" is the fully resolved leaf path (e.g., /blog/hello) — it's unique to one page.
Entry ID pattern
ID uses
Contains
Persists?
"layout:<treePath>"
Tree path
LayoutComponent with <Children /> as children prop
Yes
"template:<treePath>"
Tree path
TemplateComponent with <Children /> as children prop
Nested <Slot> chain with boundaries, LayoutSegmentProviders, template keys
No (always fresh)
"__route"
N/A
String value of the route wiring entry ID (e.g., "route:/blog/hello"). Runtime invariant: missing or non-string __route throws actionable error instead of silent blank screen.
No
Entry ID uses tree path, not visible segment path. "Tree path" includes route groups: layout:/(dashboard) vs layout:/(marketing) are distinct entries even though both resolve to URL /. This prevents collisions when different route groups define different layouts at the same visible URL segment. The routeSegments array already preserves route groups. Root layout switching detection also uses tree path comparison — /(dashboard)/layout.tsx → /(marketing)/layout.tsx is detected as a different root layout and triggers MPA navigation.
Example
Route /blog/hello with root layout, blog layout, blog template, blog error/loading, and @modal parallel slot on root:
Error/not-found boundaries need to reset on navigation (stale error state from a previous route must not persist). The route wiring is always re-rendered per navigation, so boundaries in the wiring are naturally fresh for cross-route navigations (different route wiring entry ID → React unmounts/remounts boundary instances).
Same-route navigations (e.g., search param changes) keep the same wiring entry ID, so the boundary instance persists. Current gap:ErrorBoundary does NOT have pathname-based reset — only NotFoundBoundary does. ErrorBoundary has getDerivedStateFromError but no getDerivedStateFromProps reset path. This means same-route navigations after an error would keep the error state visible. Fix required in PR 2b or 2c: add pathname-based reset to ErrorBoundary (same pattern as NotFoundBoundary). PR 3 includes an explicit test case.
The boundary sits INSIDE <Slot id="layout:/blog">'s children, so it becomes the layout's <Children />. Errors from pages/nested layouts are caught by the boundary, but errors from the layout itself propagate UP to the parent segment's boundary. This matches Next.js semantics exactly.
Why LayoutSegmentProvider lives in the route wiring
Segment values (useSelectedLayoutSegment(s)) depend on the current route, not the layout. Since layouts persist but routes change, the provider must be in the per-navigation structure. The server computes exact segment values per layout level and embeds them in the route wiring.
parallelRoutesKey support in LayoutSegmentProvider
Status: The context type migration is complete (#737). LayoutSegmentContext is now SegmentMap and useSelectedLayoutSegments(parallelRoutesKey) indexes into the map. Per-slot segment population happens in PR 2c when route wiring wraps each slot with its own LayoutSegmentProvider.
useSelectedLayoutSegment(s) accepts an optional parallelRoutesKey argument (default "children") that selects which parallel route's segments to return. The flat map architecture enables this at two levels:
Layout-level provider — LayoutSegmentProvider accepts segmentMap: Record<string, string[]> instead of childSegments: string[]. The map includes "children" (the main route segments) plus an entry for each parallel slot at that layout level (e.g., { children: ["blog","hello"], modal: [] }). useSelectedLayoutSegments("modal") called from within the layout indexes into the map.
Per-slot provider — each parallel slot's <Slot> in the route wiring is wrapped with its own LayoutSegmentProvider containing { children: slotSegments }. This ensures components rendered inside a slot get the slot's own segments when calling useSelectedLayoutSegments() without arguments — matching Next.js behavior where each parallel route subtree has its own segment context.
The hook changes are minimal: useChildSegments(key = "children") indexes into the map instead of returning the flat array. The context type changes from React.Context<string[]> to React.Context<SegmentMap> where SegmentMap = { readonly children: string[] } & Readonly<Record<string, string[]>>.
Why templates are separate entries with keyed Slots
Templates must remount when navigation crosses their segment boundary (unlike layouts which persist). The template is its own flat map entry. The Slot for the template in the route wiring carries a key prop set to the immediate child segment value at the template's depth — NOT the full destination route path. This matches Next.js, where InnerLayoutRouter is keyed by createRouterCacheKey(childSegment).
Example: A template at /blog with routes /blog/hello and /blog/world:
Navigating /blog/hello → /blog/world: key changes from "hello" to "world" → template remounts ✓
A root template at / during /blog/hello → /blog/world: key stays "blog" → template does NOT remount ✓
Root template at / during /blog/hello → /about: key changes from "blog" to "about" → template remounts ✓
Search param changes do NOT change the key (key is derived from path segments only). This matches Next.js behavior where templates persist across search param changes but remount on segment changes.
Parent templates only remount when their own direct child segment changes, not when deeper descendants change. This prevents unnecessary remounts of root-level templates on every page navigation.
Parallel slots
Parallel slots (@modal, @sidebar) are separate entries keyed as "slot:<slotName>:<treePath>" (tree path includes route groups). Layout entries reference them via <ParallelSlot name="..." /> client components passed as named props. The Slot component accepts an optional parallelSlots prop that gets provided via ParallelSlotsContext.
Route wiring passes parallel slot content through, with each slot wrapped in its own LayoutSegmentProvider so components inside the slot get correct segment context:
Next.js has three distinct behaviors when a parallel slot doesn't match the current route. The Slot component must handle all three:
Soft navigation (client-side nav to a route that doesn't define the slot): Preserve the last active content. The old slot entry stays in the elements map (merge doesn't remove it), and the Slot component renders the persisted entry. This is the default behavior — navigating from a route with @modal to one without keeps the modal visible.
Hard load / initial render (direct URL load, full page refresh): Fall back to default.js. If the route defines a default.js for the slot, render it. The server includes default.js content in the slot entry for hard loads. This is the "initial state" of an unmatched slot.
No default.js exists: Trigger the nearest not-found boundary. If a parallel slot has no match and no default.js fallback, the slot should render the nearest NotFoundBoundary. In development, a warning should be emitted (similar to Next.js's MissingSlotContext).
Design decision (from review): The server sends an explicit sentinel value for unmatched slots on hard loads. The sentinel is a unique Symbol (Symbol.for("vinext.unmatchedSlot")), not null, because default.tsx can legitimately return null (e.g., feed/@modal/default.tsx already does this in the test fixtures). Using null as the sentinel would make it impossible to distinguish "render default.js which returned null" from "no default.js exists."
The Slot component distinguishes three states:
Key absent from map (!(id in elements)) → entry persists from prior soft nav (case 1). Render the persisted entry.
Key present with UNMATCHED_SLOT symbol → server explicitly says this slot is unmatched and has no default.js (case 3). Trigger not-found boundary.
Key present with any other value (including null) → render the entry (case 2). This covers default.js that returns null.
exportconstUNMATCHED_SLOT=Symbol.for("vinext.unmatchedSlot");// In Slot component:if(!(idinelements))returnnull;// case 1: persisted from soft navconstelement=elements[id];if(element===UNMATCHED_SLOT){/* case 3: trigger not-found */}returnelement;// case 2: render normally (including null from default.tsx)
This keeps the distinction in the data, not the component, and avoids needing an isInitialLoad context flag.
RSC wire format consideration (PR 2c):Symbol values cannot survive React's flight serialization. Since slot.tsx is "use client", the RSC environment cannot import UNMATCHED_SLOT directly — it gets a client reference, not the actual Symbol. PR 2c must handle the translation: the server uses a serializable marker for unmatched slots in the elements map, and the client translates it to the UNMATCHED_SLOT Symbol after createFromFetch() resolves. The Slot component always checks against the Symbol, never against the wire format.
Client Components
Four new "use client" components in packages/vinext/src/shims/slot.tsx:
Slot
The core composition primitive. Reads an entry from the elements map by ID, provides children and parallel slots via context.
functionSlot({
id,
children,
parallelSlots,}: {id: string;children?: ReactNode;parallelSlots?: Readonly<Record<string,ReactNode>>;}){constelements=use(useContext(ElementsContext));// Case 1: absent key — entry persists from prior soft navif(!(idinelements))returnnull;// Case 3: UNMATCHED_SLOT sentinel — no default.js, trigger not-found boundaryconstelement=elements[id];if(element===UNMATCHED_SLOT){notFound();}// Case 2: render the entry (including null from default.tsx that returns null)return(<ParallelSlotsContext.Providervalue={parallelSlots??null}><ChildrenContext.Providervalue={children??null}>{element}</ChildrenContext.Provider></ParallelSlotsContext.Provider>);}
Children
How layouts render their child content. Maintains the Next.js {children} prop API — layouts receive <Children /> as their children prop, which reads the actual children from context.
ChildrenContext defaults to null. If <Children /> is rendered without a parent <Slot>, it renders nothing.
ParallelSlot
How layouts render named slots.
functionParallelSlot({ name }: {name: string}){constslots=useContext(ParallelSlotsContext);returnslots?.[name]??null;}
mergeElementsPromise
The layout persistence mechanism. Shallow-merges new entries into existing ones. Uses WeakMap caching for referential stability (same input pair always returns same output Promise).
// WeakMap-based memoization: same (prev, next) pair always returns same Promise.// Critical for React's referential stability — without this, every merge creates// a new Promise, and use() in Slot would trigger unnecessary re-renders.constcache1=newWeakMap<Promise<Elements>,WeakMap<Promise<Elements>,Promise<Elements>>>();functionmergeElementsPromise(prev: Promise<Elements>,next: Promise<Elements>): Promise<Elements>{letcache2=cache1.get(prev);if(!cache2){cache2=newWeakMap();cache1.set(prev,cache2);}letresult=cache2.get(next);if(!result){result=Promise.all([prev,next]).then(([a,b])=>({ ...a, ...b}));cache2.set(next,result);}returnresult;}
Concurrency safety
Known issue with the chained-promise model: Because each merge is Promise.all([prev, next]), a later navigation B depends on every earlier unresolved navigation A. A slow or abandoned navigation blocks all subsequent ones. Next.js avoids this with an interruptible reducer that can supersede pending navigations.
Mitigation for Phase 1: Navigation should await the new Promise<Elements> (from createFromFetch) before dispatching to the reducer. The reducer's mergeElementsPromise then receives a prev promise that is always already-resolved (it was the state at dispatch time) and a next promise that is also resolved (we awaited it). This eliminates the chaining problem because both inputs to Promise.all are settled.
Abandoned navigations (user clicks a new link mid-flight) are naturally superseded — the fetch is abandoned (AbortController), and only the latest resolved payload reaches the reducer. The startTransition wrapper ensures React batches the update without showing intermediate states.
Loading state trade-off (from adversarial review): Awaiting the full response before dispatch means loading boundaries in the new route wiring cannot become visible during client navigation until the entire flat-map payload resolves. This is a regression from current behavior where React Flight can stream and show loading boundaries incrementally. The trade-off is correctness (no chained promises) vs streaming (loading states during nav). Revisit in Phase 4 (incremental streaming) whether the streaming promise can be passed directly to the reducer — this would require solving the chained-promise problem differently (e.g., promise superseding in the reducer instead of pre-await).
The elements map grows monotonically in Phase 1 (old entries are never evicted). Unreferenced entries are lightweight (just ReactNode references) — a long session visiting 100 routes accumulates ~100 ReactNode references, which is negligible. Eviction is deferred to Phase 2 (skip header), where stale layout entries become a real concern: if the client tells the server "I already have layout:/" but that entry contains stale data, the user sees outdated content. The Phase 2 eviction strategy will distinguish static layouts (no headers()/cookies()/uncached fetch() — safely skippable, no TTL needed) from dynamic layouts (always re-rendered, or skippable with configurable stale times à la Next.js's staleTimes). For reference: Waku has no eviction; Next.js uses a 3-tier segment cache with 50MB LRU and configurable staleTimes (static default 5min, dynamic default 0s).
Context structure
ElementsContext: Context<Promise<Elements>> — the shared flat map, held by BrowserRoot
ChildrenContext: Context<ReactNode> — set per Slot, read by Children. Default: null
ParallelSlotsContext: Context<Record<string, ReactNode> | null> — set per Slot that has parallel slots. Default: null
In the pseudocode, ChildrenProvider and ParallelSlotsProvider are shorthand for ChildrenContext.Provider and ParallelSlotsContext.Provider.
Server Entry Changes (app-rsc-entry.ts)
The code generator produces a buildPageElements() function (replacing buildPageElement()) that returns Record<string, ReactNode>.
buildPageElements()
Iterates the layout chain, producing separate entries:
Layout entries: For each layout in the chain, create an entry keyed "layout:<segmentPath>" containing the layout component with <Children /> as its children prop and <ParallelSlot> references as named props.
Template entries: For each template, create an entry keyed "template:<segmentPath>" containing the template component with <Children />.
Page entry: Single entry keyed "page:<routePath>" with the page component, params, and searchParams.
Parallel slot entries: For each parallel slot, create an entry keyed "slot:<slotName>:<segmentPath>" containing the slot's page wrapped with its own layout/loading/error boundaries.
Route wiring entry: Entry keyed "route:<routePath>" containing the nested Slot chain with boundaries, LayoutSegmentProviders, and template keys. Built by a new buildRouteWiring() function.
The RSC plugin's renderToReadableStream supports structured payloads — a Record<string, ReactNode> root is valid (confirmed by the plugin maintainer and verified locally via React Flight round-trip). However, all entries gate on a single root promise: every Slot reads from one shared Promise<Elements> via use(), so the entire app waits for the root record to resolve before any entry renders. Suspense boundaries inside individual entries work after the record resolves, but this does not enable true per-entry incremental reveal. The "incremental streaming per entry" future work item would require a fundamentally different store shape (e.g., per-entry promises or a streaming map).
ISR cache
ISR cache entries are keyed by build ID, so deploying the new version naturally invalidates old entries. No manual cache migration is needed. The raw bytes stored in the cache change format (structured payload instead of single node), but the cache layer is format-agnostic — it stores and retrieves ArrayBuffer snapshots.
The renderFreshPageForCache and background regeneration paths in the RSC entry must use buildPageElements() instead of buildPageElement().
The RSC entry has standalone rendering paths for error pages (renderHTTPAccessFallbackPage, renderErrorBoundaryPage) that build React trees outside the normal route flow. These paths also switch to the flat map format so the client doesn't need to handle two response formats.
Layout entries from the normal route are included if available (the error page renders inside the nearest layout). If no layout is available, the error page renders standalone.
Entry ID limitations and known gaps
The flat map entry IDs use tree paths (including route groups) to avoid collisions. Known gaps:
Intercepting routes: The same pathname can render different trees when intercepted (modal) vs directly loaded (full page). The current IDs don't encode interception context. Next.js tracks this via previousNextUrl in router state — refreshing an intercepted route stays intercepted; directly loaded routes stay non-intercepted. For Phase 1, intercepting routes work at the route-matching level (the server picks the intercepted vs direct route), but the visited response cache and entry IDs don't distinguish between the two contexts. This means refreshing an intercepted route may not preserve the interception. This is a known parity gap to address in a follow-up.
Root layout switching (must be in PR 2c): Navigating between routes with different root layouts is an MPA navigation in Next.js (isNavigatingToNewRootLayout() triggers a full page load). Detected in the browser entry's navigation path — after deserializing new elements, compare root layout segment path with current. If different → window.location.assign() instead of merge. Merging elements from different root layouts produces a broken tree, so this is a correctness requirement, not a follow-up.
Dynamic segment identity: Entry IDs use tree path (/blog) not segment value (/blog/[slug]). This is correct — layouts persist across different param values within the same segment. A /blog/[slug]/layout.tsx is the same layout for /blog/hello and /blog/world.
Metadata resolution (runs before buildPageElements(), injected into page or route wiring)
Browser Entry Changes (app-browser-entry.ts)
BrowserRoot
Changes from managing a single ReactNode to managing Promise<Elements> and routeId as atomic state via useReducer. Using two separate useState calls would allow intermediate renders where routeId and elements are out of sync (e.g., new route ID against old elements map). A reducer guarantees both update together.
createFromReadableStream() returns Promise<Elements> (the structured payload). This becomes initialElements.
Navigation flow
1. Check visited response cache → if hit, restore as Promise<Elements>
2. Otherwise fetch RSC response (with AbortController for cancellation)
3. Buffer full response (snapshotRscResponse — unchanged)
4. Store in visited response cache (unchanged — still ArrayBuffer)
5. Deserialize via createFromFetch() → await to get resolved Promise<Elements>
6. Read `__route` key from resolved elements to get routeId
7. dispatch({ type: 'navigate', elements: resolvedPromise, routeId })
8. Reducer merges via mergeElementsPromise, updates routeId atomically
9. renderNavigationPayload() triggers startTransition
Step 7–8 are the key differences: atomic merge via reducer instead of separate state updates. Old layout entries persist. Awaiting at step 5 ensures both promise inputs to the merge are resolved, avoiding the chained-promise concurrency problem.
Torn URL state (from adversarial review): The reducer makes elements and routeId atomic, but usePathname() / useSearchParams() / useParams() read from a separate store (window.location + useSyncExternalStore). Currently navigateImpl() calls history.pushState() and notifies listeners before the RSC fetch completes, so persisted layouts would observe the destination URL while still rendering old content. Fix required in PR 2c: defer the history.pushState() and listener notification until after step 5 (RSC response received), or use a pending URL pattern where hooks don't update until startTransition commits the new elements. The history update and reducer dispatch must happen inside the same startTransition call to ensure React commits them atomically.
router.refresh()
Merges fresh elements via dispatch({ type: 'navigate', elements, routeId }). The server re-renders all entries for the current route (everything is fresh), and the reducer merges them atomically. Since all current-route entries are present in the response, the merge effectively updates every value while preserving entries from other routes accumulated during prior navigations. React reconciliation preserves layout DOM and state for unchanged layouts.
Both Next.js (FreshnessPolicy.RefreshAll with preserved tree structure) and Waku (router.reload() → same merge path as navigation) use merge semantics for refresh. Additionally, navigation caches should be invalidated since cached RSC responses may contain pre-refresh data.
Server actions
The existing ServerActionResult wire format changes. Currently: { root: ReactNode, returnValue? }. After: { root: Record<string, ReactNode>, returnValue? }. The root field contains the full flat elements map.
The client handles server action responses by:
Extracting root (the elements map) and returnValue
Reading root.__route to get the route wiring ID
Merging via dispatch({ type: 'navigate', elements, routeId }). The reducer merges post-mutation data into the existing map atomically. This preserves layout state (e.g., sidebar toggles, form inputs in layouts) while incorporating updated data. Both Next.js and Waku use merge semantics for server actions.
Updating routeId to the new wiring ID
Invalidating navigation caches (as today) — cached RSC responses may contain pre-mutation data
The isServerActionResult discriminator checks for { root: object, returnValue } shape — unchanged in logic, just the root type is now an elements record.
Back/forward navigation
Restore from visited response cache. The response is a full elements map. Merge with current map so layouts visited on other routes also persist.
HMR (rsc:update)
The HMR handler fetches a fresh RSC payload and must:
Deserialize the response as Promise<Elements> (not ReactNode)
Replace via dispatch({ type: 'replace', elements, routeId }). Unlike navigation, refresh, and server actions (which all merge), HMR replaces because code changes invalidate all module references and cached element state.
The reducer sets the new elements directly (no merge), updating routeId atomically.
HMR is the only operation that does a full replace. Both Waku (clears staticPathSetRef + cachedIdSetRef on HMR) and Next.js (full re-render on code change) agree that HMR should discard all cached state.
Visited response cache
No structural change. Stores ArrayBuffer snapshots of full RSC responses. The response now contains a flat map, but the cache is format-agnostic (just bytes).
Two-phase navigation commit
Unchanged in structure. NavigationCommitSignal still fires useLayoutEffect to drain pre-paint effects.
global-error.tsx
The global error boundary wraps the entire application, including the root layout. It lives in BrowserRoot (shown in the reducer-based code above), outside the ElementsContext.Provider and Slot chain. This catches errors from any layout (including root) and displays the global error fallback.
Both BrowserRoot and VinextFlightRoot (SSR) must include the GlobalErrorBoundary wrapper at the same tree position to avoid hydration mismatches. The globalError component reference is passed to both during hydration (generated by the RSC entry).
SSR Entry Changes (app-ssr-entry.ts)
Minimal changes. The VinextFlightRoot component wraps the deserialized elements map in ElementsContext.Provider and renders <Slot id={routeId} />. Critically, the SSR tree must mirror the BrowserRoot structure — including the GlobalErrorBoundary wrapper — to avoid hydration mismatches:
The routeId is read from elements.__route (the well-known key in the elements record). This travels with the RSC payload without needing header parsing. During SSR, the elements are available synchronously after use(createFromReadableStream(...)), so __route is immediately accessible.
Navigation context injection stays the same. ServerInsertedHTMLContext.Provider wrapping and font data injection must be preserved at the same tree positions in VinextFlightRoot to avoid breaking CSS-in-JS support (styled-components, emotion) and font optimization.
Integration Points (No Changes Needed)
Navigation shims (navigation.ts): ✅ Done in feat: implement parallelRoutesKey support in useSelectedLayoutSegment(s) #737.useSelectedLayoutSegment(s) reads from LayoutSegmentContext which is now SegmentMap. useChildSegments(key) indexes into the map. Per-slot providers (added in PR 2c's route wiring) ensure components inside a slot see the slot's own segments.
Link prefetching (link.tsx): Stores ArrayBuffer snapshots. Format-agnostic.
Form component (form.tsx): Delegates to navigateClientSide().
Testing Strategy
New unit tests
Slot renders entry from elements map, provides children via context
Children reads from context, returns children
ParallelSlot reads named slot from context
mergeElementsPromise correctly shallow-merges, caches by reference
Components inside a parallel slot get slot-scoped segments via per-slot provider
router.refresh() merges fresh entries, preserves layout state
Server actions merge post-mutation entries, preserves layout state
Back/forward navigation merges correctly
E2E tests (Playwright)
Navigate between sibling routes, assert shared layout DOM node identity persists
Client component state (counter, input value) survives navigation within same layout
Template content remounts on navigation (state resets)
Error boundary catches page error, then navigation clears it
Merge/Replace Semantics Summary
Operation
Semantics
Rationale
Navigation
MERGE
Preserve layouts, replace pages. Old layout entries persist via shallow spread.
Server action
MERGE
Preserve layout state after mutations. Post-mutation data merged in. Matches Next.js and Waku.
router.refresh()
MERGE
Re-fetch all data, preserve tree structure. All entries are fresh in response, so merge effectively updates everything.
Back/forward
MERGE (from cache)
Restore cached response, preserve accumulated layouts from other routes.
HMR
REPLACE
Code changed — all module references and cached state are stale. Only operation that does full replace.
Future Work (Not in Phase 1 Scope)
Interception context tracking (Phase 2): Entry IDs and visited response cache need to encode whether a route was loaded via interception or directly. Required for previousNextUrl parity — refreshing an intercepted route should stay intercepted. See "Entry ID limitations" section. This is correctness, not perf — prioritized before skip headers.
Skip header optimization (Phase 3, X-Vinext-Router-Skip): Client sends cached entry IDs to server. Server skips rendering static layouts. Pure optimization — persistence works without it.
Static vs dynamic layout detection (Phase 3): Required for skip header. Layouts using headers(), cookies(), or uncached fetch() are dynamic; others are static.
Entry eviction + LRU (Phase 3): When skip headers are added, stale layout entries become a real concern. Eviction strategy will distinguish static layouts (safely skippable, no TTL) from dynamic layouts (always re-rendered or skippable with configurable stale times). Bounded cache size as safety valve.
Incremental streaming per entry (Phase 4): Currently all entries gate on a single root promise. True per-entry incremental reveal would require a fundamentally different store shape (per-entry promises or a streaming map), not just a change to renderToReadableStream. Lowest priority — Suspense inside entries already works.
Implementation plan for layout persistence (#639). PR 1 (
segmentMapmigration) has landed as #737. PR 2a (client primitives) is implemented on branchfeat/layout-persistence-pr-2a. See roadmap comment for the full PR breakdown and status.Problem
The current RSC pipeline is monolithic: the server builds one complete React tree (root layout through all nested layouts down to the page) and passes it to
renderToReadableStream()as a singleReactNode. On client-side navigation,createFromFetch()deserializes it into oneReactNodeand replaces the entire React tree. This causes every layout to remount on navigation, replaying CSS animations and losing client component state.Solution
Adopt Waku's flat keyed map architecture. Instead of one nested tree, the server returns a
Record<string, ReactNode>where each layout, page, template, parallel slot, and route wiring are separate entries. On navigation, new entries are shallow-merged into existing ones. Old entries not in the new response persist. This preserves layouts across navigations.Reference implementation: https://github.com/wakujs/waku — specifically
packages/waku/src/minimal/client.tsx(Slot, Root, mergeElementsPromise) andpackages/waku/src/router/(route wiring, skip header).Scope: Phase 1 only — flat map + merge. The skip header optimization (
X-Vinext-Router-Skip) is deferred to a follow-up. The server always renders all entries; the client merges and preserves old layout entries.Core Principle
Everything that changes on navigation lives in the wiring. Everything that persists lives in the entries.
Flat Map Payload Structure
The server produces a
Record<string, ReactNode>with the following entry taxonomy:Entry ID conventions: "tree path" is the layout's position in the filesystem route tree, including route groups (e.g.,
/,/(dashboard),/(dashboard)/blog). This prevents collisions when different route groups define different layouts at the same visible URL. "Route path" is the fully resolved leaf path (e.g.,/blog/hello) — it's unique to one page."layout:<treePath>"<Children />as children prop"template:<treePath>"<Children />as children prop"page:<routePath>""slot:<slotName>:<treePath>""route:<routePath>"<Slot>chain with boundaries, LayoutSegmentProviders, template keys"__route""route:/blog/hello"). Runtime invariant: missing or non-string__routethrows actionable error instead of silent blank screen.Example
Route
/blog/hellowith root layout, blog layout, blog template, blog error/loading, and@modalparallel slot on root:Why boundaries live in the route wiring
Error/not-found boundaries need to reset on navigation (stale error state from a previous route must not persist). The route wiring is always re-rendered per navigation, so boundaries in the wiring are naturally fresh for cross-route navigations (different route wiring entry ID → React unmounts/remounts boundary instances).
Same-route navigations (e.g., search param changes) keep the same wiring entry ID, so the boundary instance persists. Current gap:
ErrorBoundarydoes NOT have pathname-based reset — onlyNotFoundBoundarydoes.ErrorBoundaryhasgetDerivedStateFromErrorbut nogetDerivedStateFromPropsreset path. This means same-route navigations after an error would keep the error state visible. Fix required in PR 2b or 2c: add pathname-based reset toErrorBoundary(same pattern asNotFoundBoundary). PR 3 includes an explicit test case.The boundary sits INSIDE
<Slot id="layout:/blog">'s children, so it becomes the layout's<Children />. Errors from pages/nested layouts are caught by the boundary, but errors from the layout itself propagate UP to the parent segment's boundary. This matches Next.js semantics exactly.Why LayoutSegmentProvider lives in the route wiring
Segment values (
useSelectedLayoutSegment(s)) depend on the current route, not the layout. Since layouts persist but routes change, the provider must be in the per-navigation structure. The server computes exact segment values per layout level and embeds them in the route wiring.parallelRoutesKeysupport in LayoutSegmentProvideruseSelectedLayoutSegment(s)accepts an optionalparallelRoutesKeyargument (default"children") that selects which parallel route's segments to return. The flat map architecture enables this at two levels:Layout-level provider —
LayoutSegmentProvideracceptssegmentMap: Record<string, string[]>instead ofchildSegments: string[]. The map includes"children"(the main route segments) plus an entry for each parallel slot at that layout level (e.g.,{ children: ["blog","hello"], modal: [] }).useSelectedLayoutSegments("modal")called from within the layout indexes into the map.Per-slot provider — each parallel slot's
<Slot>in the route wiring is wrapped with its ownLayoutSegmentProvidercontaining{ children: slotSegments }. This ensures components rendered inside a slot get the slot's own segments when callinguseSelectedLayoutSegments()without arguments — matching Next.js behavior where each parallel route subtree has its own segment context.The hook changes are minimal:
useChildSegments(key = "children")indexes into the map instead of returning the flat array. The context type changes fromReact.Context<string[]>toReact.Context<SegmentMap>whereSegmentMap = { readonly children: string[] } & Readonly<Record<string, string[]>>.Why templates are separate entries with keyed Slots
Templates must remount when navigation crosses their segment boundary (unlike layouts which persist). The template is its own flat map entry. The
Slotfor the template in the route wiring carries akeyprop set to the immediate child segment value at the template's depth — NOT the full destination route path. This matches Next.js, whereInnerLayoutRouteris keyed bycreateRouterCacheKey(childSegment).Example: A template at
/blogwith routes/blog/helloand/blog/world:/blog/hello→/blog/world: key changes from"hello"to"world"→ template remounts ✓/during/blog/hello→/blog/world: key stays"blog"→ template does NOT remount ✓/during/blog/hello→/about: key changes from"blog"to"about"→ template remounts ✓Search param changes do NOT change the key (key is derived from path segments only). This matches Next.js behavior where templates persist across search param changes but remount on segment changes.
Parent templates only remount when their own direct child segment changes, not when deeper descendants change. This prevents unnecessary remounts of root-level templates on every page navigation.
Parallel slots
Parallel slots (
@modal,@sidebar) are separate entries keyed as"slot:<slotName>:<treePath>"(tree path includes route groups). Layout entries reference them via<ParallelSlot name="..." />client components passed as named props. TheSlotcomponent accepts an optionalparallelSlotsprop that gets provided viaParallelSlotsContext.Route wiring passes parallel slot content through, with each slot wrapped in its own
LayoutSegmentProviderso components inside the slot get correct segment context:Unmatched parallel slot behavior
Next.js has three distinct behaviors when a parallel slot doesn't match the current route. The
Slotcomponent must handle all three:Soft navigation (client-side nav to a route that doesn't define the slot): Preserve the last active content. The old slot entry stays in the elements map (merge doesn't remove it), and the
Slotcomponent renders the persisted entry. This is the default behavior — navigating from a route with@modalto one without keeps the modal visible.Hard load / initial render (direct URL load, full page refresh): Fall back to
default.js. If the route defines adefault.jsfor the slot, render it. The server includesdefault.jscontent in the slot entry for hard loads. This is the "initial state" of an unmatched slot.No
default.jsexists: Trigger the nearest not-found boundary. If a parallel slot has no match and nodefault.jsfallback, the slot should render the nearestNotFoundBoundary. In development, a warning should be emitted (similar to Next.js'sMissingSlotContext).Design decision (from review): The server sends an explicit sentinel value for unmatched slots on hard loads. The sentinel is a unique Symbol (
Symbol.for("vinext.unmatchedSlot")), notnull, becausedefault.tsxcan legitimately returnnull(e.g.,feed/@modal/default.tsxalready does this in the test fixtures). Usingnullas the sentinel would make it impossible to distinguish "render default.js which returned null" from "no default.js exists."The
Slotcomponent distinguishes three states:!(id in elements)) → entry persists from prior soft nav (case 1). Render the persisted entry.UNMATCHED_SLOTsymbol → server explicitly says this slot is unmatched and has nodefault.js(case 3). Trigger not-found boundary.null) → render the entry (case 2). This coversdefault.jsthat returnsnull.This keeps the distinction in the data, not the component, and avoids needing an
isInitialLoadcontext flag.RSC wire format consideration (PR 2c):
Symbolvalues cannot survive React's flight serialization. Sinceslot.tsxis"use client", the RSC environment cannot importUNMATCHED_SLOTdirectly — it gets a client reference, not the actual Symbol. PR 2c must handle the translation: the server uses a serializable marker for unmatched slots in the elements map, and the client translates it to theUNMATCHED_SLOTSymbol aftercreateFromFetch()resolves. TheSlotcomponent always checks against the Symbol, never against the wire format.Client Components
Four new
"use client"components inpackages/vinext/src/shims/slot.tsx:SlotThe core composition primitive. Reads an entry from the elements map by ID, provides children and parallel slots via context.
ChildrenHow layouts render their child content. Maintains the Next.js
{children}prop API — layouts receive<Children />as theirchildrenprop, which reads the actual children from context.ChildrenContextdefaults tonull. If<Children />is rendered without a parent<Slot>, it renders nothing.ParallelSlotHow layouts render named slots.
mergeElementsPromiseThe layout persistence mechanism. Shallow-merges new entries into existing ones. Uses WeakMap caching for referential stability (same input pair always returns same output Promise).
Concurrency safety
Known issue with the chained-promise model: Because each merge is
Promise.all([prev, next]), a later navigation B depends on every earlier unresolved navigation A. A slow or abandoned navigation blocks all subsequent ones. Next.js avoids this with an interruptible reducer that can supersede pending navigations.Mitigation for Phase 1: Navigation should await the new
Promise<Elements>(fromcreateFromFetch) before dispatching to the reducer. The reducer'smergeElementsPromisethen receives a prev promise that is always already-resolved (it was the state at dispatch time) and a next promise that is also resolved (we awaited it). This eliminates the chaining problem because both inputs toPromise.allare settled.Abandoned navigations (user clicks a new link mid-flight) are naturally superseded — the fetch is abandoned (AbortController), and only the latest resolved payload reaches the reducer. The
startTransitionwrapper ensures React batches the update without showing intermediate states.Loading state trade-off (from adversarial review): Awaiting the full response before dispatch means loading boundaries in the new route wiring cannot become visible during client navigation until the entire flat-map payload resolves. This is a regression from current behavior where React Flight can stream and show loading boundaries incrementally. The trade-off is correctness (no chained promises) vs streaming (loading states during nav). Revisit in Phase 4 (incremental streaming) whether the streaming promise can be passed directly to the reducer — this would require solving the chained-promise problem differently (e.g., promise superseding in the reducer instead of pre-await).
The elements map grows monotonically in Phase 1 (old entries are never evicted). Unreferenced entries are lightweight (just ReactNode references) — a long session visiting 100 routes accumulates ~100 ReactNode references, which is negligible. Eviction is deferred to Phase 2 (skip header), where stale layout entries become a real concern: if the client tells the server "I already have
layout:/" but that entry contains stale data, the user sees outdated content. The Phase 2 eviction strategy will distinguish static layouts (noheaders()/cookies()/uncachedfetch()— safely skippable, no TTL needed) from dynamic layouts (always re-rendered, or skippable with configurable stale times à la Next.js'sstaleTimes). For reference: Waku has no eviction; Next.js uses a 3-tier segment cache with 50MB LRU and configurablestaleTimes(staticdefault 5min,dynamicdefault 0s).Context structure
ElementsContext: Context<Promise<Elements>>— the shared flat map, held byBrowserRootChildrenContext: Context<ReactNode>— set perSlot, read byChildren. Default:nullParallelSlotsContext: Context<Record<string, ReactNode> | null>— set perSlotthat has parallel slots. Default:nullIn the pseudocode,
ChildrenProviderandParallelSlotsProviderare shorthand forChildrenContext.ProviderandParallelSlotsContext.Provider.Server Entry Changes (
app-rsc-entry.ts)The code generator produces a
buildPageElements()function (replacingbuildPageElement()) that returnsRecord<string, ReactNode>.buildPageElements()Iterates the layout chain, producing separate entries:
Layout entries: For each layout in the chain, create an entry keyed
"layout:<segmentPath>"containing the layout component with<Children />as its children prop and<ParallelSlot>references as named props.Template entries: For each template, create an entry keyed
"template:<segmentPath>"containing the template component with<Children />.Page entry: Single entry keyed
"page:<routePath>"with the page component, params, and searchParams.Parallel slot entries: For each parallel slot, create an entry keyed
"slot:<slotName>:<segmentPath>"containing the slot's page wrapped with its own layout/loading/error boundaries.Route wiring entry: Entry keyed
"route:<routePath>"containing the nested Slot chain with boundaries, LayoutSegmentProviders, and template keys. Built by a newbuildRouteWiring()function.renderToReadableStreamcallThe RSC plugin's
renderToReadableStreamsupports structured payloads — aRecord<string, ReactNode>root is valid (confirmed by the plugin maintainer and verified locally via React Flight round-trip). However, all entries gate on a single root promise: everySlotreads from one sharedPromise<Elements>viause(), so the entire app waits for the root record to resolve before any entry renders. Suspense boundaries inside individual entries work after the record resolves, but this does not enable true per-entry incremental reveal. The "incremental streaming per entry" future work item would require a fundamentally different store shape (e.g., per-entry promises or a streaming map).ISR cache
ISR cache entries are keyed by build ID, so deploying the new version naturally invalidates old entries. No manual cache migration is needed. The raw bytes stored in the cache change format (structured payload instead of single node), but the cache layer is format-agnostic — it stores and retrieves
ArrayBuffersnapshots.The
renderFreshPageForCacheand background regeneration paths in the RSC entry must usebuildPageElements()instead ofbuildPageElement().Error/not-found/forbidden boundary rendering paths
The RSC entry has standalone rendering paths for error pages (
renderHTTPAccessFallbackPage,renderErrorBoundaryPage) that build React trees outside the normal route flow. These paths also switch to the flat map format so the client doesn't need to handle two response formats.For these paths, the elements map is minimal:
Layout entries from the normal route are included if available (the error page renders inside the nearest layout). If no layout is available, the error page renders standalone.
Entry ID limitations and known gaps
The flat map entry IDs use tree paths (including route groups) to avoid collisions. Known gaps:
Intercepting routes: The same pathname can render different trees when intercepted (modal) vs directly loaded (full page). The current IDs don't encode interception context. Next.js tracks this via
previousNextUrlin router state — refreshing an intercepted route stays intercepted; directly loaded routes stay non-intercepted. For Phase 1, intercepting routes work at the route-matching level (the server picks the intercepted vs direct route), but the visited response cache and entry IDs don't distinguish between the two contexts. This means refreshing an intercepted route may not preserve the interception. This is a known parity gap to address in a follow-up.Root layout switching (must be in PR 2c): Navigating between routes with different root layouts is an MPA navigation in Next.js (
isNavigatingToNewRootLayout()triggers a full page load). Detected in the browser entry's navigation path — after deserializing new elements, compare root layout segment path with current. If different →window.location.assign()instead of merge. Merging elements from different root layouts produces a broken tree, so this is a correctness requirement, not a follow-up.Dynamic segment identity: Entry IDs use tree path (
/blog) not segment value (/blog/[slug]). This is correct — layouts persist across different param values within the same segment. A/blog/[slug]/layout.tsxis the same layout for/blog/helloand/blog/world.What stays the same
_trieMatch)makeThenableParams(),toPlainSearchParams()buildPageElements(), injected into page or route wiring)Browser Entry Changes (
app-browser-entry.ts)BrowserRootChanges from managing a single
ReactNodeto managingPromise<Elements>androuteIdas atomic state viauseReducer. Using two separateuseStatecalls would allow intermediate renders where routeId and elements are out of sync (e.g., new route ID against old elements map). A reducer guarantees both update together.Navigation dispatches
{ type: 'navigate', ... }(merge). HMR dispatches{ type: 'replace', ... }(full replace).Hydration
createFromReadableStream()returnsPromise<Elements>(the structured payload). This becomesinitialElements.Navigation flow
Step 7–8 are the key differences: atomic merge via reducer instead of separate state updates. Old layout entries persist. Awaiting at step 5 ensures both promise inputs to the merge are resolved, avoiding the chained-promise concurrency problem.
Torn URL state (from adversarial review): The reducer makes
elementsandrouteIdatomic, butusePathname()/useSearchParams()/useParams()read from a separate store (window.location+useSyncExternalStore). CurrentlynavigateImpl()callshistory.pushState()and notifies listeners before the RSC fetch completes, so persisted layouts would observe the destination URL while still rendering old content. Fix required in PR 2c: defer thehistory.pushState()and listener notification until after step 5 (RSC response received), or use a pending URL pattern where hooks don't update untilstartTransitioncommits the new elements. The history update and reducer dispatch must happen inside the samestartTransitioncall to ensure React commits them atomically.router.refresh()Merges fresh elements via
dispatch({ type: 'navigate', elements, routeId }). The server re-renders all entries for the current route (everything is fresh), and the reducer merges them atomically. Since all current-route entries are present in the response, the merge effectively updates every value while preserving entries from other routes accumulated during prior navigations. React reconciliation preserves layout DOM and state for unchanged layouts.Both Next.js (
FreshnessPolicy.RefreshAllwith preserved tree structure) and Waku (router.reload()→ same merge path as navigation) use merge semantics for refresh. Additionally, navigation caches should be invalidated since cached RSC responses may contain pre-refresh data.Server actions
The existing
ServerActionResultwire format changes. Currently:{ root: ReactNode, returnValue? }. After:{ root: Record<string, ReactNode>, returnValue? }. Therootfield contains the full flat elements map.The client handles server action responses by:
root(the elements map) andreturnValueroot.__routeto get the route wiring IDdispatch({ type: 'navigate', elements, routeId }). The reducer merges post-mutation data into the existing map atomically. This preserves layout state (e.g., sidebar toggles, form inputs in layouts) while incorporating updated data. Both Next.js and Waku use merge semantics for server actions.routeIdto the new wiring IDThe
isServerActionResultdiscriminator checks for{ root: object, returnValue }shape — unchanged in logic, just theroottype is now an elements record.Back/forward navigation
Restore from visited response cache. The response is a full elements map. Merge with current map so layouts visited on other routes also persist.
HMR (
rsc:update)The HMR handler fetches a fresh RSC payload and must:
Promise<Elements>(notReactNode)dispatch({ type: 'replace', elements, routeId }). Unlike navigation, refresh, and server actions (which all merge), HMR replaces because code changes invalidate all module references and cached element state.HMR is the only operation that does a full replace. Both Waku (clears
staticPathSetRef+cachedIdSetRefon HMR) and Next.js (full re-render on code change) agree that HMR should discard all cached state.Visited response cache
No structural change. Stores
ArrayBuffersnapshots of full RSC responses. The response now contains a flat map, but the cache is format-agnostic (just bytes).Two-phase navigation commit
Unchanged in structure.
NavigationCommitSignalstill firesuseLayoutEffectto drain pre-paint effects.global-error.tsxThe global error boundary wraps the entire application, including the root layout. It lives in
BrowserRoot(shown in the reducer-based code above), outside theElementsContext.ProviderandSlotchain. This catches errors from any layout (including root) and displays the global error fallback.Both
BrowserRootandVinextFlightRoot(SSR) must include theGlobalErrorBoundarywrapper at the same tree position to avoid hydration mismatches. TheglobalErrorcomponent reference is passed to both during hydration (generated by the RSC entry).SSR Entry Changes (
app-ssr-entry.ts)Minimal changes. The
VinextFlightRootcomponent wraps the deserialized elements map inElementsContext.Providerand renders<Slot id={routeId} />. Critically, the SSR tree must mirror theBrowserRootstructure — including theGlobalErrorBoundarywrapper — to avoid hydration mismatches:The
routeIdis read fromelements.__route(the well-known key in the elements record). This travels with the RSC payload without needing header parsing. During SSR, the elements are available synchronously afteruse(createFromReadableStream(...)), so__routeis immediately accessible.Navigation context injection stays the same.
ServerInsertedHTMLContext.Providerwrapping and font data injection must be preserved at the same tree positions inVinextFlightRootto avoid breaking CSS-in-JS support (styled-components, emotion) and font optimization.Integration Points (No Changes Needed)
navigation.ts): ✅ Done in feat: implement parallelRoutesKey support in useSelectedLayoutSegment(s) #737.useSelectedLayoutSegment(s)reads fromLayoutSegmentContextwhich is nowSegmentMap.useChildSegments(key)indexes into the map. Per-slot providers (added in PR 2c's route wiring) ensure components inside a slot see the slot's own segments.link.tsx): StoresArrayBuffersnapshots. Format-agnostic.form.tsx): Delegates tonavigateClientSide().Testing Strategy
New unit tests
Slotrenders entry from elements map, provides children via contextChildrenreads from context, returns childrenParallelSlotreads named slot from contextmergeElementsPromisecorrectly shallow-merges, caches by referencebuildRouteWiring()produces correct Slot chain with boundaries, segment providers, template keysModified tests
tests/app-router.test.ts— navigation between routes sharing a layout preserves layout (key behavioral test: DOM persistence, client component state, CSS animation continuity)tests/entry-templates.test.ts— generated entry assertions updated for flat map structuretests/features.test.ts— error boundaries, loading, not-found still workuseSelectedLayoutSegment(s)returns correct values after navigationuseSelectedLayoutSegment(s)(parallelRoutesKey)returns correct per-slot segmentsrouter.refresh()merges fresh entries, preserves layout stateE2E tests (Playwright)
Merge/Replace Semantics Summary
router.refresh()Future Work (Not in Phase 1 Scope)
previousNextUrlparity — refreshing an intercepted route should stay intercepted. See "Entry ID limitations" section. This is correctness, not perf — prioritized before skip headers.X-Vinext-Router-Skip): Client sends cached entry IDs to server. Server skips rendering static layouts. Pure optimization — persistence works without it.headers(),cookies(), or uncachedfetch()are dynamic; others are static.renderToReadableStream. Lowest priority — Suspense inside entries already works.