-
Notifications
You must be signed in to change notification settings - Fork 306
fix: clear stale parallel slots on traversal in mergeElements #816
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
a7f6b93
b4c2b40
b26a410
6d23e51
d996df4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -453,7 +453,7 @@ function dispatchBrowserTree( | |
| elements: AppElements, | ||
| navigationSnapshot: ClientNavigationRenderSnapshot, | ||
| renderId: number, | ||
| actionType: "navigate" | "replace", | ||
| actionType: "navigate" | "replace" | "traverse", | ||
| routeId: string, | ||
| rootLayoutTreePath: string | null, | ||
| useTransitionMode: boolean, | ||
|
|
@@ -484,7 +484,7 @@ async function renderNavigationPayload( | |
| navId: number, | ||
| prePaintEffect: (() => void) | null = null, | ||
| useTransition = true, | ||
| actionType: "navigate" | "replace" = "navigate", | ||
| actionType: "navigate" | "replace" | "traverse" = "navigate", | ||
| ): Promise<void> { | ||
| const renderId = ++nextNavigationRenderId; | ||
| const committed = new Promise<void>((resolve) => { | ||
|
|
@@ -783,6 +783,7 @@ async function main(): Promise<void> { | |
| navId, | ||
| createNavigationCommitEffect(href, historyUpdateMode, cachedParams), | ||
| isSameRoute, | ||
| navigationKind === "traverse" ? "traverse" : "navigate", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Echoing the previous review's note: the |
||
| ); | ||
| } finally { | ||
| // Always clear _snapshotPending so the outer catch does not | ||
|
|
@@ -871,6 +872,7 @@ async function main(): Promise<void> { | |
| navId, | ||
| createNavigationCommitEffect(href, historyUpdateMode, navParams), | ||
| isSameRoute, | ||
| navigationKind === "traverse" ? "traverse" : "navigate", | ||
| ); | ||
| } finally { | ||
| // Always clear _snapshotPending after renderNavigationPayload returns or | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -16,7 +16,7 @@ export type AppRouterAction = { | |||||||||||||||||||||||||||||||||||
| renderId: number; | ||||||||||||||||||||||||||||||||||||
| rootLayoutTreePath: string | null; | ||||||||||||||||||||||||||||||||||||
| routeId: string; | ||||||||||||||||||||||||||||||||||||
| type: "navigate" | "replace"; | ||||||||||||||||||||||||||||||||||||
| type: "navigate" | "replace" | "traverse"; | ||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| export type PendingNavigationCommit = { | ||||||||||||||||||||||||||||||||||||
|
|
@@ -41,6 +41,14 @@ export function routerReducer(state: AppRouterState, action: AppRouterAction): A | |||||||||||||||||||||||||||||||||||
| rootLayoutTreePath: action.rootLayoutTreePath, | ||||||||||||||||||||||||||||||||||||
| routeId: action.routeId, | ||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||
| case "traverse": | ||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||
| elements: mergeElements(state.elements, action.elements, true), | ||||||||||||||||||||||||||||||||||||
| navigationSnapshot: action.navigationSnapshot, | ||||||||||||||||||||||||||||||||||||
| renderId: action.renderId, | ||||||||||||||||||||||||||||||||||||
| rootLayoutTreePath: action.rootLayoutTreePath, | ||||||||||||||||||||||||||||||||||||
| routeId: action.routeId, | ||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+36
to
+44
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: the
Suggested change
This keeps the exhaustive switch and |
||||||||||||||||||||||||||||||||||||
| case "replace": | ||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||
| elements: action.elements, | ||||||||||||||||||||||||||||||||||||
|
|
@@ -92,7 +100,7 @@ export async function createPendingNavigationCommit(options: { | |||||||||||||||||||||||||||||||||||
| nextElements: Promise<AppElements>; | ||||||||||||||||||||||||||||||||||||
| navigationSnapshot: ClientNavigationRenderSnapshot; | ||||||||||||||||||||||||||||||||||||
| renderId: number; | ||||||||||||||||||||||||||||||||||||
| type: "navigate" | "replace"; | ||||||||||||||||||||||||||||||||||||
| type: "navigate" | "replace" | "traverse"; | ||||||||||||||||||||||||||||||||||||
| }): Promise<PendingNavigationCommit> { | ||||||||||||||||||||||||||||||||||||
| const elements = await options.nextElements; | ||||||||||||||||||||||||||||||||||||
| const metadata = readAppElementsMetadata(elements); | ||||||||||||||||||||||||||||||||||||
|
|
@@ -118,7 +126,7 @@ export async function resolveAndClassifyNavigationCommit(options: { | |||||||||||||||||||||||||||||||||||
| nextElements: Promise<AppElements>; | ||||||||||||||||||||||||||||||||||||
| renderId: number; | ||||||||||||||||||||||||||||||||||||
| startedNavigationId: number; | ||||||||||||||||||||||||||||||||||||
| type: "navigate" | "replace"; | ||||||||||||||||||||||||||||||||||||
| type: "navigate" | "replace" | "traverse"; | ||||||||||||||||||||||||||||||||||||
| }): Promise<ClassifiedPendingNavigationCommit> { | ||||||||||||||||||||||||||||||||||||
| const pending = await createPendingNavigationCommit({ | ||||||||||||||||||||||||||||||||||||
| currentState: options.currentState, | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,7 +22,11 @@ export const ParallelSlotsContext = React.createContext<Readonly< | |
| Record<string, React.ReactNode> | ||
| > | null>(null); | ||
|
|
||
| export function mergeElements(prev: AppElements, next: AppElements): AppElements { | ||
| export function mergeElements( | ||
| prev: AppElements, | ||
| next: AppElements, | ||
| clearAbsentSlots = false, | ||
| ): AppElements { | ||
| const merged: Record<string, AppElementValue> = { ...prev, ...next }; | ||
| // On soft navigation, unmatched parallel slots preserve their previous subtree | ||
| // instead of firing notFound(). Only hard navigation (full page load) should 404. | ||
|
|
@@ -32,6 +36,18 @@ export function mergeElements(prev: AppElements, next: AppElements): AppElements | |
| merged[key] = prev[key]; | ||
| } | ||
| } | ||
| // On traversal (browser back/forward), the server renders the full destination | ||
| // route tree. A slot absent from next means the destination route tree does not | ||
| // include it, so clear it rather than keeping the stale prev value. This only | ||
| // runs for traversals because soft forward navigations may omit parent layout | ||
| // slots that should be preserved. | ||
| if (clearAbsentSlots) { | ||
| for (const key of Object.keys(merged)) { | ||
| if (key.startsWith("slot:") && !Object.hasOwn(next, key)) { | ||
| delete merged[key]; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit (non-blocking): Iterating |
||
| } | ||
| } | ||
| } | ||
| return merged; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -277,6 +277,43 @@ describe("slot primitives", () => { | |
| expect(merged["slot:modal:/"]).toBe(UNMATCHED_SLOT); | ||
| }); | ||
|
|
||
| it("mergeElements clears stale slots absent from next when clearAbsentSlots is set", async () => { | ||
| const { mergeElements } = await import("../packages/vinext/src/shims/slot.js"); | ||
|
|
||
| const merged = mergeElements( | ||
| { | ||
| "layout:/": React.createElement("div", null, "layout"), | ||
| "page:/feed": React.createElement("div", null, "feed"), | ||
| "slot:modal:/feed": React.createElement("div", null, "intercepted modal"), | ||
| }, | ||
| { | ||
| "layout:/": React.createElement("div", null, "layout"), | ||
| "page:/feed": React.createElement("div", null, "feed"), | ||
| }, | ||
| true, | ||
| ); | ||
|
|
||
| expect(Object.hasOwn(merged, "slot:modal:/feed")).toBe(false); | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider adding a test for the interaction between This is the "traversal to a route that declares the slot but doesn't match it" case. The current code handles it correctly, but it's a subtle interaction worth pinning with a test. |
||
|
|
||
| it("mergeElements preserves absent slots when clearAbsentSlots is not set", async () => { | ||
| const { mergeElements } = await import("../packages/vinext/src/shims/slot.js"); | ||
|
|
||
| const merged = mergeElements( | ||
| { | ||
| "layout:/": React.createElement("div", null, "layout"), | ||
| "page:/dashboard": React.createElement("div", null, "dashboard"), | ||
| "slot:team:/dashboard": React.createElement("div", null, "team panel"), | ||
| }, | ||
| { | ||
| "page:/dashboard/settings": React.createElement("div", null, "settings"), | ||
| }, | ||
| ); | ||
|
|
||
| // Without clearAbsentSlots, absent slots survive (soft nav to child route) | ||
| expect(Object.hasOwn(merged, "slot:team:/dashboard")).toBe(true); | ||
| }); | ||
|
|
||
| it("Slot renders element from resolved context", async () => { | ||
| const mod = await import("../packages/vinext/src/shims/slot.js"); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ternary
navigationKind === "traverse" ? "traverse" : "navigate"appears identically in two places (here and line 875). This correctly maps"refresh"→"navigate", which makes sense since refresh should merge like a forward navigation. Just noting: if a fourthNavigationKindis ever added, both sites would need updating. Not worth extracting now, but worth a comment if the mapping ever becomes non-trivial.