From b57bfcd4c9cf38c0013d0b0414146ed4b4c84acb Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 12 Sep 2026 00:58:15 -0700 Subject: [PATCH] fix: a guard redirecting as the held route lands is a redirect hop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit navigate() from render in the flush that lands a held navigation saw isPending(source) === false — the location signal has committed the destination even though nothing has painted and history.set has not run — so the hop was declared as a fresh navigation: the first destination settled `superseded`, the second `committed` in 1ms with no interaction. The integration now tracks the write between its location commit and its history commit (RouterIntegration.inflight); navigateFromRoute counts a hop when the previous navigation is pending or in flight. One navigation, one `redirects` entry, timed from the click; replace/scroll inherit as for a pending hop. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/observe-redirect-inflight.md | 5 +++ src/routers/factory.tsx | 6 ++- src/routing.ts | 7 +++- src/types.ts | 8 ++++ test/observe-navigation.spec.tsx | 54 +++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 .changeset/observe-redirect-inflight.md diff --git a/.changeset/observe-redirect-inflight.md b/.changeset/observe-redirect-inflight.md new file mode 100644 index 00000000..445e3e4f --- /dev/null +++ b/.changeset/observe-redirect-inflight.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +A `navigate()` issued while the previous navigation has landed but not yet reached history — a guard redirecting from render as the held route lands — is now a redirect hop of that navigation rather than a new one. The integration tracks the write between its location commit and its history commit (`RouterIntegration.inflight`), and hop depth counts it alongside `isPending(source)`. Observe builds declare one navigation with a `redirects` entry for the abandoned destination, timed from the click; `replace`/`scroll` inherit from the original navigation as they do for a pending hop. diff --git a/src/routers/factory.tsx b/src/routers/factory.tsx index 781e3f0f..644ca872 100644 --- a/src/routers/factory.tsx +++ b/src/routers/factory.tsx @@ -253,6 +253,8 @@ function createIntegration( match: (pathname: string) => RouteMatch[] ): RouterIntegration { let committing = false; + // Written, not yet in history (see `RouterIntegration.inflight`). + let inflight: LocationChange | undefined; const wrap = (value: string | LocationChange) => (typeof value === "string" ? { value } : value); const [read, write] = createSignal(wrap(history.get()), { equals: (a, b) => @@ -267,10 +269,12 @@ function createIntegration( const commit = () => { write(next); if (next._navigation && next._navigation > 0) { + inflight = next; // Register out of band so a destination error boundary replacing the // Router subtree cannot suppress the winning history commit. runWithOwner(null, () => onSettled(() => { + if (inflight === next) inflight = undefined; if (read() !== next) return; committing = true; try { @@ -303,7 +307,7 @@ function createIntegration( }) ); - return { signal, utils: history.utils }; + return { signal, inflight: () => inflight, utils: history.utils }; } /** diff --git a/src/routing.ts b/src/routing.ts index f1674ba7..fd66e257 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -1046,12 +1046,15 @@ export function createRouterContext( throw new Error(`Path '${to}' is not a routable path`); } + // A redirect hop: the previous navigation is still pending, or has landed + // but not yet reached history (a guard redirecting in the landing flush + // — its destination was never shown either way). const headed = latest(source); const navigationDepth = !isServer && - isPending(source) && headed._navigation !== undefined && - headed._navigation > 0 + headed._navigation > 0 && + (isPending(source) || integration.inflight?.() === headed) ? headed._navigation : 0; diff --git a/src/types.ts b/src/types.ts index 546f4fb5..f84f55cc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,6 +125,14 @@ export interface RouterIntegration { // static integration provides plain functions that can't carry the // `$REFRESH` brand. signal: [get: () => LocationChange, set: (next: LocationChange) => void]; + /** + * The navigation written but not yet committed to history — the window + * between its location write and the settle that pushes it. A destination + * in that window was never shown, so a `navigate()` issued inside it (a + * guard redirecting as the held route lands) is a hop of that navigation + * rather than a new one, exactly as one issued while it is still pending. + */ + inflight?: () => LocationChange | undefined; utils?: Partial; } diff --git a/test/observe-navigation.spec.tsx b/test/observe-navigation.spec.tsx index 6eab7d8f..f1f45c40 100644 --- a/test/observe-navigation.spec.tsx +++ b/test/observe-navigation.spec.tsx @@ -167,6 +167,60 @@ describe("observe tier: navigations declared to attribution", () => { } }); + test("a guard that navigates as the held destination lands is a hop of that navigation, not a new one", async () => { + let navigate!: Navigator; + const getSession = query(async () => { + await new Promise(r => setTimeout(r, 10)); + return { authed: false }; + }, "observe-session"); + // The app-authored guard: read the session (held), then redirect in + // render once it lands. /private never reaches history. + const Private = () => { + const session = createMemo(() => getSession()); + const nav = useNavigate(); + const view = createMemo(() => { + if (!session().authed) { + nav("/login", { replace: true }); + return null; + } + return private; + }); + return <>{view()}; + }; + const Router = createRouter({ + routes: [ + { + path: "/", + component: () => { + navigate = useNavigate(); + return
Home
; + } + }, + { path: "/private", component: Private }, + { path: "/login", component: () => login-page } + ] as const, + history: memoryHistory() + }); + + const { div, cleanup } = mount(Router); + try { + const before = attribution.navigations().length; + navigate("/private"); + await settle(60); + expect(div.querySelector('[data-route="login"]')).toBeTruthy(); + + expect(attribution.navigations().length).toBe(before + 1); + const nav = last(); + expect(nav.name).toBe("/login"); + expect(nav.from).toBe("/"); + expect(nav.writes).toBe(2); + expect(nav.redirects?.map(h => h.to)).toEqual(["/private"]); + expect(nav.outcome).toBe("held"); + } finally { + cleanup(); + } + }); + test("a lazy subtree that loads during the hold names the exact route it resolved to", async () => { let navigate!: Navigator; const pluginRoutes = defineRoutes([