Skip to content

fix(app-router): match current navigation identity - #2876

Open
james-elicx wants to merge 5 commits into
mainfrom
codex/fix-app-basic-navigation-current
Open

fix(app-router): match current navigation identity#2876
james-elicx wants to merge 5 commits into
mainfrom
codex/fix-app-basic-navigation-current

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

Fix the three non-cache test/e2e/app-dir/app/index.test.ts failures recorded by deploy-suite run 31439707085 / test report job 93624401572. The archived report recorded 100 passed, 3 failed, and 5 skipped for this suite.

Failure mapping:

  • <Link /> > should soft push: a new push after back could replay the departed visited-response snapshot instead of fetching and committing the current server output. Departed snapshots are now demoted after history restoration.
  • <Link /> > should soft replace: an exact-current Link could reuse the response that produced the page already on screen. Exact raw URL identity now refreshes page segments without adding history, including basePath, hash, and encoded-query cases.
  • template component > should render the template that is a server component and rerender on navigation: server template output followed child remount identity, replacing the template seed during child navigation. Template seed identity is now derived from parameters owned by the template boundary while child template state still remounts.

The navigation planner now makes browser-space versus already app-relative snapshot URLs explicit, preserves raw query spelling (%20 versus +), handles basePath prefix collisions, refreshes an unchanged hash, and treats a changed or removed hash as same-document navigation.

Validation

  • Next.js v16.2.6 (ee6e79b1792a4d401ddf2480f40a83549fe8e722):
    • REPO="$(pwd)" NEXTJS_DIR="/Users/jamesanderson/Developer/vinext/.nextjs-ref" ./scripts/run-targeted-nextjs-e2e.sh test/e2e/app-dir/app/index.test.ts
    • 103 passed, 5 skipped, 0 failed
  • 1,538 core unit assertions passed across the navigation shim, planner intent, and browser entry suites; 122 additional planner/prefetch assertions passed.
  • 5 focused App Router browser regressions passed.
  • 5 fresh-production basePath/hash regressions passed, including zero RSC requests on hash removal and exact %20 identity.
  • Focused format, lint, type checks, and git diff --check passed.
  • Four independent reviews completed; the final review reported NO FINDINGS.

This PR intentionally targets only the three non-cache failures above.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Aug 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2876
npm i https://pkg.pr.new/create-vinext-app@2876
npm i https://pkg.pr.new/@vinext/types@2876
npm i https://pkg.pr.new/vinext@2876

commit: a0f53c6

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared a0f53c6 against base d558d62 using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 1 regressed · 4 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 135.2 KB 135.8 KB ⚫ +0.5%
Client entry size (gzip) vinext 122.7 KB 123.3 KB ⚫ +0.5%
Dev server cold start vinext 2.61 s 2.69 s 🔴 +3.0%
Production build time vinext 2.66 s 2.61 s 🟢 -1.9%
RSC entry closure size (gzip) vinext 114.2 KB 114.2 KB ⚫ +0.0%
Server bundle size (gzip) vinext 193.0 KB 193.1 KB ⚫ +0.1%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(app-router): match current navigation identity

Reviewed all 18 changed files, traced the runtime seams end-to-end, and re-ran the affected unit suites (navigation-planner-early-intent, shims, app-browser-entry, prefetch-cache — 1615+313 assertions passing) plus vp check on the five modified source files (format/lint/types clean).

Verdict: NO BLOCKING ISSUES — approve

The three fixes are correct, well-scoped, and match Next.js behavior. Details below.

1. Soft push — departed-snapshot demotion (app-browser-entry.ts, navigation.ts)

restoreHistoryStateSnapshot now advances clientNavigationCacheGeneration before clearVisitedResponseCache() + disableNavigationResponsePrefetchCacheReuse(). Verified this ordering is load-bearing: both async publication sites (app-browser-entry.ts:2326 and :2350) gate on navigationCacheGeneration !== clientNavigationCacheGeneration and bail, so an in-flight storeVisitedResponseSnapshot that captured the old generation cannot repopulate the departed response after the maps are cleared. The comment accurately documents this.

