Skip to content

Waku-style Layout Persistence for vinext #726

@NathanDrake2406

Description

@NathanDrake2406

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.

Reference implementation: https://github.com/wakujs/waku — specifically packages/waku/src/minimal/client.tsx (Slot, Root, mergeElementsPromise) and packages/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

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 No (re-rendered each nav)
"page:<routePath>" Route path PageComponent with params + searchParams No
"slot:<slotName>:<treePath>" Tree path Parallel slot content (page + slot layout + slot boundaries) No
"route:<routePath>" Route path 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:

{
  "layout:/":            <RootLayout params={...} modal={<ParallelSlot name="modal" />}><Children /></RootLayout>,
  "layout:/blog":        <BlogLayout params={...}><Children /></BlogLayout>,
  "template:/blog":      <BlogTemplate params={...}><Children /></BlogTemplate>,
  "page:/blog/hello":    <BlogPost params={...} searchParams={...} />,
  "slot:modal:/":        <ModalContent params={...} />,

  "route:/blog/hello":
    <LayoutSegmentProvider segmentMap={{ children: ["blog","hello"], modal: [] }}>
      <Slot id="layout:/" parallelSlots={{ modal: <LayoutSegmentProvider segmentMap={{ children: [] }}><Slot id="slot:modal:/" /></LayoutSegmentProvider> }}>
        <ErrorBoundary errorComponent={RootError}>
          <Suspense fallback={<RootLoading />}>
            <NotFoundBoundary notFound={<RootNotFound />}>
              <LayoutSegmentProvider segmentMap={{ children: ["hello"] }}>
                <Slot id="layout:/blog">
                  <ErrorBoundary errorComponent={BlogError}>
                    <Suspense fallback={<BlogLoading />}>
                      <NotFoundBoundary notFound={<BlogNotFound />}>
                        <Slot id="template:/blog" key="hello">
                          <LayoutSegmentProvider segmentMap={{ children: [] }}>
                            <Slot id="page:/blog/hello" />
                          </LayoutSegmentProvider>
                        </Slot>
                      </NotFoundBoundary>
                    </Suspense>
                  </ErrorBoundary>
                </Slot>
              </LayoutSegmentProvider>
            </NotFoundBoundary>
          </Suspense>
        </ErrorBoundary>
      </Slot>
    </LayoutSegmentProvider>
}

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: 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:

  1. Layout-level providerLayoutSegmentProvider 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.

  2. 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:

<Slot id="layout:/" parallelSlots={{
  modal: <LayoutSegmentProvider segmentMap={{ children: [] }}>
           <Slot id="slot:modal:/" />
         </LayoutSegmentProvider>
}}>

Unmatched parallel slot behavior

Next.js has three distinct behaviors when a parallel slot doesn't match the current route. The Slot component must handle all three:

  1. 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.

  2. 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.

  3. 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.
export const UNMATCHED_SLOT = Symbol.for("vinext.unmatchedSlot");

// In Slot component:
if (!(id in elements)) return null; // case 1: persisted from soft nav
const element = elements[id];
if (element === UNMATCHED_SLOT) { /* case 3: trigger not-found */ }
return element; // 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.

