Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/observe-navigation-origin.md
Original file line number Diff line number Diff line change
@@ -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`).
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,8 @@ const isRouting = useIsRouting();
return <div classList={{ "grey-out": isRouting() }}>...</div>;
```

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):
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
71 changes: 37 additions & 34 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

108 changes: 83 additions & 25 deletions src/routers/factory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -23,6 +25,7 @@ import {
createBranches,
createRouterContext,
getRouteMatches,
mergeParams,
registerFlightRouter,
RouterContextObj,
trackLazySubtrees,
Expand All @@ -36,6 +39,7 @@ import type {
OutputMatch,
Params,
RouteDefinition,
RouteMatch,
RouteInfo,
RouteParams,
RoutePreloadFunc,
Expand Down Expand Up @@ -205,35 +209,89 @@ export interface RouterInstance<R extends readonly RouteDefinition[] = RouteDefi
match(url: string): OutputMatch[];
}

/**
* What one location write is, for solid's observe tier: the parametrized
* route it heads to, the params, where it came from, and — when the write is
* the router chasing a redirect while the previous target is still pending —
* which hop of that navigation it is. The engine times the navigation from
* this declaration (or from the user event enclosing it) until its writes
* are through, and names holds and re-runs after it.
*
* `name` and `params` are getters: the engine reads them when the navigation
* settles, not when it starts, so a lazy route subtree that loaded during the
* hold names the exact route it resolved to rather than its placeholder.
* Redirect depth comes from `_navigation` (1 = a navigation, n = its
* (n - 1)th redirect hop, -1 = the browser moved: back/forward, hash).
*/
function describeNavigation(
match: (pathname: string) => 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<Record<string, string>>) : 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();
}
];

Expand Down Expand Up @@ -287,6 +345,8 @@ export function createRouter<const R extends readonly RouteDefinition[]>(
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
Expand All @@ -309,7 +369,7 @@ export function createRouter<const R extends readonly RouteDefinition[]>(
}
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,
Expand Down Expand Up @@ -341,9 +401,7 @@ export function createRouter<const R extends readonly RouteDefinition[]>(
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,
Expand Down
Loading
Loading