Skip to content

fix: clear stale parallel slots on traversal in mergeElements#816

Merged
james-elicx merged 5 commits intocloudflare:mainfrom
NathanDrake2406:fix/traversal-stale-parallel-slots
Apr 10, 2026
Merged

fix: clear stale parallel slots on traversal in mergeElements#816
james-elicx merged 5 commits intocloudflare:mainfrom
NathanDrake2406:fix/traversal-stale-parallel-slots

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Summary

  • Adds "traverse" as a first-class action type in the router reducer, distinct from "navigate" and "replace"
  • mergeElements gains a clearAbsentSlots flag (default false) that removes slot keys present in prev but absent from next
  • The traverse action type sets this flag, clearing stale parallel slots on browser back/forward
  • Soft forward navigations continue to preserve absent slots (unchanged behavior)

Context

When navigating back from an intercepted route (e.g., /feed with photo modal -> browser back), the RSC response for the destination omits the @modal slot. Previously, mergeElements carried the stale modal forward from prev via { ...prev, ...next }.

The fix cannot simply clear all absent slots because soft forward navigation (e.g., /dashboard -> /dashboard/settings) intentionally omits parent layout slots that haven't changed. Those must be preserved.

The solution: thread the navigation kind through the action type. Browser back/forward dispatches "traverse" instead of "navigate", which tells mergeElements to clear absent slots.

Closes #814

