@solidjs/router 2.0.0-next.26 → 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/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, 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 } 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) => {
@@ -517,6 +524,64 @@ function createPathsProxy(renderPath = p => p, base = "") {
517
524
  return node(normalizePath(base));
518
525
  }
519
526
 
527
+ // The light half of server component routes: the brand the router core reads
528
+ // off a route `component`, and the derivation of a route's call arguments.
529
+ // Deliberately free of `query`/`dynamic` — routing.ts imports this module,
530
+ // and routing.ts is in every app's eager graph. The heavy half (the helper
531
+ // that builds the branded component) lives in serverRouteComponent.ts and only
532
+ // enters a bundle when an app calls `serverRouteComponent()`.
533
+
534
+ const SERVER_ROUTE = Symbol("solid-router.serverRoute");
535
+
536
+ /**
537
+ * What `serverRouteComponent()` attaches to the route component it returns, and
538
+ * what the router core drives it through: `call` is the query-wrapped server
539
+ * function (preload and single-flight collection warm/collect through it),
540
+ * `render` mounts the resolved server component for the current args with
541
+ * the route's props (`children` is the outlet).
542
+ */
543
+
544
+ /** The brand carried by a route component built with `serverRouteComponent()`, if any. */
545
+ function serverRouteOf(component) {
546
+ return component?.[SERVER_ROUTE];
547
+ }
548
+
549
+ /**
550
+ * Derive a server route's call arguments from a match: the match's params
551
+ * (this route's pattern and its ancestors' — never a child's) and the route's
552
+ * `search` schema output when one is declared. Reads `query` only when a
553
+ * schema exists, so a search-agnostic route never tracks the query string.
554
+ */
555
+ function serverRouteArgs(route, params, query) {
556
+ const schema = route.key?.search;
557
+ let search;
558
+ if (schema) {
559
+ const raw = {
560
+ ...query
561
+ };
562
+ const outcome = validateSearch(schema, raw);
563
+ // Issues leave the raw values in place, matching useSearchParams: search
564
+ // strings are user input, so defaults belong in the schema itself.
565
+ search = outcome.issues ? raw : outcome.value;
566
+ }
567
+ return {
568
+ params: {
569
+ ...params
570
+ },
571
+ search
572
+ };
573
+ }
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]);
575
+
576
+ /**
577
+ * Structural equality for the args memo: a navigation produces fresh match
578
+ * objects even when this level's params are unchanged, and an equal call
579
+ * must not re-enter the source (and refetch).
580
+ */
581
+ function serverRouteArgsEqual(a, b) {
582
+ return shallowEqual(a.params, b.params) && shallowEqual(a.search, b.search);
583
+ }
584
+
520
585
  const MAX_REDIRECTS = 100;
521
586
 
