diff --git a/.changeset/observe-navigation-origin.md b/.changeset/observe-navigation-origin.md
new file mode 100644
index 00000000..d84d09f6
--- /dev/null
+++ b/.changeset/observe-navigation-origin.md
@@ -0,0 +1,7 @@
+---
+"@solidjs/router": patch
+---
+
+Navigations are declared to Solid's observe tier. Every client location write — `navigate()`, a redirect chased while the previous target is still pending, the browser's own back/forward — now runs inside `OBSERVE.attribution.withOrigin` with the parametrized route pattern, params, and origin location, so the attribution engine names holds and re-runs after the route (`navigation to /users/:id (/users/42)`), times the navigation from the user event to settle, folds redirect hops onto the navigation they belong to (`redirected from /files`), and reports routes in `feedback().navigations`. The route name and params are read late — at settle — so a lazy route subtree that loaded during the hold is named by the exact route it resolved to, not its placeholder. The router's location signal and its `matches`, `routingPending`, and lazy-subtree memos carry names so they read as themselves in diagnostics rather than as `signal`/`computed`.
+
+Nothing changes in production builds: `OBSERVE` is undefined there and the declaration folds out. Requires `solid-js` 2.0.0-rc.8 (`OBSERVE.attribution.withOrigin`).
diff --git a/README.md b/README.md
index a8d29e09..3cd5923d 100644
--- a/README.md
+++ b/README.md
@@ -750,6 +750,8 @@ const isRouting = useIsRouting();
return
...
;
```
+In Solid's dev and observe builds the router also declares every navigation to the attribution engine (`solid-js/attribution`): holds and re-runs caused by a navigation are named after the route pattern (`navigation to /users/:id`), timed from the user event that started it, and redirect hops fold onto the navigation they belong to. `attribution.navigations()` and `feedback().navigations` list them; nothing of this exists in production builds.
+
### useMatch
Tests a path *pattern you supply* against the current location; returns a memo of match information or `undefined`. It never consults the route tree — the pattern doesn't have to correspond to a defined route. The match's `params` are typed from the pattern, and a typed path node works too (a concrete URL — useful for "am I here" checks):
diff --git a/package.json b/package.json
index 6e4bbb0e..a9f2b951 100644
--- a/package.json
+++ b/package.json
@@ -48,21 +48,21 @@
"@rollup/plugin-node-resolve": "15.3.0",
"@rollup/plugin-terser": "0.4.4",
"@solidjs/vite-plugin": "3.0.0-next.35",
- "@solidjs/web": "^2.0.0-rc.7",
+ "@solidjs/web": "^2.0.0-rc.8",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.0",
"babel-preset-solid": "^2.0.0-rc.2",
"jsdom": "^25.0.1",
"prettier": "^3.4.1",
"rollup": "^4.27.4",
- "solid-js": "^2.0.0-rc.7",
+ "solid-js": "^2.0.0-rc.8",
"typescript": "^5.7.2",
"vite": "^8.2.2",
"vitest": "^4.1.11"
},
"peerDependencies": {
- "@solidjs/web": "^2.0.0-rc.7",
- "solid-js": "^2.0.0-rc.7"
+ "@solidjs/web": "^2.0.0-rc.8",
+ "solid-js": "^2.0.0-rc.8"
},
"packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 089d4f73..3d544410 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -28,10 +28,10 @@ importers:
version: 0.4.4(rollup@4.27.4)
'@solidjs/vite-plugin':
specifier: 3.0.0-next.35
- version: 3.0.0-next.35(@solidjs/web@2.0.0-rc.7(solid-js@2.0.0-rc.7))(solid-js@2.0.0-rc.7)(vite@8.2.2(@types/node@22.10.0)(terser@5.36.0))
+ version: 3.0.0-next.35(@solidjs/web@2.0.0-rc.8(solid-js@2.0.0-rc.8))(solid-js@2.0.0-rc.8)(vite@8.2.2(@types/node@22.10.0)(terser@5.36.0))
'@solidjs/web':
- specifier: ^2.0.0-rc.7
- version: 2.0.0-rc.7(solid-js@2.0.0-rc.7)
+ specifier: ^2.0.0-rc.8
+ version: 2.0.0-rc.8(solid-js@2.0.0-rc.8)
'@types/jest':
specifier: ^29.5.14
version: 29.5.14
@@ -40,7 +40,7 @@ importers:
version: 22.10.0
babel-preset-solid:
specifier: ^2.0.0-rc.2
- version: 2.0.0-rc.2(@babel/core@7.26.0)(solid-js@2.0.0-rc.7)
+ version: 2.0.0-rc.2(@babel/core@7.26.0)(solid-js@2.0.0-rc.8)
jsdom:
specifier: ^25.0.1
version: 25.0.1
@@ -51,8 +51,8 @@ importers:
specifier: ^4.27.4
version: 4.27.4
solid-js:
- specifier: ^2.0.0-rc.7
- version: 2.0.0-rc.7
+ specifier: ^2.0.0-rc.8
+ version: 2.0.0-rc.8
typescript:
specifier: ^5.7.2
version: 5.7.2
@@ -680,8 +680,9 @@ packages:
'@solidjs/compiler@2.0.0-rc.4':
resolution: {integrity: sha512-lKx6Jp1KbHxqO+v+g7cRbm8I1DHx/10Lj8bKG4vmdGnCfSlFbyhCd8cPTLdLV0YVG7GGSzzF5m8NkC9NlfGN5w==}
- '@solidjs/signals@2.0.0-rc.7':
- resolution: {integrity: sha512-JY0OJ5nGeqxGKOAqCtuoHkFBkaWQYxmf+yBQE8vw/o9C7yUF+Kan9PwcrL0ZHR6+MgyixBUtTfBigwou5hwW5w==}
+ '@solidjs/signals@2.0.0-rc.8':
+ resolution: {integrity: sha512-EGk9WkxnlQtqdERjPgS8gvWVjKLZnBQr95e65FOAs9MqkxVhHIjgipan7svZZ6n0rkp6vSc9GxyeY836boaT6w==}
+ engines: {node: '>=22.12.0'}
'@solidjs/vite-plugin@3.0.0-next.35':
resolution: {integrity: sha512-8Mlftd+WfZkwOoCZRyMxA8innT8b2D/qawTu+28RW/Hj4eSmStSLx4dHjYeH9MxyOwo7DQStAyHAADk5LFQVRw==}
@@ -698,10 +699,11 @@ packages:
'@testing-library/jest-dom':
optional: true
- '@solidjs/web@2.0.0-rc.7':
- resolution: {integrity: sha512-qsKKWR4PzzPw8ZGFR0Oc2776G1ONMMBKPJWO3Opf3cWHoczgfhknW6NED0WtdCuR6T8usFTWCZgMM5Cc0h14Dg==}
+ '@solidjs/web@2.0.0-rc.8':
+ resolution: {integrity: sha512-GeJEHtSjsbvRLpREfbQa5ksn6EuJKjxY/kXxuOP5Sn5FlXz6fuL3wHJr88PYlCOPV1KxxxWSc5h5jzKMKQQXDg==}
+ engines: {node: '>=22.12.0'}
peerDependencies:
- solid-js: ^2.0.0-rc.7
+ solid-js: ^2.0.0-rc.8
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -1472,14 +1474,14 @@ packages:
serialize-javascript@6.0.1:
resolution: {integrity: sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==}
- seroval-plugins@1.5.6:
- resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==}
+ seroval-plugins@1.6.7:
+ resolution: {integrity: sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw==}
engines: {node: '>=10'}
peerDependencies:
seroval: ^1.0
- seroval@1.5.6:
- resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==}
+ seroval@1.6.7:
+ resolution: {integrity: sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ==}
engines: {node: '>=10'}
shebang-command@2.0.0:
@@ -1504,8 +1506,9 @@ packages:
smob@1.4.1:
resolution: {integrity: sha512-9LK+E7Hv5R9u4g4C3p+jjLstaLe11MDsL21UpYaCNmapvMkYhqCV4A/f/3gyH8QjMyh6l68q9xC85vihY9ahMQ==}
- solid-js@2.0.0-rc.7:
- resolution: {integrity: sha512-3APJcwGbJ3YzXzPXwl0R3cAiogXLacXdXSFasdE2uw1Gzj5xqDW/0bJu4fs75KK5WzXg+JfpkcsxBjTxkbnLQQ==}
+ solid-js@2.0.0-rc.8:
+ resolution: {integrity: sha512-0DwASKxvwXWAxCiea71cN3bmsyBIOUGmmg/pjSfhxPg+2pBVCh/ZKmwLNjKlPjc8SHeu52ZTio6tBBp1TcPB8A==}
+ engines: {node: '>=22.12.0'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
@@ -2486,28 +2489,28 @@ snapshots:
'@solidjs/compiler-wasm32-wasi': 2.0.0-rc.4
'@solidjs/compiler-win32-x64-msvc': 2.0.0-rc.4
- '@solidjs/signals@2.0.0-rc.7': {}
+ '@solidjs/signals@2.0.0-rc.8': {}
- '@solidjs/vite-plugin@3.0.0-next.35(@solidjs/web@2.0.0-rc.7(solid-js@2.0.0-rc.7))(solid-js@2.0.0-rc.7)(vite@8.2.2(@types/node@22.10.0)(terser@5.36.0))':
+ '@solidjs/vite-plugin@3.0.0-next.35(@solidjs/web@2.0.0-rc.8(solid-js@2.0.0-rc.8))(solid-js@2.0.0-rc.8)(vite@8.2.2(@types/node@22.10.0)(terser@5.36.0))':
dependencies:
'@ampproject/remapping': 2.3.0
'@babel/core': 7.26.0
'@solidjs/babel-plugin': 2.0.0-rc.4(@babel/core@7.26.0)
'@solidjs/compiler': 2.0.0-rc.4
- '@solidjs/web': 2.0.0-rc.7(solid-js@2.0.0-rc.7)
+ '@solidjs/web': 2.0.0-rc.8(solid-js@2.0.0-rc.8)
'@types/babel__core': 7.20.5
merge-anything: 5.1.7
- solid-js: 2.0.0-rc.7
+ solid-js: 2.0.0-rc.8
vite: 8.2.2(@types/node@22.10.0)(terser@5.36.0)
vitefu: 1.0.4(vite@8.2.2(@types/node@22.10.0)(terser@5.36.0))
transitivePeerDependencies:
- supports-color
- '@solidjs/web@2.0.0-rc.7(solid-js@2.0.0-rc.7)':
+ '@solidjs/web@2.0.0-rc.8(solid-js@2.0.0-rc.8)':
dependencies:
- seroval: 1.5.6
- seroval-plugins: 1.5.6(seroval@1.5.6)
- solid-js: 2.0.0-rc.7
+ seroval: 1.6.7
+ seroval-plugins: 1.6.7(seroval@1.6.7)
+ solid-js: 2.0.0-rc.8
'@standard-schema/spec@1.1.0': {}
@@ -2651,12 +2654,12 @@ snapshots:
asynckit@0.4.0: {}
- babel-preset-solid@2.0.0-rc.2(@babel/core@7.26.0)(solid-js@2.0.0-rc.7):
+ babel-preset-solid@2.0.0-rc.2(@babel/core@7.26.0)(solid-js@2.0.0-rc.8):
dependencies:
'@babel/core': 7.26.0
'@dom-expressions/babel-plugin-jsx': 0.50.0-next.44(@babel/core@7.26.0)
optionalDependencies:
- solid-js: 2.0.0-rc.7
+ solid-js: 2.0.0-rc.8
better-path-resolve@1.0.0:
dependencies:
@@ -3247,11 +3250,11 @@ snapshots:
dependencies:
randombytes: 2.1.0
- seroval-plugins@1.5.6(seroval@1.5.6):
+ seroval-plugins@1.6.7(seroval@1.6.7):
dependencies:
- seroval: 1.5.6
+ seroval: 1.6.7
- seroval@1.5.6: {}
+ seroval@1.6.7: {}
shebang-command@2.0.0:
dependencies:
@@ -3267,12 +3270,12 @@ snapshots:
smob@1.4.1: {}
- solid-js@2.0.0-rc.7:
+ solid-js@2.0.0-rc.8:
dependencies:
- '@solidjs/signals': 2.0.0-rc.7
+ '@solidjs/signals': 2.0.0-rc.8
csstype: 3.2.3
- seroval: 1.5.6
- seroval-plugins: 1.5.6(seroval@1.5.6)
+ seroval: 1.6.7
+ seroval-plugins: 1.6.7(seroval@1.6.7)
source-map-js@1.2.1: {}
diff --git a/src/routers/factory.tsx b/src/routers/factory.tsx
index dc8e64c6..781e3f0f 100644
--- a/src/routers/factory.tsx
+++ b/src/routers/factory.tsx
@@ -10,9 +10,11 @@ import {
sharedConfig,
untrack
} from "solid-js";
-// standalone import: `DEV` is undefined in solid's production build, so app
-// bundlers fold `DEV &&` diagnostics out of shipped bundles
-import { DEV } from "solid-js";
+// standalone imports: `DEV` is undefined in solid's production build and
+// `OBSERVE` outside its observe/dev builds, so app bundlers fold the
+// `DEV &&` diagnostics and the `OBSERVE &&` attribution out of shipped bundles
+import { DEV, OBSERVE } from "solid-js";
+import type { NavigationRef } from "solid-js/attribution";
import { getRequestEvent, isServer } from "@solidjs/web";
import type { JSX } from "@solidjs/web";
import { setupLinkClaims } from "../claims.js";
@@ -23,6 +25,7 @@ import {
createBranches,
createRouterContext,
getRouteMatches,
+ mergeParams,
registerFlightRouter,
RouterContextObj,
trackLazySubtrees,
@@ -36,6 +39,7 @@ import type {
OutputMatch,
Params,
RouteDefinition,
+ RouteMatch,
RouteInfo,
RouteParams,
RoutePreloadFunc,
@@ -205,35 +209,89 @@ export interface RouterInstance RouteMatch[],
+ next: LocationChange,
+ from: string
+): NavigationRef {
+ const pathname = new URL(next.value, mockBase).pathname;
+ const matches = () => untrack(() => match(pathname));
+ const ref: NavigationRef = {
+ kind: "navigation",
+ to: next.value,
+ from,
+ get name() {
+ const m = matches();
+ return m.length ? m[m.length - 1].route.pattern || "/" : pathname;
+ },
+ get params() {
+ const m = matches();
+ return m.length ? (mergeParams(m) as Readonly>) : undefined;
+ }
+ };
+ if (next._navigation !== undefined && next._navigation > 1) ref.redirect = next._navigation - 1;
+ return ref;
+}
+
/** Wraps a history adapter in the integration signal the router core consumes. Must run under a reactive owner. */
-function createIntegration(history: RouterHistory): RouterIntegration {
+function createIntegration(
+ history: RouterHistory,
+ match: (pathname: string) => RouteMatch[]
+): RouterIntegration {
let committing = false;
const wrap = (value: string | LocationChange) => (typeof value === "string" ? { value } : value);
const [read, write] = createSignal(wrap(history.get()), {
equals: (a, b) =>
a.value === b.value && a.state === b.state && a._navigation === b._navigation,
- ownedWrite: true
+ ownedWrite: true,
+ name: "location"
});
const signal: RouterIntegration["signal"] = [
read,
(next: LocationChange) => {
if (sharedConfig.registry && !sharedConfig.done) sharedConfig.done = true;
- write(next);
- if (next._navigation && next._navigation > 0) {
- // Register out of band so a destination error boundary replacing the
- // Router subtree cannot suppress the winning history commit.
- runWithOwner(null, () =>
- onSettled(() => {
- if (read() !== next) return;
- committing = true;
- try {
- history.set(next);
- } finally {
- committing = false;
- }
- })
- );
- }
+ const commit = () => {
+ write(next);
+ if (next._navigation && next._navigation > 0) {
+ // Register out of band so a destination error boundary replacing the
+ // Router subtree cannot suppress the winning history commit.
+ runWithOwner(null, () =>
+ onSettled(() => {
+ if (read() !== next) return;
+ committing = true;
+ try {
+ history.set(next);
+ } finally {
+ committing = false;
+ }
+ })
+ );
+ }
+ };
+ // Every client location write passes here — navigate(), a redirect hop,
+ // the browser's own back/forward — so this is the one place the
+ // navigation is declared. `read()` still holds the committed location
+ // while a navigation is pending, which is the `from` a hop wants too.
+ OBSERVE
+ ? OBSERVE.attribution.withOrigin(
+ describeNavigation(match, next, untrack(read).value),
+ commit
+ )
+ : commit();
}
];
@@ -287,6 +345,8 @@ export function createRouter(
return compiled;
};
const renderPath = (config.history && config.history.utils && config.history.utils.renderPath) || undefined;
+ const matchPath = (pathname: string) =>
+ getRouteMatches(branches(), config.transformUrl ? config.transformUrl(pathname) : pathname);
function RouterComponent(props: RouterProps): JSX.Element {
// One router per app: the session (location, history, delegation, link
@@ -309,7 +369,7 @@ export function createRouter(
}
const integration = isServer
? staticIntegration(props.url, config.history && config.history.utils)
- : createIntegration(history || browserHistory());
+ : createIntegration(history || browserHistory(), matchPath);
let context: Owner;
const routerState = createRouterContext(integration, branches, () => context, {
base: basePath,
@@ -341,9 +401,7 @@ export function createRouter(
routes: config.routes,
config,
match(url: string): OutputMatch[] {
- const u = new URL(url, mockBase);
- const pathname = config.transformUrl ? config.transformUrl(u.pathname) : u.pathname;
- return getRouteMatches(branches(), pathname).map(({ route, path, params }) => ({
+ return matchPath(new URL(url, mockBase).pathname).map(({ route, path, params }) => ({
path: route.originalPath,
pattern: route.pattern,
match: path,
diff --git a/src/routing.ts b/src/routing.ts
index 32d3e851..f1674ba7 100644
--- a/src/routing.ts
+++ b/src/routing.ts
@@ -868,57 +868,65 @@ export function createRouterContext(
let read = lazyReaders.get(record);
if (!read) {
read = runWithOwner(routerOwner, () =>
- createMemo(() => {
- const result = resolveLazySubtree(record);
- return result instanceof Promise ? result.then(() => undefined) : undefined;
- })
+ createMemo(
+ () => {
+ const result = resolveLazySubtree(record);
+ return result instanceof Promise ? result.then(() => undefined) : undefined;
+ },
+ { name: "lazyRoutes" }
+ )
);
lazyReaders.set(record, read);
}
return read();
};
- const matches = createMemo(() => {
- const pathname =
- typeof options.transformUrl === "function"
- ? options.transformUrl(location.pathname)
- : location.pathname;
- const m = getRouteMatches(branches(), pathname);
- // An unresolved lazy subtree parks readers on not-ready semantics — the
- // navigation transition (or the SSR stream) holds until the table lands.
- // NotReadyError (not a returned promise) because a match chain is full
- // of component functions the hydration serializer must never see. The
- // recompute comes from the version-signal dependency on the client and
- // from the carried promise's retry on the server; a boundary nested
- // inside a boundary just parks the recomputed chain again.
- const pending = unresolvedLazyMatches(m);
- if (pending.length) {
- if (isServer) {
- // SSR carries the Promise through NotReadyError so the streaming
- // renderer can resume without attempting to serialize route
- // definitions (which contain component functions).
- const all = Promise.all(pending.map(resolveLazySubtree));
- all.catch(() => {});
- throw new NotReadyError(all);
- } else {
- // On the client the source must be a reactive async node so transition
- // settlement and rejection delivery remain inside the signals graph.
- for (const boundary of pending) readLazySubtree(boundary);
+ const matches = createMemo(
+ () => {
+ const pathname =
+ typeof options.transformUrl === "function"
+ ? options.transformUrl(location.pathname)
+ : location.pathname;
+ const m = getRouteMatches(branches(), pathname);
+ // An unresolved lazy subtree parks readers on not-ready semantics — the
+ // navigation transition (or the SSR stream) holds until the table lands.
+ // NotReadyError (not a returned promise) because a match chain is full
+ // of component functions the hydration serializer must never see. The
+ // recompute comes from the version-signal dependency on the client and
+ // from the carried promise's retry on the server; a boundary nested
+ // inside a boundary just parks the recomputed chain again.
+ const pending = unresolvedLazyMatches(m);
+ if (pending.length) {
+ if (isServer) {
+ // SSR carries the Promise through NotReadyError so the streaming
+ // renderer can resume without attempting to serialize route
+ // definitions (which contain component functions).
+ const all = Promise.all(pending.map(resolveLazySubtree));
+ all.catch(() => {});
+ throw new NotReadyError(all);
+ } else {
+ // On the client the source must be a reactive async node so transition
+ // settlement and rejection delivery remain inside the signals graph.
+ for (const boundary of pending) readLazySubtree(boundary);
+ }
}
- }
- return m;
- });
+ return m;
+ },
+ { name: "matches" }
+ );
- const routingPending = createMemo(() =>
- isPending(() => {
- try {
- matches();
- } catch (e) {
- if (e instanceof NotReadyError) throw e;
- }
- location.search;
- location.hash;
- })
+ const routingPending = createMemo(
+ () =>
+ isPending(() => {
+ try {
+ matches();
+ } catch (e) {
+ if (e instanceof NotReadyError) throw e;
+ }
+ location.search;
+ location.hash;
+ }),
+ { name: "routingPending" }
);
const isRouting = () => routingPending() || isPending(source);
diff --git a/test/observe-navigation.spec.tsx b/test/observe-navigation.spec.tsx
new file mode 100644
index 00000000..6eab7d8f
--- /dev/null
+++ b/test/observe-navigation.spec.tsx
@@ -0,0 +1,252 @@
+// Observe tier: every client location write is declared to solid's
+// attribution engine as a navigation (`OBSERVE.attribution.withOrigin`), so
+// holds and re-runs it causes are named after the route, redirect hops fold
+// onto the navigation they belong to, and a lazy subtree that resolved during
+// the hold names the exact route it landed on. Nothing here exists in the
+// production build: `OBSERVE` is undefined there and the declaration folds out.
+import { createMemo } from "solid-js";
+import { render } from "@solidjs/web";
+import { attribution } from "solid-js/attribution";
+import { vi } from "vitest";
+import {
+ createRouter,
+ defineRoutes,
+ memoryHistory,
+ query,
+ useNavigate,
+ useParams
+} from "../src/index.js";
+import type { Navigator } from "../src/index.js";
+
+const settle = async (ms = 0) => {
+ await new Promise(resolve => queueMicrotask(() => resolve()));
+ await new Promise(resolve => setTimeout(resolve, ms));
+};
+
+const redirectResponse = (to: string) =>
+ new Response(null, { status: 302, headers: { Location: to } });
+
+function mount(Router: (props: any) => any) {
+ const div = document.createElement("div");
+ document.body.appendChild(div);
+ const dispose = render(() => , div);
+ return {
+ div,
+ cleanup() {
+ dispose();
+ div.remove();
+ }
+ };
+}
+
+const last = () => attribution.navigations()[attribution.navigations().length - 1];
+
+describe("observe tier: navigations declared to attribution", () => {
+ const originalScrollTo = window.scrollTo;
+ beforeEach(() => {
+ window.scrollTo = vi.fn();
+ attribution.enable({ log: false });
+ });
+ afterEach(() => attribution.disable());
+ afterAll(() => {
+ window.scrollTo = originalScrollTo;
+ });
+
+ test("navigate() declares the parametrized route, params, and origin location", async () => {
+ let navigate!: Navigator;
+ const Router = createRouter({
+ routes: [
+ {
+ path: "/",
+ component: () => {
+ navigate = useNavigate();
+ return Home
;
+ }
+ },
+ {
+ path: "/users/:id",
+ component: () => {
+ const params = useParams();
+ return {params.id}
;
+ }
+ }
+ ] as const,
+ history: memoryHistory()
+ });
+
+ const { div, cleanup } = mount(Router);
+ try {
+ const before = attribution.navigations().length;
+ navigate("/users/42");
+ await settle();
+ expect(div.querySelector('[data-route="user"]')?.textContent).toBe("42");
+
+ expect(attribution.navigations().length).toBe(before + 1);
+ const nav = last();
+ expect(nav.name).toBe("/users/:id");
+ expect(nav.to).toBe("/users/42");
+ expect(nav.from).toBe("/");
+ expect(nav.params).toEqual({ id: "42" });
+ expect(nav.redirects).toBeUndefined();
+ expect(nav.outcome).toBe("committed");
+ } finally {
+ cleanup();
+ }
+ });
+
+ test("the root route is named '/' rather than its empty pattern", async () => {
+ let navigate!: Navigator;
+ const Router = createRouter({
+ routes: [
+ { path: "/", component: () => Home
},
+ {
+ path: "/about",
+ component: () => {
+ navigate = useNavigate();
+ return About
;
+ }
+ }
+ ] as const,
+ history: memoryHistory("/about")
+ });
+ const { cleanup } = mount(Router);
+ try {
+ navigate("/");
+ await settle();
+ expect(last().name).toBe("/");
+ expect(last().from).toBe("/about");
+ } finally {
+ cleanup();
+ }
+ });
+
+ test("a redirect thrown while the navigation is pending is a hop of that navigation, not a new one", async () => {
+ let navigate!: Navigator;
+ const getFiles = query(async () => {
+ await new Promise(r => setTimeout(r, 10));
+ throw redirectResponse("/login");
+ }, "observe-files");
+ const FilePage = () => {
+ const files = createMemo(() => getFiles());
+ return files:{String(files())};
+ };
+ const Router = createRouter({
+ routes: [
+ {
+ path: "/",
+ component: () => {
+ navigate = useNavigate();
+ return Home
;
+ }
+ },
+ { path: "/files", component: FilePage },
+ { path: "/login", component: () => login-page }
+ ] as const,
+ history: memoryHistory()
+ });
+
+ const { div, cleanup } = mount(Router);
+ try {
+ const before = attribution.navigations().length;
+ navigate("/files");
+ await settle(60);
+ expect(div.querySelector('[data-route="login"]')).toBeTruthy();
+
+ // one navigation, two writes, the abandoned destination recorded as a hop
+ expect(attribution.navigations().length).toBe(before + 1);
+ const nav = last();
+ expect(nav.name).toBe("/login");
+ expect(nav.to).toBe("/login");
+ expect(nav.from).toBe("/");
+ expect(nav.writes).toBe(2);
+ expect(nav.redirects?.map(h => h.to)).toEqual(["/files"]);
+ expect(nav.redirects?.[0].name).toBe("/files");
+ 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([
+ { path: "/", component: () => Plugins
},
+ {
+ path: "/widgets/:id",
+ component: () => {
+ const params = useParams();
+ return {params.id}
;
+ }
+ }
+ ]);
+ const Router = createRouter({
+ routes: [
+ {
+ path: "/",
+ component: () => {
+ navigate = useNavigate();
+ return Home
;
+ }
+ },
+ {
+ path: "/plugins",
+ component: (props: any) => ,
+ children: () => Promise.resolve({ default: pluginRoutes })
+ }
+ ] as const,
+ history: memoryHistory()
+ });
+
+ const { div, cleanup } = mount(Router);
+ try {
+ navigate("/plugins/widgets/7");
+ // At declaration only the placeholder matches; the engine re-reads the
+ // ref at settle, by which time the table has loaded.
+ expect(last().to).toBe("/plugins/widgets/7");
+ await settle(10);
+ expect(div.querySelector('[data-route="widget"]')?.textContent).toBe("7");
+ const nav = last();
+ expect(nav.name).toBe("/plugins/widgets/:id");
+ expect(nav.params).toEqual({ id: "7" });
+ expect(nav.outcome).toBe("held");
+ } finally {
+ cleanup();
+ }
+ });
+
+ test("the browser moving (back/forward) is declared too", async () => {
+ let navigate!: Navigator;
+ const history = memoryHistory();
+ const Router = createRouter({
+ routes: [
+ {
+ path: "/",
+ component: () => {
+ navigate = useNavigate();
+ return Home
;
+ }
+ },
+ { path: "/users/:id", component: () => user
}
+ ] as const,
+ history
+ });
+ const { div, cleanup } = mount(Router);
+ try {
+ navigate("/users/1");
+ await settle();
+ expect(div.querySelector('[data-route="user"]')).toBeTruthy();
+
+ const before = attribution.navigations().length;
+ history.go(-1);
+ await settle();
+ expect(div.querySelector('[data-route="home"]')).toBeTruthy();
+ expect(attribution.navigations().length).toBe(before + 1);
+ const nav = last();
+ expect(nav.name).toBe("/");
+ expect(nav.from).toBe("/users/1");
+ expect(nav.redirects).toBeUndefined();
+ } finally {
+ cleanup();
+ }
+ });
+});