Test plan

  • Unit test: mergeElements clears stale slots absent from next when clearAbsentSlots is set
  • Unit test: mergeElements preserves absent slots when clearAbsentSlots is not set
  • Existing mergeElements tests pass (UNMATCHED_SLOT preservation, shallow merge)
  • E2E: parallel slot content persists on soft navigation to child route (existing, should still pass in CI)
  • E2E: back/forward on intercepted routes (depends on feat: encode interception context in App Router payload IDs and caches #753 fixtures, tracked separately)

Add "traverse" as a first-class action type in the router reducer.
When the browser navigates back/forward, the action type is now
"traverse" instead of "navigate", which tells mergeElements to
clear slot keys absent from the server response.

On soft forward navigation, absent slots are still preserved (the
server may omit parent layout slots that haven't changed). Only
traversals clear them, because the server renders the full
destination route tree on popstate.

Closes cloudflare#814
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 10, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@816

commit: d996df4

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-scoped fix. The approach of threading "traverse" as a distinct action type to differentiate browser back/forward from soft forward navigation is the right call — it avoids overloading the existing navigate path and makes the intent explicit at every layer.

The mergeElements logic is correct: the clearAbsentSlots loop runs after the UNMATCHED_SLOT preservation loop, so a slot explicitly sent as UNMATCHED_SLOT by the server (present in next) won't be cleared, while a slot completely absent from the traversal response will be. The slot: prefix filter correctly protects layout/page/metadata keys from deletion.

A few observations below — one minor suggestion and a couple of notes on test coverage. Nothing blocking.

Comment on lines +44 to +51
case "traverse":
return {
elements: mergeElements(state.elements, action.elements, true),
navigationSnapshot: action.navigationSnapshot,
renderId: action.renderId,
rootLayoutTreePath: action.rootLayoutTreePath,
routeId: action.routeId,
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the "traverse" and "navigate" cases are identical except for the clearAbsentSlots boolean. You could collapse them to reduce duplication:

Suggested change
case "traverse":
return {
elements: mergeElements(state.elements, action.elements, true),
navigationSnapshot: action.navigationSnapshot,
renderId: action.renderId,
rootLayoutTreePath: action.rootLayoutTreePath,
routeId: action.routeId,
};
case "traverse":
case "navigate":
return {
elements: mergeElements(state.elements, action.elements, action.type === "traverse"),
navigationSnapshot: action.navigationSnapshot,
renderId: action.renderId,
rootLayoutTreePath: action.rootLayoutTreePath,
routeId: action.routeId,
};

This keeps the exhaustive switch and never guard intact. Not blocking — the current form is also clear.

Comment thread tests/slot.test.ts
);

expect(Object.hasOwn(merged, "slot:modal:/feed")).toBe(false);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a test for the interaction between clearAbsentSlots and the UNMATCHED_SLOT preservation logic. Specifically: when clearAbsentSlots=true and next explicitly contains a slot as UNMATCHED_SLOT that prev has real content for, the slot should survive (restored to prev content by the UNMATCHED_SLOT loop, and not cleared because the key is present in next).

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.

navId,
createNavigationCommitEffect(href, historyUpdateMode, cachedParams),
isSameRoute,
navigationKind === "traverse" ? "traverse" : "navigate",
Copy link
Copy Markdown
Contributor

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 fourth NavigationKind is ever added, both sites would need updating. Not worth extracting now, but worth a comment if the mapping ever becomes non-trivial.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 10, 2026

Review posted and approved. Here's a summary of the feedback:

Approved — the fix is correct and well-designed. Three inline comments:

  1. app-browser-state.ts:44-51 — Minor suggestion to collapse the "traverse" and "navigate" switch cases since they differ only by the clearAbsentSlots boolean. Not blocking.

  2. tests/slot.test.ts:297 — Suggested adding a test for the interaction between clearAbsentSlots=true and the UNMATCHED_SLOT preservation logic (the case where next explicitly sends UNMATCHED_SLOT during a traversal, and prev has real content — the slot should survive). The code handles this correctly but the subtle interaction is worth pinning.

  3. app-browser-entry.ts:786 — Noted the duplicated ternary navigationKind === "traverse" ? "traverse" : "navigate" at two call sites, with a comment that it correctly maps "refresh" to "navigate" but both sites would need updating if a fourth NavigationKind is added.

github run

…T traversal test

Address review feedback on cloudflare#816:
- Collapse the identical navigate/traverse switch cases in routerReducer,
  passing `action.type === "traverse"` as the clearAbsentSlots boolean.
  Keeps exhaustive switch and never guard intact.
- Add a test pinning the subtle interaction between clearAbsentSlots=true
  and the UNMATCHED_SLOT preservation loop: a slot sent as UNMATCHED_SLOT
  during traversal (key present in next) is restored from prev real content
  and is not cleared by the absent-slot loop.

https://claude.ai/code/session_01RhqyMQWoHBr8qrLZoUjoZj
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-scoped fix. The approach is sound: threading "traverse" as a distinct action type through the reducer pipeline gives mergeElements the context it needs to differentiate browser back/forward (clear stale slots) from soft forward navigation (preserve absent slots).

The loop ordering in mergeElements is correct — UNMATCHED_SLOT restoration runs before clearAbsentSlots deletion, and since UNMATCHED_SLOT slots are present in next (they pass Object.hasOwn(next, key)), they survive the clear loop. The tests cover the three key cases well.

I see the previous review's suggestion to collapse the switch cases has been adopted. Two minor observations below — neither blocking.


Suggestion (not blocking): Consider adding a routerReducer test for type: "traverse" in tests/app-browser-entry.test.ts. The mergeElements unit tests in slot.test.ts cover the core logic, but a reducer-level test would pin the wiring — confirming the reducer actually passes clearAbsentSlots=true when the action type is "traverse". This would catch a regression if someone changes the action.type === "traverse" condition in the reducer.

it("clears stale parallel slots on traverse", () => {
  const state = createState({
    elements: createResolvedElements("route:/feed", "/", {
      "slot:modal:/feed": React.createElement("div", null, "modal"),
    }),
  });
  const nextElements = createResolvedElements("route:/feed", "/");
  const nextState = routerReducer(state, {
    elements: nextElements,
    navigationSnapshot: createState().navigationSnapshot,
    renderId: 1,
    rootLayoutTreePath: "/",
    routeId: "route:/feed",
    type: "traverse",
  });
  expect(Object.hasOwn(nextState.elements, "slot:modal:/feed")).toBe(false);
});

navId,
createNavigationCommitEffect(href, historyUpdateMode, cachedParams),
isSameRoute,
navigationKind === "traverse" ? "traverse" : "navigate",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Echoing the previous review's note: the navigationKind === "traverse" ? "traverse" : "navigate" ternary appears identically at lines 786 and 875. This is fine for now (only three NavigationKind values), but if a fourth kind is added, a small helper like toActionType(navigationKind) would prevent the two sites from drifting.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 10, 2026

Review submitted successfully. Here's a summary of my review:

Approved — the fix is correct and well-designed.

Key observations:

  1. Logic ordering is correct in mergeElements: the UNMATCHED_SLOT restoration loop runs before the clearAbsentSlots deletion loop. Since UNMATCHED_SLOT slots are present in next (they pass Object.hasOwn(next, key)), they survive the clear loop. This means a traversal to a route that declares but doesn't match a slot correctly preserves the previous content.

  2. The "traverse" action type is cleanly threaded from the popstate handler through navigateRscrenderNavigationPayloaddispatchBrowserTreerouterReducermergeElements. The switch case collapse (combining "traverse" and "navigate") is clean.

  3. Test coverage is solid for the three key cases in mergeElements: clear absent slots on traversal, preserve UNMATCHED_SLOT on traversal, and preserve absent slots on soft nav.

Two non-blocking suggestions:

  • Add a routerReducer-level integration test for type: "traverse" in app-browser-entry.test.ts to pin the wiring between the reducer and mergeElements.
  • The duplicated navigationKind === "traverse" ? "traverse" : "navigate" ternary at two call sites could eventually become a helper if a fourth NavigationKind is added.

github run

Address second-round review feedback on cloudflare#816:
- Extract toActionType(kind: NavigationKind) helper to consolidate the
  duplicated `navigationKind === "traverse" ? "traverse" : "navigate"`
  ternary that appeared at two call sites in renderNavigationPayload.
  A comment documents the "refresh" → "navigate" mapping intent and
  flags both sites as needing updates if NavigationKind gains new values.
- Add routerReducer integration tests in app-browser-entry.test.ts:
  "traverse" clears stale parallel slots; "navigate" preserves them.
  These pin the wiring between the reducer and mergeElements.

https://claude.ai/code/session_01RhqyMQWoHBr8qrLZoUjoZj
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-scoped fix. The approach is correct: threading "traverse" as a distinct action type from popstate through the full pipeline (navigateRscrenderNavigationPayloadcreatePendingNavigationCommitdispatchBrowserTreerouterReducermergeElements) gives the merge logic the context it needs to differentiate browser back/forward from soft forward navigation.

The implementation details are solid:

  • Loop ordering in mergeElements is correct — UNMATCHED_SLOT restoration (line 34-37) runs before clearAbsentSlots deletion (line 44-49). Since UNMATCHED_SLOT entries are present in next (they pass Object.hasOwn(next, key)), they survive the clear loop. This correctly handles the "traversal to a route that declares but doesn't match a slot" case.

  • The slot: prefix guard (line 46) correctly protects layout/page/metadata keys from deletion during traversal.

  • toActionType helper (line 82-84) addresses the previous review's note about duplicated ternaries, centralizing the NavigationKind → action type mapping with a clear comment about "refresh" mapping to "navigate".

  • Switch case collapse in the reducer (line 36-37 of app-browser-state.ts) is clean — the action.type === "traverse" expression inside the shared case body keeps behavior distinct without duplicating the return shape.

  • Test coverage is thorough: unit tests for all three mergeElements cases (clear absent, preserve UNMATCHED_SLOT on traversal, preserve absent on soft nav), plus reducer-level integration tests that pin the wiring between routerReducer and mergeElements.

No blocking issues.

if (clearAbsentSlots) {
for (const key of Object.keys(merged)) {
if (key.startsWith("slot:") && !Object.hasOwn(next, key)) {
delete merged[key];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (non-blocking): Iterating Object.keys(merged) and deleting in-place is fine here since the delete doesn't affect the already-captured keys array, but it's worth noting this is safe specifically because Object.keys() returns a snapshot. If this ever moved to a for...in loop it would be subtly broken. The current form is correct.

// Maps NavigationKind to the AppRouterAction type used by the reducer.
// "refresh" is intentionally treated as "navigate" (merge, preserve absent slots).
// Both call sites must stay in sync — update here if NavigationKind gains new values.
function toActionType(kind: NavigationKind): "navigate" | "traverse" {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call extracting this into a named helper with the doc comment. This directly addresses the prior review feedback about the duplicated ternary and makes the "refresh""navigate" mapping explicit and centralized.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 10, 2026

Review submitted and approved. Here's a summary:

Approved — the fix is correct and well-designed.

The PR cleanly threads "traverse" as a distinct action type from the popstate handler through the full navigation pipeline down to mergeElements, which uses the new clearAbsentSlots flag to remove stale parallel slots on browser back/forward while preserving the existing soft-forward-navigation behavior.

Key things I verified:

  1. Loop ordering in mergeElements is correct — UNMATCHED_SLOT restoration runs before the clearAbsentSlots deletion, so slots explicitly sent as UNMATCHED_SLOT by the server survive traversal clearing.
  2. The slot: prefix guard correctly protects non-slot keys (layout, page, metadata) from deletion.
  3. toActionType helper addresses prior review feedback about duplicated ternaries.
  4. Test coverage is thorough at both the mergeElements unit level and the routerReducer integration level.

Two non-blocking inline comments noting the Object.keys() snapshot safety property and the toActionType extraction.

github run

@james-elicx james-elicx merged commit 19f4fac into cloudflare:main Apr 10, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: mergeElements preserves stale parallel slots during traversals

3 participants