-
Notifications
You must be signed in to change notification settings - Fork 375
fix(app-router): restore shallow pathname on history traversal #2829
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
base: main
Are you sure you want to change the base?
Changes from all commits
ee5604e
126007f
9724e23
5e4bdab
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 |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ const VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY = "__vinext_previousNextUrl"; | |
| const VINEXT_HISTORY_INDEX_HISTORY_STATE_KEY = "__vinext_historyIndex"; | ||
| const VINEXT_BFCACHE_IDS_HISTORY_STATE_KEY = "__vinext_bfcacheIds"; | ||
| const VINEXT_BFCACHE_VERSION_HISTORY_STATE_KEY = "__vinext_bfcacheVersion"; | ||
| const VINEXT_SHALLOW_URL_HISTORY_STATE_KEY = "__vinext_shallowUrl"; | ||
|
|
||
| type HistoryStateRecord = { | ||
| [key: string]: unknown; | ||
|
|
@@ -39,6 +40,11 @@ type HistoryStateSnapshotRestoreDecision<TState> = | |
| export class HistoryStateSnapshotCache<TState> { | ||
| readonly #maxEntries: number; | ||
| readonly #snapshots = new Map<number, HistoryStateSnapshot<TState>>(); | ||
| // External shallow entries can point at a pathname that has no matching app | ||
| // route. Their rendered tree is therefore the only in-memory restoration | ||
| // source and must survive both general cache invalidation and eviction from | ||
| // the bounded navigation snapshot cache. | ||
| readonly #durableSnapshots = new Map<number, TState>(); | ||
|
|
||
| constructor(options: { maxEntries: number }) { | ||
| this.#maxEntries = options.maxEntries; | ||
|
|
@@ -48,9 +54,24 @@ export class HistoryStateSnapshotCache<TState> { | |
| this.#snapshots.clear(); | ||
| } | ||
|
|
||
| remember(options: { bfcacheVersion: number; historyIndex: number | null; state: TState }): void { | ||
| remember(options: { | ||
| bfcacheVersion: number; | ||
| durable?: boolean; | ||
| historyIndex: number | null; | ||
| state: TState; | ||
| }): void { | ||
| if (options.historyIndex === null) return; | ||
|
|
||
| if (options.durable === true) { | ||
| this.#snapshots.delete(options.historyIndex); | ||
| this.#durableSnapshots.set(options.historyIndex, options.state); | ||
|
Comment on lines
+65
to
+67
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.
Every hydrated external AGENTS.md reference: AGENTS.md:L477-L483 Useful? React with 👍 / 👎. |
||
| return; | ||
| } | ||
|
|
||
| // A normal navigation replacing the same traversal entry supersedes any | ||
| // shallow restoration state previously associated with that index. | ||
| this.#durableSnapshots.delete(options.historyIndex); | ||
|
Comment on lines
+71
to
+73
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.
Fresh evidence after the prior cache-invalidation fix: when Back restores a durable shallow entry, Useful? React with 👍 / 👎. |
||
|
|
||
| this.#snapshots.delete(options.historyIndex); | ||
| this.#snapshots.set(options.historyIndex, { | ||
| bfcacheVersion: options.bfcacheVersion, | ||
|
|
@@ -75,13 +96,22 @@ export class HistoryStateSnapshotCache<TState> { | |
| return { kind: "skip", reason: "missing-history-index", targetHistoryIndex }; | ||
| } | ||
|
|
||
| if (options.guarded) { | ||
| return { kind: "skip", reason: "guarded", targetHistoryIndex }; | ||
| } | ||
|
|
||
| if (this.#durableSnapshots.has(targetHistoryIndex)) { | ||
| return { | ||
| kind: "restore", | ||
| state: this.#durableSnapshots.get(targetHistoryIndex)!, | ||
| targetHistoryIndex, | ||
| }; | ||
| } | ||
|
|
||
| const snapshot = this.#snapshots.get(targetHistoryIndex); | ||
| if (!snapshot) { | ||
| return { kind: "skip", reason: "missing-snapshot", targetHistoryIndex }; | ||
| } | ||
| if (options.guarded) { | ||
| return { kind: "skip", reason: "guarded", targetHistoryIndex }; | ||
| } | ||
| if (snapshot.bfcacheVersion !== options.currentBfcacheVersion) { | ||
| this.#snapshots.delete(targetHistoryIndex); | ||
| return { kind: "skip", reason: "stale-bfcache-version", targetHistoryIndex }; | ||
|
|
@@ -148,9 +178,14 @@ export class RestorableClientStateController<TState> { | |
| this.#invalidateBfcacheIds(); | ||
| } | ||
|
|
||
| rememberHistoryStateSnapshot(options: { historyIndex: number | null; state: TState }): void { | ||
| rememberHistoryStateSnapshot(options: { | ||
| durable?: boolean; | ||
| historyIndex: number | null; | ||
| state: TState; | ||
| }): void { | ||
| this.#snapshots.remember({ | ||
| bfcacheVersion: this.#currentBfcacheVersion, | ||
| durable: options.durable, | ||
| historyIndex: options.historyIndex, | ||
| state: options.state, | ||
| }); | ||
|
|
@@ -246,22 +281,42 @@ export function createHistoryStateWithNavigationMetadata( | |
| export function createExternalHistoryStatePreservingMetadata( | ||
| callerState: unknown, | ||
| currentHistoryState: unknown, | ||
| shallowUrl?: string, | ||
| traversalIndexOverride?: number | null, | ||
| ): unknown { | ||
| const previousNextUrl = readHistoryStatePreviousNextUrl(currentHistoryState); | ||
| const traversalIndex = readHistoryStateTraversalIndex(currentHistoryState); | ||
| const traversalIndex = | ||
| traversalIndexOverride === undefined | ||
| ? readHistoryStateTraversalIndex(currentHistoryState) | ||
| : traversalIndexOverride; | ||
| const bfcacheIds = readHistoryStateBfcacheIds(currentHistoryState); | ||
| const bfcacheVersion = readHistoryStateBfcacheVersion(currentHistoryState); | ||
|
|
||
| if (previousNextUrl === null && traversalIndex === null && bfcacheIds === null) { | ||
| if ( | ||
| previousNextUrl === null && | ||
| traversalIndex === null && | ||
| bfcacheIds === null && | ||
| shallowUrl === undefined | ||
| ) { | ||
| return callerState; | ||
| } | ||
|
|
||
| return createHistoryStateWithNavigationMetadata(callerState, { | ||
| bfcacheIds, | ||
| bfcacheVersion: bfcacheIds === null ? undefined : bfcacheVersion, | ||
| previousNextUrl, | ||
| traversalIndex, | ||
| }); | ||
| const nextState = | ||
| createHistoryStateWithNavigationMetadata(callerState, { | ||
| bfcacheIds, | ||
| bfcacheVersion: bfcacheIds === null ? undefined : bfcacheVersion, | ||
| previousNextUrl, | ||
| traversalIndex, | ||
| }) ?? {}; | ||
| if (shallowUrl !== undefined) { | ||
| nextState[VINEXT_SHALLOW_URL_HISTORY_STATE_KEY] = shallowUrl; | ||
| } | ||
| return Object.keys(nextState).length > 0 ? nextState : null; | ||
| } | ||
|
|
||
| export function readHistoryStateShallowUrl(state: unknown): string | null { | ||
| const value = readHistoryStateRecord(state)?.[VINEXT_SHALLOW_URL_HISTORY_STATE_KEY]; | ||
| return typeof value === "string" ? value : null; | ||
| } | ||
|
|
||
| export function readHistoryStatePreviousNextUrl(state: unknown): string | null { | ||
|
|
||
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.
On a Chromium document loaded with URL userinfo, such as
http://user:pass@host/,window.location.hrefstrips the userinfo whiledocument.URLretains it (the existing#2614hydration regression covers this). Resolving a caller's relativepushState/replaceStateURL againstlocation.hrefand forwarding this credential-free absolutehrefto the controller makes the native history operation throwSecurityError, whereas passing the caller's relative URL succeeds. Use the absolute URL for the navigation snapshot only, while preserving the original URL for the native history write.Useful? React with 👍 / 👎.