Skip to content

fix: mergeElements preserves stale parallel slots during traversals #814

@NathanDrake2406

Description

@NathanDrake2406

Problem

When navigating back from an intercepted route (e.g., /feed -> click photo -> browser back), the intercepted modal slot persists instead of being dismissed.

Root cause (updated)

The initial analysis was incomplete. The problem is NOT simply that absent slot keys survive the spread in mergeElements. There are two distinct scenarios where a slot key can be absent from the next response:

  1. Soft nav to child route (e.g., /dashboard -> /dashboard/settings): The server only re-renders the matched route, not the parent layout's parallel slots. Absent slot keys should be PRESERVED from the previous state. This is the existing correct behavior.

  2. Traversal from intercepted route (e.g., browser back from /feed with modal to /feed without): The server renders the destination route tree. The @modal slot is either absent or UNMATCHED_SLOT. The previous slot content (the intercepted modal) should be CLEARED, not preserved.

mergeElements handles both cases identically today: preserve previous slot content when the key is absent or has UNMATCHED_SLOT. This is correct for case 1 but wrong for case 2.

Why a simple mergeElements fix doesn't work

Adding "delete absent slot keys" to mergeElements breaks case 1 (the parallel slot content persists on soft navigation to child route E2E test). The function doesn't know whether it's being called for a soft nav or a traversal.

Fix direction

The fix needs navigation context. Options:

  1. Thread a flag through the reducer: Add a clearUnmatchedSlots boolean to AppRouterAction. Set it to true for traversal actions. After mergeElements, the reducer deletes preserved slots that the server marked UNMATCHED_SLOT when the flag is set.

  2. Post-process in the traversal path: Instead of changing mergeElements, have the traversal code path in app-browser-entry.ts strip stale slot entries from the elements before dispatching to the reducer.

  3. Server-side signal: Have the server include explicit "clear this slot" markers (distinct from UNMATCHED_SLOT) for slots that should not be preserved on traversal.

Option 1 is probably the simplest correct approach: it keeps mergeElements generic and puts the navigation-aware logic where the navigation type is already known.

Reproduction

test("back then forward restores intercepted modal view", async ({ page }) => {
  await page.goto("/feed");
  await page.click("#feed-photo-42-link");
  await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible();

  await page.goBack();
  await expect(page.locator('[data-testid="feed-page"]')).toBeVisible();
  await expect(page.locator('[data-testid="photo-modal"]')).not.toBeVisible(); // FAILS
});

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions