fix(app-router): clear useLinkStatus pending after interrupted navigation (#1527)#1839
Conversation
The Safari coalescing fallback in syncCurrentHistoryStatePreviousNextUrl called the patched window.history.replaceState, which fires notifyLinkNavigationStart + commitClientNavigationState. On a URL-unchanged metadata sync (refresh/traversal commit) that could clear an unrelated <Link>'s useLinkStatus() pending state a few ms early on Safari. Route the retry through replaceHistoryStateWithoutNotify so the browser sees the same native replaceState without the patched-path side effects (#1527).
|
/bigbonk review for issues |
commit: |
|
There was a problem hiding this comment.
Review: clear useLinkStatus pending after interrupted navigation
Reviewed the registry, the navigateClientSide/history/popstate wiring, the <Link> integration, and the Safari replaceState hardening. The fix is well-scoped and the design is sound. Ran tests/link-status-registry.test.ts (6/6) and tests/link.test.ts (117/117) — all green. vp check passes on the new files.
What's correct and worth calling out
-
Marker consumption relies on synchronous
startTransition. Inlink.tsxthe sequence issetLinkForCurrentNavigation(setter)→setPending(true)→startTransition(() => navigateClientSide(...)), andnavigateClientSideconsumes the marker as its very first synchronous statement (navigation.ts:1685). Because React runs thestartTransitioncallback synchronously andnavigateClientSideruns to its firstawaitsynchronously, the link-initiated marker is always set and consumed within the same click handler turn. I verified the tricky rapid-double-click-on-same-link case stays correct because each click re-sets the marker before its ownnotifyLinkNavigationStartconsumes it. Good — but this is load-bearing and undocumented; see the suggestion below. -
App Router history commits won't prematurely clear the navigating link. The commit path in
app-browser-entry.ts:373-375usespushHistoryStateWithoutNotify/replaceHistoryStateWithoutNotify, so committing a<Link>navigation does not firenotifyLinkNavigationStart. The patchedpushState/replaceStateonly fire the hook whenstate.suppressUrlNotifyCount === 0, i.e. genuine shallow routing. -
Safari
replaceStatefallback fix is consistent. Routing the URL-unchanged metadata sync throughreplaceHistoryStateWithoutNotify(app-browser-entry.ts:640) matches the first-attempt path at line 629 and avoids the patched side effects. Correct. -
Unmount safety.
clearLinkForCurrentNavigationon unmount + themountedRefguard inside the stable setter mean a stale navigation never callssetStateon an unmounted<Link>.
Suggestions (non-blocking)
-
PR body claims form submit is covered, but there's no direct path/test for it. The description lists "a form submit" as an interrupting navigation that clears pending. Server-action/form navigations only clear pending if they ultimately route through
navigateClientSideor the patchedpushState/replaceState. If forms commit history some other way, this case isn't actually covered. Worth confirming the form-submit path hits one of these three hooks, and ideally adding a registry-level test mirroring the upstream form-submit scenario (the PR ports the multi-click/router.push/shallow/back cases but not form submit). -
Document the synchronous-
startTransitiondependency. The whole "keep link pending vs. reset" distinction hinges onsetLinkForCurrentNavigationand the consumingnotifyLinkNavigationStartrunning in the same synchronous turn. A one-line comment nearsetLinkForCurrentNavigation(setter)inlink.tsx:980noting thatnavigateClientSidemust consume the marker synchronously (and why) would protect this invariant against a future refactor that defers the navigate call. -
Two separate
popstatelisteners.navigation.tsnow registers a dedicatednotifyLinkNavigationStartpopstate listener (2571) right before the existing Pages-Router scroll-restore listener (2579). Functionally fine since both are guarded bypatchInstalled, but folding the call into the existing listener (or a comment explaining why it's intentionally separate) would reduce the chance someone later removes the "duplicate."
Parity note
Verified the four client entry points: the hook is fired from navigateClientSide (covers App Router programmatic + Link), patched pushState/replaceState (shallow routing), and popstate (back/forward). The registry is client-only and guarded by typeof window, so the RSC/SSR separate-module-graph gotcha doesn't apply. No prod-server/worker-entry changes needed since this is all browser-side runtime.
Overall: solid, behavior matches the Next.js linkForMostRecentNavigation model. The only thing I'd want resolved before merge is confirming the form-submit claim (suggestion 1).
|
Review posted to PR #1839. SummaryI reviewed the The fix is sound. Key findings:
Non-blocking suggestions raised:
The only item I'd want resolved before merge is confirming the form-submit claim. |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: clear useLinkStatus pending after interrupted navigation
Independent re-review of the registry, the four navigation hook sites, the <Link> integration, and the Safari replaceState hardening. I traced every entry point, reasoned through the interrupted-navigation orderings, and ran the tests (tests/link-status-registry.test.ts 6/6, tests/link.test.ts 117/117, vp check clean on the new files).
The fix is correct and ready to merge. No blocking issues.
Verified correctness
-
Marker can never get stuck
true.setLinkForCurrentNavigation(setter)only runs inside theif (getNavigationRuntime()?.functions.navigate)block inlink.tsx:980, immediately followed bystartTransition(() => navigateClientSide(...)).navigateClientSidefiresnotifyLinkNavigationStart()as its first synchronous statement (navigation.ts:1685), before any early-return path (external/hash). SinceReact.startTransitionruns its callback synchronously up to the firstawait, the link-initiated marker is always set and consumed in the same click turn. There is no code path that registers a link without then callingnavigateClientSide. -
Interrupting-navigation orderings hold. Walked through Link A pending → Link B interrupts:
setLinkForCurrentNavigation(B)resets A via the identity check (A !== B), B stays pending. When A's original promise later settles, A's.finally()callsclearLinkForCurrentNavigation(A), which no-ops because the tracker is now B (linkSetterForMostRecentNavigation === setteris false). Both B-settles-first and A-settles-first orderings are correct. The identity guard inclearLinkForCurrentNavigationis what makes stale completions safe. -
Faithful adaptation of Next.js
links.ts. Compared against upstreamsetLinkForCurrentNavigation/linkForMostRecentNavigation/unmountLinkForCurrentNavigation. vinext correctly splits the single upstream function into the link-path setter plus a universalnotifyLinkNavigationStart()(thelink === nullcase), bridged by thecurrentNavigationIsLinkInitiatedmarker. Behaviorally equivalent. vinext resets the previous link outside a transition (upstream wraps instartTransition); harmless for an idle/urgent state update. -
Form submit IS covered (resolves the open question from the prior review).
next/formGET navigation routes throughnavigateClientSide(form.tsx:453), which firesnotifyLinkNavigationStart(). So a form-submit interrupting a pending<Link>clears it. POST/server-action forms don't client-navigate, matching Next.js. The PR-body claim is accurate; the only gap is a missing dedicated registry test for it (non-blocking). -
No double-clear / no premature clear. App Router history commits use
pushHistoryStateWithoutNotify/replaceHistoryStateWithoutNotify(app-browser-entry.ts:373-375), so committing a<Link>navigation never re-fires the hook. Back/forward in App Router goes throughhandlePopstate→ the runtimenavigate(its own impl, notnavigateClientSide), sonotifyLinkNavigationStartfires once from thenavigation.tspopstate listener. The function is idempotent for the clearing case regardless. -
Safari fallback is consistent. Both attempts in
syncCurrentHistoryStatePreviousNextUrlnow usereplaceHistoryStateWithoutNotify(lines 629 and 640), avoiding the patched-path side effects on a URL-unchanged metadata sync. Correct. -
Unmount safety.
clearLinkForCurrentNavigation(setter)on unmount + themountedRefguard inside the stablesetPendingRefsetter prevent callingsetStateon an unmounted<Link>. The registry test confirms a dropped setter is never invoked.
Suggestions (non-blocking, can land separately)
- Add a registry-level form-submit test. The path is covered at runtime, but the test suite ports multi-click / router.push / shallow / back and not form submit. A 7th test mirroring the upstream form-submit scenario would lock in the claim.
- Document the synchronous-
startTransitioninvariant. A one-line comment nearsetLinkForCurrentNavigation(setter)inlink.tsx:980noting thatnavigateClientSidemust consume the marker synchronously (and why) would protect this load-bearing assumption against a future refactor that defers the navigate call. - Two
popstatelisteners innavigation.ts(2571 and 2579). Functionally fine — both under thepatchInstalledguard. Folding or a "intentionally separate" comment would reduce the chance someone deletes the apparent duplicate.
Overall: well-scoped, behavior matches the Next.js linkForMostRecentNavigation model, registry is client-only (typeof window guarded) so the RSC/SSR module-graph gotcha doesn't apply. LGTM.
|
Review posted to PR #1839. SummaryI independently re-reviewed the The fix is correct and ready to merge — no blocking issues. Key verifications:
Non-blocking suggestions: add a registry-level form-submit test, document the load-bearing synchronous- |
Problem
useLinkStatus()is expected to return{ pending: false }once a navigation settles. In vinext the pending flag stayed sticky (pending: 1) when a navigation was interrupted by a different navigation than the one the<Link>started — a second link click, a programmaticrouter.push/router.replace, a form submit, shallow routing via rawhistory.pushState, or browser back/forward. The<Link>'s own completion handler was the only thing that cleared its pending state, so an interrupting navigation left it stuck.Surfaced by the Next.js Deploy Suite:
test/e2e/use-link-status/index.test.ts(~3 failures). Part of #1328.Fix
Introduce a small link-status pending registry (
packages/vinext/src/shims/internal/link-status-registry.ts) that tracks the single<Link>driving the most recent App Router navigation, mirroring Next.js'slinkForMostRecentNavigation/setLinkForCurrentNavigationadapted to vinext's per-<Link>React state model.<Link>registers its (mount-guarded)setPendingsetter viasetLinkForCurrentNavigation, marking the navigation as link-initiated.navigateClientSidecallsnotifyLinkNavigationStart()at the start of every App Router navigation. A link-initiated navigation consumes its marker and stays pending; any other navigation resets the previously-tracked link to idle.history.pushState/replaceStateand apopstatelistener so shallow routing and back/forward clear stale pending too.navigation-runtime.ts(notifyLinkNavigationStart) to avoid a circular import betweenshims/link.tsxandshims/navigation.ts. Links drop their setter from the registry on unmount, so a later navigation never calls into an unmounted component.Also hardened the Safari
replaceStatecoalescing fallback inapp-browser-entry.ts(syncCurrentHistoryStatePreviousNextUrl): it called the patchedwindow.history.replaceState, which firesnotifyLinkNavigationStart+commitClientNavigationState. On a URL-unchanged metadata sync that could clear an unrelated<Link>'s pending state a few ms early on Safari. The retry now goes throughreplaceHistoryStateWithoutNotify, so the browser sees the same nativereplaceStatewithout the patched-path side effects.Tests
tests/link-status-registry.test.ts(6 tests) reproduces the upstream scenarios at the registry level: link-initiated navigation stays pending; multi-click clears the previous link;router.push, shallow routing, and back clear sticky pending; unmounted/cleared links are never called; and unrelated pending links are left untouched.Closes #1527