522
587
  /**
@@ -762,8 +827,7 @@ function useSearchParams(path) {
762
827
  for (const match of router.matches()) {
763
828
  const schema = match.route.key.search;
764
829
  if (!schema) continue;
765
- const outcome = schema["~standard"].validate(raw);
766
- if (outcome instanceof Promise) throw new Error("Async Standard Schema validation is not supported for search params");
830
+ const outcome = validateSearch(schema, raw);
767
831
  if (!outcome.issues) result = Object.assign(result || {
768
832
  ...raw
769
833
  }, outcome.value);
@@ -1460,42 +1524,66 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1460
1524
  return (to, options) => navigateFromRoute(route, to, options);
1461
1525
  }
1462
1526
  function preloadRoute(url, preloadData) {
1463
- const matches = getRouteMatches(branches(), url.pathname);
1527
+ const next = getRouteMatches(branches(), url.pathname);
1464
1528
  // An unresolved lazy subtree in the chain: the placeholder's
1465
1529
  // component.preload (below) kicks the table load; once it lands,
1466
1530
  // preload again so the real inner routes warm too. Preloads are
1467
1531
  // speculative: a failed load (held sync throw or rejection) is ignored
1468
1532
  // here — the real navigation surfaces and retries it.
1469
- 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);
1470
1534
  if (boundary) {
1471
1535
  try {
1472
1536
  resolveLazySubtree(boundary.route.lazy).then(() => preloadRoute(url, preloadData), () => {});
1473
1537
  } catch {}
1474
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
+ };
1475
1554
  const prevIntent = preloadIntent;
1476
1555
  preloadIntent = "preload";
1477
- for (let match in matches) {
1556
+ for (let match in next) {
1478
1557
  const {
1479
1558
  route,
1480
1559
  params
1481
- } = matches[match];
1560
+ } = next[match];
1482
1561
  route.component && route.component.preload && route.component.preload();
1483
1562
  const {
1484
- preload
1563
+ preload,
1564
+ component
1485
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));
1486
1568
  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
- }));
1569
+ preloadData && !unchanged && runWithOwner(getContext(), () => {
1570
+ // A server component route's data IS its call: warm the same
1571
+ // query entry the render will read, under the same derived args.
1572
+ const server = serverRouteOf(component);
1573
+ server && server.call(serverRouteArgs(route, params, query));
1574
+ preload && preload({
1575
+ params,
1576
+ location: {
1577
+ pathname: url.pathname,
1578
+ search: url.search,
1579
+ hash: url.hash,
1580
+ query,
1581
+ state: null,
1582
+ key: ""
1583
+ },
1584
+ intent: "preload"
1585
+ });
1586
+ });
1499
1587
  inPreloadFn = false;
1500
1588
  }
1501
1589
  preloadIntent = prevIntent;
@@ -1551,14 +1639,30 @@ function createRouteContext(router, parent, outlet, match, matches = () => [matc
1551
1639
  pattern,
1552
1640
  params,
1553
1641
  path,
1554
- outlet: () => component ? createComponent(component, {
1555
- params,
1556
- location,
1557
- data,
1558
- get children() {
1559
- return outlet();
1642
+ outlet: () => {
1643
+ if (!component) return outlet();
1644
+ const routeProps = {
1645
+ params,
1646
+ location,
1647
+ data,
1648
+ get children() {
1649
+ return outlet();
1650
+ }
1651
+ };
1652
+ // A `serverRouteComponent()` route: the router derives the call from
1653
+ // the match — THIS level's params (never a child's) and the declared
1654
+ // search output — and mounts through the brand. Structural equality
1655
+ // keeps a navigation that leaves this level's args unchanged from
1656
+ // re-entering the call. See serverRouteComponent.ts.
1657
+ const server = serverRouteOf(component);
1658
+ if (server) {
1659
+ const args = createMemo(() => serverRouteArgs(match().route, match().params, location.query), {
1660
+ equals: serverRouteArgsEqual
1661
+ });
1662
+ return server.render(args, routeProps);
1560
1663
  }
1561
- }) : outlet(),
1664
+ return createComponent(component, routeProps);
1665
+ },
1562
1666
  resolvePath(to) {
1563
1667
  return resolvePath(base.path(), to, path());
1564
1668
  }
@@ -1717,6 +1821,9 @@ function dataOnly(event, routerState, branches) {
1717
1821
  route,
1718
1822
  params
1719
1823
  } = matches[match];
1824
+ // a server component route's markup is collected like any query result
1825
+ const server = serverRouteOf(route.component);
1826
+ server && server.call(serverRouteArgs(route, params, routerState.location.query));
1720
1827
  route.preload && route.preload({
1721
1828
  params,
1722
1829
  location: routerState.location,
@@ -2246,6 +2353,8 @@ function createRouter(config) {
2246
2353
  };
2247
2354
  const renderPath = config.history && config.history.utils && config.history.utils.renderPath || undefined;
2248
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];
2249
2358
  function RouterComponent(props) {
2250
2359
  // One router per app: the session (location, history, delegation, link
2251
2360
  // claims, preloading) has a single owner, and a second instance would
@@ -2303,7 +2412,7 @@ function createRouter(config) {
2303
2412
  routes: config.routes,
2304
2413
  config,
2305
2414
  match(url) {
2306
- return matchPath(new URL(url, mockBase).pathname).map(({
2415
+ return matchPath(pathnameOf(url)).map(({
2307
2416
  route,
2308
2417
  path,
2309
2418
  params
@@ -2314,6 +2423,17 @@ function createRouter(config) {
2314
2423
  params,
2315
2424
  info: route.info
2316
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;
2317
2437
  }
2318
2438
  });
2319
2439
  // Built on first access (a getter via Object.assign would run during the
@@ -2406,6 +2526,93 @@ const useBeforeLeave = listener => {
2406
2526
  onCleanup(s);
2407
2527
  };
2408
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
+
2409
2616
  const LocationHeader = "Location";
2410
2617
  const PRELOAD_TIMEOUT$1 = 5000;
2411
2618
  const CACHE_TIMEOUT = 180000;
@@ -3389,10 +3596,12 @@ function push(ch, value) {
3389
3596
  // connection is alive and is itself the freshness mechanism — a
3390
3597
  // push-driven producer yields the mutated state on its own, and tearing
3391
3598
  // down healthy connections on every mutation defeats the model.
3392
- // - Single-flight payloads deliver INTO open channels, exactly as they seed
3393
- // the query cache: the mutation response is the round trip, the channel
3394
- // adopts the value immediately, and the live stream stays authoritative
3395
- // 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.
3396
3605
  let hooked = false;
3397
3606
  function hookRevalidate() {
3398
3607
  if (hooked) return;
@@ -3438,11 +3647,11 @@ function hookRevalidate() {
3438
3647
  * definite rejection (4xx: the transport stamps HTTP statuses onto
3439
3648
  * failures) ends the channel and surfaces the error to consumers, as does
3440
3649
  * a first-connect failure.
3441
- * - Server functions are declared GET at creation (like `query`). Live
3442
- * queries participate in single-flight on the delivery side: a mutation's
3443
- * flight payload pushes straight into open channels (the mutation
3444
- * response is the round trip), while the post-mutation sweep leaves the
3445
- * 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.)
3446
3655
  * - Calling under preload intent warms the channel (a temporary hold keeps
3447
3656
  * it open through the preload window), so navigation renders against an
3448
3657
  * already-connected stream instead of holding the transition on connect
@@ -3526,4 +3735,4 @@ var serverForms = /*#__PURE__*/Object.freeze({
3526
3735
  submitServerForm: submitServerForm
3527
3736
  });