function Slot({
  id,
  children,
  parallelSlots,
}: {
  id: string;
  children?: ReactNode;
  parallelSlots?: Readonly<Record<string, ReactNode>>;
}) {
  const elements = use(useContext(ElementsContext));

  // Case 1: absent key — entry persists from prior soft nav
  if (!(id in elements)) return null;

  // Case 3: UNMATCHED_SLOT sentinel — no default.js, trigger not-found boundary
  const element = elements[id];
  if (element === UNMATCHED_SLOT) {
    notFound();
  }

  // Case 2: render the entry (including null from default.tsx that returns null)
  return (
    <ParallelSlotsContext.Provider value={parallelSlots ?? null}>
      <ChildrenContext.Provider value={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.

const ChildrenContext = createContext<ReactNode>(null);

function Children() {
  return useContext(ChildrenContext);
}

ChildrenContext defaults to null. If <Children /> is rendered without a parent <Slot>, it renders nothing.

ParallelSlot

How layouts render named slots.

function ParallelSlot({ name }: { name: string }) {
  const slots = useContext(ParallelSlotsContext);
  return slots?.[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.
const cache1 = new WeakMap<Promise<Elements>, WeakMap<Promise<Elements>, Promise<Elements>>>();

function mergeElementsPromise(prev: Promise<Elements>, next: Promise<Elements>): Promise<Elements> {
  let cache2 = cache1.get(prev);
  if (!cache2) {
    cache2 = new WeakMap();
    cache1.set(prev, cache2);
  }
  let result = cache2.get(next);
  if (!result) {
    result = Promise.all([prev, next]).then(([a, b]) => ({ ...a, ...b }));
    cache2.set(next, result);
  }
  return result;
}

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.

1. Fetch RSC response
2. Await createFromFetch() → resolved Promise<Elements>
3. dispatch({ type: 'navigate', elements: resolvedPromise, routeId })
4. Reducer merges resolved prev + resolved next → no blocking

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:

  1. 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.

  2. Template entries: For each template, create an entry keyed "template:<segmentPath>" containing the template component with <Children />.

  3. Page entry: Single entry keyed "page:<routePath>" with the page component, params, and searchParams.

  4. 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.

  5. Route wiring entry: Entry keyed "route:<routePath>" containing the nested Slot chain with boundaries, LayoutSegmentProviders, and template keys. Built by a new buildRouteWiring() function.

renderToReadableStream call

const elements = buildPageElements(route, params, searchParams, opts);
const rscStream = renderToReadableStream(elements);

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().

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:

{
  "__route": "route:/__error",
  "page:/__error": <ErrorPage statusCode={...} />,
  "route:/__error":
    <Slot id="layout:/">
      <Slot id="page:/__error" />
    </Slot>
}

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.

What stays the same

  • Route matching (_trieMatch)
  • Middleware pipeline
  • makeThenableParams(), toPlainSearchParams()
  • Intercepting route detection (server-side — picks correct route; client-side context tracking deferred)
  • 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.

type RouterState = {
  elements: Promise<Elements>;
  routeId: string;
};

type RouterAction =
  | { type: 'navigate'; elements: Promise<Elements>; routeId: string }
  | { type: 'replace'; elements: Promise<Elements>; routeId: string };

function routerReducer(state: RouterState, action: RouterAction): RouterState {
  switch (action.type) {
    case 'navigate':
      return {
        elements: mergeElementsPromise(state.elements, action.elements),
        routeId: action.routeId,
      };
    case 'replace':
      return {
        elements: action.elements,
        routeId: action.routeId,
      };
  }
}

function BrowserRoot({ initialElements, initialRouteId, initialNavigationSnapshot }) {
  const [state, dispatch] = useReducer(routerReducer, {
    elements: initialElements,
    routeId: initialRouteId,
  });

  return (
    <GlobalErrorBoundary errorComponent={globalError}>
      <ElementsContext.Provider value={state.elements}>
        <Slot id={state.routeId} />
      </ElementsContext.Provider>
    </GlobalErrorBoundary>
  );
}

Navigation dispatches { type: 'navigate', ... } (merge). HMR dispatches { type: 'replace', ... } (full replace).

Hydration

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:

  1. Extracting root (the elements map) and returnValue
  2. Reading root.__route to get the route wiring ID
  3. 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.
  4. Updating routeId to the new wiring ID
  5. 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:

  1. Deserialize the response as Promise<Elements> (not ReactNode)
  2. 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.
  3. 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:

function VinextFlightRoot() {
  const elements = use(createFromReadableStream(rscStream));
  const routeId = elements.__route;
  return (
    <GlobalErrorBoundary errorComponent={globalError}>
      <ElementsContext.Provider value={Promise.resolve(elements)}>
        <Slot id={routeId} />
      </ElementsContext.Provider>
    </GlobalErrorBoundary>
  );
}

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
  • buildRouteWiring() produces correct Slot chain with boundaries, segment providers, template keys

Modified 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 structure
  • tests/features.test.ts — error boundaries, loading, not-found still work
  • Template remounting on navigation (key-driven)
  • Parallel slots render correctly
  • useSelectedLayoutSegment(s) returns correct values after navigation
  • useSelectedLayoutSegment(s)(parallelRoutesKey) returns correct per-slot segments
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions