Skip to content

Commit 7d29908

Browse files
fix(app-router): promote default slot persistence through route state (#1229)
* fix(app-router): promote default slot persistence through route state Default and unmatched parallel slots were still inferred from AppElements transport markers. That made soft navigation persistence depend on wire shape instead of planner-owned route state, which breaks the #726 invariant that AppElementsWire is transport only. Thread slot binding metadata through AppElements, browser state, and the navigation planner. The commit boundary now preserves previous slot content and binding proof only when target route-state marks the slot default or unmatched and the visible state proves a mounted/default value exists. Tests cover the new planner contract, metadata validation, route wiring, mergeElements behavior, and browser commit lifecycle for default and unmatched slot targets. * fix(app-router): align intercepted slot binding metadata Intercepted slot payloads could render active modal content while their route-state metadata still described the slot as default. The browser planner could then preserve the previous default slot for an ID that the fresh payload should replace, breaking intercepted navigations in app-router E2E. Derive slot IDs through one helper for both metadata and rendering, assert graph/wire ID divergence, and compute binding state from the same resolved override default export used by the render path. Move the app hydration marker to the committed root layout effect so E2E link clicks wait for a hydrated tree. * fix(app-router): harden slot binding preservation Review follow-up for #726-CORE-14. Centralize AppElements slot binding normalization so duplicate slot ids and stale owner layout ids fail at the wire boundary instead of creating quiet last-write-wins behavior. Alias planner slot binding snapshots to the AppElements binding shape, share slot id ordering helpers, document the no-proof compatibility law, and add negative planner/wire tests plus a browser-visible soft navigation E2E. Refs #726. * fix(app-router): align slot binding proof with rendering * docs(app-router): clarify slot preservation invariants
1 parent a5805b6 commit 7d29908

17 files changed

Lines changed: 1281 additions & 65 deletions

packages/vinext/src/global.d.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ declare global {
4141
__VINEXT_ROOT__: Root | undefined;
4242

4343
/**
44-
* High-resolution timestamp recorded after client hydration completes.
45-
* Used by instrumentation-client compatibility tests.
44+
* High-resolution timestamp recorded after client hydration is usable.
45+
* Pages Router writes after hydrateRoot() returns; App Router writes after
46+
* the first committed tree attaches browser router state.
4647
*/
4748
__VINEXT_HYDRATED_AT: number | undefined;
4849

packages/vinext/src/server/app-browser-entry.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ function BrowserRoot({
508508
renderId: 0,
509509
rootLayoutTreePath: initialMetadata.rootLayoutTreePath,
510510
routeId: initialMetadata.routeId,
511+
slotBindings: initialMetadata.slotBindings,
511512
visibleCommitVersion: 0,
512513
});
513514
const treeState = isRouterStatePromise(treeStateValue) ? use(treeStateValue) : treeStateValue;
@@ -532,6 +533,11 @@ function BrowserRoot({
532533
stateRef,
533534
);
534535
browserRouterStateHasEverCommitted = true;
536+
// App Router uses this timestamp as first committed tree readiness: the
537+
// browser router state is attached and link/router interactions can safely
538+
// observe the committed tree. It is intentionally later than hydrateRoot()
539+
// returning.
540+
window.__VINEXT_HYDRATED_AT = performance.now();
535541
return () => {
536542
detach();
537543
setMountedSlotsHeader(null);
@@ -1000,7 +1006,6 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void {
10001006
options: hydrateRootOptions,
10011007
startTransition,
10021008
});
1003-
window.__VINEXT_HYDRATED_AT = performance.now();
10041009

10051010
// Exposed so the navigation shim's `router.refresh()` can invalidate the
10061011
// entire client navigation cache (visited-response + prefetch) before

packages/vinext/src/server/app-browser-state.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getMountedSlotIds,
55
getMountedSlotIdsHeader,
66
type AppElements,
7+
type AppElementsSlotBinding,
78
type LayoutFlags,
89
} from "./app-elements.js";
910
import { createRscRequestHeaders } from "./app-rsc-cache-busting.js";
@@ -65,6 +66,7 @@ export type AppRouterState = {
6566
navigationSnapshot: ClientNavigationRenderSnapshot;
6667
rootLayoutTreePath: string | null;
6768
routeId: string;
69+
slotBindings: readonly AppElementsSlotBinding[];
6870
visibleCommitVersion: number;
6971
};
7072

@@ -79,6 +81,7 @@ export type AppRouterAction = {
7981
renderId: number;
8082
rootLayoutTreePath: string | null;
8183
routeId: string;
84+
slotBindings: readonly AppElementsSlotBinding[];
8285
type: "navigate" | "replace" | "traverse";
8386
};
8487

@@ -95,6 +98,7 @@ type DispatchPendingNavigationCommitDispositionDecision = {
9598
disposition: "dispatch";
9699
preserveAbsentSlots: boolean;
97100
preserveElementIds: readonly string[];
101+
preservePreviousSlotIds: readonly string[];
98102
trace: NavigationTrace;
99103
};
100104
type NonDispatchPendingNavigationCommitDispositionDecision = {
@@ -290,6 +294,7 @@ function createVisibleRouteSnapshot(state: AppRouterState): RouteSnapshotV0 {
290294
mountedParallelSlots: createMountedParallelSlotSnapshots(state.elements),
291295
rootBoundaryId: state.rootLayoutTreePath,
292296
routeId: state.routeId,
297+
slotBindings: state.slotBindings,
293298
};
294299
}
295300

@@ -304,6 +309,7 @@ function createPendingRouteSnapshot(pending: PendingNavigationCommit): RouteSnap
304309
mountedParallelSlots: createMountedParallelSlotSnapshots(pending.action.elements),
305310
rootBoundaryId: pending.rootLayoutTreePath,
306311
routeId: pending.routeId,
312+
slotBindings: pending.action.slotBindings,
307313
};
308314
}
309315

