Skip to content

fix: a guard redirecting as the held route lands is a redirect hop - #604

Open
ryansolid wants to merge 1 commit into
nextfrom
observe-redirect-inflight
Open

fix: a guard redirecting as the held route lands is a redirect hop#604
ryansolid wants to merge 1 commit into
nextfrom
observe-redirect-inflight

Conversation

@ryansolid

Copy link
Copy Markdown
Member

What

A navigate() issued while the previous navigation has landed but not yet reached history is a redirect hop of that navigation, not a new one.

  • RouterIntegration.inflight?: () => LocationChange | undefined — the integration keeps the write between write(next) and the onSettled that calls history.set(next), clearing it in that callback whether the write won or was replaced.
  • navigateFromRoute: hop depth is headed._navigation when isPending(source) || integration.inflight?.() === headed (was: isPending(source) only). The _navigation counter, replace/scroll inheritance and MAX_REDIRECTS all key off the same depth, so those follow.

Why

The app-authored guard shape — read the session (held), then navigate("/login") from render once it lands — ran in the flush that lands /private. In that flush isPending(source) is already false (the location signal has committed the value) even though nothing has painted and history.set has not run. So the second write got _navigation: 1 and, on the observe tier, was declared to withOrigin without redirect: the attribution engine recorded /private as superseded under the click and /login as a separate committed navigation of 1ms with no interaction — the 124ms the user actually waited attributed to nothing.

The existing hop test (a redirect() thrown from a query) never hit this because it fires while the query is still pending. The router's own behaviour already treated the first destination as unrealized (onSettled skips history.set because read() !== next); the depth logic just didn't know it.

Found by the Sentry spike's record-driven adapter on next.24. Verified there: one navigation /login, redirects: [/private], from: /, held, 124ms, under the click.

Tests

New: "a guard that navigates as the held destination lands is a hop of that navigation, not a new one" (observe-navigation.spec.tsx) — fails on next (two records), passes here (writes 2, redirects [/private], from "/", outcome held). 402 client + 35 server tests, test:types pass.

Companion engine fix from the same run: solidjs/solid#3380.

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 <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Sep 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b57bfcd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@solidjs/router Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

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.

1 participant