disableNavigationResponsePrefetchCacheReuse keys off prefetchKind === undefined, which correctly targets only visited-response snapshots seeded via seedPrefetchResponseSnapshot (navigation.ts:1133, no prefetchKind) while leaving explicit Link/router prefetches (prefetchKind defaults to "navigation"/"loading-shell"/"route-tree") consumable. Setting cacheForNavigation = false causes consumePrefetchResponse/findPrefetchCacheEntryForNavigation (navigation.ts:1586, :1644) to skip the entry, while the separate optimisticRouteTemplateSources set (app-browser-entry.ts:241) still exposes it as an optimistic template — consistent with the docstring. The link.tsx cacheForNavigation reads are on the prefetch policy object, not cache entries, so demotion does not interfere with shell prefetch logic.

2. Soft replace + basePath/hash/query identity (navigation-planner.ts, navigation.ts)

The new currentUrlSpace discriminator cleanly fixes the basePath double-strip: an app-relative snapshot pathname is never re-stripped, so an app route whose first segment equals basePath (fixture app/docs/foo under basePath /docs) keeps its real segment. The appRelativeSnapshot vs browser split is applied asymmetrically and correctly (current side respects the flag; target side is always browser-space).

The exact-identity branch uses raw current.search === next.search && current.hash === next.hashsamePageRefresh (flight, bypass cache), which correctly diverges from the parsed sameSearch used for hash/search classification. This preserves %20 vs + as distinct spellings for exact identity (covered by the "keeps %20 and + as distinct raw spellings" test), while equivalent encodings with an unchanged hash fall through to an ordinary flight — the intended semantics. Adding search to ClientNavigationRenderSnapshot and simplifying createSnapshotPathAndSearch to pathname + search is the right way to preserve raw spelling that ReadonlyURLSearchParams would canonicalize away.

Hash removal now classifies as sameDocumentScroll with hash: ""; verified scrollToHashTarget("") scrolls to top (hash-scroll.ts:14), matching browser same-document behavior for #foo → no-hash. Good parity fix.

3. Server template seed identity (app-browser-visible-commit.ts)

hasSameServerTemplateSeedIdentity compares resolveAppPageRouteStateKey over the template's own treePath segments (treePath.split("/").filter(Boolean) — same idiom as app-page-element-builder.ts:767) rather than child-remount identity. This preserves the server template's rendered seed across child navigation while still remounting child template state, and the lane gate (navigation/traverse/server-action, requiring reuseCurrentBfcacheIds) correctly excludes refresh/HMR so a changed owner param still installs fresh output. navigationSnapshot is non-optional on both state and action, so no null-safety gap.

Tests & tooling

  • E2E specs are properly ported from test/e2e/app-dir/app/index.test.ts with source-linked comments (follows AGENTS.md). Fixtures use crypto.randomUUID() render IDs to assert fresh-vs-replayed output — a good signal.
  • playwright.config.ts: gating the basepath-i18n server behind VINEXT_BASEPATH_E2E_BASE_URL plus the server != null (loose) filter correctly tolerates the new undefined. Minor nit (non-blocking): when the env var is set, use.baseURL still hardcodes http://localhost:4196; if the intent is to point at an external server the baseURL won't follow. Fine if the override is only meant to reuse a locally-started server on the same port.

All required checks (Check, Vitest, Playwright E2E) are green.

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2876.

Summary

I reviewed all 18 changed files, traced the runtime logic end-to-end, ran the affected unit suites (all passing), and ran vp check on the modified source (clean). Verdict: no blocking issues.

The PR correctly fixes three App Router navigation-identity bugs:

  1. Soft pushrestoreHistoryStateSnapshot advances the cache generation before clearing caches, and disableNavigationResponsePrefetchCacheReuse demotes only visited-response snapshots (prefetchKind === undefined), keeping explicit prefetches consumable. I verified the generation-ordering is load-bearing against the two async publication sites that gate on it.

  2. Soft replace / basePath / hash / query identity — the new currentUrlSpace discriminator fixes the basePath double-strip cleanly; raw search/hash equality drives exact-identity refresh while parsed equality drives hash/search classification (correctly keeping %20 vs + distinct); hash removal now scrolls-to-top as same-document, matching browser/Next.js behavior.

  3. Server template seed — identity is derived from the template's own treePath params (same idiom used elsewhere in the codebase), preserving the server seed across child navigation while still remounting child state; lane gating correctly excludes refresh/HMR.

