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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -750,6 +750,8 @@ const isRouting = useIsRouting();
750
750
  return <div classList={{ "grey-out": isRouting() }}>...</div>;
751
751
  ```
752
752
 
753
+ In Solid's dev and observe builds the router also declares every navigation to the attribution engine (`solid-js/attribution`): holds and re-runs caused by a navigation are named after the route pattern (`navigation to /users/:id`), timed from the user event that started it, and redirect hops fold onto the navigation they belong to. `attribution.navigations()` and `feedback().navigations` list them; nothing of this exists in production builds.
754
+
753
755
  ### useMatch
754
756
 
755
757
  Tests a path *pattern you supply* against the current location; returns a memo of match information or `undefined`. It never consults the route tree — the pattern doesn't have to correspond to a defined route. The match's `params` are typed from the pattern, and a typed path node works too (a concrete URL — useful for "am I here" checks):
@@ -1,13 +1,13 @@
1
1
  import { $TRACK, action as createSolidAction, createMemo, onCleanup, getOwner } from "solid-js";
2
2
  import { isResponseEnvelope, isServer, REVALIDATE_HEADER } from "@solidjs/web";
3
- import { createServerReference, decodeRedirectHeaderValue, decodeResponsePayload, parseServerFunctionUrl, REDIRECT_HEADER, subscribeFlightData } from "@solidjs/web/server-functions";
3
+ import { createServerReference, decodeRedirectHeaderValue, decodeResponsePayload, parseServerFunctionActionUrl, REDIRECT_HEADER, subscribeFlightData } from "@solidjs/web/server-functions";
4
4
  // The explicit /server specifier is safe here: the only call site is
5
5
  // server-guarded, so client builds tree-shake the codec away.
6
6
  import { decodeFlashCookie } from "@solidjs/web/server-functions/server";
7
7
  import { provideFlashDecoder, provideFlightConsumer, useRouter } from "../routing.js";
8
8
  import { setRouterFormHandler } from "./events.js";
9
9
  import { mockBase, setFunctionName } from "../utils.js";
10
- import { cacheKeyOp, deliverFlightData, hashKey, revalidate, query } from "./query.js";
10
+ import { cacheKeyOp, deliverFlightData, hashKey, readRevalidateKeys, revalidate, query } from "./query.js";
11
11
  const submitHooksSymbol = Symbol("routerActionSubmitHooks");
12
12
  const settledHooksSymbol = Symbol("routerActionSettledHooks");
13
13
  const invokeSymbol = Symbol("routerActionInvoke");
@@ -77,7 +77,7 @@ export function handleFormAction(evt, router, actionBase) {
77
77
  * later real registration overrides it).
78
78
  */
79
79
  function createServerFormAction(url) {
80
- const id = parseServerFunctionUrl(url);
80
+ const id = parseServerFunctionActionUrl(url);
81
81
  if (!id)
82
82
  return undefined;
83
83
  // typecheck resolves the server half of the dual module; this path only
@@ -267,8 +267,7 @@ function toAction(invoke, url, boundArgs = [], base = url, submitHooks = new Map
267
267
  // Only remove the registration if it still belongs to this instance —
268
268
  // a re-created action (e.g. a new `.with()` binding after revalidation)
269
269
  // may have registered itself under the same URL since.
270
- getOwner() &&
271
- onCleanup(() => actions.get(url) === fn && actions.delete(url));
270
+ getOwner() && onCleanup(() => actions.get(url) === fn && actions.delete(url));
272
271
  }
273
272
  return fn;
274
273
  }
@@ -326,7 +325,7 @@ function applyResponseMetadata(metadata, navigate, flightData) {
326
325
  let keys;
327
326
  if (metadata) {
328
327
  if (metadata.headers.has(REVALIDATE_HEADER))
329
- keys = metadata.headers.get(REVALIDATE_HEADER).split(",");
328
+ keys = readRevalidateKeys(metadata.headers.get(REVALIDATE_HEADER));
330
329
  // The carrier delivers the target RESOLVED to an absolute url
331
330
  // (solidjs/solid#3102), so the soft/hard split is a real origin
332
331
  // comparison — never a guess from how the author spelled the target,
@@ -23,5 +23,14 @@ export declare namespace query {
23
23
  export var clear: () => void;
24
24
  export { _a as delete };
25
25
  }
26
+ /**
27
+ * Reads an `X-Revalidate` header value into the keys the cache ops take.
28
+ * Named keys are prefix-matched; an empty declaration (`revalidate: []`)
29
+ * yields `[""]`, which `matchKey` matches to nothing; the reserved `*`
30
+ * yields `undefined` — this router's own spelling of everything, the same
31
+ * scope an action with no declaration gets, so an author who wrote "all"
32
+ * explicitly lands on the same path as the default.
33
+ */
34
+ export declare function readRevalidateKeys(value: string): string[] | undefined;
26
35
  export declare function matchKey(key: string, keys: string[]): boolean;
27
36
  export declare function hashKey<T extends Array<any>>(args: T): string;
@@ -1,4 +1,4 @@
1
- import { createSignal, getObserver, getOwner, onCleanup, sharedConfig } from "solid-js";
1
+ import { createSignal, getObserver, getOwner, onCleanup, sharedConfig, untrack } from "solid-js";
2
2
  // Everything server-function-shaped comes off the CORE entry: detection
3
3
  // (isServerFunction/getServerFunctionMetadata, registered-symbol reads) and
4
4
  // the late-bound RPC seam (getServerFunctionRPC). The server-functions
@@ -95,7 +95,15 @@ export function query(fn, name) {
95
95
  }
96
96
  const cachedFn = ((...args) => {
97
97
  const cache = getCache();
98
- const intent = getIntent();
98
+ // The intent is a fact about the moment of this read — which navigation
99
+ // (if any) is asking — not a dependency of the value: `intent()` probes
100
+ // `isPending`/`latest` of the router's location, and tracked here every
101
+ // consumer memo would re-run as each navigation starts and lands. Under a
102
+ // redirect that leaves a route section, the leaving section's memo is a
103
+ // zombie the transaction is removing; dirtied through the probe's lane
104
+ // channel it runs anyway (solid #3444), sees the entry the redirect just
105
+ // invalidated, and fires the phantom refetch the sweep exists to avoid.
106
+ const intent = untrack(getIntent);
99
107
  const inPreloadFn = getInPreloadFn();
100
108
  const owner = getOwner();
101
109
  const router = owner ? useRouter() : undefined;
@@ -189,7 +197,13 @@ export function query(fn, name) {
189
197
  !isServer && intent === "navigate" && cached[4][1](cached[0]); // update version
190
198
  }
191
199
  else {
192
- cache.set(key, (cached = [stamp, res, , intent, createSignal(stamp, { ownedWrite: true })]));
200
+ cache.set(key, (cached = [
201
+ stamp,
202
+ res,
203
+ ,
204
+ intent,
205
+ createSignal(stamp, { ownedWrite: true })
206
+ ]));
193
207
  cached[4].count = 0;
194
208
  }
195
209
  if (tracking) {
@@ -240,9 +254,13 @@ export function query(fn, name) {
240
254
  const url = v.headers.get(LocationHeader);
241
255
  if (url !== null) {
242
256
  // invalidate the redirect's revalidation keys before navigating so
243
- // the destination's preloads see the miss and fetch fresh (#580 thread)
244
- const keys = !isServer && v.headers.get(REVALIDATE_HEADER)?.split(",");
245
- keys && cacheKeyOp(keys, entry => (entry[0] = 0));
257
+ // the destination's preloads see the miss and fetch fresh (#580 thread).
258
+ // A read that redirects declares nothing by default (unlike an
259
+ // action), so only a header present here sweeps: named keys their
260
+ // matches, `*` everything.
261
+ const declared = !isServer ? v.headers.get(REVALIDATE_HEADER) : null;
262
+ const keys = declared !== null ? readRevalidateKeys(declared) : undefined;
263
+ declared !== null && cacheKeyOp(keys, entry => (entry[0] = 0));
246
264
  // client + server relative redirect
247
265
  const soft = navigate && url.startsWith("/");
248
266
  if (soft)
@@ -254,7 +272,7 @@ export function query(fn, name) {
254
272
  // sweep the live signals inside the same transition as the
255
273
  // navigation: surviving consumers (shared layouts) refetch and
256
274
  // hold the commit, so the destination never paints stale data
257
- keys && revalidate(keys, false);
275
+ declared !== null && revalidate(keys, false);
258
276
  // Hold the read pending on the client: the navigation unmounts this
259
277
  // consumer, and resolving `undefined` instead hands a missing value
260
278
  // to whatever renders before the transition commits (a downstream
@@ -305,12 +323,37 @@ query.set = (key, value) => {
305
323
  cached[3] = "preload";
306
324
  }
307
325
  else {
308
- cache.set(key, (cached = [now, Promise.resolve(value), value, "preload", createSignal(now, { ownedWrite: true })]));
326
+ cache.set(key, (cached = [
327
+ now,
328
+ Promise.resolve(value),
329
+ value,
330
+ "preload",
331
+ createSignal(now, { ownedWrite: true })
332
+ ]));
309
333
  cached[4].count = 0;
310
334
  }
311
335
  };
312
336
  query.delete = (key) => getCache().delete(key);
313
337
  query.clear = () => getCache().clear();
338
+ /**
339
+ * The reserved `X-Revalidate` key meaning every entry — `revalidate: "*"`
340
+ * on `@solidjs/web`'s response helpers (`REVALIDATE_ALL` there). The
341
+ * host-independent spelling of "all", for hosts whose default is narrower
342
+ * than this router's.
343
+ */
344
+ const REVALIDATE_ALL = "*";
345
+ /**
346
+ * Reads an `X-Revalidate` header value into the keys the cache ops take.
347
+ * Named keys are prefix-matched; an empty declaration (`revalidate: []`)
348
+ * yields `[""]`, which `matchKey` matches to nothing; the reserved `*`
349
+ * yields `undefined` — this router's own spelling of everything, the same
350
+ * scope an action with no declaration gets, so an author who wrote "all"
351
+ * explicitly lands on the same path as the default.
352
+ */
353
+ export function readRevalidateKeys(value) {
354
+ const keys = value.split(",");
355
+ return keys.includes(REVALIDATE_ALL) ? undefined : keys;
356
+ }
314
357
  export function matchKey(key, keys) {
315
358
  for (let k of keys) {
316
359
  if (k && key.startsWith(k))
package/dist/index.d.ts CHANGED
@@ -25,4 +25,4 @@ export { mergeSearchString as _mergeSearchString } from "./utils.js";
25
25
  export { int } from "./paths.js";
26
26
  export type { RoutePaths, PathParamsOf, PathEnd, TypedMatchFilter, DefaultSearchTypes } from "./paths.js";
27
27
  export * from "./data/index.js";
28
- export type { Location, LocationChange, 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";
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";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { getOwner, runWithOwner, createMemo, createRenderEffect, onCleanup, untrack, createContext, createSignal, useContext, NotReadyError, isPending, DEV, latest, createComponent, createRoot, Show, createEffect, sharedConfig, onSettled, getObserver, $TRACK, action as action$1 } from 'solid-js';
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
2
  import { registerElementClaim, delegateEvents, isServer, getRequestEvent, hasFlashCookie, clearFlashCookie, createComponent as createComponent$1, memo, isServerFunction, getServerFunctionMetadata, getServerFunctionRPC, isResponseEnvelope, REVALIDATE_HEADER } from '@solidjs/web';
3
- import { subscribeFlightData, decodeRedirectHeaderValue, REDIRECT_HEADER, decodeResponsePayload, parseServerFunctionUrl, createServerReference } from '@solidjs/web/server-functions';
3
+ import { subscribeFlightData, decodeRedirectHeaderValue, REDIRECT_HEADER, decodeResponsePayload, parseServerFunctionActionUrl, createServerReference } from '@solidjs/web/server-functions';
4
4
  import { decodeFlashCookie } from '@solidjs/web/server-functions/server';
5
5
 
6
6
  const hasSchemeRegex = /^(?:[a-z0-9]+:)?\/\//i;
@@ -519,6 +519,29 @@ function createPathsProxy(renderPath = p => p, base = "") {
519
519
 
520
520
  const MAX_REDIRECTS = 100;
521
521
 
522
+ /**
523
+ * Resolves a write against `headed`, the location the router is heading to
524
+ * — the integration's current value as its functional updater serves it, an
525
+ * unflushed write of this same tick included. `undefined` means nothing to
526
+ * write: the composer declined, or the router is already heading there (same
527
+ * `value` and `state`). The one place the no-op rule lives, for every
528
+ * integration (see `LocationWrite`).
529
+ */
530
+ function resolveLocationWrite(headed, next) {
531
+ const value = typeof next.value === "function" ? next.value(headed) : next.value;
532
+ if (value === undefined || value === headed.value && next.state === headed.state) return;
533
+ return {
534
+ ...next,
535
+ value
536
+ };
537
+ }
538
+
539
+ /**
540
+ * A destination composed on the location the router is heading to, resolved
541
+ * inside the integration's write (see `LocationWrite`). Internal: the
542
+ * navigator `useSearchParams` builds on accepts it; `Navigator` does not.
543
+ */
544
+
522
545
  /** Consider this API opaque and internal. It is likely to change in the future. */
523
546
  const RouterContextObj = createContext();
524
547
  const RouteContextObj = createContext();
@@ -711,16 +734,16 @@ function useSearchParams(path) {
711
734
  const location = router.location;
712
735
  const navigate = useNavigate();
713
736
  const setSearchParams = (params, options) => {
714
- const to = untrack(() => {
715
- // merge onto the in-flight navigation target (if any) so consecutive
716
- // synchronous calls compose instead of the later one winning
717
- const pending = router.pendingTarget && new URL(router.pendingTarget.value, mockBase);
718
- const pathname = pending ? pending.pathname : location.pathname;
719
- const search = pending ? pending.search : location.search;
720
- const hash = pending ? pending.hash : location.hash;
721
- return pathname + mergeSearchString(search, params) + hash;
722
- });
723
- navigate(to, {
737
+ // Merged onto the location the router is heading to, inside the write:
738
+ // a write is invisible to every read channel until its flush (A28), so
739
+ // an earlier write of the same tick — a second synchronous call, or a
740
+ // `navigate()` just before — is only seen by composing on the updater's
741
+ // `headed`. Consecutive calls both apply; a call during a pending
742
+ // navigation lands on that navigation's target.
743
+ navigate(headed => {
744
+ const url = new URL(headed.value[0] === "/" ? mockBase + headed.value : headed.value, mockBase);
745
+ return url.pathname + mergeSearchString(url.search, params) + url.hash;
746
+ }, {
724
747
  scroll: false,
725
748
  resolve: false,
726
749
  ...options
@@ -1221,6 +1244,8 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1221
1244
  read = runWithOwner(routerOwner, () => createMemo(() => {
1222
1245
  const result = resolveLazySubtree(record);
1223
1246
  return result instanceof Promise ? result.then(() => undefined) : undefined;
1247
+ }, {
1248
+ name: "lazyRoutes"
1224
1249
  }));
1225
1250
  lazyReaders.set(record, read);
1226
1251
  }
@@ -1252,6 +1277,8 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1252
1277
  }
1253
1278
  }
1254
1279
  return m;
1280
+ }, {
1281
+ name: "matches"
1255
1282
  });
1256
1283
  const routingPending = createMemo(() => isPending(() => {
1257
1284
  try {
@@ -1261,7 +1288,9 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1261
1288
  }
1262
1289
  location.search;
1263
1290
  location.hash;
1264
- }));
1291
+ }), {
1292
+ name: "routingPending"
1293
+ });
1265
1294
  const isRouting = () => routingPending() || isPending(source);
1266
1295
  const transitionIntent = () => {
1267
1296
  if (!isPending(source)) return;
@@ -1319,18 +1348,6 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1319
1348
  }
1320
1349
  return;
1321
1350
  }
1322
- // A paths node carries its logical path under the Href brand — read
1323
- // that rather than coercing: toString() renders the *display* href
1324
- // (eg. hash mode's `#` prefix), which is for the DOM, not for routing.
1325
- // Foreign Href-branded values without the slot still coerce.
1326
- if (typeof to !== "string") to = to[HREF] || to.toString();
1327
- // Display hrefs can still arrive as plain strings: terminating paths
1328
- // calls type as `string`, and redirect Location headers round-trip
1329
- // through here. Under hash mode those start with `#` — a spelling no
1330
- // logical path uses — so map them back through the integration's
1331
- // parser, exactly like the anchor click handler does. Elsewhere
1332
- // parsePath is identity and `#...` keeps its URL meaning below.
1333
- if (to[0] === "#") to = parsePath(to);
1334
1351
  const {
1335
1352
  replace,
1336
1353
  resolve,
@@ -1342,49 +1359,98 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1342
1359
  scroll: true,
1343
1360
  ...options
1344
1361
  };
