mirror of
https://github.com/cloudflare/vinext.git
synced 2026-05-09 16:35:46 +02:00
[GH-ISSUE #918] Navigation Architecture #201
Labels
No labels
enhancement
enhancement
good first issue
help wanted
nextjs-tracking
nextjs-tracking
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
starred/vinext#201
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @NathanDrake2406 on GitHub (Apr 27, 2026).
Original GitHub issue: https://github.com/cloudflare/vinext/issues/918
I'm quite unhappy with #876, so I did some research on navigation architecture from other frameworks. Please let me know ur thoughts. Would be great if we could ask bonk for an adversarial review as well :)
Context
Recent App Router fixes moved us in the right direction:
isSameRouteraces during rapid navigationisPendingalive for programmaticrouter.push/replace/refreshThe recurring smell is that we are solving each race locally:
activeNavigationIdchecks, pending promise settlement, popstate handling, same-URL server action commits, cache seeding, and hard-navigation recovery are spread across the browser entry and navigation shim.I think the deeper primitive should be:
Existing Vinext Prior Work
#690 is important context because it was already a major App Router navigation rework, not just a bug patch. It introduced the two-phase navigation model: same-route changes can stay inside
startTransition, while cross-route changes use synchronous updates to avoid the Firefox scheduler hang. It also moved URL/history commit into a layout-effect lifecycle, added navigation IDs for stale bailouts, added render snapshots for hook consistency, and added visited RSC response caching. That PR shows the shape of the real problem: visible route commits span React scheduling, URL/history effects, snapshots, cache state, and stale async work.#745 is the smaller version of the same lesson. Because #690 defers URL commits,
window.location.pathnamecan be stale during rapid A → B → C navigations. #745 fixed that by making the pending destination explicit viapendingPathname, withnavIdownership so superseded navigations cannot clear the active navigation's pending state. That is already a controller-like idea: represent the in-flight intent directly, and only let the owner settle it.So I do not think the proposal below should undo #690 or #745. It should consolidate their lessons into one lifecycle owner instead of adding one more local guard for every new race.
Code Reality Check
After reading the current navigation code, I think this proposal is realistic because the primitives already exist; they are just not owned by one boundary yet.
Current operation identity already exists as
activeNavigationIdplus pending browser-router state inapp-browser-entry.ts. Programmatic pending state is published throughPendingBrowserRouterState,beginPendingBrowserRouterState(),settlePendingBrowserRouterState(), andresolvePendingBrowserRouterState(). That is already an operation lifecycle; it just is not named as one.The candidate-commit seam already exists in
app-browser-state.ts.createPendingNavigationCommit()builds a commit-ready router action, andresolvePendingNavigationCommitDisposition()classifies it asdispatch,hard-navigate, orskip. That maps very closely to the proposed rule: async work produces a candidate result, then lifecycle logic decides whether it may commit.#745's
pendingPathnameis already explicit pending intent innavigation.ts. It exists because committedwindow.locationcan intentionally lag behind the navigation being rendered. That is the same philosophy as a controller: visible intent needs a durable owner instead of re-reading incidental browser state.Prefetch is already close to the proposed cache-only lane.
prefetchRscResponse()snapshots RSC responses into cache, whileconsumePrefetchResponse()only hands compatible settled snapshots to a later navigation; prefetch itself does not commit visible UI. The proposal should preserve that shape and make it explicit.The back/forward gap is also visible in the code today.
router.back()androuter.forward()currently callwindow.history.back()/window.history.forward()directly, and the App Routerpopstatelistener starts the"traverse"RSC navigation after the browser event. That means there is no traversal intent before the synchronous history call, which explains why #876 needed extra machinery to keepisPendinglatched.The server-action gap is explicitly documented in
commitSameUrlNavigatePayload():activeNavigationIdis not strong enough if a same-URL navigation fully commits while a server action is awaiting its pending commit. That is the strongest evidence for addingvisibleCommitVersionrather than continuing to patch aroundactiveNavigationId.So the implementation direction should not be “replace the navigation system.” It should be “make the existing architecture explicit”: wrap the current operation ID, pending intent, candidate commit, pending promise, snapshot lifecycle, and cache-only prefetch behavior behind one lifecycle owner.
Philosophy
The visible route should have one commit authority.
A late RSC response resolving is not inherently wrong. A late server action resolving is not inherently wrong. A late prefetch resolving is not wrong. The bug is letting resolution imply authority to mutate visible state.
So the core invariant should be:
Abort is useful, but abort is not correctness. React Router's concurrency docs call out the same underlying web-platform reality: canceling a browser request releases client resources, but the request may still reach the server, so stale work still needs commit/revalidation rules rather than relying on abort alone (React Router: Network Concurrency Management).
Prior Art
React Router / Remix have the cleanest product semantics: latest navigation wins, interrupted requests are cancelled, and stale revalidation results are discarded. Their docs explicitly frame this as browser-like concurrency management: the latest link click or form submission takes priority, while older work is cancelled or prevented from committing stale data.
Next.js App Router has the RSC-specific lesson: centralize actions in a queue, mark superseded actions as discarded, and let navigation/restore actions preempt pending work. Its server action reducer also records when a server action revalidated data so the queue can trigger a refresh if that action was discarded instead of applying stale state.
TanStack Router separates normal loads from preloads and gives preloads separate freshness/cache behavior. That maps well to our “prefetch seeds cache only” rule.
Angular and Vue Router both expose typed cancellation/failure reasons such as superseded/cancelled/aborted. That suggests our internal lifecycle should use explicit terminal states rather than boolean flags and early returns.
SvelteKit's navigation APIs are also useful prior art: it exposes navigation lifecycle hooks (
beforeNavigate,afterNavigate,onNavigate) plus explicit invalidation/preload/refresh APIs, which reinforces that navigation, preloading, invalidation, and refresh should be named lifecycle concepts rather than incidental branches.Proposed Model
Do not replace the current App Router navigation architecture. Extract and centralize the lifecycle it already has.
The first version of the controller should wrap existing primitives rather than replacing them:
ClientNavigationStateas the hook/external-store layerrouterReducer()andAppRouterActionas the tree update mechanismcreatePendingNavigationCommit()/resolvePendingNavigationCommitDisposition()as the candidate-commit seamThe controller should understand lanes as policy labels, not necessarily separate queues or classes on day one:
visible: push, replace, link navigation, redirect continuationtraverse: browser back/forwardrefresh: same-URL visible revalidationaction: server action POST plus optional RSC patch/redirect/revalidationprefetch: background cache fill onlyrecovery: hydration/HMR/hard-navigation recoveryIt should expose terminal states as real data:
committedsupersededabortedfailedhard-navigatedcache-seededrefresh-scheduledThe important rules:
visibleCommitVersion, not justactiveNavigationId.That last point matters because URL-based or navigation-id-only checks are too weak. A same-URL server action/refresh can change visible route state without changing the URL. If an older action resumes after that, it needs to know its base visible commit is stale.
Back/Forward
router.back()androuter.forward()are the awkward case because the call is synchronous and thepopstatearrives later.#876 found the real requirement: to keep
useTransition().isPendingalive, we need to arm pending state inside the caller’s transition before callinghistory.back/forward.But that should be modeled as a traversal intent, not a global FIFO queue in the shim.
The browser's newer Navigation API gives us some useful information here: it centralizes navigation/history handling and exposes current/nearby history entry details like
currentEntry,entries(),canGoBack, andcanGoForward. Where that API can prove the traversal is possible and same-document, we can safely arm pending. Where it cannot, we should degrade deliberately rather than guessing.Suggested shape:
router.back/forwardasks the lifecycle controller to create a traversal intent.popstate, match the browser event/current entry to the traversal intent.What To Salvage From #876
Keep:
Do not keep as architecture:
navId !== activeNavigationIdchecks as the main modelprogrammaticTransitionas a domain conceptAcceptance Criteria
This issue is solved when these behaviors are structurally true, not just patched case-by-case:
Review Questions
visibleCommitVersionbe the primitive for same-URL/server-action races?createPendingNavigationCommit()/resolvePendingNavigationCommitDisposition()) rather than replacing the reducer/snapshot machinery?@james-elicx commented on GitHub (Apr 27, 2026):
I think it might make sense to get #726 over the line before we do another significant re-work. Do you know what the status of that one was? From what I recall, I left some comments about the approach on one of the PRs and it was stopped part-way through? Is there a plan to finish that work before we look at this?
@NathanDrake2406 commented on GitHub (Apr 27, 2026):
Yes, I’m unhappy with that too. I have a bigger plan that I’m working on right now (researching with GPT 5.5 Pro!)
At a high level, segment persistence is fundamentally a tree problem, so a tree-based architecture would be a better fit than a flat map. I should have followed Next there.
The skip-header PR would have committed us further to the flat-map structure so it was a very good thing you blocked it!
I also have something important happening tomorrow, mostly thanks to my contributions here and you merging them. If it works out, it should let me contribute much more seriously. I’ll let you know if there’s good news.