Tests are properly ported from the corresponding Next.js e2e file with source links. I flagged one non-blocking nit: when VINEXT_BASEPATH_E2E_BASE_URL is set in playwright.config.ts, use.baseURL still hardcodes localhost:4196, so an external server wouldn't be targeted — fine if the override is only meant to reuse a same-port local server.

All required CI checks are green.

github run

@james-elicx
james-elicx marked this pull request as ready for review August 11, 2026 00:36
@james-elicx

Copy link
Copy Markdown
Member Author

Independent targeted Next.js E2E verification on immutable head de38f7f84314a7e110b995d39db7ba8d1d97b6c3:

  • Suite: test/e2e/app-dir/app/index.test.ts
  • Command: required scripts/run-targeted-nextjs-e2e.sh wrapper
  • Result: PASS — 103 passed, 5 skipped, 0 failed (108 total)
  • Retries: 0/0

The checkout remained clean and the local/remote PR head SHA matched after the run.

@james-elicx

Copy link
Copy Markdown
Member Author

Independent full-diff review of immutable head de38f7f84314a7e110b995d39db7ba8d1d97b6c3: NO FINDINGS.

I reviewed all 18 changed files independently of Big Bonk, covering App Router soft push/replace identity, history restoration, navigation/prefetch cache ownership, basePath and raw query/hash handling, server-template seed preservation, and the ported browser/unit coverage.

Correctness/parity checks:

  • history restore advances the publication generation before clearing visited responses, so an in-flight departed response cannot repopulate the cache;
  • only completed navigation responses are demoted; explicit Link/router prefetches remain reusable;
  • browser-space targets and already app-relative snapshots are distinguished, preventing basePath double stripping while retaining same-origin gating;
  • raw search spelling controls exact identity, while parsed search equality controls hash-only navigation, preserving %20 versus +;
  • hash removal stays same-document and scrolls to the document top without an RSC request;
  • template seed identity is derived from parameters owned by the template tree prefix, while template bfcache identity still changes to remount client state; refresh/HMR and owner-param changes install fresh output.

Independent validation on this exact SHA:

  • focused unit suites: 1615/1615 passed
  • App Router soft-navigation/template Playwright: 5/5 passed
  • production basePath/hash/query Playwright: 5/5 passed
  • previously posted exact Next.js test/e2e/app-dir/app/index.test.ts proof: 103 passed, 5 skipped, 0 failed, retry 0/0 — fix(app-router): match current navigation identity #2876 (comment)
  • scoped format/lint/types and git diff --check: clean

The local and remote heads still match, the worktree is clean, all checks are green, and the PR is mergeable.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review: fix(app-router): match current navigation identity

Reviewed all 20 changed files, traced the three fixes end-to-end, and re-ran the affected unit suites (navigation-planner-early-intent, app-visited-response-cache, app-browser-entry, prefetch-cache, shims — 1631 assertions passing) plus tsc --noEmit on the package (clean).

Verdict: no blocking issues

The three navigation-identity fixes are correct and well-scoped. Notes below, plus the one non-blocking nit already surfaced.

1. Soft push — history-restore demotion (app-browser-entry.ts, navigation.ts, app-visited-response-cache.ts)

restoreHistoryStateSnapshot advances clientNavigationCacheGeneration before deleteInvalidatedHistoryRestoreEntries + disableNavigationResponsePrefetchCacheReuse. I confirmed the ordering is load-bearing: every async publication site (:2294/:2296/:2335/:2360) reads navigationCacheGeneration up front and re-checks it immediately before each storeVisitedResponseSnapshot, with no await between the guard and the .set(), so an in-flight departed-response publication cannot repopulate after the caches are pruned. Good.

The two caches stay consistent: storeVisitedResponseSnapshot threads the same reuseAfterHistoryRestore flag into both createVisitedResponseCacheEntry (visited) and seedPrefetchResponseSnapshot (prefetch). Visited retention keys on reuseAfterHistoryRestore; prefetch demotion additionally spares prefetchKind !== undefined (explicit Link/router prefetches), which never live in the visited cache — so no divergence. hasNavigationResponseHistoryLifetime (dynamic stale > 0, or a resolved serverStaleTime) correctly licenses segment-cache reuse, and initial hydration only marks the seed reusable for static/intercepted responses.

2. Soft replace + basePath/hash/query identity (navigation-planner.ts, navigation.ts)