1362
+ const resolveTarget = to => {
1363
+ // Display hrefs can still arrive as plain strings: terminating paths
1364
+ // calls type as `string`, and redirect Location headers round-trip
1365
+ // through here. Under hash mode those start with `#` — a spelling no
1366
+ // logical path uses — so map them back through the integration's
1367
+ // parser, exactly like the anchor click handler does. Elsewhere
1368
+ // parsePath is identity and `#...` keeps its URL meaning below.
1369
+ if (to[0] === "#") to = parsePath(to);
1370
+ // A string means what the same string means as an href. Leading "/"
1371
+ // stays base-prefixed; anything else resolves URL-style against the
1372
+ // current location — `new URL` collapses `..` and handles `?`/`#`-only
1373
+ // references natively (#502). A cross-origin result (scheme or
1374
+ // protocol-relative input) falls through as unroutable.
1375
+ if (!resolve) return resolvePath((!to || to[0] === "?") && location.pathname || "", to);
1376
+ if (to[0] === "/") return route.resolvePath(to);
1377
+ const url = new URL(to, mockBase + location.pathname + location.search + location.hash);
1378
+ return url.origin === mockBase ? url.pathname + url.search + url.hash : undefined;
1379
+ };
1380
+ const unroutable = to => new Error(`Path '${to}' is not a routable path`);
1381
+
1382
+ // The flushed world: the pending navigation when one is held, else the
1383
+ // committed location. Hop depth, the history policy a hop inherits and
1384
+ // the destination the leave guard is told all read it. A write of this
1385
+ // same tick is not in it (A28) — see `compose` below.
1386
+ //
1387
+ // A redirect hop: the previous navigation is still pending, or has landed
1388
+ // but not yet reached history (a guard redirecting in the landing flush
1389
+ // — its destination was never shown either way).
1390
+ const headed = latest(source);
1345
1391
 
