Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/observe-redirect-inflight.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion src/routers/factory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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 {
Expand Down Expand Up @@ -303,7 +307,7 @@ function createIntegration(
})
);

return { signal, utils: history.utils };
return { signal, inflight: () => inflight, utils: history.utils };
}

/**
Expand Down
7 changes: 5 additions & 2 deletions src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RouterUtils>;
}

Expand Down
54 changes: 54 additions & 0 deletions test/observe-navigation.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <span data-route="private">private</span>;
});
return <>{view()}</>;
};
const Router = createRouter({
routes: [
{
path: "/",
component: () => {
navigate = useNavigate();
return <div data-route="home">Home</div>;
}
},
{ path: "/private", component: Private },
{ path: "/login", component: () => <span data-route="login">login-page</span> }
] 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([
Expand Down
Loading