Skip to content

fix(app-router): preserve history metadata for external state updates#1259

Merged
james-elicx merged 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/preserve-app-history-state
May 16, 2026
Merged

fix(app-router): preserve history metadata for external state updates#1259
james-elicx merged 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/preserve-app-history-state

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Overview

Field Details
Goal Preserve App Router interception metadata when app code calls window.history.pushState or window.history.replaceState directly.
Core change Copy the current vinext previousNextUrl history marker onto caller-provided history state before delegating to the browser History API.
Main boundary Public History API wrappers in the next/navigation shim.
Primary files packages/vinext/src/shims/navigation.ts, packages/vinext/src/server/app-history-state.ts, tests/shims.test.ts
Expected impact External shallow URL updates no longer drop the interception source needed by later App Router traversal and refresh paths.

Why

App Router history entries need to carry enough internal state for later traversal to reconstruct the same router context. Vinext stores the intercepted-route source URL in history.state.__vinext_previousNextUrl; if an external shallow pushState or replaceState replaces that state with caller data, later App Router work can lose the interception context.

Area Principle / invariant What this PR changes
History API wrappers External URL updates should update public URL hooks without erasing App Router internals. The wrappers now preserve vinext history metadata before calling the original browser method.
Intercepting routes previousNextUrl is the client-side source for deriving interception context during traversal. The marker survives external object, null, and undefined caller state.
Next.js parity Next.js copies internal App Router state before external pushState and replaceState. Vinext now follows that same contract for its equivalent metadata.

What Changed

Scenario Before After
history.pushState({ myData }, '', url) while current history state has __vinext_previousNextUrl Stored only { myData }, dropping the vinext marker. Stores caller data plus __vinext_previousNextUrl.
history.pushState(null, '', url) in the same state Stored null. Stores the current vinext marker.
history.replaceState(null/undefined, '', url) in the same state Replaced the entry with null or undefined. Replaces it with the current vinext marker.
Maintainer review path
  1. packages/vinext/src/server/app-history-state.ts extracts the existing previousNextUrl history helpers and adds the external-copy helper.
  2. packages/vinext/src/shims/navigation.ts uses that helper in the patched public pushState and replaceState paths.
  3. packages/vinext/src/server/app-browser-state.ts re-exports the extracted helpers so existing App Router imports keep the same boundary.
  4. tests/shims.test.ts covers the public wrapper behavior with object, null, and undefined caller state.
Validation
  • vp test run tests/shims.test.ts -t "preserves App Router history metadata"
  • vp test run tests/shims.test.ts
  • vp test run tests/app-browser-entry.test.ts -t "previousNextUrl"
  • vp test run tests/app-browser-entry.test.ts
  • vp check
  • Commit hook also ran formatting, lint/type checks, and knip.
Risk / compatibility
  • Public API: no new public API surface.
  • Runtime: only affects patched browser History API calls when the current entry already contains vinext App Router metadata.
  • Compatibility: aligns with the Next.js App Router wrapper behavior for direct History API usage.
  • Existing app risk: low. Caller state is still preserved when present, with the internal vinext marker copied alongside it.
Non-goals
  • This does not change Pages Router history behavior.
  • This does not redesign scroll restoration state.
  • This does not add e2e coverage for a full intercepted-route modal flow because the failing contract is at the public History API wrapper boundary.

References

Reference Why it matters
Next.js copyNextJsInternalHistoryState Shows Next.js copies internal App Router history state into external caller state.
Next.js external pushState and replaceState wrappers Confirms this is intentional wrapper behavior, not incidental state shape.
Next.js shallow-routing tests Covers direct History API usage with object, null, and undefined state inputs.
Next.js segment-cache nextUrl regression context Documents why the referring nextUrl dimension matters for interception-aware routing and caching.

External history.pushState and history.replaceState currently store caller data as-is. That drops vinext's App Router previousNextUrl marker when apps perform shallow URL updates inside an intercepted route, so later traversal can lose the interception source.

The History API wrappers now copy the current App Router metadata onto caller-provided state before delegating to the browser history method. A focused shim regression covers object, null, and undefined caller state against the same contract Next.js preserves.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 16, 2026

Open in StackBlitz

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

commit: 1db48f8

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 16, 2026 07:40
@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 extraction and straightforward fix. The new app-history-state.ts module is a good separation, the wrapper semantics match the Next.js copyNextJsInternalHistoryState pattern, and the test covers the key state shapes (object, null, undefined). A few minor observations below, none blocking.

return callerState;
}

return createHistoryStateWithPreviousNextUrl(callerState, previousNextUrl);
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: createHistoryStateWithPreviousNextUrl returns HistoryStateRecord | null, but createExternalHistoryStatePreservingMetadata is typed as returning unknown. When previousNextUrl is non-null and callerState is null, the returned value is { __vinext_previousNextUrl: "/feed" } — which is fine — but on the callerState passthrough path (line 40), the return type is unknown because the caller's original value is returned as-is.

This works correctly at runtime, but the return type could be narrowed. Not blocking.

Comment thread tests/shims.test.ts
vi.resetModules();
await import("../packages/vinext/src/shims/navigation.js");

win.history.pushState({ myData: { foo: "bar" } }, "", "/photo/1?filter=active");
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 case where the current history state does not have __vinext_previousNextUrl (the no-metadata baseline). This would verify the passthrough path in createExternalHistoryStatePreservingMetadata (line 39-41 of app-history-state.ts) — i.e., that caller state is returned unchanged when there's no vinext metadata to preserve.

For example:

win.history.state = { unrelated: true };
win.history.pushState({ myData: 1 }, "", "/photo/2");
expect(win.history.state).toEqual({ myData: 1 }); // no metadata injected

Not blocking — the unit under test (createExternalHistoryStatePreservingMetadata) is simple enough that the passthrough is obvious from reading it.

Comment thread tests/shims.test.ts
vi.resetModules();
await import("../packages/vinext/src/shims/navigation.js");

win.history.pushState({ myData: { foo: "bar" } }, "", "/photo/1?filter=active");
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.

Also worth considering: a case where the caller's own state object contains __vinext_previousNextUrl (e.g., a library naively serializing full history state). The current implementation correctly overwrites it with the current entry's value via createHistoryStateWithPreviousNextUrl. A test documenting that the internal key wins would make the intent explicit, but it's low priority given how unlikely the collision is.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted. Here's a summary of what I found:

Verdict: Approved

The PR is clean and well-scoped. It:

  1. Correctly extracts the previousNextUrl history helpers from app-browser-state.ts into a new app-history-state.ts module, with re-exports preserving the existing import boundary.

  2. Adds createExternalHistoryStatePreservingMetadata that reads the current history entry's vinext marker and copies it onto the caller-provided state before delegating to the native History API. The passthrough path (no metadata present) returns caller state unchanged.

  3. Applies the helper in the right places — only in the external-facing patched pushState/replaceState wrappers, not in the internal pushHistoryStateWithoutNotify/replaceHistoryStateWithoutNotify which correctly bypass the patch via originalPushState/originalReplaceState.

  4. Tests cover the important state shapes: object with user data, null, and undefined — matching the Next.js shallow-routing test matrix.

I left three non-blocking observations:

  • The return type of createExternalHistoryStatePreservingMetadata could be narrowed from unknown
  • A no-metadata baseline test case (verifying the passthrough path) would be nice
  • A test documenting that vinext's internal key wins over any caller-provided __vinext_previousNextUrl would make the intent explicit

github run

@james-elicx james-elicx merged commit 4988436 into cloudflare:main May 16, 2026
28 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.

2 participants