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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { getOwner, runWithOwner, createMemo, createRenderEffect, onCleanup, untrack, createContext, createSignal, useContext, NotReadyError, DEV, isPending, latest, createComponent, createRoot, Show, createEffect, OBSERVE, onSettled, getObserver, isHydrating, isHydratable, $TRACK, action as action$1 } from 'solid-js';
2
- import { registerElementClaim, delegateEvents, isServer, getRequestEvent, hasFlashCookie, clearFlashCookie, createComponent as createComponent$1, memo, dynamic, isServerFunction, getServerFunctionMetadata, getServerFunctionRPC, takeHydrationValue, isResponseEnvelope, REVALIDATE_HEADER, getHydrationWriter } from '@solidjs/web';
1
+ import { getOwner, runWithOwner, createMemo, createRenderEffect, onCleanup, untrack, isPending, NotReadyError, DEV, latest, createContext, createSignal, useContext, createComponent, createOwner, createRoot, Show, onSettled, isHydrating, OBSERVE, getObserver, isHydratable, $TRACK, action as action$1 } from 'solid-js';
2
+ import { registerElementClaim, delegateEvents, dispatchAsInteraction, isServer, getRequestEvent, hasFlashCookie, clearFlashCookie, createComponent as createComponent$1, memo, dynamicComponent, isServerFunction, getServerFunctionMetadata, getServerFunctionRPC, takeHydrationValue, isResponseEnvelope, REVALIDATE_HEADER, getHydrationWriter } from '@solidjs/web';
3
3
  import { REDIRECT_HEADER, decodeRedirectHeaderValue, subscribeFlightData, decodeResponsePayload, parseServerFunctionActionUrl, createServerReference } from '@solidjs/web/server-functions';
4
4
 
5
5
  const hasSchemeRegex = /^(?:[a-z0-9]+:)?\/\//i;
@@ -37,28 +37,35 @@ const comparableQuery = search => {
37
37
  };
38
38
 
39
39
  /**
40
- * The link-state rule shared by claimed anchors and `useLinkState`, given
41
- * the location and a link's already-resolved target (path, optional query
42
- * and hash):
40
+ * The link-state rule shared by claimed anchors and `useLinkState`, bound to
41
+ * a location so links matched against the same one share its parse. The
42
+ * returned matcher takes a link's already-resolved target (path, optional
43
+ * query and hash):
43
44
  *
44
45
  * - `current` — same pathname and same query (parameter order and hash aside)
45
46
  * - `active` — same pathname or one under it; the router's root (`base`, or
46
47
  * `/` without one) only matches exactly, since it is a prefix of every
47
48
  * page. `end` makes every link exact-only.
48
49
  */
