fix(app-router): guard stale popstate scroll restoration#1253
Conversation
commit: |
The popstate race branch included server action commits from a separate branch. That made the PR review surface change progressive action error semantics unrelated to browser history restoration. Restore those files to main's behavior in this branch and keep only the popstate scroll restoration fix. Also correct the new popstate tests so targeted check and unit coverage pass.
The same-build visited payload E2E asserted cache replay after visible commit from a void router.push. The visited-response cache is populated when the internal RSC navigation promise resolves, so the test could navigate away before the cache entry existed and count a second fetch. Wrap the internal navigation function in the test and wait for the captured promise before asserting the replay path.
|
/bigbonk review |
There was a problem hiding this comment.
Clean extraction and correct fix. The stale-popstate scroll race is a real issue (Next.js handles it by routing popstate through the queued action reducer, which discards stale work), and the nav-id guard is the right equivalent for vinext's architecture.
The helper is well-factored: deps are explicit and testable, the unit tests cover both the "stale scroll suppression" and "stale pending cleanup" cases, and the E2E stabilization correctly addresses the cache-seeding timing gap.
One minor ordering observation below, but it's not blocking.
| deps.notifyAppRouterTransitionStart(window.location.href); | ||
| const navigate = deps.getNavigate(); | ||
| const pendingNavigation = navigate?.(window.location.href, 0, "traverse") ?? Promise.resolve(); | ||
| const popstateNavId = deps.getActiveNavigationId(); |
There was a problem hiding this comment.
Nit: The correctness of this line depends on navigate?.() calling beginNavigation() synchronously before returning the promise, so that getActiveNavigationId() on the next line reads the id that this popstate allocated. That's true today (navigateRsc is async and beginNavigation() is the first synchronous statement), but it's a subtle ordering contract between two files.
Consider adding a brief comment here noting the invariant, e.g.:
| const popstateNavId = deps.getActiveNavigationId(); | |
| // navigate() calls beginNavigation() synchronously before its first await, | |
| // so getActiveNavigationId() reads the id allocated by *this* popstate. | |
| const popstateNavId = deps.getActiveNavigationId(); |
This would help future readers understand why getActiveNavigationId is called after navigate rather than before.
| }, RSC_NAVIGATION_PROMISE_MARKER); | ||
| } | ||
|
|
||
| async function waitForLastRscNavigation(page: Page): Promise<void> { |
There was a problem hiding this comment.
Good stabilization. The waitForFunction + evaluate(await ...) two-step handles the case where the marker hasn't been set yet (first navigation hasn't started) and then the case where the promise hasn't settled yet (navigation in progress). Clean.
| const handler = createPopstateRestoreHandler({ | ||
| getActiveNavigationId: () => activeNavigationId, | ||
| getNavigate: () => { |
There was a problem hiding this comment.
The getNavigate mock mutates activeNavigationId to simulate beginNavigation() being called synchronously inside navigate. This is the right approach — it preserves the same ordering contract as production code where navigateRsc increments the active nav id synchronously before returning the promise. Well done making this explicit in the test harness.
|
Review submitted. Here's a summary of my findings: Approved -- the fix is correct and well-tested. What the PR does:
Key observations:
|
Summary
Details
app-browser-entry.tsnow delegates App Router popstate handling tocreatePopstateRestoreHandler.__VINEXT_RSC_PENDING__.Next.js parity notes
Next.js routes App Router popstate transitions through the queued navigation action path, where newer traverses can replace/discard stale work:
Validation
vp check packages/vinext/src/server/app-browser-entry.ts packages/vinext/src/server/app-browser-popstate.ts tests/app-browser-entry.test.ts tests/e2e/app-router/build-id-navigation.spec.tsvp test run tests/app-browser-entry.test.tsPLAYWRIGHT_PROJECT=app-router npx playwright test tests/e2e/app-router/build-id-navigation.spec.ts --project app-router --repeat-each=10PLAYWRIGHT_PROJECT=app-router npx playwright test --project app-routerNote: the local commit hook's full formatting check still trips on intentionally invalid upstream fixtures under
.refs/astroand.refs/sveltekit; the touched-filevp checkis clean.