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) =>
{props.children}
, + 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(); + } + }); +});