Repository navigation
fix(router): follow a server redirect on Back/Forward to a fetched entry (#1047) - #1052
Merged
Merged
Conversation
…try (#1047) handlePopstate's catch had no ServerRedirect branch, so a refetched traversal the server answered with a redirect rendered the root error boundary and left the address bar on the redirecting URL. It now navigates to the target like the push path does, with replace: true.
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.
Closes #1047
Problem
Back or Forward to a history entry that has to be fetched (it left the client's 20-entry history cache) used to show the root error boundary ("Internal Server Error") when the server now answered with a redirect, for example route middleware sending a signed-out visitor to the login page. The console logged
[Browser] Unprocessable popstate response: ServerRedirectand the address bar stayed on the protected URL. A link click orrouter.push()to the same page followed the redirect.Before vs. after
Consumer example
Sign in, open
/account, browse far enough that/accountleaves the history cache, sign out, then press Back until/account. Nothing in the app changes; the router now follows the redirect it already followed on a link click.Semantics
router.push()(unchanged)The redirect carries
error.stateas location state and_skipCache: true, the same options as the push path apart fromreplace.Code change
packages/rangojs-router/src/browser/navigation-bridge.ts,handlePopstate's catch: after the aborted/superseded return and beforeeventController.setLocation(new URL(url))(#1049), aServerRedirectbranch validates the origin withvalidateRedirectOriginandreturn this.navigate(redirectUrl, { state, replace: true, _skipCache: true }). 20 added lines including a comment, no other source change. BeforesetLocation: after it, the hooks would move to the redirecting URL and then to the target.thisis the bridge inhandlePopstateas innavigate, so the call needed no adjustment.Why replace: the browser is already on the entry that redirected. Replacing makes that entry the redirect target; Back from the target reaches the entry before it. A push leaves the redirecting entry in history and Back hits it again.
Client router chunk (cloudflare-basic): 47283 B gzip, limit 48128 B.
Also:
tests/cloudflare-basic/src/pages/auth-redirect.tsx(new cookie-gated route/auth-redirect/protected, redirect target/auth-redirect), mounted insrc/urls.tsx;router.named-routes.gen.tsregenerated (only generated file that changed). CHANGELOG### Fixed:entry; the "Still open: Back/Forward redirect" bullet of the #1031 entry is removed;skills/middleware/SKILL.mdnotes Back/Forward.Test plan
src/__tests__/navigation-bridge-traversal-location.test.ts(Back/Forward to an entry the server now redirects is not followed: "Unprocessable popstate response" and an error boundary #1047 describe): a refetched traversal rejecting withServerRedirectcallsnavigate(target, { state, replace: true, _skipCache: true }), emits no update, never callssetLocation; a cross-originServerRedirectis blocked (no navigate, no update,[rango] Redirect blocked, location unchanged). Red on origin/main:expected "navigate" to be called 1 times, but got 0 times(:288);expected [ { update: { isError: true ... } } ] to deeply equal [](:316).expectRefetchedBackFollowsRedirectByReplacingintests/shared-e2e/src/index.ts, used bypopstate-redirect.test.tsin both apps, dev and(production): target shown,page.url()is the target, noUnprocessable popstate response/[RootErrorBoundary]console output, a second Back lands on the entry before the protected one (pins replace; withreplace: falseit fails attoHaveURL(beforeUrl), received...?rejected=route-mw). Red on origin/main in all four describes atexpect(page).toHaveURL(fixture.targetUrl)(index.ts:2255), received the protected URL.expectLinkClickToRedirectingPageFollowsRedirect: passes on main and on this branch, dev and production, both apps.test:unit:all, lint, format,check:e2e-bucketing,check:e2e-parity --strict,check:docs-api,check:bundle-guards.Notes
startRedirectNavigationinpackages/react-router/lib/router/router.tsusesReplaceonly when the original navigation was a replace or the response carriesX-Remix-Replace; a POP is neither, so history[A, B (protected), C]after Back from C becomes[A, B, login], and Back fromloginlands on B, which redirects again. This router replaces instead, which is what a browser does with a document that answers 302 during a traversal, and it avoids the loop.grid-step,ls-shared-navidle) before going back. Without that wait the back raced the last push in production and restored the protected page from cache.--no-deps: the new file withnavigation-url-commit(andauth-boundaryin the router app): router app 17 passed in dev and 16 in production; cloudflare-basic 8 and 8. Gate green (router 7534 unit and 484 RSC).ServerRedirectbranch (the push path passes the caller'sreplaceandtransition, this one hard-codesreplace: true, and they sit on different sides of the aborted check; a helper would save about six lines); the console collector in the shared body (the existing hydration guard does not match the production messageUnprocessable popstate response). The assertion that pinsreplaceis the URL after the final Back; with a push the address bar ends on the redirect target again.ServerRedirectbefore its aborted/superseded check, so a superseded push whose response is a redirect could still follow it; this branch checks aborted first. (2) Neither path bounds a redirect loop (a target that redirects back). (3) If the redirect target is the same URL as the entry before the redirecting one, replacing leaves two identical adjacent entries.