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

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
@@ -371,14 +371,14 @@ Resolution is cached per thunk and append-only: the tree never changes shape aft
371
371
 
372
372
  ### Server Component Routes (experimental)
373
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:
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` takes a `query` over one and uses it as a route's `component` directly — the router owns the URL → call translation a client wrapper used to spell out:
375
375
 
376
376
  ```tsx
377
- // views.tsx
378
- "use server";
379
- import type { ServerRouteArgs } from "@solidjs/router";
377
+ // story.tsx — the source owns its key
378
+ import { query, type ServerRouteArgs } from "@solidjs/router";
380
379
 
381
- export async function storyView({ params }: ServerRouteArgs<{ id: string }>) {
380
+ export const getStory = query(async ({ params }: ServerRouteArgs<{ id: string }>) => {
381
+ "use server";
382
382
  const story = await db.stories.get(params.id);
383
383
  return props => (
384
384
  <article>
@@ -386,20 +386,46 @@ export async function storyView({ params }: ServerRouteArgs<{ id: string }>) {
386
386
  {props.children}
387
387
  </article>
388
388
  );
389
- }
389
+ }, "story");
390
390
 
391
391
  // routes.ts
392
- defineRoute({ path: "/stories/:id", component: serverRouteComponent(storyView) });
392
+ defineRoute({ path: "/stories/:id", component: serverRouteComponent(getStory) });
393
393
  ```
394
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:
395
+ The source 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
396
 
397
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
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
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.
400
+ 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
+ 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
+
404
+ - `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
+
407
+ Anything else callable with the args works too; wrapping is what gives dedupe, preload, and revalidation.
408
+
409
+ Mutations reach these routes the way they reach any query, and the cost model is worth knowing: a swept server route is a server-side re-render plus its markup in the response, not a small data blob. From a server action, `redirect(url)` re-collects everything the target shows and a bare `reload()` everything the current page shows — server routes included, shell and layouts too. On a server-component-heavy page, name what a mutation actually changed instead. `Router.keysFor(url)` answers the query keys of the server routes a URL shows, root to leaf — the app's own keys, from pure matching, so it works in an action and never spells a key:
410
+
411
+ ```ts
412
+ export const addComment = action(async (form: FormData) => {
413
+ "use server";
414
+ await db.comments.add(form);
415
+ return reload({ revalidate: Router.keysFor(paths.stories(form.get("id"))) });
416
+ });
417
+ ```
418
+
419
+ Route keys, not addresses: every story page is refetched, which is what prefix matching gives a hand-written `revalidate("story")` too.
420
+
421
+ An app shell is a pathless layout route whose `component` is a server component. With no pattern, its args are the constant `{ params: {}, search: undefined }` — one call, one address, persisting across every navigation — and as a route it gets preload, collection, and `revalidate` like any other. The `<Router>` root slot exists for client providers that need router context without following route rules; a server component has neither, so it does not go there:
422
+
423
+ ```tsx
424
+ const routes = [{ component: serverRouteComponent(query(appShell, "shell")), children: pages }];
425
+ render(() => <Router routes={routes} />, document.body);
426
+ ```
401
427
 
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.
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.
403
429
 
404
430
  ### File-System Routes
405
431
 
@@ -432,6 +458,30 @@ export default function Post(props: RouteProps<typeof route>) {
432
458
 
433
459
  The pattern string is a typing witness — at runtime the manifest's path (from the filename) is the source of truth. With the plugin's `types` option generating a literal declaration for the virtual module, the file paths flow into `paths` and the typed hooks like a hand-written tree — `paths.blog(42)` typechecks, filters and search schemas included, and `useParams(paths.blog)` works as usual anywhere under the route.
434
460
 
461
+ #### Server pages
462
+
463
+ A route file's page can be a [server component](#server-component-routes-experimental): make the default export a `"use server"` function of the route args. With `fileRoutes({ serverComponents: true })` on the `file-routes` plugin, the scanner flags such a file and the adapter builds the route the hand-written tree spells out — `serverRouteComponent(query(fn, key))` with the file's path as the key. No `query`, key, or wrapper in the route file. (That option turns on server component _routes_; server components themselves are Solid's plugin's `serverFunctions: { components: true }`, the same switch a hand-written server route needs.)
464
+
465
+ ```tsx
466
+ // routes/stories/[id].tsx
467
+ import { defineFileRoute } from "@solidjs/router/fs";
468
+ import type { ServerRouteArgs } from "@solidjs/router";
469
+
470
+ export const route = defineFileRoute("/stories/:id", { search: storySearch });
471
+
472
+ export default async function Story({ params, search }: ServerRouteArgs<typeof route>) {
473
+ "use server";
474
+ const story = await db.stories.get(params.id);
475
+ return props => <article>{story.title}{props.children}</article>;
476
+ }
477
+ ```
478
+
479
+ `ServerRouteArgs<typeof route>` reads the config as a witness like `RouteProps` does: `params` from the pattern, `search` as the schema's output (`undefined` with no schema). The key is the file — `Router.keysFor(paths.stories(7))` answers `["src/routes/stories/[id].tsx"]` (plus any server layouts above it), so an action names the page without spelling a path; because keys match by prefix, `revalidate("src/routes/stories")` refetches every page under the directory (pathless layouts have no route path of their own, so the file is what tells them apart). The directive has to be the first statement of the inline default export — behind a wrapper call or a re-export the scanner cannot see it, the page is code-split like a client page, and in development the adapter throws a directed error when the chunk resolves.
480
+
481
+ A live page names its wrapper in the `route` config — `defineFileRoute("/feed", { query: liveQuery })` — and the adapter sources through that instead of `query`. The route file imports `liveQuery`, so only an app with a live page carries it.
482
+
483
+ Apps with no server page pay nothing for any of this. The plugin folds the scan into `filesystem-routing/flags`, and the adapter's only path to `serverRouteComponent` and `query` is a module gated on that constant, so the branch — and with it the frames runtime — tree-shakes away. `pnpm build` asserts it.
484
+
435
485
  ## Typed Paths
436
486
 
437
487
  `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:
@@ -594,7 +644,7 @@ Live queries ride the router's existing machinery rather than adding their own:
594
644
  - **Preload** — calling one in a preload function warms the connection, so navigation renders against an already-delivered value instead of holding the transition on connect.
595
645
  - **SSR** — the document face renders the first value; hydration adopts it and reconnects. Server-side, consumers of a key within one request observe the same value.
596
646
  - **Revalidation** — explicit `revalidate(key)` reconnects (the producer re-yields current state by contract). The post-mutation sweep leaves healthy connections alone: the stream is its own freshness mechanism.
597
- - **Single-flight** — a mutation's flight payload pushes straight into open channels, so mutated state lands in the same round trip.
647
+ - **Single-flight** — live keys are neither collected nor swept: the stream is the freshness mechanism, and a mutation reaches a live query through its producer (the `watch` yielding the changed state), not through the mutation response. The flight-data seam that pushes payload values into open channels stays available to an integration that collects live keys itself.
598
648
 
599
649
  The callable carries the `query` conventions (`key`, `keyFor`) plus a reactive `status(...args)` read (`"idle" | "connecting" | "connected" | "reconnecting" | "closed"`) for surfacing connection state in UI.
600
650
 
@@ -18,11 +18,11 @@ export type LiveFunction<T extends (...args: any) => any> = T extends (...args:
18
18
  * definite rejection (4xx: the transport stamps HTTP statuses onto
19
19
  * failures) ends the channel and surfaces the error to consumers, as does
20
20
  * a first-connect failure.
21
- * - Server functions are declared GET at creation (like `query`). Live
22
- * queries participate in single-flight on the delivery side: a mutation's
23
- * flight payload pushes straight into open channels (the mutation
24
- * response is the round trip), while the post-mutation sweep leaves the
25
- * healthy connection in place — the live stream stays authoritative.
21
+ * - Server functions are declared GET at creation (like `query`). Live keys
22
+ * are neither collected for single-flight nor swept after a mutation: the
23
+ * stream is the freshness mechanism, and the mutation reaches a live
24
+ * query through its producer. (The flight-data hook below still adopts a
25
+ * live key an integration collects itself.)
26
26
  * - Calling under preload intent warms the channel (a temporary hold keeps
27
27
  * it open through the preload window), so navigation renders against an
28
28
  * already-connected stream instead of holding the transition on connect
@@ -205,7 +205,7 @@ function subscriberIterable(open) {
205
205
  }
206
206
  };
207
207
  return {
208
- next: () => (released ? Promise.resolve({ done: true, value: undefined }) : pull()),
208
+ next: () => released ? Promise.resolve({ done: true, value: undefined }) : pull(),
209
209
  return(value) {
210
210
  release();
211
211
  return Promise.resolve({ done: true, value });
@@ -236,10 +236,12 @@ function push(ch, value) {
236
236
  // connection is alive and is itself the freshness mechanism — a
237
237
  // push-driven producer yields the mutated state on its own, and tearing
238
238
  // down healthy connections on every mutation defeats the model.
239
- // - Single-flight payloads deliver INTO open channels, exactly as they seed
240
- // the query cache: the mutation response is the round trip, the channel
241
- // adopts the value immediately, and the live stream stays authoritative
242
- // for everything after.
239
+ // - A single-flight payload carrying a live key delivers INTO the open
240
+ // channel rather than reconnecting it. The router's own collector does not
241
+ // produce live keys (the stream is the freshness mechanism; collection
242
+ // for live server components is an open question — a mutation's region
243
+ // would outrank the standing frame stream under the transport's version
244
+ // policy), so this path serves an integration that collects them itself.
243
245
  let hooked = false;
244
246
  function hookRevalidate() {
245
247
  if (hooked)
@@ -293,11 +295,11 @@ function hookRevalidate() {
293
295
  * definite rejection (4xx: the transport stamps HTTP statuses onto
294
296
  * failures) ends the channel and surfaces the error to consumers, as does
295
297
  * a first-connect failure.
296
- * - Server functions are declared GET at creation (like `query`). Live
297
- * queries participate in single-flight on the delivery side: a mutation's
298
- * flight payload pushes straight into open channels (the mutation
299
- * response is the round trip), while the post-mutation sweep leaves the
300
- * healthy connection in place — the live stream stays authoritative.
298
+ * - Server functions are declared GET at creation (like `query`). Live keys
299
+ * are neither collected for single-flight nor swept after a mutation: the
300
+ * stream is the freshness mechanism, and the mutation reaches a live
301
+ * query through its producer. (The flight-data hook below still adopts a
302
+ * live key an integration collects itself.)
301
303
  * - Calling under preload intent warms the channel (a temporary hold keeps
302
304
  * it open through the preload window), so navigation renders against an
303
305
  * already-connected stream instead of holding the transition on connect
@@ -349,7 +351,7 @@ export function liveQuery(fn, name) {
349
351
  // reads the same guarantee). Channels live in the event, retained
350
352
  // past teardown so a later consumer replays the settled value instead
351
353
  // of reinvoking; the producer still closes with its last consumer.
352
- const router = (e.router || (e.router = {}));
354
+ const router = e.router || (e.router = {});
353
355
  const channels = router.liveChannels || (router.liveChannels = new Map());
354
356
  return subscriberIterable(() => openChannel(channels, key, () => fn(...args), false, true));
355
357
  }
package/dist/fs.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ServerPageQuery } from "./fsServer.js";
1
2
  import type { DefinedRouteFilters, RouteParams, RoutePreloadFunc, RoutePreloadFuncArgs, RouteInfo, RouteSectionComponent, StandardSchemaV1, TypedRouteConfig, ValidFilters } from "./types.js";
2
3
  /**
3
4
  * The type `defineFileRoute` hands back: `matchFilters` and `search` stay
@@ -14,6 +15,7 @@ export type FileRouteConfig<S extends string = string, T = unknown, F = undefine
14
15
  }) & {
15
16
  preload?: RoutePreloadFunc<T> | undefined;
16
17
  info?: RouteInfo | undefined;
18
+ query?: ServerPageQuery | undefined;
17
19
  };
18
20
  /**
19
21
  * Identity helper for a route file's `route` export that types `preload`'s
@@ -39,6 +41,8 @@ export declare function defineFileRoute<S extends string, T = unknown, const F =
39
41
  /** Standard Schema validator for this route's search params; its input type flows into the typed path proxy. */
40
42
  search?: Sch;
41
43
  info?: RouteInfo | undefined;
44
+ /** For a server page: the wrapper its source goes through — `query` unless named, e.g. `liveQuery`. */
45
+ query?: ServerPageQuery | undefined;
42
46
  }): FileRouteConfig<S, T, F, Sch>;
43
47
  /** A code-split module ref: delivered as a dynamic import. */
44
48
  export interface FileRouteLazyRef<M = Record<string, unknown>> {
@@ -54,7 +58,9 @@ export interface FileRouteEagerRef<M = Record<string, unknown>> {
54
58
  export interface FileRouteEntry {
55
59
  path: string;
56
60
  page?: boolean;
57
- /** Code-split by default; an eager ref when delivered with `codeSplitting: false`. */
61
+ /** The page component is a `"use server"` function; its `$component` is an eager ref to the stub. */
62
+ server?: boolean;
63
+ /** Code-split by default; an eager ref when delivered with `codeSplitting: false` or for a server page. */
58
64
  $component?: FileRouteLazyRef<any> | FileRouteEagerRef<any> | undefined;
59
65
  $$route?: FileRouteEagerRef<any> | undefined;
60
66
  children?: readonly FileRouteEntry[] | undefined;
package/dist/fs.js CHANGED
@@ -1,4 +1,7 @@
1
- import { lazy } from "solid-js";
1
+ import { DEV, lazy } from "solid-js";
2
+ import { isServerFunction } from "@solidjs/web";
3
+ import { serverRoutes } from "filesystem-routing/flags";
4
+ import * as server from "./fsServer.js";
2
5
  /**
3
6
  * Identity helper for a route file's `route` export that types `preload`'s
4
7
  * `args.params` from a pattern witness — the file's path pattern, which the
@@ -31,15 +34,36 @@ export function defineFileRoute(path, config) {
31
34
  */
32
35
  export function fileRoutes(entries) {
33
36
  const components = new Map();
34
- const componentOf = (ref) => {
37
+ const componentOf = (entry, config) => {
38
+ const ref = entry.$component;
35
39
  if ("require" in ref) {
36
- return ref.require().default;
40
+ const component = ref.require()
41
+ .default;
42
+ // A server page: the stub is the source; the file is the key (unique,
43
+ // and a directory prefix is a subtree for `revalidate`).
44
+ return serverRoutes && entry.server
45
+ ? server.serverRoute(component, (ref.src || entry.path).split("?")[0], config)
46
+ : component;
37
47
  }
38
48
  let component = components.get(ref.src);
39
49
  if (!component) {
40
50
  // moduleUrl is lazy()'s third argument as of solid 2.0.0-rc.1 (options
41
51
  // moved second); route components are default exports, so no { export }.
42
- component = lazy(ref.import, undefined, ref.src);
52
+ component = lazy(
53
+ // A `"use server"` default the scanner did not see (behind a wrapper
54
+ // call or a re-export) — or scanned without the plugin's
55
+ // `serverComponents` option — was code-split like a client page:
56
+ // say so in development rather than mount a stub as a component.
57
+ DEV
58
+ ? () => ref.import().then(mod => {
59
+ if (isServerFunction(mod.default))
60
+ throw new Error(`Route module "${ref.src}" exports a server function as its page, but it ` +
61
+ "was delivered as a client page. Enable `serverComponents: true` on the " +
62
+ 'file-routes plugin and make the `"use server"` directive the first ' +
63
+ "statement of the inline default export.");
64
+ return mod;
65
+ })
66
+ : ref.import, undefined, ref.src);
43
67
  components.set(ref.src, component);
44
68
  }
45
69
  return component;
@@ -49,7 +73,7 @@ export function fileRoutes(entries) {
49
73
  return {
50
74
  ...config,
51
75
  path: entry.path,
52
- component: entry.$component ? componentOf(entry.$component) : undefined,
76
+ component: entry.$component ? componentOf(entry, config) : undefined,
53
77
  info: { ...config.info, filesystem: true },
54
78
  children: entry.children ? entry.children.map(toRoute) : undefined
55
79
  };
@@ -0,0 +1,15 @@
1
+ import type { RouteSectionComponent } from "./types.js";
2
+ /**
3
+ * The wrapper a server page's source goes through: `query` unless the route
4
+ * config names another — `liveQuery`, imported by the route file that wants
5
+ * it, so only that app carries it.
6
+ */
7
+ export type ServerPageQuery = (fn: (...args: any[]) => any, key: string) => (...args: any[]) => any;
8
+ /**
9
+ * The server page half of the fs adapter — the only importer of `query` and
10
+ * `serverRouteComponent`. fs.ts reaches it behind `serverRoutes` from
11
+ * `filesystem-routing/flags`, so an app with no server page never bundles it.
12
+ */
13
+ export declare function serverRoute(fn: (...args: any[]) => any, key: string, config?: {
14
+ query?: ServerPageQuery | undefined;
15
+ }): RouteSectionComponent;
@@ -0,0 +1,10 @@
1
+ import { query } from "./data/query.js";
2
+ import { serverRouteComponent } from "./serverRouteComponent.js";
3
+ /**
4
+ * The server page half of the fs adapter — the only importer of `query` and
5
+ * `serverRouteComponent`. fs.ts reaches it behind `serverRoutes` from
6
+ * `filesystem-routing/flags`, so an app with no server page never bundles it.
7
+ */
8
+ export function serverRoute(fn, key, config) {
9
+ return serverRouteComponent((config?.query ?? query)(fn, key));
10
+ }
package/dist/index.d.ts CHANGED
@@ -26,4 +26,4 @@ export { int } from "./paths.js";
26
26
  export { serverRouteComponent } from "./serverRouteComponent.js";
27
27
  export type { RoutePaths, PathParamsOf, PathEnd, TypedMatchFilter, DefaultSearchTypes } from "./paths.js";
28
28
  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, ServerRouteFunction, 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, ServerRouteParams, ServerRouteFunction, ServerRouteView, Submission, BeforeLeaveEventArgs, TypedPath, TypedSearchPath, StandardSchemaV1 } from "./types.js";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
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, dynamic } from '@solidjs/web';
2
+ import { registerElementClaim, delegateEvents, isServer, getRequestEvent, hasFlashCookie, clearFlashCookie, createComponent as createComponent$1, memo, dynamic, isServerFunction, getServerFunctionMetadata, getServerFunctionRPC, isResponseEnvelope, REVALIDATE_HEADER } from '@solidjs/web';
3
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
 
@@ -38,6 +38,13 @@ function invariant(value, message) {
38
38
  function joinPaths(from, to) {
39
39
  return normalizePath(from).replace(/\/*(\*.*)?$/g, "") + normalizePath(to);
40
40
  }
41
+
42
+ /** Run a Standard Schema synchronously — the only mode search params support. */
43
+ function validateSearch(schema, raw) {
44
+ const outcome = schema["~standard"].validate(raw);
45
+ if (outcome instanceof Promise) throw new Error("Async Standard Schema validation is not supported for search params");
46
+ return outcome;
47
+ }
41
48
  function extractSearchParams(url) {
42
49
  const params = {};
43
50
  url.searchParams.forEach((value, key) => {
@@ -536,7 +543,7 @@ const SERVER_ROUTE = Symbol("solid-router.serverRoute");
536
543
 
537
544
  /** The brand carried by a route component built with `serverRouteComponent()`, if any. */
538
545
  function serverRouteOf(component) {
539
- return typeof component === "function" ? component[SERVER_ROUTE] : undefined;
546
+ return component?.[SERVER_ROUTE];
540
547
  }
541
548
 
542
549
  /**
@@ -547,17 +554,15 @@ function serverRouteOf(component) {
547
554
  */
548
555
  function serverRouteArgs(route, params, query) {
549
556
  const schema = route.key?.search;
550
- let search = undefined;
557
+ let search;
551
558
  if (schema) {
552
- const outcome = schema["~standard"].validate({
559
+ const raw = {
553
560
  ...query
554
- });
555
- if (outcome instanceof Promise) throw new Error("Async Standard Schema validation is not supported for search params");
561
+ };
562
+ const outcome = validateSearch(schema, raw);
556
563
  // Issues leave the raw values in place, matching useSearchParams: search
557
564
  // strings are user input, so defaults belong in the schema itself.
558
- search = outcome.issues ? {
559
- ...query
560
- } : outcome.value;
565
+ search = outcome.issues ? raw : outcome.value;
561
566
  }
562
567
  return {
563
568
  params: {
@@ -566,15 +571,7 @@ function serverRouteArgs(route, params, query) {
566
571
  search
567
572
  };
568
573
  }
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
- }
574
+ const shallowEqual = (a, b) => a === b || !!a && !!b && typeof a === "object" && typeof b === "object" && Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(k => a[k] === b[k]);
578
575
 
579
576
  /**
580
577
  * Structural equality for the args memo: a navigation produces fresh match
@@ -830,8 +827,7 @@ function useSearchParams(path) {
830
827
  for (const match of router.matches()) {
831
828
  const schema = match.route.key.search;
832
829
  if (!schema) continue;
833
- const outcome = schema["~standard"].validate(raw);
834
- if (outcome instanceof Promise) throw new Error("Async Standard Schema validation is not supported for search params");
830
+ const outcome = validateSearch(schema, raw);
835
831
  if (!outcome.issues) result = Object.assign(result || {
836
832
  ...raw
837
833
  }, outcome.value);
@@ -1528,33 +1524,49 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1528
1524
  return (to, options) => navigateFromRoute(route, to, options);
1529
1525
  }
1530
1526
  function preloadRoute(url, preloadData) {
1531
- const matches = getRouteMatches(branches(), url.pathname);
1527
+ const next = getRouteMatches(branches(), url.pathname);
1532
1528
  // An unresolved lazy subtree in the chain: the placeholder's
1533
1529
  // component.preload (below) kicks the table load; once it lands,
1534
1530
  // preload again so the real inner routes warm too. Preloads are
1535
1531
  // speculative: a failed load (held sync throw or rejection) is ignored
1536
1532
  // here — the real navigation surfaces and retries it.
1537
- const boundary = matches.find(m => m.route.lazy && !m.route.lazy.resolved);
1533
+ const boundary = next.find(m => m.route.lazy && !m.route.lazy.resolved);
1538
1534
  if (boundary) {
1539
1535
  try {
1540
1536
  resolveLazySubtree(boundary.route.lazy).then(() => preloadRoute(url, preloadData), () => {});
1541
1537
  } catch {}
1542
1538
  }
1539
+ // Data preloads run only for levels a navigation would mount fresh or
1540
+ // reuse with changed inputs: this level's params, and search as the
1541
+ // declared schema's output or else the raw string. Navigation itself is
1542
+ // already this selective (a matching level is reused and re-reads through
1543
+ // tracked params), so an unchanged level has nothing new to warm.
1544
+ let current;
1545
+ try {
1546
+ current = untrack(matches);
1547
+ } catch {}
1548
+ const query = extractSearchParams(url);
1549
+ const inputs = (p, q, s, r) => {
1550
+ const a = serverRouteArgs(r, p, q);
1551
+ a.search === undefined && (a.search = s);
1552
+ return a;
1553
+ };
1543
1554
  const prevIntent = preloadIntent;
1544
1555
  preloadIntent = "preload";
1545
- for (let match in matches) {
1556
+ for (let match in next) {
1546
1557
  const {
1547
1558
  route,
1548
1559
  params
1549
- } = matches[match];
1560
+ } = next[match];
1550
1561
  route.component && route.component.preload && route.component.preload();
1551
1562
  const {
1552
1563
  preload,
1553
1564
  component
1554
1565
  } = route;
1566
+ const now = current && current[match];
1567
+ const unchanged = now && now.route.key === route.key && serverRouteArgsEqual(inputs(params, query, url.search, route), inputs(now.params, location.query, location.search, route));
1555
1568
  inPreloadFn = true;
1556
- preloadData && runWithOwner(getContext(), () => {
1557
- const query = extractSearchParams(url);
1569
+ preloadData && !unchanged && runWithOwner(getContext(), () => {
1558
1570
  // A server component route's data IS its call: warm the same
1559
1571
  // query entry the render will read, under the same derived args.
1560
1572
  const server = serverRouteOf(component);
@@ -2341,6 +2353,8 @@ function createRouter(config) {
2341
2353
  };
2342
2354
  const renderPath = config.history && config.history.utils && config.history.utils.renderPath || undefined;
2343
2355
  const matchPath = pathname => getRouteMatches(branches(), config.transformUrl ? config.transformUrl(pathname) : pathname);
2356
+ // a `paths` node carries its logical pathname under the Href brand
2357
+ const pathnameOf = url => typeof url === "string" ? new URL(url, mockBase).pathname : url[HREF];
2344
2358
  function RouterComponent(props) {
2345
2359
  // One router per app: the session (location, history, delegation, link
2346
2360
  // claims, preloading) has a single owner, and a second instance would
@@ -2398,7 +2412,7 @@ function createRouter(config) {
2398
2412
  routes: config.routes,
2399
2413
  config,
2400
2414
  match(url) {
2401
- return matchPath(new URL(url, mockBase).pathname).map(({
2415
+ return matchPath(pathnameOf(url)).map(({
2402
2416
  route,
2403
2417
  path,
2404
2418
  params
@@ -2409,6 +2423,17 @@ function createRouter(config) {
2409
2423
  params,
2410
2424
  info: route.info
2411
2425
  }));
2426
+ },
2427
+ keysFor(url) {
2428
+ const pathname = typeof url === "string" ? new URL(url, mockBase).pathname : url[HREF];
2429
+ const keys = [];
2430
+ for (const {
2431
+ route
2432
+ } of matchPath(pathname)) {
2433
+ const key = serverRouteOf(route.component)?.call?.key;
2434
+ typeof key === "string" && key && !keys.includes(key) && keys.push(key);
2435
+ }
2436
+ return keys;
2412
2437
  }
2413
2438
  });
2414
2439
  // Built on first access (a getter via Object.assign would run during the
@@ -2501,6 +2526,93 @@ const useBeforeLeave = listener => {
2501
2526
  onCleanup(s);
2502
2527
  };
2503
2528
 
2529
+ // Server component routes (experimental — rides the experimental server
2530
+ // components surface in @solidjs/web; the arg shape may change).
2531
+ //
2532
+ // `serverRouteComponent(source)` turns a function of route arguments that
2533
+ // resolves to a component into a route `component`. It replaces the client
2534
+ // wrapper a server-component route used to need:
2535
+ //
2536
+ // // before: a client component exists only to make the call
2537
+ // const getStory = query(storyView, "story");
2538
+ // component: props => {
2539
+ // const View = dynamic(() => getStory(props.params.id));
2540
+ // return <View>{props.children}</View>;
2541
+ // }
2542
+ // // after
2543
+ // component: serverRouteComponent(query(storyView, "story"))
2544
+ //
2545
+ // The source is the app's: `query(fn, key)` for a request/response server
2546
+ // component, `liveQuery(fn, key)` for one that streams successive versions,
2547
+ // or any function of the args. The router does not choose the cache
2548
+ // strategy and does not own the key — `revalidate("story")` is the app's,
2549
+ // as it is for any query. What the router adds is the URL → call
2550
+ // translation: it derives the call's arguments from the match, mounts the
2551
+ // resolved component with the outlet as `children`, and calls the same
2552
+ // source under preload intent (link hover, `preloadRoute`, the single-flight
2553
+ // collector), so the query or live channel is warm before the navigation
2554
+ // renders against it.
2555
+ //
2556
+ // Arguments are derived, not read from a live location: the call's
2557
+ // `(function, arguments)` address keys the frame store and the query cache,
2558
+ // so the args must name precisely what the route depends on. They are the
2559
+ // params this route's pattern (with its ancestors') declares — never a
2560
+ // child's, so a layout does not refetch when a leaf param changes — and, only
2561
+ // when the route declares a `search` schema, its validated output.
2562
+ //
2563
+ // The router fills exactly one client position: `children`, with the outlet.
2564
+ // A server component that takes other client positions — handlers, refs,
2565
+ // named slots — has a client half, and that half is a client component; the
2566
+ // helper does not pretend otherwise. Write the wrapper for that route.
2567
+ /**
2568
+ * Use a server component as a route (experimental). `source` is a function
2569
+ * of the router-derived {@link ServerRouteArgs} — this route's `params`, and
2570
+ * `search` when the route declares a schema — resolving to a server
2571
+ * component: typically a `"use server"` function wrapped in `query()` or
2572
+ * `liveQuery()`, whose key the app names and revalidates. The router mounts
2573
+ * the result with the outlet as `children` and calls the same source under
2574
+ * preload intent, so link hover and single-flight collection warm it.
2575
+ *
2576
+ * `children` is the only client position the router fills, so the server
2577
+ * component may declare no other. One that takes client handlers, refs, or
2578
+ * slots has a client half — write an ordinary route component for it.
2579
+ *
2580
+ * ```ts
2581
+ * defineRoute({ path: "/stories/:id", component: serverRouteComponent(query(storyView, "story")) });
2582
+ * ```
2583
+ */
2584
+ function serverRouteComponent(
2585
+ // A source that ignores its args (an app shell) is `(...args: never[])`
2586
+ // once `query()` has typed it; that is the same call, so accept it too.
2587
+ source) {
2588
+ const call = source;
2589
+
2590
+ // The value is a component only so it fits the `component` field; the
2591
+ // router core never calls it — it mounts through the brand, with THIS
2592
+ // level's params and the declared search output. A direct mount (a
2593
+ // `lazy()` wrapper around the helper, a hand-written `<Route />`) would
2594
+ // have only the merged params to call with: the same view under a second
2595
+ // address, missing the cache and owning its own frame. Refuse it.
2596
+ const route = () => {
2597
+ throw new Error("serverRouteComponent(): mount it as a route's `component`" + (DEV ? " — the router derives the call from the match. It was rendered directly (through " + "lazy(), as the <Router> root, or outside a route). For an app shell, make it a " + "pathless layout route with children." : ""));
2598
+ };
2599
+ function render(args, routeProps) {
2600
+ // The source may answer a component, a promise of one, or (a live query)
2601
+ // successive components; `dynamic`'s memo lands each the same way.
2602
+ const View = dynamic(() => call(args()));
2603
+ return createComponent(View, {
2604
+ get children() {
2605
+ return routeProps.children;
2606
+ }
2607
+ });
2608
+ }
2609
+ route[SERVER_ROUTE] = {
2610
+ call,
2611
+ render
2612
+ };
2613
+ return route;
2614
+ }
2615
+
2504
2616
  const LocationHeader = "Location";
2505
2617
  const PRELOAD_TIMEOUT$1 = 5000;
2506
2618
  const CACHE_TIMEOUT = 180000;
@@ -2853,91 +2965,6 @@ function isPlainObject(obj) {
2853
2965
  return obj != null && typeof obj === "object" && (!(proto = Object.getPrototypeOf(obj)) || proto === Object.prototype);
2854
2966
  }
2855
2967
 
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
-
2941
2968
  const submitHooksSymbol = Symbol("routerActionSubmitHooks");
2942
2969
  const settledHooksSymbol = Symbol("routerActionSettledHooks");
2943
2970
  const invokeSymbol = Symbol("routerActionInvoke");
@@ -3569,10 +3596,12 @@ function push(ch, value) {
3569
3596
  // connection is alive and is itself the freshness mechanism — a
3570
3597
  // push-driven producer yields the mutated state on its own, and tearing
3571
3598
  // down healthy connections on every mutation defeats the model.
3572
- // - Single-flight payloads deliver INTO open channels, exactly as they seed
3573
- // the query cache: the mutation response is the round trip, the channel
3574
- // adopts the value immediately, and the live stream stays authoritative
3575
- // for everything after.
3599
+ // - A single-flight payload carrying a live key delivers INTO the open
3600
+ // channel rather than reconnecting it. The router's own collector does not
3601
+ // produce live keys (the stream is the freshness mechanism; collection
3602
+ // for live server components is an open question — a mutation's region
3603
+ // would outrank the standing frame stream under the transport's version
3604
+ // policy), so this path serves an integration that collects them itself.
3576
3605
  let hooked = false;
3577
3606
  function hookRevalidate() {
3578
3607
  if (hooked) return;
@@ -3618,11 +3647,11 @@ function hookRevalidate() {
3618
3647
  * definite rejection (4xx: the transport stamps HTTP statuses onto
3619
3648
  * failures) ends the channel and surfaces the error to consumers, as does
3620
3649
  * a first-connect failure.
3621
- * - Server functions are declared GET at creation (like `query`). Live
3622
- * queries participate in single-flight on the delivery side: a mutation's
3623
- * flight payload pushes straight into open channels (the mutation
3624
- * response is the round trip), while the post-mutation sweep leaves the
3625
- * healthy connection in place — the live stream stays authoritative.
3650
+ * - Server functions are declared GET at creation (like `query`). Live keys
3651
+ * are neither collected for single-flight nor swept after a mutation: the
3652
+ * stream is the freshness mechanism, and the mutation reaches a live
3653
+ * query through its producer. (The flight-data hook below still adopts a
3654
+ * live key an integration collects itself.)
3626
3655
  * - Calling under preload intent warms the channel (a temporary hold keeps
3627
3656
  * it open through the preload window), so navigation renders against an
3628
3657
  * already-connected stream instead of holding the transition on connect
@@ -1,6 +1,6 @@
1
1
  import type { JSX } from "@solidjs/web";
2
2
  import type { RoutePaths } from "../paths.js";
3
- import type { DefinedRouteFilters, LazyRouteChildren, OutputMatch, Params, RouteDefinition, RouteInfo, RouteParams, RoutePreloadFunc, RoutePreloadFuncArgs, RouteSectionComponent, RouteSectionProps, StandardSchemaV1, ValidFilters } from "../types.js";
3
+ import type { DefinedRouteFilters, LazyRouteChildren, OutputMatch, Params, RouteDefinition, RouteInfo, RouteParams, RoutePreloadFunc, RoutePreloadFuncArgs, RouteSectionComponent, RouteSectionProps, StandardSchemaV1, TypedPath, ValidFilters } from "../types.js";
4
4
  import type { RouterHistory } from "./history.js";
5
5
  /**
6
6
  * Identity helper that preserves literal types when the route tree is
@@ -116,6 +116,15 @@ export interface RouterInstance<R extends readonly RouteDefinition[] = RouteDefi
116
116
  readonly config: RouterConfig<R>;
117
117
  /** Pure matching against an arbitrary URL — no rendering or request context involved. Root→leaf; `[]` when nothing matches. */
118
118
  match(url: string): OutputMatch[];
119
+ /**
120
+ * The query keys of the server component routes a URL shows, root→leaf —
121
+ * what to name in `reload`/`respond`/`redirect`'s `revalidate` (or the
122
+ * client's `revalidate()`) to refetch those routes and nothing else. Route
123
+ * keys, not addresses: the app's `query` key for a hand-written route, the
124
+ * file for a file-system route. Pure matching, so it works in a server
125
+ * action; a `paths` node is accepted.
126
+ */
127
+ keysFor(url: string | TypedPath): string[];
119
128
  }
120
129
  export declare function createRouter<const R extends readonly RouteDefinition[]>(config: RouterConfig<R>): RouterInstance<R>;
121
130
  export {};
@@ -7,7 +7,8 @@ import { DEV, OBSERVE } from "solid-js";
7
7
  import { getRequestEvent, isServer } from "@solidjs/web";
8
8
  import { setupLinkClaims } from "../claims.js";
9
9
  import { setupNativeEvents } from "../data/events.js";
10
- import { createPathsProxy } from "../paths.js";
10
+ import { createPathsProxy, HREF } from "../paths.js";
11
+ import { serverRouteOf } from "../serverRouteShared.js";
11
12
  import { createBranches, createRouterContext, getRouteMatches, mergeParams, registerFlightRouter, resolveLocationWrite, RouterContextObj, trackLazySubtrees, useOptionalContext } from "../routing.js";
12
13
  import { mockBase } from "../utils.js";
13
14
  import { Root, Routes } from "./components.jsx";
@@ -186,6 +187,8 @@ export function createRouter(config) {
186
187
  };
187
188
  const renderPath = (config.history && config.history.utils && config.history.utils.renderPath) || undefined;
188
189
  const matchPath = (pathname) => getRouteMatches(branches(), config.transformUrl ? config.transformUrl(pathname) : pathname);
190
+ // a `paths` node carries its logical pathname under the Href brand
191
+ const pathnameOf = (url) => typeof url === "string" ? new URL(url, mockBase).pathname : url[HREF];
189
192
  function RouterComponent(props) {
190
193
  // One router per app: the session (location, history, delegation, link
191
194
  // claims, preloading) has a single owner, and a second instance would
@@ -235,13 +238,22 @@ export function createRouter(config) {
235
238
  routes: config.routes,
236
239
  config,
237
240
  match(url) {
238
- return matchPath(new URL(url, mockBase).pathname).map(({ route, path, params }) => ({
241
+ return matchPath(pathnameOf(url)).map(({ route, path, params }) => ({
239
242
  path: route.originalPath,
240
243
  pattern: route.pattern,
241
244
  match: path,
242
245
  params,
243
246
  info: route.info
244
247
  }));
248
+ },
249
+ keysFor(url) {
250
+ const pathname = typeof url === "string" ? new URL(url, mockBase).pathname : url[HREF];
251
+ const keys = [];
252
+ for (const { route } of matchPath(pathname)) {
253
+ const key = serverRouteOf(route.component)?.call?.key;
254
+ typeof key === "string" && key && !keys.includes(key) && keys.push(key);
255
+ }
256
+ return keys;
245
257
  }
246
258
  });
247
259
  // Built on first access (a getter via Object.assign would run during the
package/dist/routing.js CHANGED
@@ -4,7 +4,7 @@ import { runWithOwner } from "solid-js";
4
4
  import { DEV } from "solid-js";
5
5
  import { createComponent, createContext, createMemo, createSignal, getOwner, isPending, latest, NotReadyError, untrack, useContext } from "solid-js";
6
6
  import { clearFlashCookie, getRequestEvent, hasFlashCookie, isServer } from "@solidjs/web";
7
- import { mockBase, comparablePath, createMemoObject, extractSearchParams, invariant, resolvePath, createMatcher, joinPaths, scoreRoute, mergeSearchString, expandOptionals } from "./utils.js";
7
+ import { mockBase, comparablePath, createMemoObject, extractSearchParams, invariant, resolvePath, createMatcher, joinPaths, scoreRoute, mergeSearchString, expandOptionals, validateSearch } from "./utils.js";
8
8
  import { HREF } from "./paths.js";
9
9
  import { serverRouteOf, serverRouteArgs, serverRouteArgsEqual } from "./serverRouteShared.js";
10
10
  const MAX_REDIRECTS = 100;
@@ -197,9 +197,7 @@ export function useSearchParams(path) {
197
197
  const schema = match.route.key.search;
198
198
  if (!schema)
199
199
  continue;
200
- const outcome = schema["~standard"].validate(raw);
201
- if (outcome instanceof Promise)
202
- throw new Error("Async Standard Schema validation is not supported for search params");
200
+ const outcome = validateSearch(schema, raw);
203
201
  if (!outcome.issues)
204
202
  result = Object.assign(result || { ...raw }, outcome.value);
205
203
  }
@@ -881,31 +879,51 @@ export function createRouterContext(integration, branches, getContext, options =
881
879
  return ((to, options) => navigateFromRoute(route, to, options));
882
880
  }
883
881
  function preloadRoute(url, preloadData) {
884
- const matches = getRouteMatches(branches(), url.pathname);
882
+ const next = getRouteMatches(branches(), url.pathname);
885
883
  // An unresolved lazy subtree in the chain: the placeholder's
886
884
  // component.preload (below) kicks the table load; once it lands,
887
885
  // preload again so the real inner routes warm too. Preloads are
888
886
  // speculative: a failed load (held sync throw or rejection) is ignored
889
887
  // here — the real navigation surfaces and retries it.
890
- const boundary = matches.find(m => m.route.lazy && !m.route.lazy.resolved);
888
+ const boundary = next.find(m => m.route.lazy && !m.route.lazy.resolved);
891
889
  if (boundary) {
892
890
  try {
893
891
  resolveLazySubtree(boundary.route.lazy).then(() => preloadRoute(url, preloadData), () => { });
894
892
  }
895
893
  catch { }
896
894
  }
895
+ // Data preloads run only for levels a navigation would mount fresh or
896
+ // reuse with changed inputs: this level's params, and search as the
897
+ // declared schema's output or else the raw string. Navigation itself is
898
+ // already this selective (a matching level is reused and re-reads through
899
+ // tracked params), so an unchanged level has nothing new to warm.
900
+ let current;
901
+ try {
902
+ current = untrack(matches);
903
+ }
904
+ catch { }
905
+ const query = extractSearchParams(url);
906
+ const inputs = (p, q, s, r) => {
907
+ const a = serverRouteArgs(r, p, q);
908
+ a.search === undefined && (a.search = s);
909
+ return a;
910
+ };
897
911
  const prevIntent = preloadIntent;
898
912
  preloadIntent = "preload";
899
- for (let match in matches) {
900
- const { route, params } = matches[match];
913
+ for (let match in next) {
914
+ const { route, params } = next[match];
901
915
  route.component &&
902
916
  route.component.preload &&
903
917
  route.component.preload();
904
918
  const { preload, component } = route;
919
+ const now = current && current[match];
920
+ const unchanged = now &&
921
+ now.route.key === route.key &&
922
+ serverRouteArgsEqual(inputs(params, query, url.search, route), inputs(now.params, location.query, location.search, route));
905
923
  inPreloadFn = true;
906
924
  preloadData &&
925
+ !unchanged &&
907
926
  runWithOwner(getContext(), () => {
908
- const query = extractSearchParams(url);
909
927
  // A server component route's data IS its call: warm the same
910
928
  // query entry the render will read, under the same derived args.
911
929
  const server = serverRouteOf(component);
@@ -1,19 +1,20 @@
1
1
  import type { Component } from "solid-js";
2
- import type { Params, RouteSectionProps, ServerRouteFunction } from "./types.js";
2
+ import type { Params, RouteSectionProps, ServerRouteFunction, ServerRouteParams, TypedRouteConfig } from "./types.js";
3
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.
4
+ * Use a server component as a route (experimental). `source` is a function
5
+ * of the router-derived {@link ServerRouteArgs} — this route's `params`, and
6
+ * `search` when the route declares a schema — resolving to a server
7
+ * component: typically a `"use server"` function wrapped in `query()` or
8
+ * `liveQuery()`, whose key the app names and revalidates. The router mounts
9
+ * the result with the outlet as `children` and calls the same source under
10
+ * preload intent, so link hover and single-flight collection warm it.
10
11
  *
11
12
  * `children` is the only client position the router fills, so the server
12
13
  * component may declare no other. One that takes client handlers, refs, or
13
14
  * slots has a client half — write an ordinary route component for it.
14
15
  *
15
16
  * ```ts
16
- * defineRoute({ path: "/stories/:id", component: serverRouteComponent(storyView) });
17
+ * defineRoute({ path: "/stories/:id", component: serverRouteComponent(query(storyView, "story")) });
17
18
  * ```
18
19
  */
19
- export declare function serverRouteComponent<P extends Params = Params, S = undefined>(fn: ServerRouteFunction<P, S>): Component<RouteSectionProps<unknown, P>>;
20
+ export declare function serverRouteComponent<P extends Params | TypedRouteConfig = Params, S = undefined>(source: ServerRouteFunction<P, S> | ((...args: never[]) => ReturnType<ServerRouteFunction<P, S>>)): Component<RouteSectionProps<unknown, ServerRouteParams<P>>>;
@@ -1,26 +1,29 @@
1
1
  // Server component routes (experimental — rides the experimental server
2
2
  // components surface in @solidjs/web; the arg shape may change).
3
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:
4
+ // `serverRouteComponent(source)` turns a function of route arguments that
5
+ // resolves to a component into a route `component`. It replaces the client
6
+ // wrapper a server-component route used to need:
7
7
  //
8
8
  // // before: a client component exists only to make the call
9
+ // const getStory = query(storyView, "story");
9
10
  // component: props => {
10
11
  // const View = dynamic(() => getStory(props.params.id));
11
12
  // return <View>{props.children}</View>;
12
13
  // }
13
14
  // // after
14
- // component: serverRouteComponent(storyRoute) // async ({ params }) => { "use server"; ... }
15
+ // component: serverRouteComponent(query(storyView, "story"))
15
16
  //
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.
17
+ // The source is the app's: `query(fn, key)` for a request/response server
18
+ // component, `liveQuery(fn, key)` for one that streams successive versions,
19
+ // or any function of the args. The router does not choose the cache
20
+ // strategy and does not own the key — `revalidate("story")` is the app's,
21
+ // as it is for any query. What the router adds is the URL → call
22
+ // translation: it derives the call's arguments from the match, mounts the
23
+ // resolved component with the outlet as `children`, and calls the same
24
+ // source under preload intent (link hover, `preloadRoute`, the single-flight
25
+ // collector), so the query or live channel is warm before the navigation
26
+ // renders against it.
24
27
  //
25
28
  // Arguments are derived, not read from a live location: the call's
26
29
  // `(function, arguments)` address keys the frame store and the query cache,
@@ -34,39 +37,47 @@
34
37
  // named slots — has a client half, and that half is a client component; the
35
38
  // helper does not pretend otherwise. Write the wrapper for that route.
36
39
  import { dynamic } from "@solidjs/web";
37
- import { createComponent } from "solid-js";
38
- import { query } from "./data/query.js";
40
+ import { createComponent, DEV } from "solid-js";
39
41
  import { SERVER_ROUTE } from "./serverRouteShared.js";
40
42
  /**
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.
43
+ * Use a server component as a route (experimental). `source` is a function
44
+ * of the router-derived {@link ServerRouteArgs} — this route's `params`, and
45
+ * `search` when the route declares a schema — resolving to a server
46
+ * component: typically a `"use server"` function wrapped in `query()` or
47
+ * `liveQuery()`, whose key the app names and revalidates. The router mounts
48
+ * the result with the outlet as `children` and calls the same source under
49
+ * preload intent, so link hover and single-flight collection warm it.
47
50
  *
48
51
  * `children` is the only client position the router fills, so the server
49
52
  * component may declare no other. One that takes client handlers, refs, or
50
53
  * slots has a client half — write an ordinary route component for it.
51
54
  *
52
55
  * ```ts
53
- * defineRoute({ path: "/stories/:id", component: serverRouteComponent(storyView) });
56
+ * defineRoute({ path: "/stories/:id", component: serverRouteComponent(query(storyView, "story")) });
54
57
  * ```
55
58
  */
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);
59
+ export function serverRouteComponent(
60
+ // A source that ignores its args (an app shell) is `(...args: never[])`
61
+ // once `query()` has typed it; that is the same call, so accept it too.
62
+ source) {
63
+ const call = source;
64
+ // The value is a component only so it fits the `component` field; the
65
+ // router core never calls it — it mounts through the brand, with THIS
66
+ // level's params and the declared search output. A direct mount (a
67
+ // `lazy()` wrapper around the helper, a hand-written `<Route />`) would
68
+ // have only the merged params to call with: the same view under a second
69
+ // address, missing the cache and owning its own frame. Refuse it.
70
+ const route = () => {
71
+ throw new Error("serverRouteComponent(): mount it as a route's `component`" +
72
+ (DEV
73
+ ? " — the router derives the call from the match. It was rendered directly (through " +
74
+ "lazy(), as the <Router> root, or outside a route). For an app shell, make it a " +
75
+ "pathless layout route with children."
76
+ : ""));
77
+ };
69
78
  function render(args, routeProps) {
79
+ // The source may answer a component, a promise of one, or (a live query)
80
+ // successive components; `dynamic`'s memo lands each the same way.
70
81
  const View = dynamic(() => call(args()));
71
82
  return createComponent(View, {
72
83
  get children() {
@@ -1,9 +1,8 @@
1
+ import { validateSearch } from "./utils.js";
1
2
  export const SERVER_ROUTE = Symbol("solid-router.serverRoute");
2
3
  /** The brand carried by a route component built with `serverRouteComponent()`, if any. */
3
4
  export function serverRouteOf(component) {
4
- return typeof component === "function"
5
- ? component[SERVER_ROUTE]
6
- : undefined;
5
+ return component?.[SERVER_ROUTE];
7
6
  }
8
7
  /**
9
8
  * Derive a server route's call arguments from a match: the match's params
@@ -13,31 +12,23 @@ export function serverRouteOf(component) {
13
12
  */
14
13
  export function serverRouteArgs(route, params, query) {
15
14
  const schema = route.key?.search;
16
- let search = undefined;
15
+ let search;
17
16
  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");
17
+ const raw = { ...query };
18
+ const outcome = validateSearch(schema, raw);
21
19
  // Issues leave the raw values in place, matching useSearchParams: search
22
20
  // strings are user input, so defaults belong in the schema itself.
23
- search = outcome.issues ? { ...query } : outcome.value;
21
+ search = outcome.issues ? raw : outcome.value;
24
22
  }
25
23
  return { params: { ...params }, search };
26
24
  }
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
- }
25
+ const shallowEqual = (a, b) => a === b ||
26
+ (!!a &&
27
+ !!b &&
28
+ typeof a === "object" &&
29
+ typeof b === "object" &&
30
+ Object.keys(a).length === Object.keys(b).length &&
31
+ Object.keys(a).every(k => a[k] === b[k]));
41
32
  /**
42
33
  * Structural equality for the args memo: a navigation produces fresh match
43
34
  * objects even when this level's params are unchanged, and an equal call
package/dist/types.d.ts CHANGED
@@ -146,19 +146,31 @@ export type RouteSectionComponent<T = unknown, P extends Params = Params> = Comp
146
146
  * the route's pattern (and its ancestors') declares, plus — only when the
147
147
  * route declares a `search` schema — that schema's validated output.
148
148
  */
149
- export interface ServerRouteArgs<P extends Params = Params, S = undefined> {
150
- params: P;
151
- search: S;
149
+ export interface ServerRouteArgs<P extends Params | TypedRouteConfig = Params, S = undefined> {
150
+ params: ServerRouteParams<P>;
151
+ search: P extends TypedRouteConfig ? SearchOutputOf<P> : S;
152
152
  }
153
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.
154
+ * `ServerRouteArgs<typeof route>`: a `defineFileRoute` config is a witness —
155
+ * params from its pattern, search from its schema — the way
156
+ * `RouteProps<typeof route>` reads it for a client page.
157
+ */
158
+ export type ServerRouteParams<P> = P extends TypedRouteConfig<infer Pattern> ? RouteParams<Pattern> : Extract<P, Params>;
159
+ type SearchOutputOf<Def> = Def extends {
160
+ search: infer Sch extends StandardSchemaV1<any, any>;
161
+ } ? NonNullable<Sch["~standard"]["types"]>["output"] : undefined;
162
+ /**
163
+ * The shape `serverRouteComponent()` accepts (experimental): a function of
164
+ * the router-derived {@link ServerRouteArgs} answering a server component —
165
+ * once (a `query`-wrapped `"use server"` function), or as successive
166
+ * versions (a `liveQuery`). The component's only client position is
167
+ * `children` — the route outlet, which the router fills.
158
168
  */
159
- export type ServerRouteFunction<P extends Params = Params, S = undefined> = (args: ServerRouteArgs<P, S>) => Promise<Component<{
169
+ export type ServerRouteFunction<P extends Params | TypedRouteConfig = Params, S = undefined> = (args: ServerRouteArgs<P, S>) => ServerRouteView | Promise<ServerRouteView> | AsyncIterable<ServerRouteView>;
170
+ /** The component a server route resolves to: `children` is its only client position. */
171
+ export type ServerRouteView = Component<{
160
172
  children?: JSX.Element;
161
- }>>;
173
+ }>;
162
174
  declare const ROUTE_PATTERN: unique symbol;
163
175
  export interface TypedRouteConfig<S extends string = string> {
164
176
  readonly [ROUTE_PATTERN]: S;
@@ -354,7 +366,8 @@ export type Submission<T, U> = {
354
366
  readonly error: any;
355
367
  readonly url: string;
356
368
  clear: () => void;
357
- retry: () => void;
369
+ /** Re-run the action with the same input; resolves to its result, like the original call. */
370
+ retry: () => Promise<U>;
358
371
  };
359
372
  export interface MaybePreloadableComponent extends Component {
360
373
  preload?: () => void;
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { MatchFilters, PathMatch, RouteDescription, SearchParams, SetSearchParams } from "./types.js";
1
+ import type { StandardSchemaV1, MatchFilters, PathMatch, RouteDescription, SearchParams, SetSearchParams } from "./types.js";
2
2
  export declare const mockBase = "http://sr";
3
3
  export declare function normalizePath(path: string, omitSlash?: boolean): string;
4
4
  /** Pathname stripped of search/hash and trailing slash, lowercased — the form link matching compares. */
@@ -6,6 +6,8 @@ export declare const comparablePath: (path: string) => string;
6
6
  export declare function resolvePath(base: string, path: string, from?: string): string | undefined;
7
7
  export declare function invariant<T>(value: T | null | undefined, message: string): T;
8
8
  export declare function joinPaths(from: string, to: string): string;
9
+ /** Run a Standard Schema synchronously — the only mode search params support. */
10
+ export declare function validateSearch(schema: StandardSchemaV1<any, any>, raw: Record<string, any>): import("./types.js").StandardSchemaResult<any>;
9
11
  export declare function extractSearchParams(url: URL): SearchParams;
10
12
  export declare function createMatcher<S extends string>(path: S, partial?: boolean, matchFilters?: MatchFilters<S>): (location: string) => PathMatch | null;
11
13
  export declare function scoreRoute(route: RouteDescription): number;
package/dist/utils.js CHANGED
@@ -35,6 +35,13 @@ export function invariant(value, message) {
35
35
  export function joinPaths(from, to) {
36
36
  return normalizePath(from).replace(/\/*(\*.*)?$/g, "") + normalizePath(to);
37
37
  }
38
+ /** Run a Standard Schema synchronously — the only mode search params support. */
39
+ export function validateSearch(schema, raw) {
40
+ const outcome = schema["~standard"].validate(raw);
41
+ if (outcome instanceof Promise)
42
+ throw new Error("Async Standard Schema validation is not supported for search params");
43
+ return outcome;
44
+ }
38
45
  export function extractSearchParams(url) {
39
46
  const params = {};
40
47
  url.searchParams.forEach((value, key) => {
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "Ryan Turnquist"
7
7
  ],
8
8
  "license": "MIT",
9
- "version": "2.0.0-next.27",
9
+ "version": "2.0.0-next.28",
10
10
  "homepage": "https://github.com/solidjs/solid-router#readme",
11
11
  "repository": {
12
12
  "type": "git",
@@ -52,10 +52,16 @@
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@solidjs/web": "^2.0.0-rc.9",
55
+ "filesystem-routing": ">=0.4.0",
55
56
  "solid-js": "^2.0.0-rc.9"
56
57
  },
58
+ "peerDependenciesMeta": {
59
+ "filesystem-routing": {
60
+ "optional": true
61
+ }
62
+ },
57
63
  "scripts": {
58
- "build": "rm -rf dist && tsc && rollup -c",
64
+ "build": "rm -rf dist && tsc && rollup -c && node scripts/check-fs-gate.mjs",
59
65
  "test": "vitest run && vitest run --config vitest.config.server.ts && npm run test:types",
60
66
  "test:watch": "vitest",
61
67
  "test:server": "vitest run --config vitest.config.server.ts",