49
- function matchLink(location, target, base, end) {
50
- if (target === undefined) return {
51
- active: false,
52
- current: false
53
- };
54
- const loc = comparablePath(location.pathname);
55
- const path = comparablePath(target);
56
- const exact = loc === path;
57
- const hashless = target.split("#", 1)[0];
58
- const q = hashless.indexOf("?");
59
- return {
60
- active: exact || !end && path !== "" && path !== comparablePath(base) && loc.startsWith(path + "/"),
61
- current: exact && comparableQuery(location.search) === comparableQuery(q < 0 ? "" : hashless.slice(q))
50
+ function linkMatcher({
51
+ pathname,
52
+ search
53
+ }, base, end) {
54
+ const loc = comparablePath(pathname);
55
+ const root = comparablePath(base);
56
+ return target => {
57
+ if (target === undefined) return {
58
+ active: false,
59
+ current: false
60
+ };
61
+ const path = comparablePath(target);
62
+ const exact = loc === path;
63
+ const hashless = target.split("#", 1)[0];
64
+ const q = hashless.indexOf("?");
65
+ return {
66
+ active: exact || !end && path !== "" && path !== root && loc.startsWith(path + "/"),
67
+ current: exact && comparableQuery(search) === comparableQuery(q < 0 ? "" : hashless.slice(q))
68
+ };
62
69
  };
63
70
  }
64
71
  function resolvePath(base, path, from) {
@@ -249,9 +256,11 @@ function setFormClaimHandler(handler) {
249
256
  * included (parameter order aside)
250
257
  * - `data-active` — pathname exact or prefix match (the router's root, its
251
258
  * base path, exact only)
252
- * - `data-pending` — the link is the target of an in-flight navigation
259
+ * - `data-pending` — the link is the target of an in-flight navigation;
260
+ * opt-in through `createRouter({ links: pendingLinks })`. Without the
261
+ * plugin, claims never read pending state and the sweep does not track it.
253
262
  *
254
- * The matching rule is `matchLink`, shared with `useLinkState`. The router
263
+ * The matching rule is `linkMatcher`, shared with `useLinkState`. The router
255
264
  * only touches an `aria-current` it wrote itself: one the author set (a
256
265
  * stepper's `"step"`, a static `"page"`) is left in place, current or not.
257
266
  *
@@ -264,8 +273,9 @@ function setFormClaimHandler(handler) {
264
273
  * are the same one-shot untracked refresh, reading the element's current
265
274
  * `href` from the DOM.
266
275
  */
267
- function setupLinkClaims(router, explicitLinks) {
276
+ function setupLinkClaims(router, explicitLinks, links) {
268
277
  const basePath = router.base.path();
278
+ const plugin = links && links(router, basePath);
269
279
  // per-element record; `owned` is whether the `aria-current` on the element
270
280
  // is the router's, so it never writes over or removes an authored one
271
281
  const claimed = new WeakMap();
@@ -295,25 +305,29 @@ function setupLinkClaims(router, explicitLinks) {
295
305
  if (url.origin !== window.location.origin || !isUnderBase(url.pathname, basePath)) return;
296
306
  return url;
297
307
  }
308
+
309
+ // every anchor matches against the same location, so its parse is shared
310
+ // until the location changes
311
+ let matched;
312
+ let match;
298
313
  function linkState(a) {
299
314
  // read reactive sources unconditionally so the owning effect stays
300
315
  // subscribed even while the anchor is not router-managed
301
316
  const location = router.location;
302
- const routing = router.isRouting();
317
+ const routing = plugin && plugin.track();
303
318
  const url = managedUrl(a);
304
319
  const target = url && url.pathname + url.search;
320
+ const key = location.pathname + location.search;
305
321
  // no per-anchor `end` opt-out like useLinkState has
322
+ if (key !== matched) {
323
+ matched = key;
324
+ match = linkMatcher(location, basePath);
325
+ }
306
326
  const {
307
327
  active,
308
328
  current
309
- } = matchLink(location, target, basePath);
310
- // effects observe the committed location during a transition, so the
311
- // in-flight target comes from pendingTarget — readable here because the
312
- // isRouting write flushes after the target is assigned
313
- const pending = routing && !!router.pendingTarget && matchLink({
314
- pathname: router.pendingTarget.value,
315
- search: ""
316
- }, target, basePath).active;
329
+ } = match(target);
330
+ const pending = !!routing && plugin.pending(target);
317
331
  return {
318
332
  active,
319
333
  pending,
@@ -326,7 +340,7 @@ function setupLinkClaims(router, explicitLinks) {
326
340
  current
327
341
  }) {
328
342
  active ? a.setAttribute("data-active", "") : a.removeAttribute("data-active");
329
- pending ? a.setAttribute("data-pending", "") : a.removeAttribute("data-pending");
343
+ if (plugin) pending ? a.setAttribute("data-pending", "") : a.removeAttribute("data-pending");
330
344
  // Ownership is read against the element, not just the record. A
331
345
  // server-component morph resets attributes to the server HTML, which
332
346
  // never carries router link state, then re-claims: an owned value that
@@ -346,9 +360,10 @@ function setupLinkClaims(router, explicitLinks) {
346
360
  const refresh = (a, rec) => untrack(() => apply(a, rec, linkState(a)));
347
361
 
348
362
  // The one subscription for every anchor: compute tracks the sources
349
- // linkState derives from (the in-flight pendingTarget is readable in the
350
- // effect phase because the isRouting write flushes after the target is
351
- // assigned), the effect phase sweeps the registry untracked.
363
+ // linkState derives from (with the plugin, its pending read — the
364
+ // in-flight target is readable in the effect phase because the isRouting
365
+ // write flushes after the target is assigned), the effect phase sweeps the
366
+ // registry untracked.
352
367
  //
353
368
  // `transparent` keeps the effect invisible to the hydration id scheme.
354
369
  // This setup is client-only, so an id-consuming node here has no server
@@ -356,7 +371,7 @@ function setupLinkClaims(router, explicitLinks) {
356
371
  // slot — lazy-route lookups miss and hydration leaves server nodes
357
372
  // unclaimed. (The option is honored by the runtime but missing from the
358
373
  // published EffectOptions type, hence the cast.)
359
- createRenderEffect(() => (router.location.pathname, router.location.search, router.isRouting()), () => registry.forEach(a => refresh(a, claimed.get(a))), {
374
+ createRenderEffect(() => (router.location.pathname, router.location.search, plugin && plugin.track()), () => registry.forEach(a => refresh(a, claimed.get(a))), {
360
375
  transparent: true
361
376
  });
362
377
  onCleanup(registerElementClaim(node => {
@@ -383,6 +398,12 @@ function setupLinkClaims(router, explicitLinks) {
383
398
  }));
384
399
  }
385
400
 
401
+ /**
402
+ * App bundlers define this (`"true"` when server components are on, including
403
+ * `'external'`, otherwise `"false"`). Not assigned here: the published build
404
+ * keeps the `typeof` check so they can fold the fallback away.
405
+ */
406
+
386
407
  /**
387
408
  * The submit delegation consults this slot instead of importing the action
388
409
  * module: the action side installs its handler on first action creation
@@ -394,24 +415,43 @@ let formHandler;
394
415
  function setRouterFormHandler(handler) {
395
416
  formHandler = handler;
396
417
  }
418
+
419
+ /**
420
+ * Link preload strategies reach route preloading through this slot instead
421
+ * of the event wiring importing it: a strategy installs it when it runs (see
422
+ * preload.ts), so an app with no strategy and no `usePreloadRoute` never
423
+ * ships route preloading.
424
+ */
425
+ let linkPreloader;
426
+ function setLinkPreloader(preloader) {
427
+ linkPreloader = preloader;
428
+ }
397
429
  function setupNativeEvents({
398
- preload = true,
430
+ preload,
399
431
  explicitLinks = false,
400
- actionBase = "/_server",
401
- transformUrl
432
+ actionBase = "/_server"
402
433
  } = {}) {
403
434
  return router => {
404
435
  const basePath = router.base.path();
405
436
  const navigateFromRoute = router.navigatorFactory(router.base);
406
- let preloadTimeout;
407
- let lastElement;
408
437
  function isSvg(el) {
409
438
  return el.namespaceURI === "http://www.w3.org/2000/svg";
410
439
  }
411
440
  function handleAnchor(evt) {
412
441
  if (evt.defaultPrevented || evt.button !== 0 || evt.metaKey || evt.altKey || evt.ctrlKey || evt.shiftKey) return;
442
+ return findAnchor(evt);
443
+ }
444
+
445
+ // no button or modifier gate: focus and touch events carry neither
446
+ function findAnchor(evt) {
413
447
  const a = evt.composedPath().find(el => el instanceof Node && el.nodeName.toUpperCase() === "A");
414
- if (!a || explicitLinks && !a.hasAttribute("link")) return;
448
+ const url = a && anchorUrl(a);
449
+ return url && [a, url];
450
+ }
451
+
452
+ /** The anchor's URL when the router manages it, else `undefined`. */
453
+ function anchorUrl(a) {
454
+ if (explicitLinks && !a.hasAttribute("link")) return;
415
455
  const svg = isSvg(a);
416
456
  const href = svg ? a.href.baseVal : a.href;
417
457
  const target = svg ? a.target.baseVal : a.target;
@@ -423,7 +463,7 @@ function setupNativeEvents({
423
463
  // inherit the page origin, so the origin check below won't reject them. #382
424
464
  if (url.protocol !== "https:" && url.protocol !== "http:") return;
425
465
  if (url.origin !== window.location.origin || !isUnderBase(url.pathname, basePath)) return;
426
- return [a, url];
466
+ return url;
427
467
  }
428
468
  function handleAnchorClick(evt) {
429
469
  const res = handleAnchor(evt);
@@ -432,31 +472,14 @@ function setupNativeEvents({
432
472
  const to = router.parsePath(url.pathname + url.search + url.hash);
433
473
  const state = a.getAttribute("state");
434
474
  evt.preventDefault();
435
- navigateFromRoute(to, {
475
+ // Only a click the router acts on joins the event's interaction: this
476
+ // listener hears every click on the document.
477
+ dispatchAsInteraction(evt, () => navigateFromRoute(to, {
436
478
  resolve: false,
437
479
  replace: a.hasAttribute("replace"),
438
480
  scroll: !a.hasAttribute("noscroll"),
439
481
  state: state ? JSON.parse(state) : undefined
440
- });
441
- }
442
- function handleAnchorPreload(evt) {
443
- const res = handleAnchor(evt);
444
- if (!res) return;
445
- const [a, url] = res;
446
- transformUrl && (url.pathname = transformUrl(url.pathname));
447
- router.preloadRoute(url, a.getAttribute("preload") !== "false");
448
- }
449
- function handleAnchorMove(evt) {
450
- clearTimeout(preloadTimeout);
451
- const res = handleAnchor(evt);
452
- if (!res) return lastElement = null;
453
- const [a, url] = res;
454
- if (lastElement === a) return;
455
- transformUrl && (url.pathname = transformUrl(url.pathname));
456
- preloadTimeout = setTimeout(() => {
457
- router.preloadRoute(url, a.getAttribute("preload") !== "false");
458
- lastElement = a;
459
- }, 20);
482
+ }));
460
483
  }
461
484
  function handleFormSubmit(evt) {
462
485
  if (formHandler) return formHandler(evt, router, actionBase);
@@ -473,35 +496,31 @@ function setupNativeEvents({
473
496
  const url = new URL(ref, document.baseURI);
474
497
  const path = router.parsePath(url.pathname + url.search);
475
498
  if (!path.startsWith(actionBase) || form.method.toUpperCase() !== "POST") return;
476
- evt.preventDefault();
477
- const data = new FormData(form, evt.submitter);
478
- Promise.resolve().then(function () { return serverForms; }).then(m => m.submitServerForm(router, path, form, data));
499
+ // The import sits inside the check so bundlers drop the server-forms
500
+ // chunk. An early return above a still-present import() does not.
501
+ // Missing stays off: an undefined constant does not fold.
502
+ if (typeof __SOLID_SERVER_COMPONENTS__ !== "undefined" && __SOLID_SERVER_COMPONENTS__) {
503
+ evt.preventDefault();
504
+ const data = new FormData(form, evt.submitter);
505
+ Promise.resolve().then(function () { return serverForms; }).then(m => m.submitServerForm(router, path, form, data));
506
+ }
479
507
  }
508
+ const handleSubmit = evt => dispatchAsInteraction(evt, () => handleFormSubmit(evt));
480
509
 
481
510
  // ensure delegated event run first
482
511
  delegateEvents(["click", "submit"]);
483
512
  document.addEventListener("click", handleAnchorClick);
484
- if (preload) {
485
- document.addEventListener("mousemove", handleAnchorMove, {
486
- passive: true
487
- });
488
- document.addEventListener("focusin", handleAnchorPreload, {
489
- passive: true
490
- });
491
- document.addEventListener("touchstart", handleAnchorPreload, {
492
- passive: true
493
- });
494
- }
495
- document.addEventListener("submit", handleFormSubmit);
513
+ document.addEventListener("submit", handleSubmit);
496
514
  onCleanup(() => {
497
515
  document.removeEventListener("click", handleAnchorClick);
498
- if (preload) {
499
- document.removeEventListener("mousemove", handleAnchorMove);
500
- document.removeEventListener("focusin", handleAnchorPreload);
501
- document.removeEventListener("touchstart", handleAnchorPreload);
502
- }
503
- document.removeEventListener("submit", handleFormSubmit);
516
+ document.removeEventListener("submit", handleSubmit);
504
517
  });
518
+ // the pre-strategy boolean option is ignored (a dev warning names it)
519
+ if (preload && preload !== true) [].concat(preload).forEach(strategy => strategy({
520
+ anchor: findAnchor,
521
+ url: anchorUrl,
522
+ preload: (url, data) => linkPreloader(router, url, data)
523
+ }));
505
524
  };
506
525
  }
507
526
 
@@ -610,7 +629,7 @@ function createPathsProxy(renderPath = p => p, base = "") {
610
629
 
611
630
  // The light half of server component routes: the brand the router core reads
612
631
  // off a route `component`, and the derivation of a route's call arguments.
613
- // Deliberately free of `query`/`dynamic` — routing.ts imports this module,
632
+ // Deliberately free of `query`/`dynamicComponent` — routing.ts imports this module,
614
633
  // and routing.ts is in every app's eager graph. The heavy half (the helper
615
634
  // that builds the branded component) lives in serverRouteComponent.ts and only
616
635
  // enters a bundle when an app calls `serverRouteComponent()`.
@@ -674,6 +693,86 @@ function serverRouteArgsEqual(a, b) {
674
693
  return shallowEqual(a.params, b.params) && shallowEqual(a.search, b.search);
675
694
  }
676
695
 
696
+ /**
697
+ * Pending navigation state, for the opt-in readers only: `useIsRouting`,
698
+ * `useLinkState().pending` and the `pendingLinks` claims plugin. `isPending`
699
+ * and `latest` are UI affordances — the router's own coordination reads its
700
+ * location writes and their `onSettled` instead — so an app that renders
701
+ * none of these never bundles Solid's verdict machinery.
702
+ */
703
+
704
+ const routing = new WeakMap();
705
+
706
+ /** Whether a navigation is in flight: one memo per router, under its owner. */
707
+ function routingState(router) {
708
+ let read = routing.get(router);
709
+ if (!read) {
710
+ const {
711
+ location,
712
+ matches,
713
+ _source: source
714
+ } = router;
715
+ // `transparent`: created on first use rather than at router setup, so it
716
+ // must not take a hydration id — the server may never create it, or
717
+ // create it at a different point.
718
+ const pending = runWithOwner(router._owner, () => createMemo(() => isPending(() => {
719
+ try {
720
+ matches();
721
+ } catch (e) {
722
+ if (e instanceof NotReadyError) throw e;
723
+ }
724
+ location.search;
725
+ location.hash;
726
+ }), {
727
+ transparent: true,
728
+ ...(DEV && {
729
+ name: "routingPending"
730
+ })
731
+ }));
732
+ read = () => pending() || isPending(source);
733
+ routing.set(router, read);
734
+ }
735
+ return read;
736
+ }
737
+
738
+ /**
739
+ * The target of the in-flight programmatic navigation, if any. A
740
+ * back/forward traversal (`_navigation` -1) is not a link's target.
741
+ */
742
+ function pendingTarget(router) {
743
+ if (!routingState(router)()) return;
744
+ const target = latest(router._source);
745
+ return target._navigation && target._navigation > 0 ? target : undefined;
746
+ }
747
+
748
+ /** Whether `to` is the destination of the in-flight navigation. */
749
+ function linkPending(router, to, base, end) {
750
+ const target = pendingTarget(router);
751
+ return !!target && linkMatcher({
752
+ pathname: target.value,
753
+ search: ""
754
+ }, base, end)(to).active;
755
+ }
756
+
757
+ /**
758
+ * Opt-in `data-pending` for plain anchors. Marks claimed links whose path
759
+ * covers the in-flight destination of a link click or `navigate()` (not
760
+ * back/forward), until it lands. Agrees with `useLinkState().pending`, which
761
+ * works without it. `aria-current` and `data-active` need no plugin.
762
+ *
763
+ * @example
764
+ * ```ts
765
+ * import { createRouter, pendingLinks } from "@solidjs/router";
766
+ *
767
+ * const Router = createRouter({ routes, links: pendingLinks });
768
+ * // CSS: a[data-pending] { opacity: .6 }
769
+ * ```
770
+ */
771
+ const pendingLinks = (router, base) => ({
772
+ track: routingState(router),
773
+ pending: target => linkPending(router, target, base)
774
+ });
775
+
677
776
  const MAX_REDIRECTS = 100;
678
777
 
679
778
  /**
@@ -759,6 +858,8 @@ const useLocation = () => useRouter().location;
759
858
  /**
760
859
  * Retrieves a signal that indicates whether the router is currently processing a navigation.
761
860
  * Useful for showing pending navigation state while the next route and its data settle.
861
+ * This is the way to read routing state: `RouterContext` doesn't carry `isRouting`.
862
+ * For the in-flight destination, read the location with Solid's `isPending`/`latest`.
762
863
  *
763
864
  * @example
764
865
  * ```js
@@ -771,7 +872,7 @@ const useLocation = () => useRouter().location;
771
872
  * );
772
873
  * ```
773
874
  */
774
- const useIsRouting = () => useRouter().isRouting;
875
+ const useIsRouting = () => routingState(useRouter());
775
876
 
776
877
  /**
777
878
  * `useMatch` takes an accessor that returns the path and creates a `Memo` that returns match information if the current path matches the provided path.
@@ -825,23 +926,6 @@ const useRouteMatches = () => {
825
926
  return () => router.matches().slice();
826
927
  };
827
928
 
828
- /**
829
- * `usePreloadRoute` returns a function for warming a route by hand — the same
830
- * work link hover/focus intent triggers automatically: the matched routes'
831
- * lazy components load, and with `preloadData` their `preload` functions run.
832
- *
833
- * @example
834
- * ```js
835
- * const preload = usePreloadRoute();
836
- *
837
- * preload(paths.users(2).settings, { preloadData: true });
838
- * ```
839
- */
840
- const usePreloadRoute = () => {
841
- const pre = useRouter().preloadRoute;
842
- return (url, options = {}) => pre(url instanceof URL ? url : new URL(String(url), mockBase), options.preloadData);
843
- };
844
-
845
929
  /**
846
930
  * Retrieves a reactive, store-like object containing the current route path parameters as defined in the Route.
847
931
  *
@@ -950,7 +1034,7 @@ const useLinkState = (href, options = {}) => {
950
1034
  const location = router.location;
951
1035
  const to = useResolvedPath(() => String(href()));
952
1036
  const base = router.base.path();
953
- const state = createMemo(() => matchLink(location, to(), base, options.end));
1037
+ const state = createMemo(() => linkMatcher(location, base, options.end)(to()));
954
1038
  return {
955
1039
  active: createMemo(() => state().active),
956
1040
  current: createMemo(() => state().current),
@@ -959,10 +1043,7 @@ const useLinkState = (href, options = {}) => {
959
1043
  // observe the committed location during a transition
960
1044
  pending: createMemo(() => {
961
1045
  state(); // location dependency: mid-flight target swaps recompute
962
- return router.isRouting() && !!router.pendingTarget && matchLink({
963
- pathname: router.pendingTarget.value,
964
- search: ""
965
- }, to(), base, options.end).active;
1046
+ return linkPending(router, to(), base, options.end);
966
1047
  })
967
1048
  };
968
1049
  };
@@ -1292,6 +1373,11 @@ let preloadIntent;
1292
1373
  function getIntent() {
1293
1374
  return preloadIntent || useOptionalContext(RouterContextObj)?.intent?.();
1294
1375
  }
1376
+ function setPreloadIntent(intent) {
1377
+ const prev = preloadIntent;
1378
+ preloadIntent = intent;
1379
+ return prev;
1380
+ }
1295
1381
  let inPreloadFn = false;
1296
1382
  function getInPreloadFn() {
1297
1383
  return inPreloadFn;
@@ -1320,7 +1406,12 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1320
1406
  _navigation: isServer ? undefined : 1
1321
1407
  });
1322
1408
  }
1323
- const location = createLocation(() => source().value, () => source().state, utils.queryWrapper);
1409
+
1410
+ // The flushed world — what `latest(source)` answers — as the location's own
1411
+ // memo sees it: it recomputes under the transition that carries a write,
1412
+ // never for a write of this same tick (A28).
1413
+ let flushed = initialSource;
1414
+ const location = createLocation(() => (flushed = source()).value, () => source().state, utils.queryWrapper);
1324
1415
  // The flash cookie is consumed eagerly: its one-shot clear (Set-Cookie)
1325
1416
  // must be appended before streaming flushes the response headers, and an
1326
1417
  // unread outcome must not haunt a later request's render. Only detection
@@ -1397,7 +1488,9 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1397
1488
  // NotReadyError's source must be a reactive async node, not the raw
1398
1489
  // Promise. Keep one reader per boundary for this router owner so rejection
1399
1490
  // is delivered through the graph's error channel and resolution wakes the
1400
- // parked matches computation.
1491
+ // parked matches computation. `transparent`: the reader exists on the
1492
+ // client only (the server parks `matches` on the promise directly), so it
1493
+ // must not take a hydration id the server never allocated.
1401
1494
  const routerOwner = getOwner();
1402
1495
  const lazyReaders = new WeakMap();
1403
1496
  const readLazySubtree = record => {
@@ -1406,16 +1499,19 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1406
1499
  read = runWithOwner(routerOwner, () => createMemo(() => {
1407
1500
  const result = resolveLazySubtree(record);
1408
1501
  return result instanceof Promise ? result.then(() => undefined) : undefined;
1409
- }, DEV && {
1410
- name: "lazyRoutes"
1502
+ }, {
1503
+ transparent: true,
1504
+ ...(DEV && {
1505
+ name: "lazyRoutes"
1506
+ })
1411
1507
  }));
1412
1508
  lazyReaders.set(record, read);
1413
1509
  }
1414
1510
  return read();
1415
1511
  };
1512
+ const matchPath = pathname => getRouteMatches(branches(), options.transformUrl ? options.transformUrl(pathname) : pathname);
1416
1513
  const matches = createMemo(() => {
1417
- const pathname = typeof options.transformUrl === "function" ? options.transformUrl(location.pathname) : location.pathname;
1418
- const m = getRouteMatches(branches(), pathname);
1514
+ const m = matchPath(location.pathname);
1419
1515
  // An unresolved lazy subtree parks readers on not-ready semantics — the
1420
1516
  // navigation transition (or the SSR stream) holds until the table lands.
1421
1517
  // NotReadyError (not a returned promise) because a match chain is full
@@ -1442,28 +1538,15 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1442
1538
  }, DEV && {
1443
1539
  name: "matches"
1444
1540
  });
1445
- const routingPending = createMemo(() => isPending(() => {
1446
- try {
1447
- matches();
1448
- } catch (e) {
1449
- if (e instanceof NotReadyError) throw e;
1450
- }
1451
- location.search;
1452
- location.hash;
1453
- }), DEV && {
1454
- name: "routingPending"
1455
- });
1456
- const isRouting = () => routingPending() || isPending(source);
1541
+ const headedLocation = () => isServer ? untrack(source) : flushed;
1542
+ // A flushed navigation write whose settle has not run is still in flight.
1543
+ const unsettled = change => change._navigation !== undefined && !!integration.settled && !integration.settled(change);
1457
1544
  const transitionIntent = () => {
1458
- if (!isPending(source)) return;
1459
- const navigation = latest(source)._navigation;
1545
+ const head = headedLocation();
1546
+ if (!unsettled(head)) return;
1547
+ const navigation = head._navigation;
1460
1548
  return navigation === -1 ? "native" : navigation && navigation > 0 ? "navigate" : undefined;
1461
1549
  };
1462
- const pendingNavigation = () => {
1463
- if (!isRouting()) return;
1464
- const target = latest(source);
1465
- return target._navigation && target._navigation > 0 ? target : undefined;
1466
- };
1467
1550
  const buildParams = () => mergeParams(matches());
1468
1551
  const wrapParams = utils.paramsWrapper ? getParams => utils.paramsWrapper(getParams, branches) : getParams => createMemoObject(getParams);
1469
1552
  const params = wrapParams(buildParams);
@@ -1481,17 +1564,16 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1481
1564
  location,
1482
1565
  params,
1483
1566
  wrapParams,
1484
- isRouting,
1485
1567
  intent: transitionIntent,
1486
- get pendingTarget() {
1487
- return pendingNavigation();
1488
- },
1568
+ _source: source,
1569
+ _owner: routerOwner,
1570
+ _routeOwner: getContext,
1571
+ _match: matchPath,
1489
1572
  renderPath,
1490
1573
  parsePath,
1491
1574
  navigatorFactory,
1492
1575
  matches,
1493
1576
  beforeLeave,
1494
- preloadRoute,
1495
1577
  singleFlight: options.singleFlight === undefined ? true : options.singleFlight,
1496
1578
  get submissions() {
1497
1579
  return submissions ||= createSignal(isServer ? initSubmissions() : [], {
@@ -1546,10 +1628,10 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1546
1628
  // the destination the leave guard is told all read it. A write of this
1547
1629
  // same tick is not in it (A28) — see `compose` below.
1548
1630
  //
1549
- // A redirect hop: the previous navigation is still pending, or has landed
1550
- // but not yet reached history (a guard redirecting in the landing flush
1551
- // — its destination was never shown either way).
1552
- const headed = latest(source);
1631
+ // A redirect hop: the previous navigation's write has not settled — it
1632
+ // is still held, or a guard is redirecting in its landing flush — so its
1633
+ // destination was never shown.
1634
+ const headed = headedLocation();
1553
1635
 
1554
1636
  // A composed target (`setSearchParams`) is a function of where the
1555
1637
  // router is heading, an unflushed write of this tick included. Only
@@ -1579,7 +1661,7 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1579
1661
  resolvedTo = resolveTarget(to);
1580
1662
  if (resolvedTo === undefined) throw unroutable(to);
1581
1663
  }
1582
- const navigationDepth = !isServer && headed._navigation !== undefined && headed._navigation > 0 && (isPending(source) || integration.inflight?.() === headed) ? headed._navigation : 0;
1664
+ const navigationDepth = !isServer && headed._navigation !== undefined && headed._navigation > 0 && unsettled(headed) ? headed._navigation : 0;
1583
1665
  if (navigationDepth >= MAX_REDIRECTS) {
1584
1666
  throw new Error("Too many redirects");
1585
1667
  }
@@ -1621,71 +1703,6 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1621
1703
  route = route || useOptionalContext(RouteContextObj) || baseRoute;
1622
1704
  return (to, options) => navigateFromRoute(route, to, options);
1623
1705
  }
1624
- function preloadRoute(url, preloadData) {
1625
- const next = getRouteMatches(branches(), url.pathname);
1626
- // An unresolved lazy subtree in the chain: the placeholder's
1627
- // component.preload (below) kicks the table load; once it lands,
1628
- // preload again so the real inner routes warm too. Preloads are
1629
- // speculative: a failed load (held sync throw or rejection) is ignored
1630
- // here — the real navigation surfaces and retries it.
1631
- const boundary = next.find(m => m.route.lazy && !m.route.lazy.resolved);
1632
- if (boundary) {
1633
- try {
1634
- resolveLazySubtree(boundary.route.lazy).then(() => preloadRoute(url, preloadData), () => {});
1635
- } catch {}
1636
- }
1637
- // Data preloads run only for levels a navigation would mount fresh or
1638
- // reuse with changed inputs: this level's params, and search as the
1639
- // declared schema's output or else the raw string. Navigation itself is
1640
- // already this selective (a matching level is reused and re-reads through
1641
- // tracked params), so an unchanged level has nothing new to warm.
1642
- let current;
1643
- try {
1644
- current = untrack(matches);
1645
- } catch {}
1646
- const query = extractSearchParams(url);
1647
- const inputs = (p, q, s, r) => {
1648
- const a = serverRouteArgs(r, p, q);
1649
- a.search === undefined && (a.search = s);
1650
- return a;
1651
- };
1652
- const prevIntent = preloadIntent;
1653
- preloadIntent = "preload";
1654
- for (let match in next) {
1655
- const {
1656
- route,
1657
- params
1658
- } = next[match];
1659
- const {
1660
- preload,
1661
- component
1662
- } = route;
1663
- component?.preload?.();
1664
- const now = current && current[match];
1665
- const unchanged = now && now.route.key === route.key && serverRouteArgsEqual(inputs(params, query, url.search, route), inputs(now.params, location.query, location.search, route));
1666
- inPreloadFn = true;
1667
- preloadData && !unchanged && runWithOwner(getContext(), () => {
1668
- // A server component route's data IS its call: warm the same
1669
- // query entry the render will read, under the same derived args.
1670
- const server = serverRouteOf(component);
1671
- server && server.call(serverRouteArgs(route, params, query));
1672
- preload && preload({
1673
- params,
1674
- location: {
1675
- pathname: url.pathname,
1676
- search: url.search,
1677
- hash: url.hash,
1678
- query,
1679
- state: null,
1680
- key: ""
1681
- },
1682
- intent: "preload"
1683
- });
1684
- });
1685
- inPreloadFn = false;
1686
- }
1687
- preloadIntent = prevIntent;
1688
- }
1689
1706
 
1690
1707
  // Seeds the initial submission from a no-JS form post: the server
1691
1708
  // function runtime redirected back with the outcome in a one-shot flash
@@ -1833,9 +1850,12 @@ function Routes(props) {
1833
1850
  // they stay subscribed to `matches` and crash on a later navigation (#451)
1834
1851
  onCleanup(() => disposers.forEach(dispose => dispose()));
1835
1852
  // Route roots must outlive re-runs of the `routeStates` memo below, so they
1836
- // are created under the owner of this component rather than the memo's
1837
- // computation (which disposes its children every time it re-runs).
1838
- const owner = getOwner();
1853
+ // are created under an owner of this component rather than the memo's
1854
+ // computation (which disposes its children every time it re-runs). A
1855
+ // dedicated one, created before the memo: the roots draw hydration ids from
1856
+ // its counter, so the ids don't depend on whether the memo first parked on
1857
+ // a lazy subtree (the server's render can park where the client's doesn't).
1858
+ const owner = createOwner();
1839
1859
  const routeStates = createMemo(prev => {
1840
1860
  // While a lazy subtree resolves, `matches()` is not ready and this
1841
1861
  // computation parks with it — no route contexts are created against
@@ -2127,6 +2147,7 @@ function memoryHistory(initial = "/") {
2127
2147
  }
2128
2148
 
2129
2149
  const STORAGE_KEY = "solid-router:scroll";
2150
+ const AUTO_KEY = "solid-router:scroll-auto";
2130
2151
 
2131
2152
  /**
2132
2153
  * Explicit scroll restoration for back/forward navigation. The browser's
@@ -2141,7 +2162,7 @@ const STORAGE_KEY = "solid-router:scroll";
2141
2162
  * so restoration survives reloads, which `scrollRestoration = "manual"`
2142
2163
  * otherwise disables.
2143
2164
  *
2144
- * Restoration is a single scroll once routing settles — the same strategy
2165
+ * Restoration is a single scroll once the traversal settles — the same strategy
2145
2166
  * SvelteKit, TanStack Router and React Router use. Settling after the
2146
2167
  * transition commits is what makes the offset reachable; chasing a still-
2147
2168
  * growing document afterwards (a ResizeObserver re-asserting the offset as
@@ -2151,17 +2172,49 @@ const STORAGE_KEY = "solid-router:scroll";
2151
2172
  * layout changes can feed it back into itself. Content that commits after the
2152
2173
  * transition settles — an image without reserved space, a boundary below the
2153
2174
  * fold — keeps whatever offset the document can hold.
2175
+ *
2176
+ * A server-rendered document load is the exception: the browser restores it
2177
+ * natively once the document — streamed boundaries included — has loaded,
2178
+ * which a restore at the first client flush cannot match. So an entry is
2179
+ * handed back to the browser (`auto`) whenever its document may unload
2180
+ * (pagehide), and a hydrating router arriving on a handed-back entry leaves
2181
+ * the restore to the browser: it does not scroll, and it takes the entry back
2182
+ * (`manual`) only after load or at the first client navigation, whichever
2183
+ * comes first. A programmatic scroll during the load cancels the native
2184
+ * restore in Firefox and WebKit, and WebKit restores just after the load
2185
+ * event, so a `manual` write before then — inside a load listener included —
2186
+ * suppresses it; the write waits a task. Handed-back depths are tracked here
2187
+ * rather than read from `history.scrollRestoration`, which Firefox reports as
2188
+ * the mode before pagehide after a reload. An entry this router pushed and
2189
+ * never handed back stays `manual` and gets no native restore, so the router
2190
+ * restores it as on a client-rendered load.
2154
2191
  */
2155
- function createScrollRestoration() {
2156
- window.history.scrollRestoration = "manual";
2192
+ function createScrollRestoration(hydrating) {
2193
+ const h = window.history;
2157
2194
  // the current entry needs its depth stamp for captures to have a key, even
2158
2195
  // if something replaced history.state after the adapter stamped it
2159
2196
  saveCurrentDepth();
2197
+ const depth = () => window.history.state && window.history.state._depth;
2160
2198
  let positions = {};
2199
+ let handedBack = {};
2161
2200
  try {
2162
2201
  positions = JSON.parse(sessionStorage.getItem(STORAGE_KEY)) || {};
2202
+ handedBack = JSON.parse(sessionStorage.getItem(AUTO_KEY)) || {};
2163
2203
  } catch {}
2164
- const depth = () => window.history.state && window.history.state._depth;
2204
+ const manual = () => {
2205
+ h.scrollRestoration = "manual";
2206
+ const d = depth();
2207
+ if (d != null) delete handedBack[d];
2208
+ };
2209
+ const d = depth();
2210
+ let deferred = !!hydrating && (h.scrollRestoration === "auto" || d != null && !!handedBack[d]);
2211
+ if (!deferred) manual();
2212
+ const claim = () => {
2213
+ if (!deferred) return;
2214
+ deferred = false;
2215
+ manual();
2216
+ };
2217
+ let timer;
2165
2218
  let programmatic = false;
2166
2219
  let pending;
2167
2220
  const unbind = [bindEvent(window, "scroll", () => {
@@ -2170,10 +2223,16 @@ function createScrollRestoration() {
2170
2223
  // the user took over — a pending restore would yank them
2171
2224
  if (!programmatic) pending = undefined;
2172
2225
  }), bindEvent(window, "pagehide", () => {
2226
+ h.scrollRestoration = "auto";
2227
+ const d = depth();
2228
+ if (d != null) handedBack[d] = 1;
2173
2229
  try {
2174
2230
  sessionStorage.setItem(STORAGE_KEY, JSON.stringify(positions));
2231
+ sessionStorage.setItem(AUTO_KEY, JSON.stringify(handedBack));
2175
2232
  } catch {}
2176
- })];
2233
+ }), bindEvent(window, "pageshow", e => {
2234
+ if (e.persisted) manual();
2235
+ }), bindEvent(window, "load", () => deferred && (timer = setTimeout(claim))), () => clearTimeout(timer)];
2177
2236
  const restore = () => {
2178
2237
  if (pending == null) return;
2179
2238
  const y = positions[pending];
@@ -2186,42 +2245,38 @@ function createScrollRestoration() {
2186
2245
  programmatic = false;
2187
2246
  };
2188
2247
  return {
2189
- /** When the adapter notifies a traversal: mark the target for restoration. */
2248
+ /**
2249
+ * When the adapter notifies a traversal: mark the target and restore once
2250
+ * the transition carrying it settles. The adapter notifies right before
2251
+ * the location write, in the same tick, so the settle is that write's —
2252
+ * a same-URL traversal included, which no location key would see.
2253
+ */
2190
2254
  onPop() {
2255
+ claim();
2191
2256
  pending = depth();
2257
+ runWithOwner(null, () => onSettled(restore));
2192
2258
  },
2259
+ /** The initial page settled: restore a reload/back_forward arrival. */
2260
+ settled: () => restore(),
2261
+ /** Before a client navigation writes history: the entry it creates copies the current mode. */
2262
+ beforeWrite: claim,
2193
2263
  /** After a push: forward entries died, and this depth may be reused. */
2194
2264
  onPush() {
2195
2265
  const d = depth();
2196
- if (d != null) for (const k in positions) +k >= d && delete positions[k];
2266
+ if (d != null) {
2267
+ for (const k in positions) +k >= d && delete positions[k];
2268
+ for (const k in handedBack) +k >= d && delete handedBack[k];
2269
+ }
2197
2270
  },
2198
- create(router) {
2199
- // Restore once the traversal has settled: key on the location (a fully
2200
- // synchronous pop commits without isRouting ever flipping) and on
2201
- // isRouting, which reports in-flight transitions — native pops
2202
- // included — and holds the restore until they commit. restore() no-ops
2203
- // unless a traversal marked a target, so push navigations are inert.
2204
- // `transparent` keeps the effect invisible to the hydration id scheme —
2205
- // same reasoning as the link-claims effect (claims.ts): this setup is
2206
- // client-only, so an id-consuming node here has no server counterpart
2207
- // and every hydration id allocated after it shifts by one child slot.
2208
- // The visible failure is any <Loading> content that settled before the
2209
- // shell flush (a cache hit, a preloaded query): its serialized value and
2210
- // inlined markup are keyed under the server's ids, the shifted client
2211
- // misses both, recomputes, and re-renders the route fresh — duplicating
2212
- // the server DOM and leaving it inert.
2213
- createEffect(() => ({
2214
- url: router.location.pathname + router.location.search + router.location.hash,
2215
- routing: router.isRouting()
2216
- }), current => {
2217
- if (!current.routing) restore();
2218
- }, {
2219
- transparent: true
2220
- });
2271
+ create() {
2221
2272
  onCleanup(() => unbind.forEach(u => u()));
2222
2273
  // reload/back_forward document loads land on an existing entry (a fresh
2223
- // navigation starts a new one and belongs at the top); the effect's
2224
- // initial run performs the restore after first render
2274
+ // navigation starts a new one and belongs at the top); the router calls
2275
+ // `settled` once the initial page has; a deferred arrival is the browser's
2276
+ if (deferred) {
2277
+ document.readyState === "complete" && claim();
2278
+ return;
2279
+ }
2225
2280
  const [nav] = performance.getEntriesByType?.("navigation");
2226
2281
  if (nav && nav.type !== "navigate") pending = depth();
2227
2282
  }
@@ -2237,6 +2292,7 @@ function withScrollRestoration(history, restoration) {
2237
2292
  return {
2238
2293
  ...history,
2239
2294
  set(next) {
2295
+ restoration.beforeWrite();
2240
2296
  history.set(next);
2241
2297
  next.replace || restoration.onPush();
2242
2298
  },
@@ -2384,8 +2440,8 @@ function describeInitial(match, location) {
2384
2440
  /** Wraps a history adapter in the integration signal the router core consumes. Must run under a reactive owner. */
2385
2441
  function createIntegration(history, match) {
2386
2442
  let committing = false;
2387
- // Written, not yet in history (see `RouterIntegration.inflight`).
2388
- let inflight;
2443
+ // Writes whose transition has landed (see `RouterIntegration.settled`).
2444
+ const settled = new WeakSet();
2389
2445
  const wrap = value => typeof value === "string" ? {
2390
2446
  value
2391
2447
  } : value;
@@ -2404,14 +2460,16 @@ function createIntegration(history, match) {
2404
2460
  // the no-op rule compares against it, so `navigate()` behind another
2405
2461
  // write in one handler sees that write rather than the flushed world.
2406
2462
  write(headed => (written = resolveLocationWrite(headed, next)) || headed);
2407
- if (written && written._navigation && written._navigation > 0) {
2463
+ if (written && written._navigation) {
2408
2464
  const next = written;
2409
- inflight = next;
2410
2465
  // Register out of band so a destination error boundary replacing the
2411
- // Router subtree cannot suppress the winning history commit.
2466
+ // Router subtree cannot suppress the winning history commit. The
2467
+ // settle is tied to the transition carrying the write: it fires when
2468
+ // that lands, a rejection included, and a superseded write's settle
2469
+ // fires with the transition that absorbed it.
2412
2470
  runWithOwner(null, () => onSettled(() => {
2413
- if (inflight === next) inflight = undefined;
2414
- if (read() !== next) return;
2471
+ settled.add(next);
2472
+ if (next._navigation < 0 || read() !== next) return;
2415
2473
  committing = true;
2416
2474
  try {
2417
2475
  history.set(next);
@@ -2445,7 +2503,7 @@ function createIntegration(history, match) {
2445
2503
  }));
2446
2504
  return {
2447
2505
  signal,
2448
- inflight: () => inflight,
2506
+ settled: write => settled.has(write),
2449
2507
  utils: history.utils
2450
2508
  };
2451
2509
  }
@@ -2478,6 +2536,9 @@ function staticIntegration(url, utils) {
2478
2536
  };
2479
2537
  }
2480
2538
  function createRouter(config) {
2539
+ if (DEV && typeof config.preloadLinks === "boolean") {
2540
+ console.warn("`preloadLinks` takes preload strategies, not a boolean, and preloads nothing by default. " + "Use `preloadLinks: intentPreload()` for hover/focus preloading.");
2541
+ }
2481
2542
  const basePath = config.base || "";
2482
2543
  // Routes are immutable per instance, so compilation is shared by every
2483
2544
  // mount, request, and `match()` call — recompiled only when a lazy subtree
@@ -2510,7 +2571,7 @@ function createRouter(config) {
2510
2571
  let restoration;
2511
2572
  let history = config.history;
2512
2573
  if (!isServer && (config.scrollRestoration ?? !history)) {
2513
- restoration = createScrollRestoration();
2574
+ restoration = createScrollRestoration(isHydrating());
2514
2575
  history = withScrollRestoration(history || browserHistory(), restoration);
2515
2576
  }
2516
2577
  const integration = isServer ? staticIntegration(props.url, config.history && config.history.utils) : createIntegration(history || browserHistory(), matchPath);
@@ -2527,13 +2588,16 @@ function createRouter(config) {
2527
2588
  setupNativeEvents({
2528
2589
  preload: config.preloadLinks,
2529
2590
  explicitLinks: config.explicitLinks,
2530
- actionBase: config.actionBase,
2531
- transformUrl: config.transformUrl
2591
+ actionBase: config.actionBase
2532
2592
  })(routerState);
2533
- setupLinkClaims(routerState, config.explicitLinks);
2593
+ setupLinkClaims(routerState, config.explicitLinks, config.links);
2534
2594
  if (routerState.singleFlight) onCleanup(registerFlightRouter(routerState));
2535
- restoration && restoration.create(routerState);
2595
+ restoration && restoration.create();
2536
2596
  }
2597
+ // Registered on both sides, outside the client-only branch: an owned
2598
+ // onSettled takes a hydration id on the server too, so the ids line up.
2599
+ // A no-op when the arrival is left to the browser's native restore.
2600
+ onSettled(() => restoration && restoration.settled());
2537
2601
  return createComponent$1(RouterContextObj, {
2538
2602
  value: routerState,
2539
2603
  get children() {
@@ -2670,6 +2734,289 @@ const useBeforeLeave = listener => {
2670
2734
  onCleanup(s);
2671
2735
  };
2672
2736
 
2737
+ /**
2738
+ * Warms the routes `url` matches: their lazy components (and any lazy
2739
+ * subtree on the way) load, and with `preloadData` their `preload` functions
2740
+ * run under `intent: "preload"`. Only link preload strategies and
2741
+ * `usePreloadRoute` import it, so apps using neither don't ship it.
2742
+ */
2743
+ function preloadRoute(router, url, preloadData) {
2744
+ const next = router._match(url.pathname);
2745
+ // An unresolved lazy subtree in the chain: the placeholder's
2746
+ // component.preload (below) kicks the table load; once it lands,
2747
+ // preload again so the real inner routes warm too. Preloads are
2748
+ // speculative: a failed load (held sync throw or rejection) is ignored
2749
+ // here — the real navigation surfaces and retries it.
2750
+ const boundary = next.find(m => m.route.lazy && !m.route.lazy.resolved);
2751
+ if (boundary) {
2752
+ try {
2753
+ resolveLazySubtree(boundary.route.lazy).then(() => preloadRoute(router, url, preloadData), () => {});
2754
+ } catch {}
2755
+ }
2756
+ // Data preloads run only for levels a navigation would mount fresh or
2757
+ // reuse with changed inputs: this level's params, and search as the
2758
+ // declared schema's output or else the raw string. Navigation itself is
2759
+ // already this selective (a matching level is reused and re-reads through
2760
+ // tracked params), so an unchanged level has nothing new to warm.
2761
+ let current;
2762
+ try {
2763
+ current = untrack(router.matches);
2764
+ } catch {}
2765
+ const {
2766
+ location
2767
+ } = router;
2768
+ const query = extractSearchParams(url);
2769
+ const inputs = (p, q, s, r) => {
2770
+ const a = serverRouteArgs(r, p, q);
2771
+ a.search === undefined && (a.search = s);
2772
+ return a;
2773
+ };
2774
+ const prevIntent = setPreloadIntent("preload");
2775
+ for (let match in next) {
2776
+ const {
2777
+ route,
2778
+ params
2779
+ } = next[match];
2780
+ const {
2781
+ preload,
2782
+ component
2783
+ } = route;
2784
+ component?.preload?.();
2785
+ const now = current && current[match];
2786
+ const unchanged = now && now.route.key === route.key && serverRouteArgsEqual(inputs(params, query, url.search, route), inputs(now.params, location.query, location.search, route));
2787
+ setInPreloadFn(true);
2788
+ preloadData && !unchanged && runWithOwner(router._routeOwner(), () => {
2789
+ // A server component route's data IS its call: warm the same
2790
+ // query entry the render will read, under the same derived args.
2791
+ const server = serverRouteOf(component);
2792
+ server && server.call(serverRouteArgs(route, params, query));
2793
+ preload && preload({
2794
+ params,
2795
+ location: {
2796
+ pathname: url.pathname,
2797
+ search: url.search,
2798
+ hash: url.hash,
2799
+ query,
2800
+ state: null,
2801
+ key: ""
2802
+ },
2803
+ intent: "preload"
2804
+ });
2805
+ });
2806
+ setInPreloadFn(false);
2807
+ }
2808
+ setPreloadIntent(prevIntent);
2809
+ }
2810
+
2811
+ /**
2812
+ * `usePreloadRoute` returns a function for warming a route by hand — the same
2813
+ * work link preloading triggers: the matched routes' lazy components load,
2814
+ * and with `preloadData` their `preload` functions run.
2815
+ *
2816
+ * @example
2817
+ * ```js
2818
+ * const preload = usePreloadRoute();
2819
+ *
2820
+ * preload(paths.users(2).settings, { preloadData: true });
2821
+ * ```
2822
+ */
2823
+ const usePreloadRoute = () => {
2824
+ const router = useRouter();
2825
+ return (url, options) => preloadRoute(router, url instanceof URL ? url : new URL(String(url), mockBase), options?.preloadData);
2826
+ };
2827
+
2828
+ /**
2829
+ * Anchors as the runtime claims them: at creation and again on `href`
2830
+ * writes. `disarm` runs when the creating owner is disposed. Claims fire
2831
+ * before dynamic attributes are applied (solidjs/solid#3923), so whether a
2832
+ * link is opted in, and where it points, is read when it preloads.
2833
+ */
2834
+ function claimAnchors(arm, disarm) {
2835
+ const seen = new WeakSet();
2836
+ setLinkPreloader(preloadRoute);
2837
+ onCleanup(registerElementClaim(node => {
2838
+ if (node.nodeName.toUpperCase() !== "A") return;
2839
+ arm(node);
2840
+ // a re-claim runs under the owner writing `href`, not the anchor's
2841
+ if (seen.has(node)) return;
2842
+ seen.add(node);
2843
+ getOwner() && onCleanup(() => disarm(node));
2844
+ }));
2845
+ }
2846
+
2847
+ // requests made before the browser is idle run together once it is
2848
+ function whenIdle(fn) {
2849
+ let scheduled = false;
2850
+ return () => {
2851
+ if (scheduled) return;
2852
+ scheduled = true;
2853
+ (window.requestIdleCallback || setTimeout)(() => {
2854
+ scheduled = false;
2855
+ fn();
2856
+ });
2857
+ };
2858
+ }
2859
+
2860
+ /**
2861
+ * Preloads an ambient strategy's anchor: one that names the strategy in its
2862
+ * `preload` attribute, or with `all` any but `preload="false"`. Skipped when
2863
+ * the user asked to save data or the connection is 2g (where the Network
2864
+ * Information API is available).
2865
+ */
2866
+ const ambientPreload = ({
2867
+ url,
2868
+ preload
2869
+ }, name, all, data) => a => {
2870
+ const value = a.getAttribute("preload");
2871
+ const connection = navigator.connection;
2872
+ const href = (value === name || all && value !== "false") && !(connection && (connection.saveData || /2g/.test(connection.effectiveType))) && url(a);
2873
+ href && preload(href, data);
2874
+ };
2875
+
2876
+ /**
2877
+ * Preload a link when the pointer rests on it for `delay` ms (default 20),
2878
+ * when it takes focus, and on touchstart. Moving over a link that already
2879
+ * preloaded does not preload it again until the pointer leaves. These
2880
+ * listeners do not check modifier keys or `defaultPrevented`; clicks still do.
2881
+ * Warms route code and, unless `data` is false (default true), runs the
2882
+ * matched routes' `preload` functions.
2883
+ *
2884
+ * @example
2885
+ * ```ts
2886
+ * const Router = createRouter({ routes, preloadLinks: intentPreload() });
2887
+ * ```
2888
+ */
2889
+ const intentPreload = ({
2890
+ delay = 20,
2891
+ data = true
2892
+ } = {}) => ctx => {
2893
+ let timeout;
2894
+ let lastElement;
2895
+ setLinkPreloader(preloadRoute);
2896
+ const onIntent = evt => {
2897
+ // clear before resolving: a throw must not leave the rest timer armed
2898
+ if (evt.type === "mousemove") clearTimeout(timeout);
2899
+ const res = ctx.anchor(evt);
2900
+ const link = res && res[0].getAttribute("preload") !== "false" ? res : undefined;
2901
+ if (evt.type !== "mousemove") return link && ctx.preload(link[1], data);
2902
+ if (!link) return lastElement = undefined;
2903
+ const [a, url] = link;
2904
+ if (lastElement === a) return;
2905
+ timeout = setTimeout(() => {
2906
+ ctx.preload(url, data);
2907
+ lastElement = a;
2908
+ }, delay);
2909
+ };
2910
+ const passive = {
2911
+ passive: true
2912
+ };
2913
+ document.addEventListener("focusin", onIntent, passive);
2914
+ document.addEventListener("touchstart", onIntent, passive);
2915
+ document.addEventListener("mousemove", onIntent, passive);
2916
+ onCleanup(() => {
2917
+ clearTimeout(timeout);
2918
+ document.removeEventListener("focusin", onIntent);
2919
+ document.removeEventListener("touchstart", onIntent);
2920
+ document.removeEventListener("mousemove", onIntent);
2921
+ });
2922
+ };
2923
+
2924
+ /**
2925
+ * Preload a link on `pointerdown` (mouse, touch, or pen), ahead of its click.
2926
+ * Does not check modifier keys or `defaultPrevented`. Warms route code and,
2927
+ * unless `data` is false (default true), route data.
2928
+ */
2929
+ const tapPreload = ({
2930
+ data = true
2931
+ } = {}) => ctx => {
2932
+ setLinkPreloader(preloadRoute);
2933
+ const onTap = evt => {
2934
+ const res = ctx.anchor(evt);
2935
+ res && res[0].getAttribute("preload") !== "false" && ctx.preload(res[1], data);
2936
+ };
2937
+ document.addEventListener("pointerdown", onTap, {
2938
+ passive: true
2939
+ });
2940
+ onCleanup(() => document.removeEventListener("pointerdown", onTap));
2941
+ };
2942
+
2943
+ /**
2944
+ * Preload a link once it has stayed in the viewport for `delay` ms (default
2945
+ * 100), when the browser is next idle (`requestIdleCallback`, or `setTimeout`
2946
+ * where that is missing). Each link preloads once, and again after its `href`
2947
+ * changes; a link that leaves before then is dropped. One `IntersectionObserver`
2948
+ * is shared. `rootMargin` is passed through to it (the observer's own `"0px"`
2949
+ * when omitted). Applies to links with `preload="viewport"`, or with `all`
2950
+ * (default false) to every link except `preload="false"`. Skips Save-Data and
2951
+ * 2g connections. Warms route code only unless `data` is true (default false).
2952
+ */
2953
+ const viewportPreload = ({
2954
+ all,
2955
+ data = false,
2956
+ delay = 100,
2957
+ rootMargin
2958
+ } = {}) => ctx => {
2959
+ if (typeof IntersectionObserver === "undefined") return;
2960
+ const warm = ambientPreload(ctx, "viewport", all, data);
2961
+ // visible anchors: a pending dwell timer, or `true` once due
2962
+ const visible = new Map();
2963
+ let observer;
2964
+ const leave = a => {
2965
+ clearTimeout(visible.get(a));
2966
+ visible.delete(a);
2967
+ };
2968
+ const flush = whenIdle(() => visible.forEach((due, a) => {
2969
+ if (due !== true) return;
2970
+ observer.unobserve(a);
2971
+ visible.delete(a);
2972
+ warm(a);
2973
+ }));
2974
+ const intersect = entries => entries.forEach(({
2975
+ target: a,
2976
+ isIntersecting
2977
+ }) => isIntersecting ? visible.set(a, setTimeout(() => (visible.set(a, true), flush()), delay)) : leave(a));
2978
+ claimAnchors(a => (observer ||= new IntersectionObserver(intersect, {
2979
+ rootMargin
2980
+ })).observe(a), a => {
2981
+ observer.unobserve(a);
2982
+ leave(a);
2983
+ });
2984
+ onCleanup(() => {
2985
+ observer && observer.disconnect();
2986
+ visible.forEach((_, a) => leave(a));
2987
+ });
2988
+ };
2989
+
2990
+ /**
2991
+ * Preload links as soon as the page has loaded and the browser is idle,
2992
+ * including links mounted later. A link removed before the flush is dropped.
2993
+ * Applies to links with `preload="eager"`, or with `all` (default false) to
2994
+ * every link except `preload="false"`. Skips Save-Data and 2g connections.
2995
+ * Warms route code only unless `data` is true (default false).
2996
+ */
2997
+ const eagerPreload = ({
2998
+ all,
2999
+ data = false
3000
+ } = {}) => ctx => {
3001
+ const warm = ambientPreload(ctx, "eager", all, data);
3002
+ const queue = new Set();
3003
+ const flush = whenIdle(() => {
3004
+ queue.forEach(warm);
3005
+ queue.clear();
3006
+ });
3007
+ addEventListener("load", flush, {
3008
+ once: true
3009
+ });
3010
+ claimAnchors(a => {
3011
+ queue.add(a);
3012
+ document.readyState === "complete" && flush();
3013
+ }, a => queue.delete(a));
3014
+ onCleanup(() => {
3015
+ removeEventListener("load", flush);
3016
+ queue.clear();
3017
+ });
3018
+ };
3019
+
2673
3020
  // Server component routes (experimental — rides the experimental server
2674
3021
  // components surface in @solidjs/web; the arg shape may change).
2675
3022
  //
@@ -2680,7 +3027,7 @@ const useBeforeLeave = listener => {
2680
3027
  // // before: a client component exists only to make the call
2681
3028
  // const getStory = query(storyView, "story");
2682
3029
  // component: props => {
2683
- // const View = dynamic(() => getStory(props.params.id));
3030
+ // const View = dynamicComponent(() => getStory(props.params.id));
2684
3031
  // return <View>{props.children}</View>;
2685
3032
  // }
2686
3033
  // // after
@@ -2742,8 +3089,9 @@ source) {
2742
3089
  };
2743
3090
  function render(args, routeProps) {
2744
3091
  // The source may answer a component, a promise of one, or (a live query)
2745
- // successive components; `dynamic`'s memo lands each the same way.
2746
- const View = dynamic(() => call(args()));
3092
+ // successive components; `dynamicComponent`'s memo lands each the same
3093
+ // way. Never a tag, so the element runtime `dynamic` retains stays out.
3094
+ const View = dynamicComponent(() => call(args()));
2747
3095
  return createComponent(View, {
2748
3096
  get children() {
2749
3097
  return routeProps.children;
@@ -4098,4 +4446,4 @@ var serverForms = /*#__PURE__*/Object.freeze({
4098
4446
  submitServerForm: submitServerForm
4099
4447
  });
4100
4448
 
4101
- 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 };
4449
+ export { RouterContextObj as RouterContext, mergeSearchString as _mergeSearchString, action, browserHistory, createBeforeLeave, createRouter, defineRoute, defineRoutes, eagerPreload, hashHistory, int, intentPreload, liveQuery, memoryHistory, pendingLinks, query, revalidate, serverRouteComponent, tapPreload, useAction, useBeforeLeave, useHref, useIsRouting, useLinkState, useLocation, useMatch, useNavigate, useParams, usePreloadRoute, useResolvedPath, useRouteMatches, useSearchParams, useSubmissions, viewportPreload };