The currentUrlSpace discriminator cleanly fixes the basePath double-strip: an appRelativeSnapshot pathname is never re-stripped, so a route whose first segment equals basePath (fixture app/docs/foo under basePath /docs) keeps its real segment; the target side is always browser-space. The current side is app-relative only in bootstrapHydration (:1781); navigateClientSide passes "browser".

The exact-identity branch uses raw current.search === next.search && current.hash === next.hashsamePageRefresh (bypass cache → forced refetch, verified via planner :761/:768), correctly distinct from the parsed sameSearch that drives hash classification. This keeps %20 vs + distinct for exact identity while equivalent encodings with an unchanged hash fall through to an ordinary flight. Hash removal now classifies as sameDocumentScroll with hash: "" (scroll-to-top), matching browser/Next.js same-document behavior.

One behavior worth calling out (not a regression): equivalent-but-differently-spelled search with no hash change (e.g. ?q=%20?q=+) still falls through to a non-bypass crossDocumentFlight rather than a same-page refresh. This matched pre-PR behavior, and the "keeps %20 and + as distinct" test asserts exactly this, so it's intentional.

3. Server template seed identity (app-browser-visible-commit.ts)

hasSameServerTemplateSeedIdentity compares resolveAppPageRouteStateKey over the template's own treePath segments (parseElementKeytreePath.split("/").filter(Boolean), the same idiom used elsewhere) rather than child-remount identity. This preserves the server seed across child navigation while still remounting child state. The lane gate (navigation/traverse/server-action + reuseCurrentBfcacheIds) correctly excludes refresh/HMR, and a changed owner param installs fresh output — both covered by the new unit tests. navigationSnapshot is non-optional on state and action, so no null-safety gap.

Cross-cutting

createSnapshotPathAndSearch now returns pathname + snapshot.search (raw spelling) everywhere it is used — history displayUrl, hard-navigation targets, early-intent href — not just the exact-identity comparison. This is a wider change than the failing tests strictly required, but it is more correct (preserves the raw URL-bar spelling that ReadonlyURLSearchParams.toString() would canonicalize) and search is a required field on the single snapshot factory, so all sites are covered.

Tests & tooling

  • E2E specs are properly ported from test/e2e/app-dir/app/index.test.ts with source-linked comments; fixtures use crypto.randomUUID() render IDs to distinguish fresh vs replayed output — a good signal for these bugs.
  • Unit coverage is thorough for both the positive and negative paths of each fix.

Non-blocking nit

playwright.config.ts: when VINEXT_BASEPATH_E2E_BASE_URL is set the managed server is skipped, but use.baseURL still hardcodes http://localhost:4196, so pointing at an external server wouldn't take effect. Fine if the override is only meant to reuse a same-port local server. The server != null filter change correctly tolerates the new undefined.

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2876.

Summary

I reviewed all 20 changed files, traced the three fixes end-to-end, and re-ran the affected unit suites (1631 assertions, all passing) plus tsc --noEmit (clean). Verdict: no blocking issues.

The PR correctly fixes three App Router navigation-identity bugs:

  1. Soft pushrestoreHistoryStateSnapshot advances the cache generation before pruning caches; I verified the generation guard is re-checked immediately before each storeVisitedResponseSnapshot with no await in between, so an in-flight departed-response publication can't repopulate. The visited and prefetch caches stay consistent via a shared reuseAfterHistoryRestore flag.

  2. Soft replace / basePath / hash / query — the currentUrlSpace discriminator cleanly fixes the basePath double-strip; raw search/hash equality drives exact-identity refresh (bypass cache → forced refetch) while parsed equality drives hash classification, correctly keeping %20 vs + distinct. Hash removal now scrolls to top as same-document.

  3. Server template seed — identity is derived from the template's own treePath params, preserving the server seed across child navigation while remounting child state; lane gating excludes refresh/HMR. Both positive and negative paths are unit-tested.

I noted two non-regression items and one non-blocking nit:

  • createSnapshotPathAndSearch now preserves raw search spelling everywhere (wider than strictly required, but more correct).
  • Equivalent-but-differently-spelled search with no hash change still falls through to a non-bypass flight — matches pre-PR behavior and is asserted by a test.
  • Nit (already surfaced): when VINEXT_BASEPATH_E2E_BASE_URL is set in playwright.config.ts, use.baseURL still hardcodes localhost:4196.

github run

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.

1 participant