@solidjs/router 2.0.0-next.25 → 2.0.0-next.27

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
@@ -10,7 +10,7 @@
10
10
 
11
11
  </div>
12
12
 
13
- **Solid Router** brings fine-grained reactivity to route navigation. Routes are config objects — the single source of truth for matching *and* types — and the router upgrades HTML's own interaction verbs instead of wrapping them: `<a href={path}>` and `<form action={action}>` carry typed, URL-addressable values on real platform elements, intercepted by delegation, decorated with a shared attribute vocabulary, and fully functional without JavaScript.
13
+ **Solid Router** brings fine-grained reactivity to route navigation. Routes are config objects — the single source of truth for matching _and_ types — and the router upgrades HTML's own interaction verbs instead of wrapping them: `<a href={path}>` and `<form action={action}>` carry typed, URL-addressable values on real platform elements, intercepted by delegation, decorated with a shared attribute vocabulary, and fully functional without JavaScript.
14
14
 
15
15
  Explore the official [documentation](https://docs.solidjs.com/solid-router) for detailed guides and examples.
16
16
 
@@ -36,6 +36,7 @@ Explore the official [documentation](https://docs.solidjs.com/solid-router) for
36
36
  - [Multiple Paths](#multiple-paths)
37
37
  - [Nested Routes](#nested-routes)
38
38
  - [Lazy Route Subtrees](#lazy-route-subtrees)
39
+ - [Server Component Routes (experimental)](#server-component-routes-experimental)
39
40
  - [File-System Routes](#file-system-routes)
40
41
  - [Typed Paths](#typed-paths)
41
42
  - [Links](#links)
@@ -89,7 +90,13 @@ When a tree is composed across files (feature subtrees), wrap the extracted arra
89
90
  ```tsx
90
91
  // features/admin/routes.ts
91
92
  export const adminRoutes = defineRoutes([
92
- { path: "/admin", component: Admin, children: [/* ... */] }
93
+ {
94
+ path: "/admin",
95
+ component: Admin,
96
+ children: [
97
+ /* ... */
98
+ ]
99
+ }
93
100
  ]);
94
101
 
95
102
  // app/router.ts
@@ -138,19 +145,19 @@ The API splits across two surfaces, and the line between them is precise: **coul
138
145
 
139
146
  The instance is shared — one module-level object serving every mount, every request, every test. It is deliberately non-stateful (on the server there are many "current locations" at once), so it carries only the app's static routing vocabulary. Hooks read the live session from context.
140
147
 
141
- | Instance — facts about the *app* | Hooks — facts about the *session* |
142
- | --- | --- |
143
- | `paths` — how to spell URLs | `useLocation`, `useParams` — where am I |
144
- | `match(url)` — how would a URL match | `useNavigate`, `usePreloadRoute` — move / warm |
145
- | `routes`, `config` — what exists | `useIsRouting`, `useRouteMatches`, `useSearchParams` — live state |
148
+ | Instance — facts about the _app_ | Hooks — facts about the _session_ |
149
+ | ------------------------------------ | ----------------------------------------------------------------- |
150
+ | `paths` — how to spell URLs | `useLocation`, `useParams` — where am I |
151
+ | `match(url)` — how would a URL match | `useNavigate`, `usePreloadRoute` — move / warm |
152
+ | `routes`, `config` — what exists | `useIsRouting`, `useRouteMatches`, `useSearchParams` — live state |
146
153
 
147
154
  They compose as noun and verb — the instance supplies a typed URL, the hook acts on the current session:
148
155
 
149
156
  ```tsx
150
157
  const navigate = useNavigate();
151
- navigate(paths.users(2)); // verb(noun)
158
+ navigate(paths.users(2)); // verb(noun)
152
159
 
153
- const params = useParams(paths.users); // hook, typed by the instance
160
+ const params = useParams(paths.users); // hook, typed by the instance
154
161
  ```
155
162
 
156
163
  **Hooks are the default; import the router only when you need typed URLs or matching outside a render.** Components that only read their session (params, location, string-path navigation) never need the instance — which also means component files don't form import cycles with the router module that references them in its config.
@@ -159,17 +166,17 @@ const params = useParams(paths.users); // hook, typed by the instance
159
166
 
160
167
  A route definition supports:
161
168
 
162
- | key | type | description |
163
- | -------------- | --------------------------------------- | ------------------------------------------------------------------ |
164
- | `path` | `string \| string[]` | Path partial for this route segment |
165
- | `component` | `Component` | Component rendered for the matched segment |
169
+ | key | type | description |
170
+ | -------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------- |
171
+ | `path` | `string \| string[]` | Path partial for this route segment |
172
+ | `component` | `Component` | Component rendered for the matched segment |
166
173
  | `children` | `RouteDefinition \| RouteDefinition[] \| () => Promise<...>` | Nested route definitions, or a thunk for a [lazy subtree](#lazy-route-subtrees) |
167
- | `preload` | `RoutePreloadFunc` | Called on preload intent (hover/focus) and navigation |
168
- | `matchFilters` | `MatchFilters` | Additional constraints for matching parameters |
169
- | `search` | `StandardSchemaV1` | Search-param validator; its types flow into `paths` and hooks |
170
- | `info` | `Record<string, any>` | Arbitrary metadata, readable via `useRouteMatches` |
174
+ | `preload` | `RoutePreloadFunc` | Called on preload intent (hover/focus) and navigation |
175
+ | `matchFilters` | `MatchFilters` | Additional constraints for matching parameters |
176
+ | `search` | `StandardSchemaV1` | Search-param validator; its types flow into `paths` and hooks |
177
+ | `info` | `Record<string, any>` | Arbitrary metadata, readable via `useRouteMatches` |
171
178
 
172
- The tree is **immutable and there is one router per app** — that's what makes `paths` and the typed hooks truthful, it lets matching compile once and be shared by every mount, request, and `match()` call, and it means delegation, link state, and preloading all have a single owner. Compose large apps by spreading subtrees into the config (see `defineRoutes` above); mounting a router inside another router is not supported (nested `<Routes>` has been gone since 0.10) and warns in development. Sections whose *code* shouldn't load up front are [lazy route subtrees](#lazy-route-subtrees) — still one tree, still typed.
179
+ The tree is **immutable and there is one router per app** — that's what makes `paths` and the typed hooks truthful, it lets matching compile once and be shared by every mount, request, and `match()` call, and it means delegation, link state, and preloading all have a single owner. Compose large apps by spreading subtrees into the config (see `defineRoutes` above); mounting a router inside another router is not supported (nested `<Routes>` has been gone since 0.10) and warns in development. Sections whose _code_ shouldn't load up front are [lazy route subtrees](#lazy-route-subtrees) — still one tree, still typed.
173
180
 
174
181
  ### Dynamic Routes
175
182
 
@@ -204,8 +211,8 @@ const story = defineRoute({
204
211
  preload: ({ params }) => getStory(params.id), // params.id: string
205
212
  component: props => (
206
213
  <Story
207
- id={props.params.id} // string — the pattern guarantees it
208
- tab={props.params.tab} // string | undefined — optional param
214
+ id={props.params.id} // string — the pattern guarantees it
215
+ tab={props.params.tab} // string | undefined — optional param
209
216
  />
210
217
  )
211
218
  });
@@ -242,8 +249,8 @@ Each parameter can be validated with a `MatchFilter` — an enum array, a regex,
242
249
  import { int, type MatchFilters } from "@solidjs/router";
243
250
 
244
251
  const filters: MatchFilters = {
245
- parent: ["mom", "dad"], // enum values
246
- id: /^\d+$/, // only numbers
252
+ parent: ["mom", "dad"], // enum values
253
+ id: /^\d+$/, // only numbers
247
254
  withHtmlExtension: (v: string) => v.length > 5 && v.endsWith(".html")
248
255
  };
249
256
 
@@ -254,7 +261,7 @@ const routes = defineRoutes([
254
261
 
255
262
  So `/users/mom/123/contact.html` matches, while `/users/aunt/123/contact.html` (invalid `parent`) and `/users/mom/me/contact.html` (non-numeric `id`) don't.
256
263
 
257
- The built-in `int` filter is *typed*: it constrains matching to integers at runtime and types the param as `number` at `paths` callsites:
264
+ The built-in `int` filter is _typed_: it constrains matching to integers at runtime and types the param as `number` at `paths` callsites:
258
265
 
259
266
  ```tsx
260
267
  { path: "/users/:id", matchFilters: { id: int }, component: User }
@@ -362,6 +369,38 @@ The import only fires when something needs the subtree — hovering a link into
362
369
 
363
370
  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.
364
371
 
372
+ ### Server Component Routes (experimental)
373
+
374
+ Solid's experimental [server components](https://github.com/solidjs/solid/blob/main/documentation/solid-2.0/11-server-components.md) are `"use server"` functions that return a component. `serverRouteComponent` uses one as a route's `component` directly — the router owns the URL → call translation a client wrapper used to spell out:
375
+
376
+ ```tsx
377
+ // views.tsx
378
+ "use server";
379
+ import type { ServerRouteArgs } from "@solidjs/router";
380
+
381
+ export async function storyView({ params }: ServerRouteArgs<{ id: string }>) {
382
+ const story = await db.stories.get(params.id);
383
+ return props => (
384
+ <article>
385
+ <h1>{story.title}</h1>
386
+ {props.children}
387
+ </article>
388
+ );
389
+ }
390
+
391
+ // routes.ts
392
+ defineRoute({ path: "/stories/:id", component: serverRouteComponent(storyView) });
393
+ ```
394
+
395
+ The function is called with **derived** arguments, not a live location — the call's `(function, arguments)` address keys both the query cache and the frame store, so the args name exactly what the route depends on:
396
+
397
+ - `params`: the params this route's pattern (and its ancestors') declares — never a child's, so a layout does not refetch when a leaf param changes. `defineRoute` checks them against the pattern.
398
+ - `search`: the validated output of the route's [`search` schema](#typed-search-params), only when one is declared. Otherwise `undefined`, and the route never tracks the query string.
399
+
400
+ The router mounts the resolved component with the outlet as `children`, so a server component can be a layout. The call is wrapped in `query` (keyed by the function id): link intent warms the same entry the render reads, actions revalidate it, and the [single-flight collector](#server-integration) 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.
401
+
402
+ `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.url}` — needs no client component at all.
403
+
365
404
  ### File-System Routes
366
405
 
367
406
  The `@solidjs/router/fs` adapter turns a `file-routes` manifest into route definitions — the app imports the virtual module, the adapter maps it:
@@ -387,7 +426,7 @@ export const route = defineFileRoute("/blog/:id", {
387
426
 
388
427
  export default function Post(props: RouteProps<typeof route>) {
389
428
  props.params.id; // string
390
- props.data; // ReturnType of the preload above
429
+ props.data; // ReturnType of the preload above
391
430
  }
392
431
  ```
393
432
 
@@ -398,11 +437,11 @@ The pattern string is a typing witness — at runtime the manifest's path (from
398
437
  `paths` is a proxy inferred from the route tree. Property access descends into static segments, calls bind params, and it mirrors URL anatomy — params, then a search object, then a hash string:
399
438
 
400
439
  ```tsx
401
- paths.users(123) // ok — matchFilters flow into the callsite
402
- paths.users(2).settings // chainable into children
403
- paths.users(2, { tab: "x" }, "comments") // "/users/2?tab=x#comments"
404
- paths.about() // zero-arg/search calls terminate to a plain string
405
- paths() // "/" — the root
440
+ paths.users(123); // ok — matchFilters flow into the callsite
441
+ paths.users(2).settings; // chainable into children
442
+ paths.users(2, { tab: "x" }, "comments"); // "/users/2?tab=x#comments"
443
+ paths.about(); // zero-arg/search calls terminate to a plain string
444
+ paths(); // "/" — the root
406
445
  ```
407
446
 
408
447
  Every node coerces via `toString`, so nodes drop straight into `href`, `navigate()`, and `redirect()` without explicit termination. Accessing a segment that doesn't exist in the tree, or binding a param with the wrong type, is a compile error.
@@ -413,14 +452,14 @@ There is no link component. Use `<a>`; the router intercepts same-origin clicks
413
452
 
414
453
  Behavior modifiers are attributes, so they work identically in client, server-rendered, and third-party markup:
415
454
 
416
- | attribute | description |
417
- | ---------- | ------------------------------------------------------------------------------ |
418
- | `replace` | Replace the history entry instead of pushing |
419
- | `noscroll` | Turn off scrolling to the top after navigation |
455
+ | attribute | description |
456
+ | ---------- | --------------------------------------------------------------------------------------------------------------- |
457
+ | `replace` | Replace the history entry instead of pushing |
458
+ | `noscroll` | Turn off scrolling to the top after navigation |
420
459
  | `state` | JSON string [pushed](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState) onto the history stack |
421
- | `preload` | Set to `"false"` to opt this link out of hover/focus preloading |
422
- | `link` | Marks a router link when `explicitLinks` is enabled |
423
- | `target` | Any value (e.g. `_self`) opts the anchor out of router handling |
460
+ | `preload` | Set to `"false"` to opt this link out of hover/focus preloading |
461
+ | `link` | Marks a router link when `explicitLinks` is enabled |
462
+ | `target` | Any value (e.g. `_self`) opts the anchor out of router handling |
424
463
 
425
464
  ```tsx
426
465
  <a href={paths.login} replace>Log in</a>
@@ -431,9 +470,15 @@ Behavior modifiers are attributes, so they work identically in client, server-re
431
470
  Active and pending state is styled with CSS — one vocabulary for every kind of link:
432
471
 
433
472
  ```css
434
- nav a[aria-current="page"] { font-weight: 600; } /* exact match */
435
- nav a[data-active] { color: var(--accent); } /* exact or prefix match */
436
- a[data-pending] { opacity: 0.6; } /* target of in-flight navigation */
473
+ nav a[aria-current="page"] {
474
+ font-weight: 600;
475
+ } /* exact match */
476
+ nav a[data-active] {
477
+ color: var(--accent);
478
+ } /* exact or prefix match */
479
+ a[data-pending] {
480
+ opacity: 0.6;
481
+ } /* target of in-flight navigation */
437
482
  ```
438
483
 
439
484
  (The root path only ever matches exactly, so `href={paths()}` doesn't light up on every page.)
@@ -471,10 +516,10 @@ const routes = defineRoutes([{ path: "/users/:id", component: User, preload: pre
471
516
 
472
517
  The preload function receives:
473
518
 
474
- | key | type | description |
475
- | -------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
476
- | params | object | The route parameters (same value as `useParams()` inside the route component) |
477
- | location | `{ pathname, search, hash, query, state, key }` | Path information (corresponds to [`useLocation()`](#uselocation)) |
519
+ | key | type | description |
520
+ | -------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
521
+ | params | object | The route parameters (same value as `useParams()` inside the route component) |
522
+ | location | `{ pathname, search, hash, query, state, key }` | Path information (corresponds to [`useLocation()`](#uselocation)) |
478
523
  | 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 |
479
524
 
480
525
  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`.
@@ -513,12 +558,14 @@ const todos = createProjection(() => getTodos(), []);
513
558
  Keys support targeted invalidation:
514
559
 
515
560
  ```ts
516
- getUser.key; // "users"
561
+ getUser.key; // "users"
517
562
  getUser.keyFor(5); // "users[5]"
518
563
  ```
519
564
 
520
565
  Revalidate with the `revalidate` export or by setting `revalidate` keys on action responses — the whole key invalidates every entry for the query, `keyFor` invalidates one.
521
566
 
567
+ A query may also redirect — a guard read that throws or returns `redirect()` (from `@solidjs/web`) navigates instead of resolving: same-origin targets navigate softly with `replace`, other origins leave the document, any `revalidate` keys on the response invalidate first, and the read itself stays pending so nothing renders the redirect as data. This holds for `"use server"` queries too, where the transport carries the redirect to the client rather than letting `fetch` follow it.
568
+
522
569
  ### `liveQuery` (experimental)
523
570
 
524
571
  `query`'s live sibling: a keyed query over a value-shaped stream. The function is an async iterable (typically an async generator server function) whose yields are successive **values of one logical query** — each yield is the current state, not an event — with the contract that it re-yields current state on every invocation:
@@ -553,7 +600,7 @@ The callable carries the `query` conventions (`key`, `keyFor`) plus a reactive `
553
600
 
554
601
  ### `action`
555
602
 
556
- A router action is *an action with a URL* — Solid's mutation primitive plus URL addressability, submission tracking, and response handling. Data helpers come from the router; response helpers (`redirect`, `reload`) come from `@solidjs/web` — they're protocol-level and work without the router:
603
+ A router action is _an action with a URL_ — Solid's mutation primitive plus URL addressability, submission tracking, and response handling. Data helpers come from the router; response helpers (`redirect`, `reload`) come from `@solidjs/web` — they're protocol-level and work without the router:
557
604
 
558
605
  ```tsx
559
606
  import { action } from "@solidjs/router";
@@ -578,7 +625,10 @@ const updateUser = action(async (form: FormData) => {
578
625
  Actions only work with POST requests, so put `method="post"` on your form. Submitting forms get `aria-busy="true"` automatically while the action (including its revalidation) is in flight — the same CSS story as links:
579
626
 
580
627
  ```css
581
- form[aria-busy] button { pointer-events: none; opacity: 0.6; }
628
+ form[aria-busy] button {
629
+ pointer-events: none;
630
+ opacity: 0.6;
631
+ }
582
632
  ```
583
633
 
584
634
  Forms work without JavaScript: a real POST, a redirect back, and the result seeded into submission state through a one-shot flash cookie. Single-flight mutations are on by default — the mutation response carries the refreshed route data in the same round trip.
@@ -662,7 +712,7 @@ const routes = defineRoutes([
662
712
 
663
713
  ```tsx
664
714
  const [search, setSearch] = useSearchParams(paths.search);
665
- search.page; // number (parsed, not "2")
715
+ search.page; // number (parsed, not "2")
666
716
  setSearch({ page: search.page + 1 }); // typed setter
667
717
 
668
718
  <a href={paths.search({ q: "solid", page: 2 })}>Search</a>; // typed builder
@@ -676,26 +726,26 @@ Without a schema, `useSearchParams()` behaves as before: raw string values, merg
676
726
  createRouter(config);
677
727
  ```
678
728
 
679
- | option | type | description |
680
- | --------------- | -------------------------- | ------------------------------------------------------------------------------------------------------ |
681
- | `routes` | `RouteDefinition[]` | The route tree — inline arrays infer literally; wrap extracted trees in `defineRoutes` |
682
- | `base` | `string` | Base url to use for matching routes |
683
- | `preload` | `RoutePreloadFunc` | App-wide preload: once per mount/request, result reaches the root render-prop as `props.data` |
684
- | `history` | `RouterHistory` | History adapter; defaults to browser history on the client and the request URL on the server |
685
- | `singleFlight` | `boolean` | Single-flight mutations, default `true` |
686
- | `actionBase` | `string` | Root url for server actions, default `/_server` |
687
- | `preloadLinks` | `boolean` | Preload route code/data on link hover and focus, default `true` |
688
- | `explicitLinks` | `boolean` | Require the `link` attribute for router handling instead of intercepting all anchors, default `false` |
689
- | `transformUrl` | `(url: string) => string` | Rewrite URLs before matching |
729
+ | option | type | description |
730
+ | --------------- | ------------------------- | ----------------------------------------------------------------------------------------------------- |
731
+ | `routes` | `RouteDefinition[]` | The route tree — inline arrays infer literally; wrap extracted trees in `defineRoutes` |
732
+ | `base` | `string` | Base url to use for matching routes |
733
+ | `preload` | `RoutePreloadFunc` | App-wide preload: once per mount/request, result reaches the root render-prop as `props.data` |
734
+ | `history` | `RouterHistory` | History adapter; defaults to browser history on the client and the request URL on the server |
735
+ | `singleFlight` | `boolean` | Single-flight mutations, default `true` |
736
+ | `actionBase` | `string` | Root url for server actions, default `/_server` |
737
+ | `preloadLinks` | `boolean` | Preload route code/data on link hover and focus, default `true` |
738
+ | `explicitLinks` | `boolean` | Require the `link` attribute for router handling instead of intercepting all anchors, default `false` |
739
+ | `transformUrl` | `(url: string) => string` | Rewrite URLs before matching |
690
740
 
691
741
  The returned instance is the provider component and carries the static surface:
692
742
 
693
- | member | description |
694
- | --------- | ------------------------------------------------------------------------------------------------ |
695
- | `paths` | The [typed path proxy](#typed-paths) |
696
- | `match` | Pure matching against an arbitrary URL — no rendering or request context; root→leaf, `[]` if none |
697
- | `routes` | The config tree |
698
- | `config` | The full config — lets server integrations consume the instance directly |
743
+ | member | description |
744
+ | -------- | ------------------------------------------------------------------------------------------------- |
745
+ | `paths` | The [typed path proxy](#typed-paths) |
746
+ | `match` | Pure matching against an arbitrary URL — no rendering or request context; root→leaf, `[]` if none |
747
+ | `routes` | The config tree |
748
+ | `config` | The full config — lets server integrations consume the instance directly |
699
749
 
700
750
  ## Router Primitives
701
751
 
@@ -706,7 +756,7 @@ Hooks read the live session off router context.
706
756
  Retrieves a reactive, store-like object of the current route's path parameters. Pass a paths node for typing:
707
757
 
708
758
  ```tsx
709
- const params = useParams(); // Params (strings)
759
+ const params = useParams(); // Params (strings)
710
760
  const params = useParams(paths.users); // { id: string } — typed from the tree
711
761
  ```
712
762
 
@@ -754,7 +804,7 @@ In Solid's dev and observe builds the router also declares every navigation to t
754
804
 
755
805
  ### useMatch
756
806
 
757
- Tests a path *pattern you supply* against the current location; returns a memo of match information or `undefined`. It never consults the route tree — the pattern doesn't have to correspond to a defined route. The match's `params` are typed from the pattern, and a typed path node works too (a concrete URL — useful for "am I here" checks):
807
+ Tests a path _pattern you supply_ against the current location; returns a memo of match information or `undefined`. It never consults the route tree — the pattern doesn't have to correspond to a defined route. The match's `params` are typed from the pattern, and a typed path node works too (a concrete URL — useful for "am I here" checks):
758
808
 
759
809
  ```tsx
760
810
  const match = useMatch(() => "/admin/*rest");
@@ -766,7 +816,7 @@ const here = useMatch(() => paths.users(2));
766
816
 
767
817
  ### useRouteMatches
768
818
 
769
- Returns an accessor of the router's *resolved* matches for the current location — the chain of route definitions producing the current render, outermost first. This is the counterpart to `useMatch`: one reflects the route tree, the other tests a pattern. Useful for reading `info` metadata:
819
+ Returns an accessor of the router's _resolved_ matches for the current location — the chain of route definitions producing the current render, outermost first. This is the counterpart to `useMatch`: one reflects the route tree, the other tests a pattern. Useful for reading `info` metadata:
770
820
 
771
821
  ```tsx
772
822
  const matches = useRouteMatches();
@@ -888,7 +938,7 @@ This guide maps from the stable 0.x releases (Solid 1). 1.0 removes the componen
888
938
  <Router root={App}>
889
939
  <Route path="/users" component={Users} />
890
940
  <Route path="/users/:id" component={User} />
891
- </Router>
941
+ </Router>;
892
942
 
893
943
  // 1.0
894
944
  const Router = createRouter({
@@ -898,7 +948,7 @@ const Router = createRouter({
898
948
  ]
899
949
  });
900
950
 
901
- <Router>{props => <App {...props} />}</Router>
951
+ <Router>{props => <App {...props} />}</Router>;
902
952
  ```
903
953
 
904
954
  - `<HashRouter>` → `createRouter({ routes, history: hashHistory() })`
@@ -1,15 +1,20 @@
1
1
  import { createSignal, getObserver, getOwner, onCleanup, sharedConfig, untrack } from "solid-js";
2
2
  // Everything server-function-shaped comes off the CORE entry: detection
3
3
  // (isServerFunction/getServerFunctionMetadata, registered-symbol reads) and
4
- // the late-bound RPC seam (getServerFunctionRPC). The server-functions
5
- // entry itself — the fetch transport + the seroval codec behind it — is
6
- // deliberately NOT imported here: query() is in every router app's eager
7
- // graph, and a static import made every zero-server-function app ship
4
+ // the late-bound RPC seam (getServerFunctionRPC). The transport itself —
5
+ // the fetch RPC client + the seroval codec behind it — is deliberately NOT
6
+ // imported here: query() is in every router app's eager graph, and a static
7
+ // import of `decodeResponse` made every zero-server-function app ship
8
8
  // ~9 KB gz of codec it could never invoke. The transport registers itself
9
9
  // into the seam when a `'use server'` reference is created (compiled
10
10
  // output, module scope), so by the time a server function can reach
11
11
  // query() the seam is filled; plain-fetch apps read undefined forever.
12
12
  import { getRequestEvent, getServerFunctionMetadata, getServerFunctionRPC, isResponseEnvelope, isServer, isServerFunction, REVALIDATE_HEADER } from "@solidjs/web";
13
+ // The redirect carrier's name and decoder are the exception: two pure,
14
+ // dependency-free bindings off a `sideEffects: false` entry, so they
15
+ // tree-shake to a few hundred bytes without dragging the codec in (the same
16
+ // bindings action.ts already imports statically).
17
+ import { decodeRedirectHeaderValue, REDIRECT_HEADER } from "@solidjs/web/server-functions";
13
18
  import { useRouter, getIntent, getInPreloadFn } from "../routing.js";
14
19
  const LocationHeader = "Location";
15
20
  const PRELOAD_TIMEOUT = 5000;
@@ -62,7 +67,12 @@ export function revalidate(key, force = true) {
62
67
  const now = Date.now();
63
68
  cacheKeyOp(key, entry => {
64
69
  force && (entry[0] = 0); //force cache miss
65
- entry[4][1](now); // retrigger live signals
70
+ // retrigger live signals. The version is the entry's fetch stamp, and
71
+ // a signal write of an equal value is a no-op — an entry fetched within
72
+ // this same millisecond (a mount whose redirect lands before the clock
73
+ // ticks) would otherwise never be told to refetch, and the surviving
74
+ // consumer paints stale. A sweep must notify unconditionally.
75
+ entry[4][1](v => (v === now ? now + 1 : now));
66
76
  });
67
77
  const keys = key === undefined ? undefined : Array.isArray(key) ? key : [key];
68
78
  for (const hook of revalidateHooks)
@@ -251,7 +261,27 @@ export function query(fn, name) {
251
261
  e.response.headers.set(key, value);
252
262
  }
253
263
  }
254
- const url = v.headers.get(LocationHeader);
264
+ let url = v.headers.get(LocationHeader);
265
+ // A `"use server"` redirect reaches a client-side read masked: the
266
+ // transport answers scripted callers with a 200, drops `Location`
267
+ // and carries "<status> <absolute-url>" in REDIRECT_HEADER instead.
268
+ // Decode it with the runtime's own reader, as action() does. The
269
+ // carrier arrives RESOLVED to an absolute url, so same-origin vs
270
+ // cross-origin is a real origin comparison: same-origin folds to
271
+ // a path the soft branch below navigates under the router, any
272
+ // other origin keeps its href and the document goes with it. This
273
+ // stays synchronous on purpose — navigate runs in the same tick as
274
+ // the `Location` branch would, so the transition semantics match.
275
+ if (url === null && !isServer && v.headers.has(REDIRECT_HEADER)) {
276
+ const carried = decodeRedirectHeaderValue(v.headers.get(REDIRECT_HEADER));
277
+ if (carried) {
278
+ const target = new URL(carried.url);
279
+ url =
280
+ target.origin === window.location.origin
281
+ ? target.pathname + target.search + target.hash
282
+ : target.href;
283
+ }
284
+ }
255
285
  if (url !== null) {
256
286
  // invalidate the redirect's revalidation keys before navigating so
257
287
  // the destination's preloads see the miss and fetch fresh (#580 thread).
package/dist/index.d.ts CHANGED
@@ -23,6 +23,7 @@ export { useHref, useIsRouting, useLinkState, useLocation, useMatch, useNavigate
23
23
  export type { LinkState } from "./routing.js";
24
24
  export { mergeSearchString as _mergeSearchString } from "./utils.js";
25
25
  export { int } from "./paths.js";
26
+ export { serverRouteComponent } from "./serverRouteComponent.js";
26
27
  export type { RoutePaths, PathParamsOf, PathEnd, TypedMatchFilter, DefaultSearchTypes } from "./paths.js";
27
28
  export * from "./data/index.js";
28
- 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, Submission, BeforeLeaveEventArgs, TypedPath, TypedSearchPath, StandardSchemaV1 } from "./types.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, ServerRouteFunction, Submission, BeforeLeaveEventArgs, TypedPath, TypedSearchPath, StandardSchemaV1 } from "./types.js";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { getOwner, runWithOwner, createMemo, createRenderEffect, onCleanup, untrack, createContext, createSignal, useContext, NotReadyError, isPending, DEV, latest, createComponent, createRoot, Show, createEffect, sharedConfig, OBSERVE, onSettled, getObserver, $TRACK, action as action$1 } from 'solid-js';
2
- import { registerElementClaim, delegateEvents, isServer, getRequestEvent, hasFlashCookie, clearFlashCookie, createComponent as createComponent$1, memo, isServerFunction, getServerFunctionMetadata, getServerFunctionRPC, isResponseEnvelope, REVALIDATE_HEADER } from '@solidjs/web';
3
- import { subscribeFlightData, decodeRedirectHeaderValue, REDIRECT_HEADER, decodeResponsePayload, parseServerFunctionActionUrl, createServerReference } from '@solidjs/web/server-functions';
2
+ import { registerElementClaim, delegateEvents, isServer, getRequestEvent, hasFlashCookie, clearFlashCookie, createComponent as createComponent$1, memo, isServerFunction, getServerFunctionMetadata, getServerFunctionRPC, isResponseEnvelope, REVALIDATE_HEADER, dynamic } from '@solidjs/web';
3
+ import { REDIRECT_HEADER, decodeRedirectHeaderValue, subscribeFlightData, decodeResponsePayload, parseServerFunctionActionUrl, createServerReference } from '@solidjs/web/server-functions';
4
4
  import { decodeFlashCookie } from '@solidjs/web/server-functions/server';
5
5
 
6
6
  const hasSchemeRegex = /^(?:[a-z0-9]+:)?\/\//i;
@@ -517,6 +517,74 @@ function createPathsProxy(renderPath = p => p, base = "") {
517
517
  return node(normalizePath(base));
518
518
  }
519
519
 
520
+ // The light half of server component routes: the brand the router core reads
521
+ // off a route `component`, and the derivation of a route's call arguments.
522
+ // Deliberately free of `query`/`dynamic` — routing.ts imports this module,
523
+ // and routing.ts is in every app's eager graph. The heavy half (the helper
524
+ // that builds the branded component) lives in serverRouteComponent.ts and only
525
+ // enters a bundle when an app calls `serverRouteComponent()`.
526
+
527
+ const SERVER_ROUTE = Symbol("solid-router.serverRoute");
528
+
529
+ /**
530
+ * What `serverRouteComponent()` attaches to the route component it returns, and
531
+ * what the router core drives it through: `call` is the query-wrapped server
532
+ * function (preload and single-flight collection warm/collect through it),
533
+ * `render` mounts the resolved server component for the current args with
534
+ * the route's props (`children` is the outlet).
535
+ */
536
+
537
+ /** The brand carried by a route component built with `serverRouteComponent()`, if any. */
538
+ function serverRouteOf(component) {
539
+ return typeof component === "function" ? component[SERVER_ROUTE] : undefined;
540
+ }
541
+
542
+ /**
543
+ * Derive a server route's call arguments from a match: the match's params
544
+ * (this route's pattern and its ancestors' — never a child's) and the route's
545
+ * `search` schema output when one is declared. Reads `query` only when a
546
+ * schema exists, so a search-agnostic route never tracks the query string.
547
+ */
548
+ function serverRouteArgs(route, params, query) {
549
+ const schema = route.key?.search;
550
+ let search = undefined;
551
+ if (schema) {
552
+ const outcome = schema["~standard"].validate({
553
+ ...query
554
+ });
555
+ if (outcome instanceof Promise) throw new Error("Async Standard Schema validation is not supported for search params");
556
+ // Issues leave the raw values in place, matching useSearchParams: search
557
+ // strings are user input, so defaults belong in the schema itself.
558
+ search = outcome.issues ? {
559
+ ...query
560
+ } : outcome.value;
561
+ }
562
+ return {
563
+ params: {
564
+ ...params
565
+ },
566
+ search
567
+ };
568
+ }
569
+ function shallowEqual(a, b) {
570
+ if (a === b) return true;
571
+ if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
572
+ const ka = Object.keys(a);
573
+ const kb = Object.keys(b);
574
+ if (ka.length !== kb.length) return false;
575
+ for (const k of ka) if (a[k] !== b[k]) return false;
576
+ return true;
577
+ }
578
+
579
+ /**
580
+ * Structural equality for the args memo: a navigation produces fresh match
581
+ * objects even when this level's params are unchanged, and an equal call
582
+ * must not re-enter the source (and refetch).
583
+ */
584
+ function serverRouteArgsEqual(a, b) {
585
+ return shallowEqual(a.params, b.params) && shallowEqual(a.search, b.search);
586
+ }
587
+
520
588
  const MAX_REDIRECTS = 100;
521
589
 
522
590
  /**
@@ -1481,21 +1549,29 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1481
1549
  } = matches[match];
1482
1550
  route.component && route.component.preload && route.component.preload();
1483
1551
  const {
1484
- preload
1552
+ preload,
1553
+ component
1485
1554
  } = route;
1486
1555
  inPreloadFn = true;
1487
- preloadData && preload && runWithOwner(getContext(), () => preload({
1488
- params,
1489
- location: {
1490
- pathname: url.pathname,
1491
- search: url.search,
1492
- hash: url.hash,
1493
- query: extractSearchParams(url),
1494
- state: null,
1495
- key: ""
1496
- },
1497
- intent: "preload"
1498
- }));
1556
+ preloadData && runWithOwner(getContext(), () => {
1557
+ const query = extractSearchParams(url);
1558
+ // A server component route's data IS its call: warm the same
1559
+ // query entry the render will read, under the same derived args.
1560
+ const server = serverRouteOf(component);
1561
+ server && server.call(serverRouteArgs(route, params, query));
1562
+ preload && preload({
1563
+ params,
1564
+ location: {
1565
+ pathname: url.pathname,
1566
+ search: url.search,
1567
+ hash: url.hash,
1568
+ query,
1569
+ state: null,
1570
+ key: ""
1571
+ },
1572
+ intent: "preload"
1573
+ });
1574
+ });
1499
1575
  inPreloadFn = false;
1500
1576
  }
1501
1577
  preloadIntent = prevIntent;
@@ -1551,14 +1627,30 @@ function createRouteContext(router, parent, outlet, match, matches = () => [matc
1551
1627
  pattern,
1552
1628
  params,
1553
1629
  path,
1554
- outlet: () => component ? createComponent(component, {
1555
- params,
1556
- location,
1557
- data,
1558
- get children() {
1559
- return outlet();
1630
+ outlet: () => {
1631
+ if (!component) return outlet();
1632
+ const routeProps = {
1633
+ params,
1634
+ location,
1635
+ data,
1636
+ get children() {
1637
+ return outlet();
1638
+ }
1639
+ };
1640
+ // A `serverRouteComponent()` route: the router derives the call from
1641
+ // the match — THIS level's params (never a child's) and the declared
1642
+ // search output — and mounts through the brand. Structural equality
1643
+ // keeps a navigation that leaves this level's args unchanged from
1644
+ // re-entering the call. See serverRouteComponent.ts.
1645
+ const server = serverRouteOf(component);
1646
+ if (server) {
1647
+ const args = createMemo(() => serverRouteArgs(match().route, match().params, location.query), {
1648
+ equals: serverRouteArgsEqual
1649
+ });
1650
+ return server.render(args, routeProps);
1560
1651
  }
1561
- }) : outlet(),
1652
+ return createComponent(component, routeProps);
1653
+ },
1562
1654
  resolvePath(to) {
1563
1655
  return resolvePath(base.path(), to, path());
1564
1656
  }
@@ -1717,6 +1809,9 @@ function dataOnly(event, routerState, branches) {
1717
1809
  route,
1718
1810
  params
1719
1811
  } = matches[match];
1812
+ // a server component route's markup is collected like any query result
1813
+ const server = serverRouteOf(route.component);
1814
+ server && server.call(serverRouteArgs(route, params, routerState.location.query));
1720
1815
  route.preload && route.preload({
1721
1816
  params,
1722
1817
  location: routerState.location,
@@ -2457,7 +2552,12 @@ function revalidate(key, force = true) {
2457
2552
  const now = Date.now();
2458
2553
  cacheKeyOp(key, entry => {
2459
2554
  force && (entry[0] = 0); //force cache miss
2460
- entry[4][1](now); // retrigger live signals
2555
+ // retrigger live signals. The version is the entry's fetch stamp, and
2556
+ // a signal write of an equal value is a no-op — an entry fetched within
2557
+ // this same millisecond (a mount whose redirect lands before the clock
2558
+ // ticks) would otherwise never be told to refetch, and the surviving
2559
+ // consumer paints stale. A sweep must notify unconditionally.
2560
+ entry[4][1](v => v === now ? now + 1 : now);
2461
2561
  });
2462
2562
  const keys = key === undefined ? undefined : Array.isArray(key) ? key : [key];
2463
2563
  for (const hook of revalidateHooks) hook(keys, force);
@@ -2619,7 +2719,25 @@ function query(fn, name) {
2619
2719
  if (key == "set-cookie") e.response.headers.append("set-cookie", value);else e.response.headers.set(key, value);
2620
2720
  }
2621
2721
  }
2622
- const url = v.headers.get(LocationHeader);
2722
+ let url = v.headers.get(LocationHeader);
2723
+
2724
+ // A `"use server"` redirect reaches a client-side read masked: the
2725
+ // transport answers scripted callers with a 200, drops `Location`
2726
+ // and carries "<status> <absolute-url>" in REDIRECT_HEADER instead.
2727
+ // Decode it with the runtime's own reader, as action() does. The
2728
+ // carrier arrives RESOLVED to an absolute url, so same-origin vs
2729
+ // cross-origin is a real origin comparison: same-origin folds to
2730
+ // a path the soft branch below navigates under the router, any
2731
+ // other origin keeps its href and the document goes with it. This
2732
+ // stays synchronous on purpose — navigate runs in the same tick as
2733
+ // the `Location` branch would, so the transition semantics match.
2734
+ if (url === null && !isServer && v.headers.has(REDIRECT_HEADER)) {
2735
+ const carried = decodeRedirectHeaderValue(v.headers.get(REDIRECT_HEADER));
2736
+ if (carried) {
2737
+ const target = new URL(carried.url);
2738
+ url = target.origin === window.location.origin ? target.pathname + target.search + target.hash : target.href;
2739
+ }
2740
+ }
2623
2741
  if (url !== null) {
2624
2742
  // invalidate the redirect's revalidation keys before navigating so
2625
2743
  // the destination's preloads see the miss and fetch fresh (#580 thread).
@@ -2735,6 +2853,91 @@ function isPlainObject(obj) {
2735
2853
  return obj != null && typeof obj === "object" && (!(proto = Object.getPrototypeOf(obj)) || proto === Object.prototype);
2736
2854
  }
2737
2855
 
2856
+ // Server component routes (experimental — rides the experimental server
2857
+ // components surface in @solidjs/web; the arg shape may change).
2858
+ //
2859
+ // `serverRouteComponent(fn)` turns a `"use server"` function that returns a
2860
+ // component into a route `component`. It replaces the client wrapper a
2861
+ // server-component route used to need:
2862
+ //
2863
+ // // before: a client component exists only to make the call
2864
+ // component: props => {
2865
+ // const View = dynamic(() => getStory(props.params.id));
2866
+ // return <View>{props.children}</View>;
2867
+ // }
2868
+ // // after
2869
+ // component: serverRouteComponent(storyRoute) // async ({ params }) => { "use server"; ... }
2870
+ //
2871
+ // The mechanism is the same one the wrapper used — `query` for cache identity
2872
+ // (preload participation, single-flight collection, revalidation after
2873
+ // actions) and `dynamic` for the equals-gated mount that morphs in place —
2874
+ // applied by the router rather than restated per route. What the router adds
2875
+ // is the URL → call translation: it derives the call's arguments from the
2876
+ // match and drives the same call from hover preload and from the single
2877
+ // flight collector, so one entry serves render, preload, and mutation
2878
+ // responses.
2879
+ //
2880
+ // Arguments are derived, not read from a live location: the call's
2881
+ // `(function, arguments)` address keys the frame store and the query cache,
2882
+ // so the args must name precisely what the route depends on. They are the
2883
+ // params this route's pattern (with its ancestors') declares — never a
2884
+ // child's, so a layout does not refetch when a leaf param changes — and, only
2885
+ // when the route declares a `search` schema, its validated output.
2886
+ //
2887
+ // The router fills exactly one client position: `children`, with the outlet.
2888
+ // A server component that takes other client positions — handlers, refs,
2889
+ // named slots — has a client half, and that half is a client component; the
2890
+ // helper does not pretend otherwise. Write the wrapper for that route.
2891
+ /**
2892
+ * Use a server component as a route (experimental). `fn` is a `"use server"`
2893
+ * function taking the router-derived {@link ServerRouteArgs} — this route's
2894
+ * `params`, and `search` when the route declares a schema — and resolving to
2895
+ * a server component. The router mounts it with the outlet as `children`,
2896
+ * warms the same call on link intent, and collects it for single-flight
2897
+ * mutation responses.
2898
+ *
2899
+ * `children` is the only client position the router fills, so the server
2900
+ * component may declare no other. One that takes client handlers, refs, or
2901
+ * slots has a client half — write an ordinary route component for it.
2902
+ *
2903
+ * ```ts
2904
+ * defineRoute({ path: "/stories/:id", component: serverRouteComponent(storyView) });
2905
+ * ```
2906
+ */
2907
+ function serverRouteComponent(fn) {
2908
+ const reference = fn;
2909
+ // One query entry per reference: the cache key (the function id) has to
2910
+ // agree between the render that mounts the route, the hover preload that
2911
+ // warms it, and the single-flight collector that re-produces its region.
2912
+ // A server function reference always carries its build-stable id; a
2913
+ // hand-built brand (tests) falls back to the function name.
2914
+ const call = query(reference, "route:" + (reference.id || reference.name));
2915
+
2916
+ // The component itself is the fallback for a mount the router core does
2917
+ // not drive (a plain `createComponent`): it has only the merged params to
2918
+ // go on, so it calls with those. The core mounts through the brand with
2919
+ // this level's params instead.
2920
+ const route = routeProps => render(() => ({
2921
+ params: {
2922
+ ...routeProps.params
2923
+ },
2924
+ search: undefined
2925
+ }), routeProps);
2926
+ function render(args, routeProps) {
2927
+ const View = dynamic(() => call(args()));
2928
+ return createComponent(View, {
2929
+ get children() {
2930
+ return routeProps.children;
2931
+ }
2932
+ });
2933
+ }
2934
+ route[SERVER_ROUTE] = {
2935
+ call,
2936
+ render
2937
+ };
2938
+ return route;
2939
+ }
2940
+
2738
2941
  const submitHooksSymbol = Symbol("routerActionSubmitHooks");
2739
2942
  const settledHooksSymbol = Symbol("routerActionSettledHooks");
2740
2943
  const invokeSymbol = Symbol("routerActionInvoke");
@@ -3503,4 +3706,4 @@ var serverForms = /*#__PURE__*/Object.freeze({
3503
3706
  submitServerForm: submitServerForm
3504
3707
  });
3505
3708
 
3506
- export { RouterContextObj as RouterContext, mergeSearchString as _mergeSearchString, action, browserHistory, createBeforeLeave, createRouter, defineRoute, defineRoutes, hashHistory, int, liveQuery, memoryHistory, query, revalidate, useAction, useBeforeLeave, useHref, useIsRouting, useLinkState, useLocation, useMatch, useNavigate, useParams, usePreloadRoute, useResolvedPath, useRouteMatches, useSearchParams, useSubmissions };
3709
+ export { RouterContextObj as RouterContext, mergeSearchString as _mergeSearchString, action, browserHistory, createBeforeLeave, createRouter, defineRoute, defineRoutes, hashHistory, int, liveQuery, memoryHistory, query, revalidate, serverRouteComponent, useAction, useBeforeLeave, useHref, useIsRouting, useLinkState, useLocation, useMatch, useNavigate, useParams, usePreloadRoute, useResolvedPath, useRouteMatches, useSearchParams, useSubmissions };
package/dist/index.jsx CHANGED
@@ -3,4 +3,5 @@ export * from "./lifecycle.js";
3
3
  export { useHref, useIsRouting, useLinkState, useLocation, useMatch, useNavigate, usePreloadRoute, useParams, useResolvedPath, useRouteMatches, useSearchParams, RouterContextObj as RouterContext } from "./routing.js";
4
4
  export { mergeSearchString as _mergeSearchString } from "./utils.js";
5
5
  export { int } from "./paths.js";
6
+ export { serverRouteComponent } from "./serverRouteComponent.js";
6
7
  export * from "./data/index.js";
@@ -2,6 +2,7 @@
2
2
  import { createMemo, createRoot, getOwner, onCleanup, runWithOwner, untrack, Show } from "solid-js";
3
3
  import { getRequestEvent, isServer } from "@solidjs/web";
4
4
  import { createRouteContext, getIntent, getRouteMatches, resolveLazySubtree, RouteContextObj, setInPreloadFn, unresolvedLazyMatches } from "../routing.js";
5
+ import { serverRouteArgs, serverRouteOf } from "../serverRouteShared.js";
5
6
  export function Root(props) {
6
7
  const location = props.routerState.location;
7
8
  const params = props.routerState.params;
@@ -129,6 +130,9 @@ function dataOnly(event, routerState, branches) {
129
130
  if (!prevMatches[match] || matches[match].route !== prevMatches[match].route)
130
131
  event.router.dataOnly = true;
131
132
  const { route, params } = matches[match];
133
+ // a server component route's markup is collected like any query result
134
+ const server = serverRouteOf(route.component);
135
+ server && server.call(serverRouteArgs(route, params, routerState.location.query));
132
136
  route.preload &&
133
137
  route.preload({
134
138
  params,
package/dist/routing.js CHANGED
@@ -6,6 +6,7 @@ import { createComponent, createContext, createMemo, createSignal, getOwner, isP
6
6
  import { clearFlashCookie, getRequestEvent, hasFlashCookie, isServer } from "@solidjs/web";
7
7
  import { mockBase, comparablePath, createMemoObject, extractSearchParams, invariant, resolvePath, createMatcher, joinPaths, scoreRoute, mergeSearchString, expandOptionals } from "./utils.js";
8
8
  import { HREF } from "./paths.js";
9
+ import { serverRouteOf, serverRouteArgs, serverRouteArgsEqual } from "./serverRouteShared.js";
9
10
  const MAX_REDIRECTS = 100;
10
11
  /**
11
12
  * Resolves a write against `headed`, the location the router is heading to
@@ -900,22 +901,29 @@ export function createRouterContext(integration, branches, getContext, options =
900
901
  route.component &&
901
902
  route.component.preload &&
902
903
  route.component.preload();
903
- const { preload } = route;
904
+ const { preload, component } = route;
904
905
  inPreloadFn = true;
905
906
  preloadData &&
906
- preload &&
907
- runWithOwner(getContext(), () => preload({
908
- params,
909
- location: {
910
- pathname: url.pathname,
911
- search: url.search,
912
- hash: url.hash,
913
- query: extractSearchParams(url),
914
- state: null,
915
- key: ""
916
- },
917
- intent: "preload"
918
- }));
907
+ runWithOwner(getContext(), () => {
908
+ const query = extractSearchParams(url);
909
+ // A server component route's data IS its call: warm the same
910
+ // query entry the render will read, under the same derived args.
911
+ const server = serverRouteOf(component);
912
+ server && server.call(serverRouteArgs(route, params, query));
913
+ preload &&
914
+ preload({
915
+ params,
916
+ location: {
917
+ pathname: url.pathname,
918
+ search: url.search,
919
+ hash: url.hash,
920
+ query,
921
+ state: null,
922
+ key: ""
923
+ },
924
+ intent: "preload"
925
+ });
926
+ });
919
927
  inPreloadFn = false;
920
928
  }
921
929
  preloadIntent = prevIntent;
@@ -965,16 +973,29 @@ export function createRouteContext(router, parent, outlet, match, matches = () =
965
973
  pattern,
966
974
  params,
967
975
  path,
968
- outlet: () => component
969
- ? createComponent(component, {
976
+ outlet: () => {
977
+ if (!component)
978
+ return outlet();
979
+ const routeProps = {
970
980
  params,
971
981
  location,
972
982
  data,
973
983
  get children() {
974
984
  return outlet();
975
985
  }
976
- })
977
- : outlet(),
986
+ };
987
+ // A `serverRouteComponent()` route: the router derives the call from
988
+ // the match — THIS level's params (never a child's) and the declared
989
+ // search output — and mounts through the brand. Structural equality
990
+ // keeps a navigation that leaves this level's args unchanged from
991
+ // re-entering the call. See serverRouteComponent.ts.
992
+ const server = serverRouteOf(component);
993
+ if (server) {
994
+ const args = createMemo(() => serverRouteArgs(match().route, match().params, location.query), { equals: serverRouteArgsEqual });
995
+ return server.render(args, routeProps);
996
+ }
997
+ return createComponent(component, routeProps);
998
+ },
978
999
  resolvePath(to) {
979
1000
  return resolvePath(base.path(), to, path());
980
1001
  }
package/dist/server.js CHANGED
@@ -24,6 +24,7 @@
24
24
  import { provideRequestEvent } from "@solidjs/web/storage";
25
25
  import { createBranches, getRouteMatches, mergeParams, peekLazySubtrees, resolveLazySubtree } from "./routing.js";
26
26
  import { extractSearchParams } from "./utils.js";
27
+ import { serverRouteArgs, serverRouteOf } from "./serverRouteShared.js";
27
28
  // the instance is the provider component, so it (unlike an options object) is a function
28
29
  function isRouterInstance(options) {
29
30
  return typeof options === "function";
@@ -146,6 +147,11 @@ function runPreloads(event, branches, url, previousUrl, rootPreload) {
146
147
  if (!prevMatches[match] || matches[match].route !== prevMatches[match].route)
147
148
  event.router.dataOnly = true;
148
149
  const { route, params } = matches[match];
150
+ // A server component route's markup is collected like any query result:
151
+ // the same call (function id + derived args) the client is showing, so
152
+ // the response's region addresses what is mounted.
153
+ const server = serverRouteOf(route.component);
154
+ server && server.call(serverRouteArgs(route, params, location.query));
149
155
  route.preload &&
150
156
  route.preload({
151
157
  params,
@@ -0,0 +1,19 @@
1
+ import type { Component } from "solid-js";
2
+ import type { Params, RouteSectionProps, ServerRouteFunction } from "./types.js";
3
+ /**
4
+ * Use a server component as a route (experimental). `fn` is a `"use server"`
5
+ * function taking the router-derived {@link ServerRouteArgs} — this route's
6
+ * `params`, and `search` when the route declares a schema — and resolving to
7
+ * a server component. The router mounts it with the outlet as `children`,
8
+ * warms the same call on link intent, and collects it for single-flight
9
+ * mutation responses.
10
+ *
11
+ * `children` is the only client position the router fills, so the server
12
+ * component may declare no other. One that takes client handlers, refs, or
13
+ * slots has a client half — write an ordinary route component for it.
14
+ *
15
+ * ```ts
16
+ * defineRoute({ path: "/stories/:id", component: serverRouteComponent(storyView) });
17
+ * ```
18
+ */
19
+ export declare function serverRouteComponent<P extends Params = Params, S = undefined>(fn: ServerRouteFunction<P, S>): Component<RouteSectionProps<unknown, P>>;
@@ -0,0 +1,79 @@
1
+ // Server component routes (experimental — rides the experimental server
2
+ // components surface in @solidjs/web; the arg shape may change).
3
+ //
4
+ // `serverRouteComponent(fn)` turns a `"use server"` function that returns a
5
+ // component into a route `component`. It replaces the client wrapper a
6
+ // server-component route used to need:
7
+ //
8
+ // // before: a client component exists only to make the call
9
+ // component: props => {
10
+ // const View = dynamic(() => getStory(props.params.id));
11
+ // return <View>{props.children}</View>;
12
+ // }
13
+ // // after
14
+ // component: serverRouteComponent(storyRoute) // async ({ params }) => { "use server"; ... }
15
+ //
16
+ // The mechanism is the same one the wrapper used — `query` for cache identity
17
+ // (preload participation, single-flight collection, revalidation after
18
+ // actions) and `dynamic` for the equals-gated mount that morphs in place —
19
+ // applied by the router rather than restated per route. What the router adds
20
+ // is the URL → call translation: it derives the call's arguments from the
21
+ // match and drives the same call from hover preload and from the single
22
+ // flight collector, so one entry serves render, preload, and mutation
23
+ // responses.
24
+ //
25
+ // Arguments are derived, not read from a live location: the call's
26
+ // `(function, arguments)` address keys the frame store and the query cache,
27
+ // so the args must name precisely what the route depends on. They are the
28
+ // params this route's pattern (with its ancestors') declares — never a
29
+ // child's, so a layout does not refetch when a leaf param changes — and, only
30
+ // when the route declares a `search` schema, its validated output.
31
+ //
32
+ // The router fills exactly one client position: `children`, with the outlet.
33
+ // A server component that takes other client positions — handlers, refs,
34
+ // named slots — has a client half, and that half is a client component; the
35
+ // helper does not pretend otherwise. Write the wrapper for that route.
36
+ import { dynamic } from "@solidjs/web";
37
+ import { createComponent } from "solid-js";
38
+ import { query } from "./data/query.js";
39
+ import { SERVER_ROUTE } from "./serverRouteShared.js";
40
+ /**
41
+ * Use a server component as a route (experimental). `fn` is a `"use server"`
42
+ * function taking the router-derived {@link ServerRouteArgs} — this route's
43
+ * `params`, and `search` when the route declares a schema — and resolving to
44
+ * a server component. The router mounts it with the outlet as `children`,
45
+ * warms the same call on link intent, and collects it for single-flight
46
+ * mutation responses.
47
+ *
48
+ * `children` is the only client position the router fills, so the server
49
+ * component may declare no other. One that takes client handlers, refs, or
50
+ * slots has a client half — write an ordinary route component for it.
51
+ *
52
+ * ```ts
53
+ * defineRoute({ path: "/stories/:id", component: serverRouteComponent(storyView) });
54
+ * ```
55
+ */
56
+ export function serverRouteComponent(fn) {
57
+ const reference = fn;
58
+ // One query entry per reference: the cache key (the function id) has to
59
+ // agree between the render that mounts the route, the hover preload that
60
+ // warms it, and the single-flight collector that re-produces its region.
61
+ // A server function reference always carries its build-stable id; a
62
+ // hand-built brand (tests) falls back to the function name.
63
+ const call = query(reference, "route:" + (reference.id || reference.name));
64
+ // The component itself is the fallback for a mount the router core does
65
+ // not drive (a plain `createComponent`): it has only the merged params to
66
+ // go on, so it calls with those. The core mounts through the brand with
67
+ // this level's params instead.
68
+ const route = (routeProps) => render(() => ({ params: { ...routeProps.params }, search: undefined }), routeProps);
69
+ function render(args, routeProps) {
70
+ const View = dynamic(() => call(args()));
71
+ return createComponent(View, {
72
+ get children() {
73
+ return routeProps.children;
74
+ }
75
+ });
76
+ }
77
+ route[SERVER_ROUTE] = { call, render };
78
+ return route;
79
+ }
@@ -0,0 +1,33 @@
1
+ import type { Accessor } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
3
+ import type { Params, RouteDescription, RouteSectionProps, SearchParams, ServerRouteArgs } from "./types.js";
4
+ export declare const SERVER_ROUTE: unique symbol;
5
+ /**
6
+ * What `serverRouteComponent()` attaches to the route component it returns, and
7
+ * what the router core drives it through: `call` is the query-wrapped server
8
+ * function (preload and single-flight collection warm/collect through it),
9
+ * `render` mounts the resolved server component for the current args with
10
+ * the route's props (`children` is the outlet).
11
+ */
12
+ export interface ServerRouteBrand {
13
+ call: (args: ServerRouteArgs<Params, unknown>) => unknown;
14
+ render: (args: Accessor<ServerRouteArgs<Params, unknown>>, route: RouteSectionProps) => JSX.Element;
15
+ }
16
+ export type BrandedRouteComponent = Function & {
17
+ [SERVER_ROUTE]?: ServerRouteBrand;
18
+ };
19
+ /** The brand carried by a route component built with `serverRouteComponent()`, if any. */
20
+ export declare function serverRouteOf(component: unknown): ServerRouteBrand | undefined;
21
+ /**
22
+ * Derive a server route's call arguments from a match: the match's params
23
+ * (this route's pattern and its ancestors' — never a child's) and the route's
24
+ * `search` schema output when one is declared. Reads `query` only when a
25
+ * schema exists, so a search-agnostic route never tracks the query string.
26
+ */
27
+ export declare function serverRouteArgs(route: RouteDescription, params: Params, query: SearchParams): ServerRouteArgs<Params, unknown>;
28
+ /**
29
+ * Structural equality for the args memo: a navigation produces fresh match
30
+ * objects even when this level's params are unchanged, and an equal call
31
+ * must not re-enter the source (and refetch).
32
+ */
33
+ export declare function serverRouteArgsEqual(a: ServerRouteArgs<Params, unknown>, b: ServerRouteArgs<Params, unknown>): boolean;
@@ -0,0 +1,48 @@
1
+ export const SERVER_ROUTE = Symbol("solid-router.serverRoute");
2
+ /** The brand carried by a route component built with `serverRouteComponent()`, if any. */
3
+ export function serverRouteOf(component) {
4
+ return typeof component === "function"
5
+ ? component[SERVER_ROUTE]
6
+ : undefined;
7
+ }
8
+ /**
9
+ * Derive a server route's call arguments from a match: the match's params
10
+ * (this route's pattern and its ancestors' — never a child's) and the route's
11
+ * `search` schema output when one is declared. Reads `query` only when a
12
+ * schema exists, so a search-agnostic route never tracks the query string.
13
+ */
14
+ export function serverRouteArgs(route, params, query) {
15
+ const schema = route.key?.search;
16
+ let search = undefined;
17
+ if (schema) {
18
+ const outcome = schema["~standard"].validate({ ...query });
19
+ if (outcome instanceof Promise)
20
+ throw new Error("Async Standard Schema validation is not supported for search params");
21
+ // Issues leave the raw values in place, matching useSearchParams: search
22
+ // strings are user input, so defaults belong in the schema itself.
23
+ search = outcome.issues ? { ...query } : outcome.value;
24
+ }
25
+ return { params: { ...params }, search };
26
+ }
27
+ function shallowEqual(a, b) {
28
+ if (a === b)
29
+ return true;
30
+ if (!a || !b || typeof a !== "object" || typeof b !== "object")
31
+ return false;
32
+ const ka = Object.keys(a);
33
+ const kb = Object.keys(b);
34
+ if (ka.length !== kb.length)
35
+ return false;
36
+ for (const k of ka)
37
+ if (a[k] !== b[k])
38
+ return false;
39
+ return true;
40
+ }
41
+ /**
42
+ * Structural equality for the args memo: a navigation produces fresh match
43
+ * objects even when this level's params are unchanged, and an equal call
44
+ * must not re-enter the source (and refetch).
45
+ */
46
+ export function serverRouteArgsEqual(a, b) {
47
+ return shallowEqual(a.params, b.params) && shallowEqual(a.search, b.search);
48
+ }
package/dist/types.d.ts CHANGED
@@ -141,6 +141,24 @@ export interface RouteSectionProps<T = unknown, P extends Params = Params> {
141
141
  children?: JSX.Element;
142
142
  }
143
143
  export type RouteSectionComponent<T = unknown, P extends Params = Params> = Component<RouteSectionProps<T, P>> | Component<Omit<RouteSectionProps<T, P>, "children">> | Component<{}>;
144
+ /**
145
+ * What a server component route is called with (experimental): the params
146
+ * the route's pattern (and its ancestors') declares, plus — only when the
147
+ * route declares a `search` schema — that schema's validated output.
148
+ */
149
+ export interface ServerRouteArgs<P extends Params = Params, S = undefined> {
150
+ params: P;
151
+ search: S;
152
+ }
153
+ /**
154
+ * The shape `serverRouteComponent()` accepts (experimental): a `"use server"`
155
+ * function taking the router-derived {@link ServerRouteArgs} and resolving
156
+ * to a server component whose only client position is `children` — the route
157
+ * outlet, which the router fills.
158
+ */
159
+ export type ServerRouteFunction<P extends Params = Params, S = undefined> = (args: ServerRouteArgs<P, S>) => Promise<Component<{
160
+ children?: JSX.Element;
161
+ }>>;
144
162
  declare const ROUTE_PATTERN: unique symbol;
145
163
  export interface TypedRouteConfig<S extends string = string> {
146
164
  readonly [ROUTE_PATTERN]: S;
@@ -341,7 +359,13 @@ export type Submission<T, U> = {
341
359
  export interface MaybePreloadableComponent extends Component {
342
360
  preload?: () => void;
343
361
  }
344
- export type CacheEntry = [number, Promise<any>, any, Intent | undefined, Signal<number> & {
345
- count: number;
346
- }];
362
+ export type CacheEntry = [
363
+ number,
364
+ Promise<any>,
365
+ any,
366
+ Intent | undefined,
367
+ Signal<number> & {
368
+ count: number;
369
+ }
370
+ ];
347
371
  export type NarrowResponse<T> = T extends ResponseEnvelope<infer U> ? U : Exclude<T, Response>;
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "Ryan Turnquist"
7
7
  ],
8
8
  "license": "MIT",
9
- "version": "2.0.0-next.25",
9
+ "version": "2.0.0-next.27",
10
10
  "homepage": "https://github.com/solidjs/solid-router#readme",
11
11
  "repository": {
12
12
  "type": "git",