Skip to content

fix(router): follow a server redirect on Back/Forward to a fetched entry (#1047) - #1052

Merged
ivogt merged 1 commit into
mainfrom
fix/1047-popstate-redirect
Oct 4, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/1047-popstate-redirect

Conversation

@ivogt

@ivogt ivogt commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

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: ServerRedirect and the address bar stayed on the protected URL. A link click or router.push() to the same page followed the redirect.

Before vs. after

// History: [/home, /account, /a, /b, ...]; /account left the history cache,
// and its route middleware now redirects to /login. Back to /account:

// before: root error boundary; address bar /account
// after:  /login is shown;     history [/home, /login, /a, /b, ...]
//         Back from /login goes to /home

Consumer example

// urls.tsx
path("/account", AccountPage, { name: "account" }, () => [
  middleware(async (ctx, next) => {
    if (!cookies().get("session")?.value) {
      return redirect("/login", 302);
    }
    await next();
  }),
]);

Sign in, open /account, browse far enough that /account leaves 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

Navigation Redirect target History effect
Link click, router.push() (unchanged) followed push, or replace if the nav was a replace
Back/Forward, entry served from the history cache not fetched unchanged
Back/Forward, entry fetched, server redirects (this PR) followed replace: the traversed entry becomes target
Back/Forward, entry fetched, redirect to another origin blocked, console.error none; page being left stays on screen
Back/Forward, entry fetched, network or decode failure unchanged error boundary for the entry

The redirect carries error.state as location state and _skipCache: true, the same options as the push path apart from replace.

Code change

packages/rangojs-router/src/browser/navigation-bridge.ts, handlePopstate's catch: after the aborted/superseded return and before eventController.setLocation(new URL(url)) (#1049), a ServerRedirect branch validates the origin with validateRedirectOrigin and return this.navigate(redirectUrl, { state, replace: true, _skipCache: true }). 20 added lines including a comment, no other source change. Before setLocation: after it, the hooks would move to the redirecting URL and then to the target. this is the bridge in handlePopstate as in navigate, 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 in src/urls.tsx; router.named-routes.gen.ts regenerated (only generated file that changed). CHANGELOG ### Fixed: entry; the "Still open: Back/Forward redirect" bullet of the #1031 entry is removed; skills/middleware/SKILL.md notes Back/Forward.

Test plan

  • Unit 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 with ServerRedirect calls navigate(target, { state, replace: true, _skipCache: true }), emits no update, never calls setLocation; a cross-origin ServerRedirect is 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).
  • E2e body expectRefetchedBackFollowsRedirectByReplacing in tests/shared-e2e/src/index.ts, used by popstate-redirect.test.ts in both apps, dev and (production): target shown, page.url() is the target, no Unprocessable popstate response / [RootErrorBoundary] console output, a second Back lands on the entry before the protected one (pins replace; with replace: false it fails at toHaveURL(beforeUrl), received ...?rejected=route-mw). Red on origin/main in all four describes at expect(page).toHaveURL(fixture.targetUrl) (index.ts:2255), received the protected URL.
  • Control expectLinkClickToRedirectingPageFollowsRedirect: passes on main and on this branch, dev and production, both apps.
  • typecheck, test:unit:all, lint, format, check:e2e-bucketing, check:e2e-parity --strict, check:docs-api, check:bundle-guards.
  • Existing e2e files (navigation-url-commit, location-state, redirect, auth-boundary, hook-render-stability) green in dev and production in both apps.

Notes

  • React Router follows such a redirect as a push: startRedirectNavigation in packages/react-router/lib/router/router.ts uses Replace only when the original navigation was a replace or the response carries X-Remix-Replace; a POP is neither, so history [A, B (protected), C] after Back from C becomes [A, B, login], and Back from login lands 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.
  • The e2e body evicts the protected entry by pushing 21 panel-fixture entries and waits for the last one to commit (grid-step, ls-shared-nav idle) before going back. Without that wait the back raced the last push in production and restored the protected page from cache.
  • Not covered by an e2e: intercept traversals that redirect, and the cross-origin case (unit only; the e2e apps have no cross-origin redirect route on a gated page).
  • Second run by the reviewer on the final commit, --no-deps: the new file with navigation-url-commit (and auth-boundary in 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).
  • Review: one independent four-angle pass (reuse, simplification, efficiency, altitude). Kept as is: two sites for the ServerRedirect branch (the push path passes the caller's replace and transition, this one hard-codes replace: 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 message Unprocessable popstate response). The assertion that pins replace is the URL after the final Back; with a push the address bar ends on the redirect target again.
  • Not changed, read from the code: (1) the push path checks ServerRedirect before 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.

…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.
@pkg-pr-new

pkg-pr-new Bot commented Oct 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@1052

commit: d3630a9

@ivogt
ivogt merged commit 48377c4 into main Oct 4, 2026
43 checks passed
@ivogt
ivogt deleted the fix/1047-popstate-redirect branch October 4, 2026 08:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Back/Forward to an entry the server now redirects is not followed: "Unprocessable popstate response" and an error boundary

1 participant