1346
- // A string means what the same string means as an href. Leading "/"
1347
- // stays base-prefixed; anything else resolves URL-style against the
1348
- // current location — `new URL` collapses `..` and handles `?`/`#`-only
1349
- // references natively (#502). A cross-origin result (scheme or
1350
- // protocol-relative input) falls through as unroutable.
1392
+ // A composed target (`setSearchParams`) is a function of where the
1393
+ // router is heading, an unflushed write of this tick included. Only
1394
+ // the integration's functional updater serves that value, so the
1395
+ // destination is resolved inside the write; `resolvedTo` here is the
1396
+ // flushed world's reading of it — what the leave guard is asked about,
1397
+ // and what the server (which never has an unflushed write) redirects to.
1398
+ let compose;
1351
1399
  let resolvedTo;
1352
- if (!resolve) resolvedTo = resolvePath((!to || to[0] === "?") && location.pathname || "", to);else if (to[0] === "/") resolvedTo = route.resolvePath(to);else {
1353
- const url = new URL(to, mockBase + location.pathname + location.search + location.hash);
1354
- resolvedTo = url.origin === mockBase ? url.pathname + url.search + url.hash : undefined;
1355
- }
1356
- if (resolvedTo === undefined) {
1357
- throw new Error(`Path '${to}' is not a routable path`);
1400
+ // A paths node carries its logical path under the Href brand — read
1401
+ // that rather than coercing: toString() renders the *display* href
1402
+ // (eg. hash mode's `#` prefix), which is for the DOM, not for routing.
1403
+ // Foreign Href-branded values without the slot still coerce. The node
1404
+ // is callable, so the brand is checked ahead of the composer form.
1405
+ const href = typeof to !== "string" ? to[HREF] : undefined;
1406
+ if (href === undefined && typeof to === "function") {
1407
+ const build = to;
1408
+ compose = headed => {
1409
+ const raw = build(headed);
1410
+ const resolved = resolveTarget(raw);
1411
+ if (resolved === undefined) throw unroutable(raw);
1412
+ return resolved;
1413
+ };
1414
+ resolvedTo = compose(headed);
1415
+ } else {
1416
+ if (typeof to !== "string") to = href || to.toString();
1417
+ resolvedTo = resolveTarget(to);
1418
+ if (resolvedTo === undefined) throw unroutable(to);
1358
1419
  }
1359
- const headed = latest(source);
1360
- const navigationDepth = !isServer && isPending(source) && headed._navigation !== undefined && headed._navigation > 0 ? headed._navigation : 0;
1420
+ const navigationDepth = !isServer && headed._navigation !== undefined && headed._navigation > 0 && (isPending(source) || integration.inflight?.() === headed) ? headed._navigation : 0;
1361
1421
  if (navigationDepth >= MAX_REDIRECTS) {
1362
1422
  throw new Error("Too many redirects");
1363
1423
  }
1364
- if (resolvedTo !== headed.value || nextState !== headed.state) {
1365
- if (isServer) {
1366
- const e = getRequestEvent();
1367
- e && (e.response = {
1368
- status: 302,
1369
- headers: new Headers({
1370
- Location: resolvedTo
1371
- })
1372
- });
1373
- setSource({
1374
- value: resolvedTo,
1375
- replace,
1376
- scroll,
1377
- state: nextState
1378
- });
1379
- } else if (!beforeLeave.current || beforeLeave.current.confirm(resolvedTo, options)) {
1380
- runWithOwner(null, () => setSource({
1381
- value: resolvedTo,
1382
- state: nextState,
1383
- replace: navigationDepth ? headed.replace : replace,
1384
- scroll: navigationDepth ? headed.scroll : scroll,
1385
- _navigation: navigationDepth + 1
1386
- }));
1387
- }
1424
+ if (isServer) {
1425
+ // A server render has no unflushed write: the static integration's
1426
+ // value is the whole world, so the no-op rule is applied here, before
1427
+ // the redirect is recorded on the response.
1428
+ if (resolvedTo === headed.value && nextState === headed.state) return;
1429
+ const e = getRequestEvent();
1430
+ e && (e.response = {
1431
+ status: 302,
1432
+ headers: new Headers({
1433
+ Location: resolvedTo
1434
+ })
1435
+ });
1436
+ setSource({
1437
+ value: resolvedTo,
1438
+ replace,
1439
+ scroll,
1440
+ state: nextState
1441
+ });
1442
+ } else if (!beforeLeave.current || beforeLeave.current.confirm(resolvedTo, options)) {
1443
+ // Heading there already (same value and state) writes nothing — the
1444
+ // integration decides that against the updater's `headed`, so a
1445
+ // navigation issued behind another write of the same tick compares
1446
+ // with that write, not with the flushed world.
1447
+ runWithOwner(null, () => setSource({
1448
+ value: compose || resolvedTo,
1449
+ state: nextState,
1450
+ replace: navigationDepth ? headed.replace : replace,
1451
+ scroll: navigationDepth ? headed.scroll : scroll,
1452
+ _navigation: navigationDepth + 1
1453
+ }));
1388
1454
  }
1389
1455
  });