@@ -374,6 +380,7 @@ function mapNavigationDecisionToPendingDisposition(
374380
disposition: "dispatch",
375381
preserveAbsentSlots: decision.proposal.preserveAbsentSlots,
376382
preserveElementIds: decision.proposal.preserveElementIds,
383+
preservePreviousSlotIds: decision.proposal.preservePreviousSlotIds,
377384
trace: decision.trace,
378385
};
379386
case "hardNavigate":
@@ -413,6 +420,7 @@ export async function createPendingNavigationCommit(options: {
413420
interceptionContext: metadata.interceptionContext,
414421
layoutIds: metadata.layoutIds,
415422
layoutFlags: metadata.layoutFlags,
423+
slotBindings: metadata.slotBindings,
416424
navigationSnapshot: options.navigationSnapshot,
417425
operation: createOperationRecord({
418426
id: options.renderId,

packages/vinext/src/server/app-browser-visible-commit.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type { ClientNavigationRenderSnapshot } from "vinext/shims/navigation";
22
import { mergeElements } from "vinext/shims/slot";
3-
import type { AppElements } from "./app-elements.js";
3+
import {
4+
normalizeAppElementsSlotBindings,
5+
type AppElements,
6+
type AppElementsSlotBinding,
7+
} from "./app-elements.js";
48
import {
59
createPendingNavigationCommit,
610
resolvePendingNavigationCommitDispositionDecision,
@@ -25,6 +29,7 @@ type VisibleCommitDecision = {
2529
disposition: "commit";
2630
preserveAbsentSlots: boolean;
2731
preserveElementIds: readonly string[];
32+
preservePreviousSlotIds: readonly string[];
2833
trace: NavigationTrace;
2934
};
3035
type HardNavigateCommitDecision = {
@@ -104,6 +109,36 @@ function commitVisibleRouterState(
104109
};
105110
}
106111

112+
function mergeSlotBindings(
113+
previousBindings: readonly AppElementsSlotBinding[],
114+
nextBindings: readonly AppElementsSlotBinding[],
115+
layoutIds: readonly string[],
116+
preservePreviousSlotIds: readonly string[],
117+
): readonly AppElementsSlotBinding[] {
118+
if (preservePreviousSlotIds.length === 0) return nextBindings;
119+
120+
const preservedSlotIds = new Set(preservePreviousSlotIds);
121+
const previousBindingsBySlotId = new Map<string, AppElementsSlotBinding>();
122+
for (const binding of previousBindings) {
123+
if (!preservedSlotIds.has(binding.slotId)) continue;
124+
previousBindingsBySlotId.set(binding.slotId, binding);
125+
}
126+
127+
const mergedBindings: AppElementsSlotBinding[] = [];
128+
const seenSlotIds = new Set<string>();
129+
for (const binding of nextBindings) {
130+
const previousBinding = previousBindingsBySlotId.get(binding.slotId);
131+
mergedBindings.push(previousBinding ?? binding);
132+
seenSlotIds.add(binding.slotId);
133+
}
134+
for (const slotId of preservePreviousSlotIds) {
135+
if (seenSlotIds.has(slotId)) continue;
136+
const previousBinding = previousBindingsBySlotId.get(slotId);
137+
if (previousBinding) mergedBindings.push(previousBinding);
138+
}
139+
return normalizeAppElementsSlotBindings(mergedBindings, { layoutIds });
140+
}
141+
107142
function reduceApprovedVisibleCommitState(
108143
state: AppRouterState,
109144
commit: ApprovedVisibleCommit,
@@ -119,6 +154,7 @@ function reduceApprovedVisibleCommitState(
119154
clearAbsentSlots: action.type === "traverse",
120155
preserveAbsentSlots: commit.decision.preserveAbsentSlots,
121156
preserveElementIds: commit.decision.preserveElementIds,
157+
preservePreviousSlotIds: commit.decision.preservePreviousSlotIds,
122158
}),
123159
interceptionContext: action.interceptionContext,
124160
layoutFlags: mergeLayoutFlags(
@@ -132,6 +168,12 @@ function reduceApprovedVisibleCommitState(
132168
renderId: action.renderId,
133169
rootLayoutTreePath: action.rootLayoutTreePath,
134170
routeId: action.routeId,
171+
slotBindings: mergeSlotBindings(
172+
state.slotBindings,
173+
action.slotBindings,
174+
action.layoutIds,
175+
commit.decision.preservePreviousSlotIds,
176+
),
135177
},
136178
action.operation,
137179
);
@@ -148,6 +190,7 @@ function reduceApprovedVisibleCommitState(
148190
renderId: action.renderId,
149191
rootLayoutTreePath: action.rootLayoutTreePath,
150192
routeId: action.routeId,
193+
slotBindings: action.slotBindings,
151194
},
152195
action.operation,
153196
);
@@ -177,6 +220,7 @@ function resolvePendingNavigationCommitDecision(options: {
177220
decision.trace,
178221
decision.preserveElementIds,
179222
decision.preserveAbsentSlots,
223+
decision.preservePreviousSlotIds,
180224
);
181225
default: {
182226
const _exhaustive: never = decision;
@@ -189,11 +233,13 @@ function createVisibleCommitDecision(
189233
trace: NavigationTrace = createNavigationTrace(NavigationTraceReasonCodes.commitCurrent),
190234
preserveElementIds: readonly string[] = [],
191235
preserveAbsentSlots: boolean = false,
236+
preservePreviousSlotIds: readonly string[] = [],
192237
): VisibleCommitDecision {
193238
return {
194239
disposition: "commit",
195240
preserveAbsentSlots,
196241
preserveElementIds: [...preserveElementIds],
242+
preservePreviousSlotIds: [...preservePreviousSlotIds],
197243
trace,
198244
};
199245
}

0 commit comments

Comments
 (0)