3528
3737
 
3529
- 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 };
3738
+ 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,
@@ -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,8 +4,9 @@ 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
+ 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
@@ -196,9 +197,7 @@ export function useSearchParams(path) {
196
197
  const schema = match.route.key.search;
197
198
  if (!schema)
198
199
  continue;
199
- const outcome = schema["~standard"].validate(raw);
200
- if (outcome instanceof Promise)
201
- throw new Error("Async Standard Schema validation is not supported for search params");
200
+ const outcome = validateSearch(schema, raw);
202
201
  if (!outcome.issues)
203
202
  result = Object.assign(result || { ...raw }, outcome.value);
204
203
  }
@@ -880,42 +879,69 @@ export function createRouterContext(integration, branches, getContext, options =
880
879
  return ((to, options) => navigateFromRoute(route, to, options));
881
880
  }
882
881
  function preloadRoute(url, preloadData) {
883
- const matches = getRouteMatches(branches(), url.pathname);
882
+ const next = getRouteMatches(branches(), url.pathname);
884
883
  // An unresolved lazy subtree in the chain: the placeholder's
885
884
  // component.preload (below) kicks the table load; once it lands,
886
885
  // preload again so the real inner routes warm too. Preloads are
887
886
  // speculative: a failed load (held sync throw or rejection) is ignored
888
887
  // here — the real navigation surfaces and retries it.
889
- 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);
890
889
  if (boundary) {
891
890
  try {
892
891
  resolveLazySubtree(boundary.route.lazy).then(() => preloadRoute(url, preloadData), () => { });
893
892
  }
894
893
  catch { }
895
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
+ };
896
911
  const prevIntent = preloadIntent;
897
912
  preloadIntent = "preload";
898
- for (let match in matches) {
899
- const { route, params } = matches[match];
913
+ for (let match in next) {
914
+ const { route, params } = next[match];
900
915
  route.component &&
901
916
  route.component.preload &&
902
917
  route.component.preload();
903
- const { preload } = route;
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));
904
923
  inPreloadFn = true;
905
924
  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
- }));
925
+ !unchanged &&
926
+ runWithOwner(getContext(), () => {
927
+ // A server component route's data IS its call: warm the same
928
+ // query entry the render will read, under the same derived args.
929
+ const server = serverRouteOf(component);
930
+ server && server.call(serverRouteArgs(route, params, query));
931
+ preload &&
932
+ preload({
933
+ params,
934
+ location: {
935
+ pathname: url.pathname,
936
+ search: url.search,
937
+ hash: url.hash,
938
+ query,
939
+ state: null,
940
+ key: ""
941
+ },
942
+ intent: "preload"
943
+ });
944
+ });
919
945
  inPreloadFn = false;
920
946
  }
921
947
  preloadIntent = prevIntent;
@@ -965,16 +991,29 @@ export function createRouteContext(router, parent, outlet, match, matches = () =
965
991
  pattern,
966
992
  params,
967
993
  path,
968
- outlet: () => component
969
- ? createComponent(component, {
994
+ outlet: () => {
995
+ if (!component)
996
+ return outlet();
997
+ const routeProps = {
970
998
  params,
971
999
  location,
972
1000
  data,
973
1001
  get children() {
974
1002
  return outlet();
975
1003
  }
976
- })
977
- : outlet(),
1004
+ };
1005
+ // A `serverRouteComponent()` route: the router derives the call from
1006
+ // the match — THIS level's params (never a child's) and the declared
1007
+ // search output — and mounts through the brand. Structural equality
1008
+ // keeps a navigation that leaves this level's args unchanged from
1009
+ // re-entering the call. See serverRouteComponent.ts.
1010
+ const server = serverRouteOf(component);
1011
+ if (server) {
1012
+ const args = createMemo(() => serverRouteArgs(match().route, match().params, location.query), { equals: serverRouteArgsEqual });
1013
+ return server.render(args, routeProps);
1014
+ }
1015
+ return createComponent(component, routeProps);
1016
+ },
978
1017
  resolvePath(to) {
979
1018
  return resolvePath(base.path(), to, path());
980
1019
  }