@solidjs/router 2.0.0-next.36 → 2.0.0-next.37

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,7 +17,7 @@ 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
22
  - **Preload Functions**: parallel data fetching following the render-as-you-fetch pattern, triggered eagerly on link hover/focus
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
@@ -425,7 +425,7 @@ const routes = [{ component: serverRouteComponent(query(appShell, "shell")), chi
425
425
  render(() => <Router routes={routes} />, document.body);
426
426
  ```
427
427
 
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.
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 `dynamicComponent()`. Interaction that lives on the server — form posts to server actions via `action={addTodo}` — needs no client component at all.
429
429
 
430
430
  ### File-System Routes
431
431
 
@@ -517,7 +517,7 @@ Behavior modifiers are attributes, so they work identically in client, server-re
517
517
  <a href="https://example.com">External — untouched</a>
518
518
  ```
519
519
 
520
- Active and pending state is styled with CSS — one vocabulary for every kind of link:
520
+ 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
521
 
522
522
  ```css
523
523
  nav a[aria-current="page"] {
@@ -528,9 +528,19 @@ nav a[data-active] {
528
528
  } /* exact or prefix match on the path */
529
529
  a[data-pending] {
530
530
  opacity: 0.6;
531
- } /* target of in-flight navigation */
531
+ } /* target of in-flight navigation — needs `links: pendingLinks` */
532
532
  ```
533
533
 
534
+ `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:
535
+
536
+ ```tsx
537
+ import { createRouter, pendingLinks } from "@solidjs/router";
538
+
539
+ const Router = createRouter({ routes, links: pendingLinks });
540
+ ```
541
+
542
+ 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.
543
+
534
544
  One rule decides both, for anchors and `useLinkState` alike:
535
545
 
536
546
  - **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.
@@ -793,6 +803,7 @@ createRouter(config);
793
803
  | `actionBase` | `string` | Root url for server actions, default `/_server` |
794
804
  | `preloadLinks` | `boolean` | Preload route code/data on link hover and focus, default `true` |
795
805
  | `explicitLinks` | `boolean` | Require the `link` attribute for router handling instead of intercepting all anchors, default `false` |
806
+ | `links` | `LinksPlugin` | Link claims plugin — `pendingLinks` adds `data-pending` to the in-flight navigation's target |
796
807
  | `transformUrl` | `(url: string) => string` | Rewrite URLs before matching |
797
808
 
798
809
  The returned instance is the provider component and carries the static surface:
@@ -850,14 +861,24 @@ See [Typed Search Params](#typed-search-params). Reads are proxied — access pr
850
861
 
851
862
  ### useIsRouting
852
863
 
853
- A signal indicating whether the router is processing a navigation — useful for pending UI while the next route and its data settle:
864
+ 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
865
 
855
866
  ```tsx
856
867
  const isRouting = useIsRouting();
857
868
  return <div classList={{ "grey-out": isRouting() }}>...</div>;
858
869
  ```
859
870
 
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.
871
+ 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:
872
+
873
+ ```tsx
874
+ import { isPending, latest } from "solid-js";
875
+
876
+ const location = useLocation();
877
+ const target = () =>
878
+ isPending(() => location.pathname) ? latest(() => location.pathname) : undefined;
879
+ ```
880
+
881
+ 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
882
 
862
883
  ### useMatch
863
884
 
@@ -905,7 +926,7 @@ Reactive `active`/`current`/`pending` state for [custom link components](#links)
905
926
 
906
927
  - `current()` — same path and same query as the location, ignoring parameter order and the hash (what `aria-current="page"` reflects)
907
928
  - `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`)
929
+ - `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
930
 
910
931
  Pass `{ end: true }` to make `active` (and `pending`) exact-path for any link.
911
932
 
@@ -1032,6 +1053,7 @@ Route props map 1:1 onto definition keys (`path`, `component`, `preload`, `match
1032
1053
  - `<A href replace noScroll state>` → `<a href replace noscroll state>` (attributes, all lowercase)
1033
1054
  - `activeClass` / `inactiveClass` → CSS attribute selectors on `[data-active]` / `[aria-current="page"]`
1034
1055
  - `end` → style exact matches with `[aria-current="page"]` (which also compares the query) instead of `[data-active]`; the root path already only matches exactly
1056
+ - Pending link styling → `[data-pending]`, opt-in with `createRouter({ routes, links: pendingLinks })`
1035
1057
  - Route-relative hrefs → typed `paths`; `useResolvedPath` / `useHref` remain for manual resolution
1036
1058
  - Custom link components → `useLinkState`
1037
1059
 
@@ -1042,6 +1064,7 @@ Route props map 1:1 onto definition keys (`path`, `component`, `preload`, `match
1042
1064
  - `redirect` / `reload` → import from `@solidjs/web`; they're protocol-level and work without the router
1043
1065
  - `json(data, init)` → `respond(data, init)` from `@solidjs/web`
1044
1066
  - `cache` (deprecated alias) → `query`
1067
+ - `RouterContext`'s `isRouting` / `pendingTarget` → `useIsRouting()`; for the in-flight destination, the [`isPending`/`latest` recipe](#useisrouting)
1045
1068
 
1046
1069
  ### Data APIs (Solid 2)
1047
1070
 
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,7 +11,9 @@ 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
18
  * The matching rule is `matchLink`, shared with `useLinkState`. The router
17
19
  * only touches an `aria-current` it wrote itself: one the author set (a
@@ -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
@@ -22,7 +22,9 @@ 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
29
  * The matching rule is `matchLink`, shared with `useLinkState`. The router
28
30
  * only touches an `aria-current` it wrote itself: one the author set (a
@@ -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();
@@ -76,22 +79,18 @@ export function setupLinkClaims(router, explicitLinks) {
76
79
  // read reactive sources unconditionally so the owning effect stays
77
80
  // subscribed even while the anchor is not router-managed
78
81
  const location = router.location;
79
- const routing = router.isRouting();
82
+ const routing = plugin && plugin.track();
80
83
  const url = managedUrl(a);
81
84
  const target = url && url.pathname + url.search;
82
85
  // no per-anchor `end` opt-out like useLinkState has
83
86
  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;
87
+ const pending = !!routing && plugin.pending(target);
90
88
  return { active, pending, current };
91
89
  }
92
90
  function apply(a, rec, { active, pending, current }) {
93
91
  active ? a.setAttribute("data-active", "") : a.removeAttribute("data-active");
94
- pending ? a.setAttribute("data-pending", "") : a.removeAttribute("data-pending");
92
+ if (plugin)
93
+ pending ? a.setAttribute("data-pending", "") : a.removeAttribute("data-pending");
95
94
  // Ownership is read against the element, not just the record. A
96
95
  // server-component morph resets attributes to the server HTML, which
97
96
  // never carries router link state, then re-claims: an owned value that
@@ -114,9 +113,10 @@ export function setupLinkClaims(router, explicitLinks) {
114
113
  }
115
114
  const refresh = (a, rec) => untrack(() => apply(a, rec, linkState(a)));
116
115
  // 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.
116
+ // linkState derives from (with the plugin, its pending read — the
117
+ // in-flight target is readable in the effect phase because the isRouting
118
+ // write flushes after the target is assigned), the effect phase sweeps the
119
+ // registry untracked.
120
120
  //
121
121
  // `transparent` keeps the effect invisible to the hydration id scheme.
122
122
  // This setup is client-only, so an id-consuming node here has no server
@@ -124,7 +124,7 @@ export function setupLinkClaims(router, explicitLinks) {
124
124
  // slot — lazy-route lookups miss and hydration leaves server nodes
125
125
  // unclaimed. (The option is honored by the runtime but missing from the
126
126
  // 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 });
127
+ createRenderEffect(() => (router.location.pathname, router.location.search, plugin && plugin.track()), () => registry.forEach(a => refresh(a, claimed.get(a))), { transparent: true });
128
128
  onCleanup(registerElementClaim(node => {
129
129
  const name = node.nodeName.toUpperCase();
130
130
  if (name === "FORM")
@@ -1,4 +1,4 @@
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;
@@ -52,12 +52,14 @@ export function setupNativeEvents({ preload = true, explicitLinks = false, actio
52
52
  const to = router.parsePath(url.pathname + url.search + url.hash);
53
53
  const state = a.getAttribute("state");
54
54
  evt.preventDefault();
55
- navigateFromRoute(to, {
55
+ // Only a click the router acts on joins the event's interaction: this
56
+ // listener hears every click on the document.
57
+ dispatchAsInteraction(evt, () => navigateFromRoute(to, {
56
58
  resolve: false,
57
59
  replace: a.hasAttribute("replace"),
58
60
  scroll: !a.hasAttribute("noscroll"),
59
61
  state: state ? JSON.parse(state) : undefined
60
- });
62
+ }));
61
63
  }
62
64
  function handleAnchorPreload(evt) {
63
65
  const res = handleAnchor(evt);
@@ -106,15 +108,17 @@ export function setupNativeEvents({ preload = true, explicitLinks = false, actio
106
108
  const data = new FormData(form, evt.submitter);
107
109
  import("./serverForms.js").then(m => m.submitServerForm(router, path, form, data));
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);
115
+ // preloads are not interactions: those listeners run outside any frame
112
116
  if (preload) {
113
117
  document.addEventListener("mousemove", handleAnchorMove, { passive: true });
114
118
  document.addEventListener("focusin", handleAnchorPreload, { passive: true });
115
119
  document.addEventListener("touchstart", handleAnchorPreload, { passive: true });
116
120
  }
117
- document.addEventListener("submit", handleFormSubmit);
121
+ document.addEventListener("submit", handleSubmit);
118
122
  onCleanup(() => {
119
123
  document.removeEventListener("click", handleAnchorClick);
120
124
  if (preload) {
@@ -122,7 +126,7 @@ export function setupNativeEvents({ preload = true, explicitLinks = false, actio
122
126
  document.removeEventListener("focusin", handleAnchorPreload);
123
127
  document.removeEventListener("touchstart", handleAnchorPreload);
124
128
  }
125
- document.removeEventListener("submit", handleFormSubmit);
129
+ document.removeEventListener("submit", handleSubmit);
126
130
  });
127
131
  };
128
132
  }
package/dist/index.d.ts CHANGED
@@ -21,9 +21,10 @@ export * from "./routers/index.js";
21
21
  export * from "./lifecycle.js";
22
22
  export { useHref, useIsRouting, useLinkState, useLocation, useMatch, useNavigate, usePreloadRoute, useParams, useResolvedPath, useRouteMatches, useSearchParams, RouterContextObj as RouterContext } from "./routing.js";
23
23
  export type { LinkState } from "./routing.js";
24
+ export { pendingLinks } from "./pending.js";
24
25
  export { mergeSearchString as _mergeSearchString } from "./utils.js";
25
26
  export { int } from "./paths.js";
26
27
  export { serverRouteComponent } from "./serverRouteComponent.js";
27
28
  export type { RoutePaths, PathParamsOf, PathEnd, TypedMatchFilter, DefaultSearchTypes } from "./paths.js";
28
29
  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";
30
+ export type { 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";