@solidjs/router 2.0.0-next.36 → 2.0.0-next.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -40
- package/dist/claims.d.ts +6 -4
- package/dist/claims.js +26 -17
- package/dist/data/events.d.ts +11 -4
- package/dist/data/events.js +45 -45
- package/dist/index.d.ts +5 -2
- package/dist/index.js +608 -260
- package/dist/index.jsx +4 -1
- package/dist/pending.d.ts +25 -0
- package/dist/pending.js +67 -0
- package/dist/preload.d.ts +53 -0
- package/dist/preload.js +175 -0
- package/dist/preloadRoute.d.ts +23 -0
- package/dist/preloadRoute.js +92 -0
- package/dist/routers/components.jsx +7 -4
- package/dist/routers/factory.d.ts +29 -4
- package/dist/routers/factory.jsx +27 -15
- package/dist/routers/scrollRestoration.d.ts +29 -5
- package/dist/routers/scrollRestoration.js +77 -32
- package/dist/routing.d.ts +8 -16
- package/dist/routing.js +39 -127
- package/dist/serverRouteComponent.js +5 -4
- package/dist/types.d.ts +46 -11
- package/dist/utils.d.ts +6 -5
- package/dist/utils.js +18 -14
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -17,9 +17,9 @@ Explore the official [documentation](https://docs.solidjs.com/solid-router) for
|
|
|
17
17
|
## Core Features
|
|
18
18
|
|
|
19
19
|
- **Typed Routing**: URLs built through a typed path proxy inferred from your route config — `paths.users(2).settings` typechecks against the tree
|
|
20
|
-
- **Plain Anchors**: no link component — `<a>` elements get `aria-current
|
|
20
|
+
- **Plain Anchors**: no link component — `<a>` elements get `aria-current` and `data-active` automatically via compiler-claimed anchors, and `data-pending` with the opt-in `pendingLinks` plugin
|
|
21
21
|
- **Universal Rendering**: one factory for browser, hash, memory, and server rendering; history adapters are imports, so unused ones never enter your bundle
|
|
22
|
-
- **Preload Functions**: parallel data fetching following the render-as-you-fetch pattern, triggered eagerly
|
|
22
|
+
- **Preload Functions**: parallel data fetching following the render-as-you-fetch pattern, triggered eagerly by opt-in [link preloading](#preloading)
|
|
23
23
|
- **Data APIs with Caching**: `query` and `action` with deduplication, revalidation, single-flight mutations, and progressive enhancement — plus experimental `liveQuery` for keyed queries over live streams
|
|
24
24
|
- **Typed Search Params**: opt-in per-route [Standard Schema](https://github.com/standard-schema/standard-schema) validation — `search.page` is a `number`, not `"2"`
|
|
25
25
|
|
|
@@ -40,6 +40,7 @@ Explore the official [documentation](https://docs.solidjs.com/solid-router) for
|
|
|
40
40
|
- [File-System Routes](#file-system-routes)
|
|
41
41
|
- [Typed Paths](#typed-paths)
|
|
42
42
|
- [Links](#links)
|
|
43
|
+
- [Preloading](#preloading)
|
|
43
44
|
- [Preload Functions](#preload-functions)
|
|
44
45
|
- [Data APIs](#data-apis)
|
|
45
46
|
- [Typed Search Params](#typed-search-params)
|
|
@@ -171,7 +172,7 @@ A route definition supports:
|
|
|
171
172
|
| `path` | `string \| string[]` | Path partial for this route segment |
|
|
172
173
|
| `component` | `Component` | Component rendered for the matched segment |
|
|
173
174
|
| `children` | `RouteDefinition \| RouteDefinition[] \| () => Promise<...>` | Nested route definitions, or a thunk for a [lazy subtree](#lazy-route-subtrees) |
|
|
174
|
-
| `preload` | `RoutePreloadFunc` | Called on
|
|
175
|
+
| `preload` | `RoutePreloadFunc` | Called on [link preloading](#preloading) and navigation |
|
|
175
176
|
| `matchFilters` | `MatchFilters` | Additional constraints for matching parameters |
|
|
176
177
|
| `search` | `StandardSchemaV1` | Search-param validator; its types flow into `paths` and hooks |
|
|
177
178
|
| `info` | `Record<string, any>` | Arbitrary metadata, readable via `useRouteMatches` |
|
|
@@ -360,11 +361,11 @@ const router = createRouter({
|
|
|
360
361
|
});
|
|
361
362
|
```
|
|
362
363
|
|
|
363
|
-
The import only fires when something needs the subtree —
|
|
364
|
+
The import only fires when something needs the subtree — preloading a link into it, navigating into it, or the server matching a URL beneath it. Until then the tree carries a placeholder that knows every URL under `/admin` belongs to the subtree without knowing its contents (static sibling routes still win without triggering the load). Everything folds in as if the routes were inline:
|
|
364
365
|
|
|
365
366
|
- **Types**: TypeScript never runs the thunk — inference flows through the import's promise type, so `paths.admin.users(2)` typechecks (match filters and search schemas included) before any of the subtree's code exists client-side. The module's `default` or `routes` export is used. Only tables genuinely built at runtime (typed as plain `RouteDefinition[]`) degrade to untyped.
|
|
366
367
|
- **Navigation**: the table load folds into the navigation transition — the old screen holds until the subtree (and its matched components) are ready, exactly like a `lazy()` route component.
|
|
367
|
-
- **Preloading**:
|
|
368
|
+
- **Preloading**: a link preload kicks the table load, and when it lands the preload continues into the inner routes' components and `preload` functions — one cascading warm-up from the earliest possible moment.
|
|
368
369
|
- **Server**: SSR resolves matched boundaries during the render (use the streaming entry point `renderToStream` — awaiting it resolves with the settled HTML — as with any async work), and the single-flight collector resolves them before its data pass.
|
|
369
370
|
|
|
370
371
|
Resolution is cached per thunk and append-only: the tree never changes shape after a subtree lands, it just gets more specific. Keep thunks deterministic — `() => import(...)` — rather than switching tables on runtime state.
|
|
@@ -399,10 +400,10 @@ The source is called with **derived** arguments, not a live location — the cal
|
|
|
399
400
|
|
|
400
401
|
A route view is route-shaped on purpose — the address stays stable and `defineRoute` can check its params against the pattern — which means it is only callable as a route. When the same server component is also used elsewhere, keep it a plain (non-exported, non-endpoint) function and have the route view call it with `params.id`. The value `serverRouteComponent` returns is a component only so it fits the `component` field; mounting it any other way (through `lazy()`, or by hand) throws, since outside the match there are only merged params to call with.
|
|
401
402
|
|
|
402
|
-
The router mounts the resolved component with the outlet as `children`, so a server component can be a layout, and it calls the same source under preload intent — link
|
|
403
|
+
The router mounts the resolved component with the outlet as `children`, so a server component can be a layout, and it calls the same source under preload intent — link preloading, `usePreloadRoute`, the [single-flight collector](#server-integration) — with the same derived args. What that call _means_ is the source's: the router does not choose the cache strategy or own the key.
|
|
403
404
|
|
|
404
405
|
- `query(fn, key)`: link intent warms the entry the render reads, `revalidate("story")` and action responses refetch it, and the collector reproduces it so a mutation's response carries the route's fresh markup. Argument changes deliver into the mounted boundary — it morphs in place rather than remounting.
|
|
405
|
-
- [`liveQuery(fn, key)`](#livequery-experimental): the frame stream stays open and the channel owns it —
|
|
406
|
+
- [`liveQuery(fn, key)`](#livequery-experimental): the frame stream stays open and the channel owns it — a link preload connects it (held through the preload window, so a preloaded link is an open stream), `revalidate(key)` reconnects, and the mutation sweep and the single-flight collector both leave it alone — nothing pulls it server-side, and the stream is its own freshness.
|
|
406
407
|
|
|
407
408
|
Anything else callable with the args works too; wrapping is what gives dedupe, preload, and revalidation.
|
|
408
409
|
|
|
@@ -425,7 +426,7 @@ const routes = [{ component: serverRouteComponent(query(appShell, "shell")), chi
|
|
|
425
426
|
render(() => <Router routes={routes} />, document.body);
|
|
426
427
|
```
|
|
427
428
|
|
|
428
|
-
`children` is the only client position the router fills, and the helper's type says so: a server component that requires other props — event handlers, refs, named slots — is rejected. Those come from the client, so that route has a client half; write it as an ordinary route component around `
|
|
429
|
+
`children` is the only client position the router fills, and the helper's type says so: a server component that requires other props — event handlers, refs, named slots — is rejected. Those come from the client, so that route has a client half; write it as an ordinary route component around `dynamicComponent()`. Interaction that lives on the server — form posts to server actions via `action={addTodo}` — needs no client component at all.
|
|
429
430
|
|
|
430
431
|
### File-System Routes
|
|
431
432
|
|
|
@@ -502,14 +503,14 @@ There is no link component. Use `<a>`; the router intercepts same-origin clicks
|
|
|
502
503
|
|
|
503
504
|
Behavior modifiers are attributes, so they work identically in client, server-rendered, and third-party markup:
|
|
504
505
|
|
|
505
|
-
| attribute | description
|
|
506
|
-
| ---------- |
|
|
507
|
-
| `replace` | Replace the history entry instead of pushing
|
|
508
|
-
| `noscroll` | Turn off scrolling to the top after navigation
|
|
509
|
-
| `state` | JSON string [pushed](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState) onto the history stack
|
|
510
|
-
| `preload` |
|
|
511
|
-
| `link` | Marks a router link when `explicitLinks` is enabled
|
|
512
|
-
| `target` | Any value (e.g. `_self`) opts the anchor out of router handling
|
|
506
|
+
| attribute | description |
|
|
507
|
+
| ---------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
508
|
+
| `replace` | Replace the history entry instead of pushing |
|
|
509
|
+
| `noscroll` | Turn off scrolling to the top after navigation |
|
|
510
|
+
| `state` | JSON string [pushed](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState) onto the history stack |
|
|
511
|
+
| `preload` | `"false"` opts this link out of all [preloading](#preloading); `"viewport"` or `"eager"` opts it into that strategy |
|
|
512
|
+
| `link` | Marks a router link when `explicitLinks` is enabled |
|
|
513
|
+
| `target` | Any value (e.g. `_self`) opts the anchor out of router handling |
|
|
513
514
|
|
|
514
515
|
```tsx
|
|
515
516
|
<a href={paths.login} replace>Log in</a>
|
|
@@ -517,7 +518,7 @@ Behavior modifiers are attributes, so they work identically in client, server-re
|
|
|
517
518
|
<a href="https://example.com">External — untouched</a>
|
|
518
519
|
```
|
|
519
520
|
|
|
520
|
-
Active and pending state is styled with CSS — one vocabulary for every kind of link:
|
|
521
|
+
Active and pending state is styled with CSS — one vocabulary for every kind of link. `aria-current` and `data-active` are automatic; `data-pending` is opt-in:
|
|
521
522
|
|
|
522
523
|
```css
|
|
523
524
|
nav a[aria-current="page"] {
|
|
@@ -528,9 +529,19 @@ nav a[data-active] {
|
|
|
528
529
|
} /* exact or prefix match on the path */
|
|
529
530
|
a[data-pending] {
|
|
530
531
|
opacity: 0.6;
|
|
531
|
-
} /* target of in-flight navigation */
|
|
532
|
+
} /* target of in-flight navigation — needs `links: pendingLinks` */
|
|
532
533
|
```
|
|
533
534
|
|
|
535
|
+
`data-pending` is opt-in, so an app that never shows pending link state doesn't ship the code that computes it. Pass the `pendingLinks` plugin to the router:
|
|
536
|
+
|
|
537
|
+
```tsx
|
|
538
|
+
import { createRouter, pendingLinks } from "@solidjs/router";
|
|
539
|
+
|
|
540
|
+
const Router = createRouter({ routes, links: pendingLinks });
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
It marks the links whose path covers the in-flight destination of a link click or `navigate()` call — by the same path rule as `data-active` — until that navigation lands. Back/forward traversals don't mark links pending. `data-pending` agrees with `useLinkState().pending` at every moment; the hook works with or without the plugin.
|
|
544
|
+
|
|
534
545
|
One rule decides both, for anchors and `useLinkState` alike:
|
|
535
546
|
|
|
536
547
|
- **current** (`aria-current="page"`) — same path and same query, ignoring parameter order and the hash. On `/?filter=active`, `<a href="/?filter=active">` is current and `<a href="/">` is not.
|
|
@@ -553,9 +564,47 @@ function TabLink(props: { href: string; children: JSX.Element }) {
|
|
|
553
564
|
}
|
|
554
565
|
```
|
|
555
566
|
|
|
567
|
+
## Preloading
|
|
568
|
+
|
|
569
|
+
The router can warm a link's route before it is clicked: preloading **code** loads the matched routes' `lazy()` components (and any [lazy subtree](#lazy-route-subtrees) on the way), and preloading **data** also runs their [preload functions](#preload-functions) with `intent: "preload"`. Nothing is preloaded unless you opt in. Earlier `next` prereleases preloaded on hover, focus, and touch by default; that built-in is gone. A boolean `preloadLinks` is a type error and, in development, a warning — it preloads nothing.
|
|
570
|
+
|
|
571
|
+
```tsx
|
|
572
|
+
import { createRouter, intentPreload } from "@solidjs/router";
|
|
573
|
+
|
|
574
|
+
const Router = createRouter({ routes, preloadLinks: intentPreload() });
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
| strategy | preloads a link when | defaults |
|
|
578
|
+
| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
|
579
|
+
| `intentPreload({ delay = 20, data = true })` | the pointer rests on it for `delay` ms, it is focused (`focusin`), or a touch starts on it (`touchstart`). Moving over a link that already preloaded does not preload it again until the pointer leaves | code and data |
|
|
580
|
+
| `tapPreload({ data = true })` | a pointer goes down on it (`pointerdown`: mouse, touch, or pen), ahead of the click | code and data |
|
|
581
|
+
| `viewportPreload({ all = false, data = false, delay = 100, rootMargin })` | it has stayed in the viewport for `delay` ms and the browser is then idle (`requestIdleCallback`, or `setTimeout` if that is missing). Once per `href`; leaving before the idle flush cancels it, and a new `href` preloads again. One shared `IntersectionObserver`. `rootMargin` is passed through (the observer's own `"0px"` when omitted) | code |
|
|
582
|
+
| `eagerPreload({ all = false, data = false })` | the page has loaded and the browser is idle, including links mounted later. Once per `href`; a link removed before that flush is dropped | code |
|
|
583
|
+
|
|
584
|
+
Pass an array to combine strategies — viewport for code, intent for data:
|
|
585
|
+
|
|
586
|
+
```tsx
|
|
587
|
+
const Router = createRouter({
|
|
588
|
+
routes,
|
|
589
|
+
preloadLinks: [viewportPreload({ all: true }), intentPreload()]
|
|
590
|
+
});
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Strategies are client-only, and an app ships only the ones it imports. `intentPreload` and `tapPreload` apply to every router link. `viewportPreload` and `eagerPreload` apply only to links that name them, `<a href="/pricing" preload="viewport">` or `preload="eager"`, unless created with `{ all: true }`. They also skip preloading when the browser reports Save-Data or a 2g connection (`effectiveType` of `"2g"` or `"slow-2g"`).
|
|
594
|
+
|
|
595
|
+
The `href` and `preload` attribute are read when the preload runs, so a dynamic `href` is the one the link has then.
|
|
596
|
+
|
|
597
|
+
Hover, focus, and touch preloads do not check modifier keys or `defaultPrevented`, and neither does `tapPreload`'s `pointerdown`. A click still does: the router ignores it when `preventDefault` was already called, when it is not the primary button, or when alt, ctrl, meta, or shift is held.
|
|
598
|
+
|
|
599
|
+
Preloading is purely an optimization: without it every navigation still loads the same code and data, just after the click instead of before it. A link with `preload="false"` is never preloaded by any strategy.
|
|
600
|
+
|
|
601
|
+
Whether a strategy also preloads data is its own `data` option, not a per-link choice. `intentPreload` and `tapPreload` default to data because the user is about to click; a data preload runs only for route levels the navigation would change, and `query` keeps the result for the few seconds until the click arrives. `viewportPreload` and `eagerPreload` warm links that are merely on screen or already on the page, so they default to code only — a data preload there is worth it only when the cache outlives the moment of clicking, as with TanStack Query or an HTTP-cached `GET` server function. Pass `{ data: true }` to opt in.
|
|
602
|
+
|
|
603
|
+
`usePreloadRoute` triggers the same work by hand.
|
|
604
|
+
|
|
556
605
|
## Preload Functions
|
|
557
606
|
|
|
558
|
-
Even with smart caches, waterfalls happen when data fetching waits on view logic or lazy-loaded code. Preload functions start fetching data in parallel with loading the route — called when a route renders, and eagerly when
|
|
607
|
+
Even with smart caches, waterfalls happen when data fetching waits on view logic or lazy-loaded code. Preload functions start fetching data in parallel with loading the route — called when a route renders, and eagerly when the router [preloads a link](#preloading) with data.
|
|
559
608
|
|
|
560
609
|
```tsx
|
|
561
610
|
import { lazy } from "solid-js";
|
|
@@ -571,11 +620,11 @@ const routes = defineRoutes([{ path: "/users/:id", component: User, preload: pre
|
|
|
571
620
|
|
|
572
621
|
The preload function receives:
|
|
573
622
|
|
|
574
|
-
| key | type | description
|
|
575
|
-
| -------- | -------------------------------------------------- |
|
|
576
|
-
| params | object | The route parameters (same value as `useParams()` inside the route component)
|
|
577
|
-
| location | `{ pathname, search, hash, query, state, key }` | Path information (corresponds to [`useLocation()`](#uselocation))
|
|
578
|
-
| intent | `"initial" \| "navigate" \| "native" \| "preload"` | Why this is being called: `initial` — first render; `navigate` — router navigation; `native` — browser back/forward; `preload` — link
|
|
623
|
+
| key | type | description |
|
|
624
|
+
| -------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
625
|
+
| params | object | The route parameters (same value as `useParams()` inside the route component) |
|
|
626
|
+
| location | `{ pathname, search, hash, query, state, key }` | Path information (corresponds to [`useLocation()`](#uselocation)) |
|
|
627
|
+
| intent | `"initial" \| "navigate" \| "native" \| "preload"` | Why this is being called: `initial` — first render; `navigate` — router navigation; `native` — browser back/forward; `preload` — link preloading, not navigating |
|
|
579
628
|
|
|
580
629
|
The factory-level `preload` option is the app-wide counterpart: it runs once per mount/request with the merged params of every match, and its result reaches the root render-prop as `props.data`.
|
|
581
630
|
|
|
@@ -596,7 +645,7 @@ const getUser = query(async id => {
|
|
|
596
645
|
A query:
|
|
597
646
|
|
|
598
647
|
1. Dedupes on the server for the lifetime of the request.
|
|
599
|
-
2. Fills a preload cache in the browser lasting 5 seconds, so
|
|
648
|
+
2. Fills a preload cache in the browser lasting 5 seconds, so link preloads and route entry share one fetch.
|
|
600
649
|
3. Refetches reactively by key on action revalidation.
|
|
601
650
|
4. Serves as a back/forward cache for browser navigation up to 5 minutes; user-initiated navigation bypasses it.
|
|
602
651
|
|
|
@@ -783,17 +832,18 @@ Without a schema, `useSearchParams()` behaves as before: raw string values, merg
|
|
|
783
832
|
createRouter(config);
|
|
784
833
|
```
|
|
785
834
|
|
|
786
|
-
| option | type
|
|
787
|
-
| --------------- |
|
|
788
|
-
| `routes` | `RouteDefinition[]`
|
|
789
|
-
| `base` | `string`
|
|
790
|
-
| `preload` | `RoutePreloadFunc`
|
|
791
|
-
| `history` | `RouterHistory`
|
|
792
|
-
| `singleFlight` | `boolean`
|
|
793
|
-
| `actionBase` | `string`
|
|
794
|
-
| `preloadLinks` | `
|
|
795
|
-
| `explicitLinks` | `boolean`
|
|
796
|
-
| `
|
|
835
|
+
| option | type | description |
|
|
836
|
+
| --------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
|
837
|
+
| `routes` | `RouteDefinition[]` | The route tree — inline arrays infer literally; wrap extracted trees in `defineRoutes` |
|
|
838
|
+
| `base` | `string` | Base url to use for matching routes |
|
|
839
|
+
| `preload` | `RoutePreloadFunc` | App-wide preload: once per mount/request, result reaches the root render-prop as `props.data` |
|
|
840
|
+
| `history` | `RouterHistory` | History adapter; defaults to browser history on the client and the request URL on the server |
|
|
841
|
+
| `singleFlight` | `boolean` | Single-flight mutations, default `true` |
|
|
842
|
+
| `actionBase` | `string` | Root url for server actions, default `/_server` |
|
|
843
|
+
| `preloadLinks` | `LinkPreload \| LinkPreload[]` | [Link preload strategies](#preloading), e.g. `intentPreload()`; none by default. A boolean is a type error |
|
|
844
|
+
| `explicitLinks` | `boolean` | Require the `link` attribute for router handling instead of intercepting all anchors, default `false` |
|
|
845
|
+
| `links` | `LinksPlugin` | Link claims plugin — `pendingLinks` adds `data-pending` to the in-flight navigation's target |
|
|
846
|
+
| `transformUrl` | `(url: string) => string` | Rewrite URLs before matching |
|
|
797
847
|
|
|
798
848
|
The returned instance is the provider component and carries the static surface:
|
|
799
849
|
|
|
@@ -850,14 +900,24 @@ See [Typed Search Params](#typed-search-params). Reads are proxied — access pr
|
|
|
850
900
|
|
|
851
901
|
### useIsRouting
|
|
852
902
|
|
|
853
|
-
A signal indicating whether the router is processing a navigation — useful for pending UI while the next route and its data settle:
|
|
903
|
+
A signal indicating whether the router is processing a navigation — useful for pending UI while the next route and its data settle. It's the way to read routing state: `RouterContext` doesn't carry `isRouting` or `pendingTarget`.
|
|
854
904
|
|
|
855
905
|
```tsx
|
|
856
906
|
const isRouting = useIsRouting();
|
|
857
907
|
return <div classList={{ "grey-out": isRouting() }}>...</div>;
|
|
858
908
|
```
|
|
859
909
|
|
|
860
|
-
|
|
910
|
+
The router doesn't expose where a navigation is heading; read it off the location with Solid's `isPending` and `latest`. It covers back/forward traversals too:
|
|
911
|
+
|
|
912
|
+
```tsx
|
|
913
|
+
import { isPending, latest } from "solid-js";
|
|
914
|
+
|
|
915
|
+
const location = useLocation();
|
|
916
|
+
const target = () =>
|
|
917
|
+
isPending(() => location.pathname) ? latest(() => location.pathname) : undefined;
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
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. A link click or a native form submit the router handles runs in the interaction Solid records for that event, so the navigation or action belongs to the same record as any `onClick` work for that click. `attribution.history("navigation")` and `feedback().navigations` list them; nothing of this exists in production builds.
|
|
861
921
|
|
|
862
922
|
### useMatch
|
|
863
923
|
|
|
@@ -892,7 +952,7 @@ declare module "@solidjs/router" {
|
|
|
892
952
|
|
|
893
953
|
### usePreloadRoute
|
|
894
954
|
|
|
895
|
-
Returns a function to preload a route manually — the same work link
|
|
955
|
+
Returns a function to preload a route manually — the same work [link preloading](#preloading) triggers automatically. Accepts strings, URLs, and typed path nodes:
|
|
896
956
|
|
|
897
957
|
```tsx
|
|
898
958
|
const preload = usePreloadRoute();
|
|
@@ -905,7 +965,7 @@ Reactive `active`/`current`/`pending` state for [custom link components](#links)
|
|
|
905
965
|
|
|
906
966
|
- `current()` — same path and same query as the location, ignoring parameter order and the hash (what `aria-current="page"` reflects)
|
|
907
967
|
- `active()` — the location's path is the link's path or lives under it, query ignored (`data-active`); a root link (`/`, which resolves to the router's `base`) is exact-only
|
|
908
|
-
- `pending()` — the link's path is the target of an in-flight navigation (`data-pending`)
|
|
968
|
+
- `pending()` — the link's path is the target of an in-flight navigation, back/forward excluded (`data-pending`). Works without the [`pendingLinks`](#links) plugin, which only adds the attribute to plain anchors
|
|
909
969
|
|
|
910
970
|
Pass `{ end: true }` to make `active` (and `pending`) exact-path for any link.
|
|
911
971
|
|
|
@@ -1032,8 +1092,10 @@ Route props map 1:1 onto definition keys (`path`, `component`, `preload`, `match
|
|
|
1032
1092
|
- `<A href replace noScroll state>` → `<a href replace noscroll state>` (attributes, all lowercase)
|
|
1033
1093
|
- `activeClass` / `inactiveClass` → CSS attribute selectors on `[data-active]` / `[aria-current="page"]`
|
|
1034
1094
|
- `end` → style exact matches with `[aria-current="page"]` (which also compares the query) instead of `[data-active]`; the root path already only matches exactly
|
|
1095
|
+
- Pending link styling → `[data-pending]`, opt-in with `createRouter({ routes, links: pendingLinks })`
|
|
1035
1096
|
- Route-relative hrefs → typed `paths`; `useResolvedPath` / `useHref` remain for manual resolution
|
|
1036
1097
|
- Custom link components → `useLinkState`
|
|
1098
|
+
- Hover/focus preloading is opt-in → `createRouter({ routes, preloadLinks: intentPreload() })`; `preload="false"` on a link skips it entirely. The previous `next` prerelease preloaded on hover by default
|
|
1037
1099
|
|
|
1038
1100
|
### Removed and renamed
|
|
1039
1101
|
|
|
@@ -1042,6 +1104,7 @@ Route props map 1:1 onto definition keys (`path`, `component`, `preload`, `match
|
|
|
1042
1104
|
- `redirect` / `reload` → import from `@solidjs/web`; they're protocol-level and work without the router
|
|
1043
1105
|
- `json(data, init)` → `respond(data, init)` from `@solidjs/web`
|
|
1044
1106
|
- `cache` (deprecated alias) → `query`
|
|
1107
|
+
- `RouterContext`'s `isRouting` / `pendingTarget` → `useIsRouting()`; for the in-flight destination, the [`isPending`/`latest` recipe](#useisrouting)
|
|
1045
1108
|
|
|
1046
1109
|
### Data APIs (Solid 2)
|
|
1047
1110
|
|
package/dist/claims.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RouterContext } from "./types.js";
|
|
1
|
+
import type { LinksPlugin, RouterContext } from "./types.js";
|
|
2
2
|
export declare function setFormClaimHandler(handler: ((form: HTMLFormElement) => void) | undefined): void;
|
|
3
3
|
/**
|
|
4
4
|
* The compiler claims every `a[href]` and `form[action]` at creation, and the
|
|
@@ -11,9 +11,11 @@ export declare function setFormClaimHandler(handler: ((form: HTMLFormElement) =>
|
|
|
11
11
|
* included (parameter order aside)
|
|
12
12
|
* - `data-active` — pathname exact or prefix match (the router's root, its
|
|
13
13
|
* base path, exact only)
|
|
14
|
-
* - `data-pending` — the link is the target of an in-flight navigation
|
|
14
|
+
* - `data-pending` — the link is the target of an in-flight navigation;
|
|
15
|
+
* opt-in through `createRouter({ links: pendingLinks })`. Without the
|
|
16
|
+
* plugin, claims never read pending state and the sweep does not track it.
|
|
15
17
|
*
|
|
16
|
-
* The matching rule is `
|
|
18
|
+
* The matching rule is `linkMatcher`, shared with `useLinkState`. The router
|
|
17
19
|
* only touches an `aria-current` it wrote itself: one the author set (a
|
|
18
20
|
* stepper's `"step"`, a static `"page"`) is left in place, current or not.
|
|
19
21
|
*
|
|
@@ -26,4 +28,4 @@ export declare function setFormClaimHandler(handler: ((form: HTMLFormElement) =>
|
|
|
26
28
|
* are the same one-shot untracked refresh, reading the element's current
|
|
27
29
|
* `href` from the DOM.
|
|
28
30
|
*/
|
|
29
|
-
export declare function setupLinkClaims(router: RouterContext, explicitLinks?: boolean): void;
|
|
31
|
+
export declare function setupLinkClaims(router: RouterContext, explicitLinks?: boolean, links?: LinksPlugin): void;
|
package/dist/claims.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { registerElementClaim } from "@solidjs/web";
|
|
2
2
|
import { createRenderEffect, getOwner, onCleanup, untrack } from "solid-js";
|
|
3
|
-
import { isUnderBase,
|
|
3
|
+
import { isUnderBase, linkMatcher } from "./utils.js";
|
|
4
4
|
/**
|
|
5
5
|
* Claimed forms are handed to this slot instead of the claims importing the
|
|
6
6
|
* action module: the action side installs it on first action creation (see
|
|
@@ -22,9 +22,11 @@ export function setFormClaimHandler(handler) {
|
|
|
22
22
|
* included (parameter order aside)
|
|
23
23
|
* - `data-active` — pathname exact or prefix match (the router's root, its
|
|
24
24
|
* base path, exact only)
|
|
25
|
-
* - `data-pending` — the link is the target of an in-flight navigation
|
|
25
|
+
* - `data-pending` — the link is the target of an in-flight navigation;
|
|
26
|
+
* opt-in through `createRouter({ links: pendingLinks })`. Without the
|
|
27
|
+
* plugin, claims never read pending state and the sweep does not track it.
|
|
26
28
|
*
|
|
27
|
-
* The matching rule is `
|
|
29
|
+
* The matching rule is `linkMatcher`, shared with `useLinkState`. The router
|
|
28
30
|
* only touches an `aria-current` it wrote itself: one the author set (a
|
|
29
31
|
* stepper's `"step"`, a static `"page"`) is left in place, current or not.
|
|
30
32
|
*
|
|
@@ -37,8 +39,9 @@ export function setFormClaimHandler(handler) {
|
|
|
37
39
|
* are the same one-shot untracked refresh, reading the element's current
|
|
38
40
|
* `href` from the DOM.
|
|
39
41
|
*/
|
|
40
|
-
export function setupLinkClaims(router, explicitLinks) {
|
|
42
|
+
export function setupLinkClaims(router, explicitLinks, links) {
|
|
41
43
|
const basePath = router.base.path();
|
|
44
|
+
const plugin = links && links(router, basePath);
|
|
42
45
|
// per-element record; `owned` is whether the `aria-current` on the element
|
|
43
46
|
// is the router's, so it never writes over or removes an authored one
|
|
44
47
|
const claimed = new WeakMap();
|
|
@@ -72,26 +75,31 @@ export function setupLinkClaims(router, explicitLinks) {
|
|
|
72
75
|
return;
|
|
73
76
|
return url;
|
|
74
77
|
}
|
|
78
|
+
// every anchor matches against the same location, so its parse is shared
|
|
79
|
+
// until the location changes
|
|
80
|
+
let matched;
|
|
81
|
+
let match;
|
|
75
82
|
function linkState(a) {
|
|
76
83
|
// read reactive sources unconditionally so the owning effect stays
|
|
77
84
|
// subscribed even while the anchor is not router-managed
|
|
78
85
|
const location = router.location;
|
|
79
|
-
const routing =
|
|
86
|
+
const routing = plugin && plugin.track();
|
|
80
87
|
const url = managedUrl(a);
|
|
81
88
|
const target = url && url.pathname + url.search;
|
|
89
|
+
const key = location.pathname + location.search;
|
|
82
90
|
// no per-anchor `end` opt-out like useLinkState has
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
matchLink({ pathname: router.pendingTarget.value, search: "" }, target, basePath).active;
|
|
91
|
+
if (key !== matched) {
|
|
92
|
+
matched = key;
|
|
93
|
+
match = linkMatcher(location, basePath);
|
|
94
|
+
}
|
|
95
|
+
const { active, current } = match(target);
|
|
96
|
+
const pending = !!routing && plugin.pending(target);
|
|
90
97
|
return { active, pending, current };
|
|
91
98
|
}
|
|
92
99
|
function apply(a, rec, { active, pending, current }) {
|
|
93
100
|
active ? a.setAttribute("data-active", "") : a.removeAttribute("data-active");
|
|
94
|
-
|
|
101
|
+
if (plugin)
|
|
102
|
+
pending ? a.setAttribute("data-pending", "") : a.removeAttribute("data-pending");
|
|
95
103
|
// Ownership is read against the element, not just the record. A
|
|
96
104
|
// server-component morph resets attributes to the server HTML, which
|
|
97
105
|
// never carries router link state, then re-claims: an owned value that
|
|
@@ -114,9 +122,10 @@ export function setupLinkClaims(router, explicitLinks) {
|
|
|
114
122
|
}
|
|
115
123
|
const refresh = (a, rec) => untrack(() => apply(a, rec, linkState(a)));
|
|
116
124
|
// The one subscription for every anchor: compute tracks the sources
|
|
117
|
-
// linkState derives from (the
|
|
118
|
-
//
|
|
119
|
-
// assigned), the effect phase sweeps the
|
|
125
|
+
// linkState derives from (with the plugin, its pending read — the
|
|
126
|
+
// in-flight target is readable in the effect phase because the isRouting
|
|
127
|
+
// write flushes after the target is assigned), the effect phase sweeps the
|
|
128
|
+
// registry untracked.
|
|
120
129
|
//
|
|
121
130
|
// `transparent` keeps the effect invisible to the hydration id scheme.
|
|
122
131
|
// This setup is client-only, so an id-consuming node here has no server
|
|
@@ -124,7 +133,7 @@ export function setupLinkClaims(router, explicitLinks) {
|
|
|
124
133
|
// slot — lazy-route lookups miss and hydration leaves server nodes
|
|
125
134
|
// unclaimed. (The option is honored by the runtime but missing from the
|
|
126
135
|
// published EffectOptions type, hence the cast.)
|
|
127
|
-
createRenderEffect(() => (router.location.pathname, router.location.search,
|
|
136
|
+
createRenderEffect(() => (router.location.pathname, router.location.search, plugin && plugin.track()), () => registry.forEach(a => refresh(a, claimed.get(a))), { transparent: true });
|
|
128
137
|
onCleanup(registerElementClaim(node => {
|
|
129
138
|
const name = node.nodeName.toUpperCase();
|
|
130
139
|
if (name === "FORM")
|
package/dist/data/events.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RouterContext } from "../types.js";
|
|
1
|
+
import type { LinkPreload, RouterContext } from "../types.js";
|
|
2
2
|
/**
|
|
3
3
|
* The submit delegation consults this slot instead of importing the action
|
|
4
4
|
* module: the action side installs its handler on first action creation
|
|
@@ -7,11 +7,18 @@ import type { RouterContext } from "../types.js";
|
|
|
7
7
|
*/
|
|
8
8
|
export type RouterFormHandler = (evt: SubmitEvent, router: RouterContext, actionBase: string) => void;
|
|
9
9
|
export declare function setRouterFormHandler(handler: RouterFormHandler | undefined): void;
|
|
10
|
+
/**
|
|
11
|
+
* Link preload strategies reach route preloading through this slot instead
|
|
12
|
+
* of the event wiring importing it: a strategy installs it when it runs (see
|
|
13
|
+
* preload.ts), so an app with no strategy and no `usePreloadRoute` never
|
|
14
|
+
* ships route preloading.
|
|
15
|
+
*/
|
|
16
|
+
declare let linkPreloader: ((router: RouterContext, url: URL, data: boolean) => void) | undefined;
|
|
17
|
+
export declare function setLinkPreloader(preloader: typeof linkPreloader): void;
|
|
10
18
|
type NativeEventConfig = {
|
|
11
|
-
preload?:
|
|
19
|
+
preload?: LinkPreload | readonly LinkPreload[];
|
|
12
20
|
explicitLinks?: boolean;
|
|
13
21
|
actionBase?: string;
|
|
14
|
-
transformUrl?: (url: string) => string;
|
|
15
22
|
};
|
|
16
|
-
export declare function setupNativeEvents({ preload, explicitLinks, actionBase
|
|
23
|
+
export declare function setupNativeEvents({ preload, explicitLinks, actionBase }?: NativeEventConfig): (router: RouterContext) => void;
|
|
17
24
|
export {};
|
package/dist/data/events.js
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
import { delegateEvents } from "@solidjs/web";
|
|
1
|
+
import { delegateEvents, dispatchAsInteraction } from "@solidjs/web";
|
|
2
2
|
import { onCleanup } from "solid-js";
|
|
3
3
|
import { isUnderBase } from "../utils.js";
|
|
4
4
|
let formHandler;
|
|
5
5
|
export function setRouterFormHandler(handler) {
|
|
6
6
|
formHandler = handler;
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Link preload strategies reach route preloading through this slot instead
|
|
10
|
+
* of the event wiring importing it: a strategy installs it when it runs (see
|
|
11
|
+
* preload.ts), so an app with no strategy and no `usePreloadRoute` never
|
|
12
|
+
* ships route preloading.
|
|
13
|
+
*/
|
|
14
|
+
let linkPreloader;
|
|
15
|
+
export function setLinkPreloader(preloader) {
|
|
16
|
+
linkPreloader = preloader;
|
|
17
|
+
}
|
|
18
|
+
export function setupNativeEvents({ preload, explicitLinks = false, actionBase = "/_server" } = {}) {
|
|
9
19
|
return (router) => {
|
|
10
20
|
const basePath = router.base.path();
|
|
11
21
|
const navigateFromRoute = router.navigatorFactory(router.base);
|
|
12
|
-
let preloadTimeout;
|
|
13
|
-
let lastElement;
|
|
14
22
|
function isSvg(el) {
|
|
15
23
|
return el.namespaceURI === "http://www.w3.org/2000/svg";
|
|
16
24
|
}
|
|
@@ -22,10 +30,19 @@ export function setupNativeEvents({ preload = true, explicitLinks = false, actio
|
|
|
22
30
|
evt.ctrlKey ||
|
|
23
31
|
evt.shiftKey)
|
|
24
32
|
return;
|
|
33
|
+
return findAnchor(evt);
|
|
34
|
+
}
|
|
35
|
+
// no button or modifier gate: focus and touch events carry neither
|
|
36
|
+
function findAnchor(evt) {
|
|
25
37
|
const a = evt
|
|
26
38
|
.composedPath()
|
|
27
39
|
.find(el => el instanceof Node && el.nodeName.toUpperCase() === "A");
|
|
28
|
-
|
|
40
|
+
const url = a && anchorUrl(a);
|
|
41
|
+
return url && [a, url];
|
|
42
|
+
}
|
|
43
|
+
/** The anchor's URL when the router manages it, else `undefined`. */
|
|
44
|
+
function anchorUrl(a) {
|
|
45
|
+
if (explicitLinks && !a.hasAttribute("link"))
|
|
29
46
|
return;
|
|
30
47
|
const svg = isSvg(a);
|
|
31
48
|
const href = svg ? a.href.baseVal : a.href;
|
|
@@ -42,7 +59,7 @@ export function setupNativeEvents({ preload = true, explicitLinks = false, actio
|
|
|
42
59
|
return;
|
|
43
60
|
if (url.origin !== window.location.origin || !isUnderBase(url.pathname, basePath))
|
|
44
61
|
return;
|
|
45
|
-
return
|
|
62
|
+
return url;
|
|
46
63
|
}
|
|
47
64
|
function handleAnchorClick(evt) {
|
|
48
65
|
const res = handleAnchor(evt);
|
|
@@ -52,34 +69,14 @@ export function setupNativeEvents({ preload = true, explicitLinks = false, actio
|
|
|
52
69
|
const to = router.parsePath(url.pathname + url.search + url.hash);
|
|
53
70
|
const state = a.getAttribute("state");
|
|
54
71
|
evt.preventDefault();
|
|
55
|
-
|
|
72
|
+
// Only a click the router acts on joins the event's interaction: this
|
|
73
|
+
// listener hears every click on the document.
|
|
74
|
+
dispatchAsInteraction(evt, () => navigateFromRoute(to, {
|
|
56
75
|
resolve: false,
|
|
57
76
|
replace: a.hasAttribute("replace"),
|
|
58
77
|
scroll: !a.hasAttribute("noscroll"),
|
|
59
78
|
state: state ? JSON.parse(state) : undefined
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
function handleAnchorPreload(evt) {
|
|
63
|
-
const res = handleAnchor(evt);
|
|
64
|
-
if (!res)
|
|
65
|
-
return;
|
|
66
|
-
const [a, url] = res;
|
|
67
|
-
transformUrl && (url.pathname = transformUrl(url.pathname));
|
|
68
|
-
router.preloadRoute(url, a.getAttribute("preload") !== "false");
|
|
69
|
-
}
|
|
70
|
-
function handleAnchorMove(evt) {
|
|
71
|
-
clearTimeout(preloadTimeout);
|
|
72
|
-
const res = handleAnchor(evt);
|
|
73
|
-
if (!res)
|
|
74
|
-
return (lastElement = null);
|
|
75
|
-
const [a, url] = res;
|
|
76
|
-
if (lastElement === a)
|
|
77
|
-
return;
|
|
78
|
-
transformUrl && (url.pathname = transformUrl(url.pathname));
|
|
79
|
-
preloadTimeout = setTimeout(() => {
|
|
80
|
-
router.preloadRoute(url, a.getAttribute("preload") !== "false");
|
|
81
|
-
lastElement = a;
|
|
82
|
-
}, 20);
|
|
79
|
+
}));
|
|
83
80
|
}
|
|
84
81
|
function handleFormSubmit(evt) {
|
|
85
82
|
if (formHandler)
|
|
@@ -102,27 +99,30 @@ export function setupNativeEvents({ preload = true, explicitLinks = false, actio
|
|
|
102
99
|
const path = router.parsePath(url.pathname + url.search);
|
|
103
100
|
if (!path.startsWith(actionBase) || form.method.toUpperCase() !== "POST")
|
|
104
101
|
return;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
102
|
+
// The import sits inside the check so bundlers drop the server-forms
|
|
103
|
+
// chunk. An early return above a still-present import() does not.
|
|
104
|
+
// Missing stays off: an undefined constant does not fold.
|
|
105
|
+
if (typeof __SOLID_SERVER_COMPONENTS__ !== "undefined" && __SOLID_SERVER_COMPONENTS__) {
|
|
106
|
+
evt.preventDefault();
|
|
107
|
+
const data = new FormData(form, evt.submitter);
|
|
108
|
+
import("./serverForms.js").then(m => m.submitServerForm(router, path, form, data));
|
|
109
|
+
}
|
|
108
110
|
}
|
|
111
|
+
const handleSubmit = (evt) => dispatchAsInteraction(evt, () => handleFormSubmit(evt));
|
|
109
112
|
// ensure delegated event run first
|
|
110
113
|
delegateEvents(["click", "submit"]);
|
|
111
114
|
document.addEventListener("click", handleAnchorClick);
|
|
112
|
-
|
|
113
|
-
document.addEventListener("mousemove", handleAnchorMove, { passive: true });
|
|
114
|
-
document.addEventListener("focusin", handleAnchorPreload, { passive: true });
|
|
115
|
-
document.addEventListener("touchstart", handleAnchorPreload, { passive: true });
|
|
116
|
-
}
|
|
117
|
-
document.addEventListener("submit", handleFormSubmit);
|
|
115
|
+
document.addEventListener("submit", handleSubmit);
|
|
118
116
|
onCleanup(() => {
|
|
119
117
|
document.removeEventListener("click", handleAnchorClick);
|
|
120
|
-
|
|
121
|
-
document.removeEventListener("mousemove", handleAnchorMove);
|
|
122
|
-
document.removeEventListener("focusin", handleAnchorPreload);
|
|
123
|
-
document.removeEventListener("touchstart", handleAnchorPreload);
|
|
124
|
-
}
|
|
125
|
-
document.removeEventListener("submit", handleFormSubmit);
|
|
118
|
+
document.removeEventListener("submit", handleSubmit);
|
|
126
119
|
});
|
|
120
|
+
// the pre-strategy boolean option is ignored (a dev warning names it)
|
|
121
|
+
if (preload && preload !== true)
|
|
122
|
+
[].concat(preload).forEach(strategy => strategy({
|
|
123
|
+
anchor: findAnchor,
|
|
124
|
+
url: anchorUrl,
|
|
125
|
+
preload: (url, data) => linkPreloader(router, url, data)
|
|
126
|
+
}));
|
|
127
127
|
};
|
|
128
128
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -19,11 +19,14 @@ export interface RouteInfo {
|
|
|
19
19
|
}
|
|
20
20
|
export * from "./routers/index.js";
|
|
21
21
|
export * from "./lifecycle.js";
|
|
22
|
-
export { useHref, useIsRouting, useLinkState, useLocation, useMatch, useNavigate,
|
|
22
|
+
export { useHref, useIsRouting, useLinkState, useLocation, useMatch, useNavigate, useParams, useResolvedPath, useRouteMatches, useSearchParams, RouterContextObj as RouterContext } from "./routing.js";
|
|
23
23
|
export type { LinkState } from "./routing.js";
|
|
24
|
+
export { pendingLinks } from "./pending.js";
|
|
25
|
+
export { eagerPreload, intentPreload, tapPreload, viewportPreload } from "./preload.js";
|
|
26
|
+
export { usePreloadRoute } from "./preloadRoute.js";
|
|
24
27
|
export { mergeSearchString as _mergeSearchString } from "./utils.js";
|
|
25
28
|
export { int } from "./paths.js";
|
|
26
29
|
export { serverRouteComponent } from "./serverRouteComponent.js";
|
|
27
30
|
export type { RoutePaths, PathParamsOf, PathEnd, TypedMatchFilter, DefaultSearchTypes } from "./paths.js";
|
|
28
31
|
export * from "./data/index.js";
|
|
29
|
-
export type { Location, LocationChange, LocationWrite, SearchParams, MatchFilter, MatchFilters, NavigateOptions, Navigator, OutputMatch, Params, PathMatch, RouteComponent, RouteParams, RouteProps, RouteSectionProps, RoutePreloadFunc, RoutePreloadFuncArgs, RouteDefinition, RouteDescription, RouteMatch, RouterIntegration, RouterUtils, SetParams, SetSearchParams, ServerRouteArgs, ServerRouteParams, ServerRouteFunction, ServerRouteView, Submission, BeforeLeaveEventArgs, TypedPath, TypedSearchPath, StandardSchemaV1 } from "./types.js";
|
|
32
|
+
export type { LinkPreload, LinksPlugin, Location, LocationChange, LocationWrite, SearchParams, MatchFilter, MatchFilters, NavigateOptions, Navigator, OutputMatch, Params, PathMatch, RouteComponent, RouteParams, RouteProps, RouteSectionProps, RoutePreloadFunc, RoutePreloadFuncArgs, RouteDefinition, RouteDescription, RouteMatch, RouterIntegration, RouterUtils, SetParams, SetSearchParams, ServerRouteArgs, ServerRouteParams, ServerRouteFunction, ServerRouteView, Submission, BeforeLeaveEventArgs, TypedPath, TypedSearchPath, StandardSchemaV1 } from "./types.js";
|