Repository navigation
fix(router): superseded push no longer follows a stale redirect; client redirect loops stop at 20 hops - #1058
Merged
Merged
Conversation
…d client redirect loops at 20 hops (#1047) navigate() checked ServerRedirect before the aborted/superseded return, so a superseded push could follow its redirect over the newer navigation. The ServerRedirect branch now sits below that return, as in handlePopstate. Both paths now share followServerRedirect, which counts hops through _redirectHops and stops at 20 (the browser document redirect limit) with the error boundary and a single console error.
commit: |
This was referenced Oct 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two loose ends from #1052 (issue #1047), both in
packages/rangojs-router/src/browser/navigation-bridge.ts. A link click orrouter.push()that was superseded by a newer navigation could still follow its own server redirect and replace the page the user had moved on to; and two pages whose middleware redirect to each other made the client navigate back and forth forever, with no error and no end.Problem
/login), the router navigated to/loginand replaced B. Back/Forward (handlePopstate, fixed in fix(router): follow a server redirect on Back/Forward to a fetched entry (#1047) #1052) already checked aborted/superseded first; the push path (navigate) checkedServerRedirectfirst./redirect-loop/pingredirects to/redirect-loop/pongand back. The client re-navigated forever. Browsers stop a document redirect chain at about 20 hops with an error.Before vs. after
Consumer example
A misconfigured gate like this now surfaces as the error boundary (the dev boundary shows the message; production shows the generic title) instead of a tab that spins. No option changes the limit.
Semantics
replace/transitioncarried over (unchanged)console.error, no further navigation (new)router.push()after a stopped loopCode change
navigation-bridge.ts: innavigate's catch, theServerRedirectbranch moved below the aborted/superseded return. Both paths now call a localfollowServerRedirect(self, error, url, hops, { replace, transition })increateNavigationBridge, which validates the origin, stops atMAX_REDIRECT_HOPS = 20(comment: mirrors browsers) by logging once and callingemitNavigationErrorwith anErrornaming the URL and hop count, and otherwise callsself.navigate(target, { state, replace, transition, _skipCache: true, _redirectHops: hops + 1 }). A user-initiated navigation has no_redirectHopsand starts at 0; Back/Forward passes 0 and its re-navigation carries 1.types.ts:_redirectHops?: numberonNavigateOptionsInternal(internal flag next to_skipCache, no public option).selfisthisso test spies onbridge.navigatestill see the re-navigation. Net source change: +29 lines (61 added, 32 removed, most of it the moved branch).Test plan
src/__tests__/navigation-bridge-traversal-location.test.ts(5 new, 12 total in file):ServerRedirect; navigate called only for A and B. Red on origin/main:navigate.mock.callsis[a, b, "http://localhost/login"](:352,expected [ 'http://localhost/a', …(2) ] to deeply equal [ 'http://localhost/a', …(1) ]).redirect looplog naming20 redirects. Red on origin/main:expected "navigate" to be called 21 times, but got 51 times(the test stops the loop itself past 50 calls so it cannot hang).expected "navigate" to be called 20 times, but got 51 times._redirectHops: 1) the cross-origin block on the push path innavigation-bridge.test.ts:468, and e2eexpectLinkClickToRedirectingPageFollowsRedirect(push path, dev and production, both apps). No unit test covered a followed push redirect before; the new "single redirect" test does.expectRedirectLoopEndsInErrorBoundaryintests/shared-e2e/src/index.ts, used by newredirect-loop.test.tsin both apps (packages/rangojs-router/e2e,tests/cloudflare-basic/e2e), dev describe and(production)describe: link click into the loop, error boundary visible within 30s, exactly one[rango] Server redirect loop ... 20 redirectsconsole line. New fixture routes/redirect-loop,/redirect-loop/ping,/redirect-loop/pongin both apps;router.named-routes.gen.tsregenerated in both (the only*.gen.tsthat changed).test:unit:all, lint, format,check:e2e-bucketing,check:e2e-parity --strict,check:docs-api,check:bundle-guards: green. Client router chunk (cloudflare-basic) 47472 B gzip, limit 48128 B (47283 B on fix(router): follow a server redirect on Back/Forward to a fetched entry (#1047) #1052, +189 B).--no-deps, dev and production: router apppopstate-redirect,navigation-url-commit,hook-render-stability,auth-boundary,redirect-loop(51 passed); cloudflare-basic the same minusauth-boundary(26 passed). Theknown gap #1046cases innavigation-url-commitprint as expected failures in both modes and both apps.Notes
The e2e asserts the boundary with
/Server redirect loop|Internal Server Error/: the default boundary shows the error message in dev and a generic title in production.The stop emits the boundary for the URL at hop 20, and the address bar stays on the page the chain started from (nothing committed).
Not changed: the redirect target equal to the entry before the redirecting one leaves two identical adjacent entries (fix(router): follow a server redirect on Back/Forward to a fetched entry (#1047) #1052 note 3).
Refs fix(router): follow a server redirect on Back/Forward to a fetched entry (#1047) #1052 (notes 1 and 2), Back/Forward to an entry the server now redirects is not followed: "Unprocessable popstate response" and an error boundary #1047.
Review: read by the reviewer directly (reuse, simplification, efficiency, depth) given the size (about 30 net lines of source). The shared
followServerRedirectreplaces two copies of the same branch, which the fix(router): follow a server redirect on Back/Forward to a fetched entry (#1047) #1052 review had left as two sites; with the hop count to carry, one helper is now the simpler form. Second run on the final commit: gate green (router 7571 unit, 512 RSC);redirect-loop,popstate-redirect(andauth-boundaryin the router app),--no-deps: router app 12 passed in dev and 11 in production, cloudflare-basic 3 and 3.