1390
1456
  }
@@ -2023,32 +2089,103 @@ function defineRoutes(routes) {
2023
2089
  function defineRoute(route) {
2024
2090
  return route;
2025
2091
  }
2092
+ /**
2093
+ * What one location write is, for solid's observe tier: the parametrized
2094
+ * route it heads to, the params, where it came from, and — when the write is
2095
+ * the router chasing a redirect while the previous target is still pending —
2096
+ * which hop of that navigation it is. The engine times the navigation from
2097
+ * this declaration (or from the user event enclosing it) until its writes
2098
+ * are through, and names holds and re-runs after it.
2099
+ *
2100
+ * `name` and `params` are getters: the engine reads them when the navigation
2101
+ * settles, not when it starts, so a lazy route subtree that loaded during the
2102
+ * hold names the exact route it resolved to rather than its placeholder.
2103
+ * Redirect depth comes from `_navigation` (1 = a navigation, n = its
2104
+ * (n - 1)th redirect hop, -1 = the browser moved: back/forward, hash).
2105
+ */
2106
+ function describeNavigation(match, next, written, from) {
2107
+ // A composed destination (`LocationWrite.value` a function) is only known
2108
+ // once the write has resolved it; the engine re-reads `to` at settle, as it
2109
+ // does `name` and `params`. A plain one is known up front.
2110
+ const to = () => {
2111
+ const w = written();
2112
+ return w ? w.value : typeof next.value === "string" ? next.value : undefined;
2113
+ };
2114
+ const pathname = () => {
2115
+ const t = to();
2116
+ return t === undefined ? undefined : new URL(t, mockBase).pathname;
2117
+ };
2118
+ const matches = pathname => untrack(() => match(pathname));
2119
+ const ref = {
2120
+ kind: "navigation",
2121
+ get to() {
2122
+ return to();
2123
+ },
2124
+ from,
2125
+ get name() {
2126
+ const p = pathname();
2127
+ if (p === undefined) return undefined;
2128
+ const m = matches(p);
2129
+ return m.length ? m[m.length - 1].route.pattern || "/" : p;
2130
+ },
2131
+ get params() {
2132
+ const p = pathname();
2133
+ const m = p === undefined ? [] : matches(p);
2134
+ return m.length ? mergeParams(m) : undefined;
2135
+ }
2136
+ };
2137
+ if (next._navigation !== undefined && next._navigation > 1) ref.redirect = next._navigation - 1;
2138
+ return ref;
2139
+ }
2140
+
2026
2141
  /** Wraps a history adapter in the integration signal the router core consumes. Must run under a reactive owner. */
2027
- function createIntegration(history) {
2142
+ function createIntegration(history, match) {
2028
2143
  let committing = false;
2144
+ // Written, not yet in history (see `RouterIntegration.inflight`).
2145
+ let inflight;
2029
2146
  const wrap = value => typeof value === "string" ? {
2030
2147
  value
2031
2148
  } : value;
2032
2149
  const [read, write] = createSignal(wrap(history.get()), {
2033
2150
  equals: (a, b) => a.value === b.value && a.state === b.state && a._navigation === b._navigation,
2034
- ownedWrite: true
2151
+ ownedWrite: true,
2152
+ name: "location"
2035
2153
  });
2036
2154
  const signal = [read, next => {
2037
2155
  if (sharedConfig.registry && !sharedConfig.done) sharedConfig.done = true;
2038
- write(next);
2039
- if (next._navigation && next._navigation > 0) {
2040
- // Register out of band so a destination error boundary replacing the
2041
- // Router subtree cannot suppress the winning history commit.
2042
- runWithOwner(null, () => onSettled(() => {
2043
- if (read() !== next) return;
2044
- committing = true;
2045
- try {
2046
- history.set(next);
2047
- } finally {
2048
- committing = false;
2049
- }
2050
- }));
2051
- }
2156
+ // What the write resolved to, or undefined when there was nothing to
2157
+ // write (see `resolveLocationWrite`).
2158
+ let written;
2159
+ const commit = () => {
2160
+ // The functional updater is the one channel that serves an unflushed
2161
+ // write of this same tick (A28): the destination composes on it and
2162
+ // the no-op rule compares against it, so `navigate()` behind another
2163
+ // write in one handler sees that write rather than the flushed world.
2164
+ write(headed => (written = resolveLocationWrite(headed, next)) || headed);
2165
+ if (written && written._navigation && written._navigation > 0) {
2166
+ const next = written;
2167
+ inflight = next;
2168
+ // Register out of band so a destination error boundary replacing the
2169
+ // Router subtree cannot suppress the winning history commit.
2170
+ runWithOwner(null, () => onSettled(() => {
2171
+ if (inflight === next) inflight = undefined;
2172
+ if (read() !== next) return;
2173
+ committing = true;
2174
+ try {
2175
+ history.set(next);
2176
+ } finally {
2177
+ committing = false;
2178
+ }
2179
+ }));
2180
+ }
2181
+ };
2182
+ // Every client location write passes here — navigate(), a redirect hop,
2183
+ // the browser's own back/forward — so this is the one place the
2184
+ // navigation is declared. `read()` still holds the committed location
2185
+ // while a navigation is pending, which is the `from` a hop wants too.
2186
+ // A write the no-op rule drops is still declared: the engine settles a
2187
+ // navigation whose write "did not survive the equality gate" on the spot.
2188
+ OBSERVE ? OBSERVE.attribution.withOrigin(describeNavigation(match, next, () => written, untrack(read).value), commit) : commit();
2052
2189
  }];
2053
2190
  history.init && onCleanup(history.init((value = history.get()) => {
2054
2191
  if (committing) return;
@@ -2059,6 +2196,7 @@ function createIntegration(history) {
2059
2196
  }));
2060
2197
  return {
2061
2198
  signal,
2199
+ inflight: () => inflight,
2062
2200
  utils: history.utils
2063
2201
  };
2064
2202
  }
@@ -2083,7 +2221,10 @@ function staticIntegration(url, utils) {
2083
2221
  value
2084
2222
  };
2085
2223
  return {
2086
- signal: [() => obj, next => Object.assign(obj, next)],
2224
+ signal: [() => obj, next => {
2225
+ const written = resolveLocationWrite(obj, next);
2226
+ written && Object.assign(obj, written);
2227
+ }],
2087
2228
  utils
2088
2229
  };
2089
2230
  }
@@ -2104,6 +2245,7 @@ function createRouter(config) {
2104
2245
  return compiled;
2105
2246
  };
2106
2247
  const renderPath = config.history && config.history.utils && config.history.utils.renderPath || undefined;
2248
+ const matchPath = pathname => getRouteMatches(branches(), config.transformUrl ? config.transformUrl(pathname) : pathname);
2107
2249
  function RouterComponent(props) {
2108
2250
  // One router per app: the session (location, history, delegation, link
2109
2251
  // claims, preloading) has a single owner, and a second instance would
@@ -2120,7 +2262,7 @@ function createRouter(config) {
2120
2262
  restoration = createScrollRestoration();
2121
2263
  history = withScrollRestoration(history || browserHistory(), restoration);
2122
2264
  }
2123
- const integration = isServer ? staticIntegration(props.url, config.history && config.history.utils) : createIntegration(history || browserHistory());
2265
+ const integration = isServer ? staticIntegration(props.url, config.history && config.history.utils) : createIntegration(history || browserHistory(), matchPath);
2124
2266
  let context;
2125
2267
  const routerState = createRouterContext(integration, branches, () => context, {
2126
2268
  base: basePath,
@@ -2161,9 +2303,7 @@ function createRouter(config) {
2161
2303
  routes: config.routes,
2162
2304
  config,
2163
2305
  match(url) {
2164
- const u = new URL(url, mockBase);
2165
- const pathname = config.transformUrl ? config.transformUrl(u.pathname) : u.pathname;
2166
- return getRouteMatches(branches(), pathname).map(({
2306
+ return matchPath(new URL(url, mockBase).pathname).map(({
2167
2307
  route,
2168
2308
  path,
2169
2309
  params
@@ -2347,7 +2487,15 @@ function query(fn, name) {
2347
2487
  }
2348
2488
  const cachedFn = (...args) => {
2349
2489
  const cache = getCache();
2350
- const intent = getIntent();
2490
+ // The intent is a fact about the moment of this read — which navigation
2491
+ // (if any) is asking — not a dependency of the value: `intent()` probes
2492
+ // `isPending`/`latest` of the router's location, and tracked here every
2493
+ // consumer memo would re-run as each navigation starts and lands. Under a
2494
+ // redirect that leaves a route section, the leaving section's memo is a
2495
+ // zombie the transaction is removing; dirtied through the probe's lane
2496
+ // channel it runs anyway (solid #3444), sees the entry the redirect just
2497
+ // invalidated, and fires the phantom refetch the sweep exists to avoid.
2498
+ const intent = untrack(getIntent);
2351
2499
  const inPreloadFn = getInPreloadFn();
2352
2500
  const owner = getOwner();
2353
2501
  const router = owner ? useRouter() : undefined;
@@ -2474,9 +2622,13 @@ function query(fn, name) {
2474
2622
  const url = v.headers.get(LocationHeader);
2475
2623
  if (url !== null) {
2476
2624
  // invalidate the redirect's revalidation keys before navigating so
2477
- // the destination's preloads see the miss and fetch fresh (#580 thread)
2478
- const keys = !isServer && v.headers.get(REVALIDATE_HEADER)?.split(",");
2479
- keys && cacheKeyOp(keys, entry => entry[0] = 0);
2625
+ // the destination's preloads see the miss and fetch fresh (#580 thread).
2626
+ // A read that redirects declares nothing by default (unlike an
2627
+ // action), so only a header present here sweeps: named keys their
2628
+ // matches, `*` everything.
2629
+ const declared = !isServer ? v.headers.get(REVALIDATE_HEADER) : null;
2630
+ const keys = declared !== null ? readRevalidateKeys(declared) : undefined;
2631
+ declared !== null && cacheKeyOp(keys, entry => entry[0] = 0);
2480
2632
 
2481
2633
  // client + server relative redirect
2482
2634
  const soft = navigate && url.startsWith("/");
@@ -2487,7 +2639,7 @@ function query(fn, name) {
2487
2639
  // sweep the live signals inside the same transition as the
2488
2640
  // navigation: surviving consumers (shared layouts) refetch and
2489
2641
  // hold the commit, so the destination never paints stale data
2490
- keys && revalidate(keys, false);
2642
+ declared !== null && revalidate(keys, false);
2491
2643
 
2492
2644
  // Hold the read pending on the client: the navigation unmounts this
2493
2645
  // consumer, and resolving `undefined` instead hands a missing value
@@ -2542,6 +2694,27 @@ query.set = (key, value) => {
2542
2694
  };
2543
2695
  query.delete = key => getCache().delete(key);
2544
2696
  query.clear = () => getCache().clear();
2697
+
2698
+ /**
2699
+ * The reserved `X-Revalidate` key meaning every entry — `revalidate: "*"`
2700
+ * on `@solidjs/web`'s response helpers (`REVALIDATE_ALL` there). The
2701
+ * host-independent spelling of "all", for hosts whose default is narrower
2702
+ * than this router's.
2703
+ */
2704
+ const REVALIDATE_ALL = "*";
2705
+
2706
+ /**
2707
+ * Reads an `X-Revalidate` header value into the keys the cache ops take.
2708
+ * Named keys are prefix-matched; an empty declaration (`revalidate: []`)
2709
+ * yields `[""]`, which `matchKey` matches to nothing; the reserved `*`
2710
+ * yields `undefined` — this router's own spelling of everything, the same
2711
+ * scope an action with no declaration gets, so an author who wrote "all"
2712
+ * explicitly lands on the same path as the default.
2713
+ */
2714
+ function readRevalidateKeys(value) {
2715
+ const keys = value.split(",");
2716
+ return keys.includes(REVALIDATE_ALL) ? undefined : keys;
2717
+ }
2545
2718
  function matchKey(key, keys) {
2546
2719
  for (let k of keys) {
2547
2720
  if (k && key.startsWith(k)) return true;
@@ -2629,7 +2802,7 @@ function handleFormAction(evt, router, actionBase) {
2629
2802
  * later real registration overrides it).
2630
2803
  */
2631
2804
  function createServerFormAction(url) {
2632
- const id = parseServerFunctionUrl(url);
2805
+ const id = parseServerFunctionActionUrl(url);
2633
2806
  if (!id) return undefined;
2634
2807
  // typecheck resolves the server half of the dual module; this path only
2635
2808
  // runs in the browser, where the client transport's signature applies
@@ -2888,7 +3061,7 @@ function setupFlightDataConsumer(router) {
2888
3061
  function applyResponseMetadata(metadata, navigate, flightData) {
2889
3062
  let keys;
2890
3063
  if (metadata) {
2891
- if (metadata.headers.has(REVALIDATE_HEADER)) keys = metadata.headers.get(REVALIDATE_HEADER).split(",");
3064
+ if (metadata.headers.has(REVALIDATE_HEADER)) keys = readRevalidateKeys(metadata.headers.get(REVALIDATE_HEADER));
2892
3065
  // The carrier delivers the target RESOLVED to an absolute url
2893
3066
  // (solidjs/solid#3102), so the soft/hard split is a real origin
2894
3067
  // comparison — never a guess from how the author spelled the target,
@@ -1,13 +1,14 @@
1
1
  /*@refresh skip*/
2
2
  import { createSignal, getOwner, onCleanup, onSettled, runWithOwner, sharedConfig, untrack } from "solid-js";
3
- // standalone import: `DEV` is undefined in solid's production build, so app
4
- // bundlers fold `DEV &&` diagnostics out of shipped bundles
5
- import { DEV } from "solid-js";
3
+ // standalone imports: `DEV` is undefined in solid's production build and
4
+ // `OBSERVE` outside its observe/dev builds, so app bundlers fold the
5
+ // `DEV &&` diagnostics and the `OBSERVE &&` attribution out of shipped bundles
6
+ import { DEV, OBSERVE } from "solid-js";
6
7
  import { getRequestEvent, isServer } from "@solidjs/web";
7
8
  import { setupLinkClaims } from "../claims.js";
8
9
  import { setupNativeEvents } from "../data/events.js";
9
10
  import { createPathsProxy } from "../paths.js";
10
- import { createBranches, createRouterContext, getRouteMatches, registerFlightRouter, RouterContextObj, trackLazySubtrees, useOptionalContext } from "../routing.js";
11
+ import { createBranches, createRouterContext, getRouteMatches, mergeParams, registerFlightRouter, resolveLocationWrite, RouterContextObj, trackLazySubtrees, useOptionalContext } from "../routing.js";
11
12
  import { mockBase } from "../utils.js";
12
13
  import { Root, Routes } from "./components.jsx";
13
14
  import { browserHistory } from "./history.js";
@@ -25,35 +26,110 @@ export function defineRoutes(routes) {
25
26
  export function defineRoute(route) {
26
27
  return route;
27
28
  }
29
+ /**
30
+ * What one location write is, for solid's observe tier: the parametrized
31
+ * route it heads to, the params, where it came from, and — when the write is
32
+ * the router chasing a redirect while the previous target is still pending —
33
+ * which hop of that navigation it is. The engine times the navigation from
34
+ * this declaration (or from the user event enclosing it) until its writes
35
+ * are through, and names holds and re-runs after it.
36
+ *
37
+ * `name` and `params` are getters: the engine reads them when the navigation
38
+ * settles, not when it starts, so a lazy route subtree that loaded during the
39
+ * hold names the exact route it resolved to rather than its placeholder.
40
+ * Redirect depth comes from `_navigation` (1 = a navigation, n = its
41
+ * (n - 1)th redirect hop, -1 = the browser moved: back/forward, hash).
42
+ */
43
+ function describeNavigation(match, next, written, from) {
44
+ // A composed destination (`LocationWrite.value` a function) is only known
45
+ // once the write has resolved it; the engine re-reads `to` at settle, as it
46
+ // does `name` and `params`. A plain one is known up front.
47
+ const to = () => {
48
+ const w = written();
49
+ return w ? w.value : typeof next.value === "string" ? next.value : undefined;
50
+ };
51
+ const pathname = () => {
52
+ const t = to();
53
+ return t === undefined ? undefined : new URL(t, mockBase).pathname;
54
+ };
55
+ const matches = (pathname) => untrack(() => match(pathname));
56
+ const ref = {
57
+ kind: "navigation",
58
+ get to() {
59
+ return to();
60
+ },
61
+ from,
62
+ get name() {
63
+ const p = pathname();
64
+ if (p === undefined)
65
+ return undefined;
66
+ const m = matches(p);
67
+ return m.length ? m[m.length - 1].route.pattern || "/" : p;
68
+ },
69
+ get params() {
70
+ const p = pathname();
71
+ const m = p === undefined ? [] : matches(p);
72
+ return m.length ? mergeParams(m) : undefined;
73
+ }
74
+ };
75
+ if (next._navigation !== undefined && next._navigation > 1)
76
+ ref.redirect = next._navigation - 1;
77
+ return ref;
78
+ }
28
79
  /** Wraps a history adapter in the integration signal the router core consumes. Must run under a reactive owner. */
29
- function createIntegration(history) {
80
+ function createIntegration(history, match) {
30
81
  let committing = false;
82
+ // Written, not yet in history (see `RouterIntegration.inflight`).
83
+ let inflight;
31
84
  const wrap = (value) => (typeof value === "string" ? { value } : value);
32
85
  const [read, write] = createSignal(wrap(history.get()), {
33
86
  equals: (a, b) => a.value === b.value && a.state === b.state && a._navigation === b._navigation,
34
- ownedWrite: true
87
+ ownedWrite: true,
88
+ name: "location"
35
89
  });
36
90
  const signal = [
37
91
  read,
38
92
  (next) => {
39
93
  if (sharedConfig.registry && !sharedConfig.done)
40
94
  sharedConfig.done = true;
41
- write(next);
42
- if (next._navigation && next._navigation > 0) {
43
- // Register out of band so a destination error boundary replacing the
44
- // Router subtree cannot suppress the winning history commit.
45
- runWithOwner(null, () => onSettled(() => {
46
- if (read() !== next)
47
- return;
48
- committing = true;
49
- try {
50
- history.set(next);
51
- }
52
- finally {
53
- committing = false;
54
- }
55
- }));
56
- }
95
+ // What the write resolved to, or undefined when there was nothing to
96
+ // write (see `resolveLocationWrite`).
97
+ let written;
98
+ const commit = () => {
99
+ // The functional updater is the one channel that serves an unflushed
100
+ // write of this same tick (A28): the destination composes on it and
101
+ // the no-op rule compares against it, so `navigate()` behind another
102
+ // write in one handler sees that write rather than the flushed world.
103
+ write(headed => (written = resolveLocationWrite(headed, next)) || headed);
104
+ if (written && written._navigation && written._navigation > 0) {
105
+ const next = written;
106
+ inflight = next;
107
+ // Register out of band so a destination error boundary replacing the
108
+ // Router subtree cannot suppress the winning history commit.
109
+ runWithOwner(null, () => onSettled(() => {
110
+ if (inflight === next)
111
+ inflight = undefined;
112
+ if (read() !== next)
113
+ return;
114
+ committing = true;
115
+ try {
116
+ history.set(next);
117
+ }
118
+ finally {
119
+ committing = false;
120
+ }
121
+ }));
122
+ }
123
+ };
124
+ // Every client location write passes here — navigate(), a redirect hop,
125
+ // the browser's own back/forward — so this is the one place the
126
+ // navigation is declared. `read()` still holds the committed location
127
+ // while a navigation is pending, which is the `from` a hop wants too.
128
+ // A write the no-op rule drops is still declared: the engine settles a
129
+ // navigation whose write "did not survive the equality gate" on the spot.
130
+ OBSERVE
131
+ ? OBSERVE.attribution.withOrigin(describeNavigation(match, next, () => written, untrack(read).value), commit)
132
+ : commit();
57
133
  }
58
134
  ];
59
135
  history.init &&
@@ -62,7 +138,7 @@ function createIntegration(history) {
62
138
  return;
63
139
  signal[1]({ ...wrap(value), _navigation: -1 });
64
140
  }));
65
- return { signal, utils: history.utils };
141
+ return { signal, inflight: () => inflight, utils: history.utils };
66
142
  }
67
143
  /**
68
144
  * Server default: a static view of the request URL — no signal machinery, a
@@ -81,7 +157,16 @@ function staticIntegration(url, utils) {
81
157
  value = u.pathname + u.search;
82
158
  }
83
159
  const obj = { value };
84
- return { signal: [() => obj, next => Object.assign(obj, next)], utils };
160
+ return {
161
+ signal: [
162
+ () => obj,
163
+ next => {
164
+ const written = resolveLocationWrite(obj, next);
165
+ written && Object.assign(obj, written);
166
+ }
167
+ ],
168
+ utils
169
+ };
85
170
  }
86
171
  export function createRouter(config) {
87
172
  const basePath = config.base || "";
@@ -100,6 +185,7 @@ export function createRouter(config) {
100
185
  return compiled;
101
186
  };
102
187
  const renderPath = (config.history && config.history.utils && config.history.utils.renderPath) || undefined;
188
+ const matchPath = (pathname) => getRouteMatches(branches(), config.transformUrl ? config.transformUrl(pathname) : pathname);
103
189
  function RouterComponent(props) {
104
190
  // One router per app: the session (location, history, delegation, link
105
191
  // claims, preloading) has a single owner, and a second instance would
@@ -119,7 +205,7 @@ export function createRouter(config) {
119
205
  }
120
206
  const integration = isServer
121
207
  ? staticIntegration(props.url, config.history && config.history.utils)
122
- : createIntegration(history || browserHistory());
208
+ : createIntegration(history || browserHistory(), matchPath);
123
209
  let context;
124
210
  const routerState = createRouterContext(integration, branches, () => context, {
125
211
  base: basePath,
@@ -149,9 +235,7 @@ export function createRouter(config) {
149
235
  routes: config.routes,
150
236
  config,
151
237
  match(url) {
152
- const u = new URL(url, mockBase);
153
- const pathname = config.transformUrl ? config.transformUrl(u.pathname) : u.pathname;
154
- return getRouteMatches(branches(), pathname).map(({ route, path, params }) => ({
238
+ return matchPath(new URL(url, mockBase).pathname).map(({ route, path, params }) => ({
155
239
  path: route.originalPath,
156
240
  pattern: route.pattern,
157
241
  match: path,
package/dist/routing.d.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  import type { JSX } from "@solidjs/web";
2
- import type { Branch, Intent, LazyBoundary, Location, MatchFilters, NavigateOptions, Navigator, Params, PathMatch, RouteDescription, RouteContext, RouteDefinition, RouteMatch, RouteParams, RouterContext, RouterIntegration, SearchParams, SetSearchParams, TypedPath, TypedSearchPath } from "./types.js";
2
+ import type { Branch, Intent, LazyBoundary, Location, LocationChange, LocationWrite, MatchFilters, NavigateOptions, Navigator, Params, PathMatch, RouteDescription, RouteContext, RouteDefinition, RouteMatch, RouteParams, RouterContext, RouterIntegration, SearchParams, SetSearchParams, TypedPath, TypedSearchPath } from "./types.js";
3
3
  import type { FlashSubmission } from "@solidjs/web/server-functions/server";
4
+ /**
5
+ * Resolves a write against `headed`, the location the router is heading to
6
+ * — the integration's current value as its functional updater serves it, an
7
+ * unflushed write of this same tick included. `undefined` means nothing to
8
+ * write: the composer declined, or the router is already heading there (same
9
+ * `value` and `state`). The one place the no-op rule lives, for every
10
+ * integration (see `LocationWrite`).
11
+ */
12
+ export declare function resolveLocationWrite(headed: LocationChange, next: LocationWrite): LocationChange | undefined;
4
13
  /** Consider this API opaque and internal. It is likely to change in the future. */
5
14
  export declare const RouterContextObj: import("solid-js").Context<RouterContext>;
6
15
  export declare const RouteContextObj: import("solid-js").Context<RouteContext>;
package/dist/routing.js CHANGED
@@ -7,6 +7,20 @@ import { clearFlashCookie, getRequestEvent, hasFlashCookie, isServer } from "@so
7
7
  import { mockBase, comparablePath, createMemoObject, extractSearchParams, invariant, resolvePath, createMatcher, joinPaths, scoreRoute, mergeSearchString, expandOptionals } from "./utils.js";
8
8
  import { HREF } from "./paths.js";
9
9
  const MAX_REDIRECTS = 100;
10
+ /**
11
+ * Resolves a write against `headed`, the location the router is heading to
12
+ * — the integration's current value as its functional updater serves it, an
13
+ * unflushed write of this same tick included. `undefined` means nothing to
14
+ * write: the composer declined, or the router is already heading there (same
15
+ * `value` and `state`). The one place the no-op rule lives, for every
16
+ * integration (see `LocationWrite`).
17
+ */
18
+ export function resolveLocationWrite(headed, next) {
19
+ const value = typeof next.value === "function" ? next.value(headed) : next.value;
20
+ if (value === undefined || (value === headed.value && next.state === headed.state))
21
+ return;
22
+ return { ...next, value };
23
+ }
10
24
  /** Consider this API opaque and internal. It is likely to change in the future. */
11
25
  export const RouterContextObj = createContext();
12
26
  export const RouteContextObj = createContext();
@@ -154,16 +168,16 @@ export function useSearchParams(path) {
154
168
  const location = router.location;
155
169
  const navigate = useNavigate();
156
170
  const setSearchParams = (params, options) => {
157
- const to = untrack(() => {
158
- // merge onto the in-flight navigation target (if any) so consecutive
159
- // synchronous calls compose instead of the later one winning
160
- const pending = router.pendingTarget && new URL(router.pendingTarget.value, mockBase);
161
- const pathname = pending ? pending.pathname : location.pathname;
162
- const search = pending ? pending.search : location.search;
163
- const hash = pending ? pending.hash : location.hash;
164
- return pathname + mergeSearchString(search, params) + hash;
165
- });
166
- navigate(to, {
171
+ // Merged onto the location the router is heading to, inside the write:
172
+ // a write is invisible to every read channel until its flush (A28), so
173
+ // an earlier write of the same tick — a second synchronous call, or a
174
+ // `navigate()` just before — is only seen by composing on the updater's
175
+ // `headed`. Consecutive calls both apply; a call during a pending
176
+ // navigation lands on that navigation's target.
177
+ navigate(headed => {
178
+ const url = new URL(headed.value[0] === "/" ? mockBase + headed.value : headed.value, mockBase);
179
+ return url.pathname + mergeSearchString(url.search, params) + url.hash;
180
+ }, {
167
181
  scroll: false,
168
182
  resolve: false,
169
183
  ...options
@@ -647,7 +661,7 @@ export function createRouterContext(integration, branches, getContext, options =
647
661
  read = runWithOwner(routerOwner, () => createMemo(() => {
648
662
  const result = resolveLazySubtree(record);
649
663
  return result instanceof Promise ? result.then(() => undefined) : undefined;
650
- }));
664
+ }, { name: "lazyRoutes" }));
651
665
  lazyReaders.set(record, read);
652
666
  }
653
667
  return read();
@@ -682,7 +696,7 @@ export function createRouterContext(integration, branches, getContext, options =
682
696
  }
683
697
  }
684
698
  return m;
685
- });
699
+ }, { name: "matches" });
686
700
  const routingPending = createMemo(() => isPending(() => {
687
701
  try {
688
702
  matches();
@@ -693,7 +707,7 @@ export function createRouterContext(integration, branches, getContext, options =
693
707
  }
694
708
  location.search;
695
709
  location.hash;
696
- }));
710
+ }), { name: "routingPending" });
697
711
  const isRouting = () => routingPending() || isPending(source);
698
712
  const transitionIntent = () => {
699
713
  if (!isPending(source))
@@ -757,76 +771,113 @@ export function createRouterContext(integration, branches, getContext, options =
757
771
  }
758
772
  return;
759
773
  }
760
- // A paths node carries its logical path under the Href brand — read
761
- // that rather than coercing: toString() renders the *display* href
762
- // (eg. hash mode's `#` prefix), which is for the DOM, not for routing.
763
- // Foreign Href-branded values without the slot still coerce.
764
- if (typeof to !== "string")
765
- to = to[HREF] || to.toString();
766
- // Display hrefs can still arrive as plain strings: terminating paths
767
- // calls type as `string`, and redirect Location headers round-trip
768
- // through here. Under hash mode those start with `#` — a spelling no
769
- // logical path uses — so map them back through the integration's
770
- // parser, exactly like the anchor click handler does. Elsewhere
771
- // parsePath is identity and `#...` keeps its URL meaning below.
772
- if (to[0] === "#")
773
- to = parsePath(to);
774
774
  const { replace, resolve, scroll, state: nextState } = {
775
775
  replace: false,
776
776
  resolve: true,
777
777
  scroll: true,
778
778
  ...options
779
779
  };
780
- // A string means what the same string means as an href. Leading "/"
781
- // stays base-prefixed; anything else resolves URL-style against the
782
- // current location — `new URL` collapses `..` and handles `?`/`#`-only
783
- // references natively (#502). A cross-origin result (scheme or
784
- // protocol-relative input) falls through as unroutable.
785
- let resolvedTo;
786
- if (!resolve)
787
- resolvedTo = resolvePath(((!to || to[0] === "?") && location.pathname) || "", to);
788
- else if (to[0] === "/")
789
- resolvedTo = route.resolvePath(to);
790
- else {
780
+ const resolveTarget = (to) => {
781
+ // Display hrefs can still arrive as plain strings: terminating paths
782
+ // calls type as `string`, and redirect Location headers round-trip
783
+ // through here. Under hash mode those start with `#` — a spelling no
784
+ // logical path uses — so map them back through the integration's
785
+ // parser, exactly like the anchor click handler does. Elsewhere
786
+ // parsePath is identity and `#...` keeps its URL meaning below.
787
+ if (to[0] === "#")
788
+ to = parsePath(to);
789
+ // A string means what the same string means as an href. Leading "/"
790
+ // stays base-prefixed; anything else resolves URL-style against the
791
+ // current location — `new URL` collapses `..` and handles `?`/`#`-only
792
+ // references natively (#502). A cross-origin result (scheme or
793
+ // protocol-relative input) falls through as unroutable.
794
+ if (!resolve)
795
+ return resolvePath(((!to || to[0] === "?") && location.pathname) || "", to);
796
+ if (to[0] === "/")
797
+ return route.resolvePath(to);
791
798
  const url = new URL(to, mockBase + location.pathname + location.search + location.hash);
792
- resolvedTo =
793
- url.origin === mockBase ? url.pathname + url.search + url.hash : undefined;
799
+ return url.origin === mockBase ? url.pathname + url.search + url.hash : undefined;
800
+ };
801
+ const unroutable = (to) => new Error(`Path '${to}' is not a routable path`);
802
+ // The flushed world: the pending navigation when one is held, else the
803
+ // committed location. Hop depth, the history policy a hop inherits and
804
+ // the destination the leave guard is told all read it. A write of this
805
+ // same tick is not in it (A28) — see `compose` below.
806
+ //
807
+ // A redirect hop: the previous navigation is still pending, or has landed
808
+ // but not yet reached history (a guard redirecting in the landing flush
809
+ // — its destination was never shown either way).
810
+ const headed = latest(source);
811
+ // A composed target (`setSearchParams`) is a function of where the
812
+ // router is heading, an unflushed write of this tick included. Only
813
+ // the integration's functional updater serves that value, so the
814
+ // destination is resolved inside the write; `resolvedTo` here is the
815
+ // flushed world's reading of it — what the leave guard is asked about,
816
+ // and what the server (which never has an unflushed write) redirects to.
817
+ let compose;
818
+ let resolvedTo;
819
+ // A paths node carries its logical path under the Href brand — read
820
+ // that rather than coercing: toString() renders the *display* href
821
+ // (eg. hash mode's `#` prefix), which is for the DOM, not for routing.
822
+ // Foreign Href-branded values without the slot still coerce. The node
823
+ // is callable, so the brand is checked ahead of the composer form.
824
+ const href = typeof to !== "string" ? to[HREF] : undefined;
825
+ if (href === undefined && typeof to === "function") {
826
+ const build = to;
827
+ compose = headed => {
828
+ const raw = build(headed);
829
+ const resolved = resolveTarget(raw);
830
+ if (resolved === undefined)
831
+ throw unroutable(raw);
832
+ return resolved;
833
+ };
834
+ resolvedTo = compose(headed);
794
835
  }
795
- if (resolvedTo === undefined) {
796
- throw new Error(`Path '${to}' is not a routable path`);
836
+ else {
837
+ if (typeof to !== "string")
838
+ to = href || to.toString();
839
+ resolvedTo = resolveTarget(to);
840
+ if (resolvedTo === undefined)
841
+ throw unroutable(to);
797
842
  }
798
- const headed = latest(source);
799
843
  const navigationDepth = !isServer &&
800
- isPending(source) &&
801
844
  headed._navigation !== undefined &&
802
- headed._navigation > 0
845
+ headed._navigation > 0 &&
846
+ (isPending(source) || integration.inflight?.() === headed)
803
847
  ? headed._navigation
804
848
  : 0;
805
849
  if (navigationDepth >= MAX_REDIRECTS) {
806
850
  throw new Error("Too many redirects");
807
851
  }
808
- if (resolvedTo !== headed.value || nextState !== headed.state) {
809
- if (isServer) {
810
- const e = getRequestEvent();
811
- e && (e.response = { status: 302, headers: new Headers({ Location: resolvedTo }) });
812
- setSource({ value: resolvedTo, replace, scroll, state: nextState });
813
- }
814
- else if (!beforeLeave.current || beforeLeave.current.confirm(resolvedTo, options)) {
815
- runWithOwner(null, () => setSource({
816
- value: resolvedTo,
817
- state: nextState,
818
- replace: navigationDepth ? headed.replace : replace,
819
- scroll: navigationDepth ? headed.scroll : scroll,
820
- _navigation: navigationDepth + 1
821
- }));
822
- }
852
+ if (isServer) {
853
+ // A server render has no unflushed write: the static integration's
854
+ // value is the whole world, so the no-op rule is applied here, before
855
+ // the redirect is recorded on the response.
856
+ if (resolvedTo === headed.value && nextState === headed.state)
857
+ return;
858
+ const e = getRequestEvent();
859
+ e && (e.response = { status: 302, headers: new Headers({ Location: resolvedTo }) });
860
+ setSource({ value: resolvedTo, replace, scroll, state: nextState });
861
+ }
862
+ else if (!beforeLeave.current || beforeLeave.current.confirm(resolvedTo, options)) {
863
+ // Heading there already (same value and state) writes nothing — the
864
+ // integration decides that against the updater's `headed`, so a
865
+ // navigation issued behind another write of the same tick compares
866
+ // with that write, not with the flushed world.
867
+ runWithOwner(null, () => setSource({
868
+ value: compose || resolvedTo,
869
+ state: nextState,
870
+ replace: navigationDepth ? headed.replace : replace,
871
+ scroll: navigationDepth ? headed.scroll : scroll,
872
+ _navigation: navigationDepth + 1
873
+ }));
823
874
  }
824
875
  });
825
876
  }
826
877
  function navigatorFactory(route) {
827
878
  // Workaround for vite issue (https://github.com/vitejs/vite/issues/3803)
828
879
  route = route || useOptionalContext(RouteContextObj) || baseRoute;
829
- return (to, options) => navigateFromRoute(route, to, options);
880
+ return ((to, options) => navigateFromRoute(route, to, options));
830
881
  }
831
882
  function preloadRoute(url, preloadData) {
832
883
  const matches = getRouteMatches(branches(), url.pathname);
package/dist/types.d.ts CHANGED
@@ -100,8 +100,31 @@ export interface LocationChange<S = unknown> {
100
100
  /** @internal Positive for programmatic navigation depth; `-1` for native history. */
101
101
  _navigation?: number;
102
102
  }
103
+ /**
104
+ * What the router hands the integration to write. `value` may compose on
105
+ * `headed` — the location the router is heading to: the last write, whether
106
+ * or not a flush has carried it yet. A write is invisible to every read
107
+ * channel until its flush (solid's A28 rule: `latest()` answers the
108
+ * pre-write value, `isPending()` is false), so a second synchronous write
109
+ * that builds on the first — `setSearchParams` twice in one handler, or once
110
+ * behind a `navigate()` — composes through the writer's own channel, the
111
+ * functional updater, rather than reading the first back. `undefined` from
112
+ * the composer, or a destination the router is already heading to (same
113
+ * `value` and `state`), writes nothing.
114
+ */
115
+ export interface LocationWrite<S = unknown> extends Omit<LocationChange<S>, "value"> {
116
+ value: string | ((headed: LocationChange) => string | undefined);
117
+ }
103
118
  export interface RouterIntegration {
104
- signal: [get: () => LocationChange, set: (next: LocationChange) => void];
119
+ signal: [get: () => LocationChange, set: (next: LocationWrite) => void];
120
+ /**
121
+ * The navigation written but not yet committed to history — the window
122
+ * between its location write and the settle that pushes it. A destination
123
+ * in that window was never shown, so a `navigate()` issued inside it (a
124
+ * guard redirecting as the held route lands) is a hop of that navigation
125
+ * rather than a new one, exactly as one issued while it is still pending.
126
+ */
127
+ inflight?: () => LocationChange | undefined;
105
128
  utils?: Partial<RouterUtils>;
106
129
  }
107
130
  export type Intent = "initial" | "native" | "navigate" | "preload";
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "Ryan Turnquist"
7
7
  ],
8
8
  "license": "MIT",
9
- "version": "2.0.0-next.23",
9
+ "version": "2.0.0-next.25",
10
10
  "homepage": "https://github.com/solidjs/solid-router#readme",
11
11
  "repository": {
12
12
  "type": "git",
@@ -38,21 +38,21 @@
38
38
  "@rollup/plugin-node-resolve": "15.3.0",
39
39
  "@rollup/plugin-terser": "0.4.4",
40
40
  "@solidjs/vite-plugin": "3.0.0-next.35",
41
- "@solidjs/web": "^2.0.0-rc.7",
41
+ "@solidjs/web": "^2.0.0-rc.9",
42
42
  "@types/jest": "^29.5.14",
43
43
  "@types/node": "^22.10.0",
44
44
  "babel-preset-solid": "^2.0.0-rc.2",
45
45
  "jsdom": "^25.0.1",
46
46
  "prettier": "^3.4.1",
47
47
  "rollup": "^4.27.4",
48
- "solid-js": "^2.0.0-rc.7",
48
+ "solid-js": "^2.0.0-rc.9",
49
49
  "typescript": "^5.7.2",
50
50
  "vite": "^8.2.2",
51
51
  "vitest": "^4.1.11"
52
52
  },
53
53
  "peerDependencies": {
54
- "@solidjs/web": "^2.0.0-rc.7",
55
- "solid-js": "^2.0.0-rc.7"
54
+ "@solidjs/web": "^2.0.0-rc.9",
55
+ "solid-js": "^2.0.0-rc.9"
56
56
  },
57
57
  "scripts": {
58
58
  "build": "rm -rf dist && tsc && rollup -c",