@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 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`, `data-active`, and `data-pending` automatically via compiler-claimed anchors
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 on link hover/focus
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 preload intent (hover/focus) and navigation |
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 — hovering 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
+ 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**: hover intent 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
+ - **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 hover, `preloadRoute`, 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
+ 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 — hover connects it (held through the preload window, so a hovered 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
+ - [`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 `dynamic()`. Interaction that lives on the server — form posts to server actions via `action={addTodo}` — needs no client component at all.
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` | Set to `"false"` to opt this link out of hover/focus preloading |
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 links are hovered or focused.
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 hover/focus, not navigating |
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 hover preloads and route entry share one fetch.
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 | description |
787
- | --------------- | ------------------------- | ----------------------------------------------------------------------------------------------------- |
788
- | `routes` | `RouteDefinition[]` | The route tree — inline arrays infer literally; wrap extracted trees in `defineRoutes` |
789
- | `base` | `string` | Base url to use for matching routes |
790
- | `preload` | `RoutePreloadFunc` | App-wide preload: once per mount/request, result reaches the root render-prop as `props.data` |
791
- | `history` | `RouterHistory` | History adapter; defaults to browser history on the client and the request URL on the server |
792
- | `singleFlight` | `boolean` | Single-flight mutations, default `true` |
793
- | `actionBase` | `string` | Root url for server actions, default `/_server` |
794
- | `preloadLinks` | `boolean` | Preload route code/data on link hover and focus, default `true` |
795
- | `explicitLinks` | `boolean` | Require the `link` attribute for router handling instead of intercepting all anchors, default `false` |
796
- | `transformUrl` | `(url: string) => string` | Rewrite URLs before matching |
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
- 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.history("navigation")` and `feedback().navigations` list them; nothing of this exists in production builds.
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 hover/focus triggers automatically. Accepts strings, URLs, and typed path nodes:
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 `matchLink`, shared with `useLinkState`. The router
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, matchLink } from "./utils.js";
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 `matchLink`, shared with `useLinkState`. The router
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 = router.isRouting();
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
- const { active, current } = matchLink(location, target, basePath);
84
- // effects observe the committed location during a transition, so the
85
- // in-flight target comes from pendingTarget — readable here because the
86
- // isRouting write flushes after the target is assigned
87
- const pending = routing &&
88
- !!router.pendingTarget &&
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
- pending ? a.setAttribute("data-pending", "") : a.removeAttribute("data-pending");
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 in-flight pendingTarget is readable in the
118
- // effect phase because the isRouting write flushes after the target is
119
- // assigned), the effect phase sweeps the registry untracked.
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, router.isRouting()), () => registry.forEach(a => refresh(a, claimed.get(a))), { transparent: true });
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")
@@ -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?: boolean;
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, transformUrl }?: NativeEventConfig): (router: RouterContext) => void;
23
+ export declare function setupNativeEvents({ preload, explicitLinks, actionBase }?: NativeEventConfig): (router: RouterContext) => void;
17
24
  export {};
@@ -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
- export function setupNativeEvents({ preload = true, explicitLinks = false, actionBase = "/_server", transformUrl } = {}) {
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
- if (!a || (explicitLinks && !a.hasAttribute("link")))
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 [a, url];
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
- navigateFromRoute(to, {
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
- evt.preventDefault();
106
- const data = new FormData(form, evt.submitter);
107
- import("./serverForms.js").then(m => m.submitServerForm(router, path, form, data));
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
- if (preload) {
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
- if (preload) {
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, usePreloadRoute, useParams, useResolvedPath, useRouteMatches, useSearchParams, RouterContextObj as RouterContext } from "./routing.js";
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";