@solidjs/router 2.0.0-next.24 → 2.0.0-next.26

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
@@ -519,6 +519,8 @@ getUser.keyFor(5); // "users[5]"
519
519
 
520
520
  Revalidate with the `revalidate` export or by setting `revalidate` keys on action responses — the whole key invalidates every entry for the query, `keyFor` invalidates one.
521
521
 
522
+ A query may also redirect — a guard read that throws or returns `redirect()` (from `@solidjs/web`) navigates instead of resolving: same-origin targets navigate softly with `replace`, other origins leave the document, any `revalidate` keys on the response invalidate first, and the read itself stays pending so nothing renders the redirect as data. This holds for `"use server"` queries too, where the transport carries the redirect to the client rather than letting `fetch` follow it.
523
+
522
524
  ### `liveQuery` (experimental)
523
525
 
524
526
  `query`'s live sibling: a keyed query over a value-shaped stream. The function is an async iterable (typically an async generator server function) whose yields are successive **values of one logical query** — each yield is the current state, not an event — with the contract that it re-yields current state on every invocation:
@@ -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,15 +1,20 @@
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
- // the late-bound RPC seam (getServerFunctionRPC). The server-functions
5
- // entry itself — the fetch transport + the seroval codec behind it — is
6
- // deliberately NOT imported here: query() is in every router app's eager
7
- // graph, and a static import made every zero-server-function app ship
4
+ // the late-bound RPC seam (getServerFunctionRPC). The transport itself —
5
+ // the fetch RPC client + the seroval codec behind it — is deliberately NOT
6
+ // imported here: query() is in every router app's eager graph, and a static
7
+ // import of `decodeResponse` made every zero-server-function app ship
8
8
  // ~9 KB gz of codec it could never invoke. The transport registers itself
9
9
  // into the seam when a `'use server'` reference is created (compiled
10
10
  // output, module scope), so by the time a server function can reach
11
11
  // query() the seam is filled; plain-fetch apps read undefined forever.
12
12
  import { getRequestEvent, getServerFunctionMetadata, getServerFunctionRPC, isResponseEnvelope, isServer, isServerFunction, REVALIDATE_HEADER } from "@solidjs/web";
13
+ // The redirect carrier's name and decoder are the exception: two pure,
14
+ // dependency-free bindings off a `sideEffects: false` entry, so they
15
+ // tree-shake to a few hundred bytes without dragging the codec in (the same
16
+ // bindings action.ts already imports statically).
17
+ import { decodeRedirectHeaderValue, REDIRECT_HEADER } from "@solidjs/web/server-functions";
13
18
  import { useRouter, getIntent, getInPreloadFn } from "../routing.js";
14
19
  const LocationHeader = "Location";
15
20
  const PRELOAD_TIMEOUT = 5000;
@@ -62,7 +67,12 @@ export function revalidate(key, force = true) {
62
67
  const now = Date.now();
63
68
  cacheKeyOp(key, entry => {
64
69
  force && (entry[0] = 0); //force cache miss
65
- entry[4][1](now); // retrigger live signals
70
+ // retrigger live signals. The version is the entry's fetch stamp, and
71
+ // a signal write of an equal value is a no-op — an entry fetched within
72
+ // this same millisecond (a mount whose redirect lands before the clock
73
+ // ticks) would otherwise never be told to refetch, and the surviving
74
+ // consumer paints stale. A sweep must notify unconditionally.
75
+ entry[4][1](v => (v === now ? now + 1 : now));
66
76
  });
67
77
  const keys = key === undefined ? undefined : Array.isArray(key) ? key : [key];
68
78
  for (const hook of revalidateHooks)
@@ -95,7 +105,15 @@ export function query(fn, name) {
95
105
  }
96
106
  const cachedFn = ((...args) => {
97
107
  const cache = getCache();
98
- const intent = getIntent();
108
+ // The intent is a fact about the moment of this read — which navigation
109
+ // (if any) is asking — not a dependency of the value: `intent()` probes
110
+ // `isPending`/`latest` of the router's location, and tracked here every
111
+ // consumer memo would re-run as each navigation starts and lands. Under a
112
+ // redirect that leaves a route section, the leaving section's memo is a
113
+ // zombie the transaction is removing; dirtied through the probe's lane
114
+ // channel it runs anyway (solid #3444), sees the entry the redirect just
115
+ // invalidated, and fires the phantom refetch the sweep exists to avoid.
116
+ const intent = untrack(getIntent);
99
117
  const inPreloadFn = getInPreloadFn();
100
118
  const owner = getOwner();
101
119
  const router = owner ? useRouter() : undefined;
@@ -189,7 +207,13 @@ export function query(fn, name) {
189
207
  !isServer && intent === "navigate" && cached[4][1](cached[0]); // update version
190
208
  }
191
209
  else {
192
- cache.set(key, (cached = [stamp, res, , intent, createSignal(stamp, { ownedWrite: true })]));
210
+ cache.set(key, (cached = [
211
+ stamp,
212
+ res,
213
+ ,
214
+ intent,
215
+ createSignal(stamp, { ownedWrite: true })
216
+ ]));
193
217
  cached[4].count = 0;
194
218
  }
195
219
  if (tracking) {
@@ -237,12 +261,36 @@ export function query(fn, name) {
237
261
  e.response.headers.set(key, value);
238
262
  }
239
263
  }
240
- const url = v.headers.get(LocationHeader);
264
+ let url = v.headers.get(LocationHeader);
265
+ // A `"use server"` redirect reaches a client-side read masked: the
266
+ // transport answers scripted callers with a 200, drops `Location`
267
+ // and carries "<status> <absolute-url>" in REDIRECT_HEADER instead.
268
+ // Decode it with the runtime's own reader, as action() does. The
269
+ // carrier arrives RESOLVED to an absolute url, so same-origin vs
270
+ // cross-origin is a real origin comparison: same-origin folds to
271
+ // a path the soft branch below navigates under the router, any
272
+ // other origin keeps its href and the document goes with it. This
273
+ // stays synchronous on purpose — navigate runs in the same tick as
274
+ // the `Location` branch would, so the transition semantics match.
275
+ if (url === null && !isServer && v.headers.has(REDIRECT_HEADER)) {
276
+ const carried = decodeRedirectHeaderValue(v.headers.get(REDIRECT_HEADER));
277
+ if (carried) {
278
+ const target = new URL(carried.url);
279
+ url =
280
+ target.origin === window.location.origin
281
+ ? target.pathname + target.search + target.hash
282
+ : target.href;
283
+ }
284
+ }
241
285
  if (url !== null) {
242
286
  // 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));
287
+ // the destination's preloads see the miss and fetch fresh (#580 thread).
288
+ // A read that redirects declares nothing by default (unlike an
289
+ // action), so only a header present here sweeps: named keys their
290
+ // matches, `*` everything.
291
+ const declared = !isServer ? v.headers.get(REVALIDATE_HEADER) : null;
292
+ const keys = declared !== null ? readRevalidateKeys(declared) : undefined;
293
+ declared !== null && cacheKeyOp(keys, entry => (entry[0] = 0));
246
294
  // client + server relative redirect
247
295
  const soft = navigate && url.startsWith("/");
248
296
  if (soft)
@@ -254,7 +302,7 @@ export function query(fn, name) {
254
302
  // sweep the live signals inside the same transition as the
255
303
  // navigation: surviving consumers (shared layouts) refetch and
256
304
  // hold the commit, so the destination never paints stale data
257
- keys && revalidate(keys, false);
305
+ declared !== null && revalidate(keys, false);
258
306
  // Hold the read pending on the client: the navigation unmounts this
259
307
  // consumer, and resolving `undefined` instead hands a missing value
260
308
  // to whatever renders before the transition commits (a downstream
@@ -305,12 +353,37 @@ query.set = (key, value) => {
305
353
  cached[3] = "preload";
306
354
  }
307
355
  else {
308
- cache.set(key, (cached = [now, Promise.resolve(value), value, "preload", createSignal(now, { ownedWrite: true })]));
356
+ cache.set(key, (cached = [
357
+ now,
358
+ Promise.resolve(value),
359
+ value,
360
+ "preload",
361
+ createSignal(now, { ownedWrite: true })
362
+ ]));
309
363
  cached[4].count = 0;
310
364
  }
311
365
  };
312
366
  query.delete = (key) => getCache().delete(key);
313
367
  query.clear = () => getCache().clear();
368
+ /**
369
+ * The reserved `X-Revalidate` key meaning every entry — `revalidate: "*"`
370
+ * on `@solidjs/web`'s response helpers (`REVALIDATE_ALL` there). The
371
+ * host-independent spelling of "all", for hosts whose default is narrower
372
+ * than this router's.
373
+ */
374
+ const REVALIDATE_ALL = "*";
375
+ /**
376
+ * Reads an `X-Revalidate` header value into the keys the cache ops take.
377
+ * Named keys are prefix-matched; an empty declaration (`revalidate: []`)
378
+ * yields `[""]`, which `matchKey` matches to nothing; the reserved `*`
379
+ * yields `undefined` — this router's own spelling of everything, the same
380
+ * scope an action with no declaration gets, so an author who wrote "all"
381
+ * explicitly lands on the same path as the default.
382
+ */
383
+ export function readRevalidateKeys(value) {
384
+ const keys = value.split(",");
385
+ return keys.includes(REVALIDATE_ALL) ? undefined : keys;
386
+ }
314
387
  export function matchKey(key, keys) {
315
388
  for (let k of keys) {
316
389
  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
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 { REDIRECT_HEADER, decodeRedirectHeaderValue, subscribeFlightData, decodeResponsePayload, parseServerFunctionActionUrl, createServerReference } from '@solidjs/web/server-functions';
4
4
  import { decodeFlashCookie } from '@solidjs/web/server-functions/server';
5
5
 
6
6
  const hasSchemeRegex = /^(?:[a-z0-9]+:)?\/\//i;
@@ -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
@@ -1325,18 +1348,6 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1325
1348
  }
1326
1349
  return;
1327
1350
  }
1328
- // A paths node carries its logical path under the Href brand — read
1329
- // that rather than coercing: toString() renders the *display* href
1330
- // (eg. hash mode's `#` prefix), which is for the DOM, not for routing.
1331
- // Foreign Href-branded values without the slot still coerce.
1332
- if (typeof to !== "string") to = to[HREF] || to.toString();
1333
- // Display hrefs can still arrive as plain strings: terminating paths
1334
- // calls type as `string`, and redirect Location headers round-trip
1335
- // through here. Under hash mode those start with `#` — a spelling no
1336
- // logical path uses — so map them back through the integration's
1337
- // parser, exactly like the anchor click handler does. Elsewhere
1338
- // parsePath is identity and `#...` keeps its URL meaning below.
1339
- if (to[0] === "#") to = parsePath(to);
1340
1351
  const {
1341
1352
  replace,
1342
1353
  resolve,
@@ -1348,49 +1359,98 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1348
1359
  scroll: true,
1349
1360
  ...options
1350
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);
1351
1391
 
1352
- // A string means what the same string means as an href. Leading "/"
1353
- // stays base-prefixed; anything else resolves URL-style against the
1354
- // current location — `new URL` collapses `..` and handles `?`/`#`-only
1355
- // references natively (#502). A cross-origin result (scheme or
1356
- // 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;
1357
1399
  let resolvedTo;
1358
- if (!resolve) resolvedTo = resolvePath((!to || to[0] === "?") && location.pathname || "", to);else if (to[0] === "/") resolvedTo = route.resolvePath(to);else {
1359
- const url = new URL(to, mockBase + location.pathname + location.search + location.hash);
1360
- resolvedTo = url.origin === mockBase ? url.pathname + url.search + url.hash : undefined;
1361
- }
1362
- if (resolvedTo === undefined) {
1363
- 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);
1364
1419
  }
1365
- const headed = latest(source);
1366
- 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;
1367
1421
  if (navigationDepth >= MAX_REDIRECTS) {
1368
1422
  throw new Error("Too many redirects");
1369
1423
  }
1370
- if (resolvedTo !== headed.value || nextState !== headed.state) {
1371
- if (isServer) {
1372
- const e = getRequestEvent();
1373
- e && (e.response = {
1374
- status: 302,
1375
- headers: new Headers({
1376
- Location: resolvedTo
1377
- })
1378
- });
1379
- setSource({
1380
- value: resolvedTo,
1381
- replace,
1382
- scroll,
1383
- state: nextState
1384
- });
1385
- } else if (!beforeLeave.current || beforeLeave.current.confirm(resolvedTo, options)) {
1386
- runWithOwner(null, () => setSource({
1387
- value: resolvedTo,
1388
- state: nextState,
1389
- replace: navigationDepth ? headed.replace : replace,
1390
- scroll: navigationDepth ? headed.scroll : scroll,
1391
- _navigation: navigationDepth + 1
1392
- }));
1393
- }
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
+ }));
1394
1454
  }
1395
1455
  });
1396
1456
  }
@@ -2043,19 +2103,34 @@ function defineRoute(route) {
2043
2103
  * Redirect depth comes from `_navigation` (1 = a navigation, n = its
2044
2104
  * (n - 1)th redirect hop, -1 = the browser moved: back/forward, hash).
2045
2105
  */
2046
- function describeNavigation(match, next, from) {
2047
- const pathname = new URL(next.value, mockBase).pathname;
2048
- const matches = () => untrack(() => match(pathname));
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));
2049
2119
  const ref = {
2050
2120
  kind: "navigation",
2051
- to: next.value,
2121
+ get to() {
2122
+ return to();
2123
+ },
2052
2124
  from,
2053
2125
  get name() {
2054
- const m = matches();
2055
- return m.length ? m[m.length - 1].route.pattern || "/" : pathname;
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;
2056
2130
  },
2057
2131
  get params() {
2058
- const m = matches();
2132
+ const p = pathname();
2133
+ const m = p === undefined ? [] : matches(p);
2059
2134
  return m.length ? mergeParams(m) : undefined;
2060
2135
  }
2061
2136
  };
@@ -2066,6 +2141,8 @@ function describeNavigation(match, next, from) {
2066
2141
  /** Wraps a history adapter in the integration signal the router core consumes. Must run under a reactive owner. */
2067
2142
  function createIntegration(history, match) {
2068
2143
  let committing = false;
2144
+ // Written, not yet in history (see `RouterIntegration.inflight`).
2145
+ let inflight;
2069
2146
  const wrap = value => typeof value === "string" ? {
2070
2147
  value
2071
2148
  } : value;
@@ -2076,12 +2153,22 @@ function createIntegration(history, match) {
2076
2153
  });
2077
2154
  const signal = [read, next => {
2078
2155
  if (sharedConfig.registry && !sharedConfig.done) sharedConfig.done = true;
2156
+ // What the write resolved to, or undefined when there was nothing to
2157
+ // write (see `resolveLocationWrite`).
2158
+ let written;
2079
2159
  const commit = () => {
2080
- write(next);
2081
- if (next._navigation && next._navigation > 0) {
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;
2082
2168
  // Register out of band so a destination error boundary replacing the
2083
2169
  // Router subtree cannot suppress the winning history commit.
2084
2170
  runWithOwner(null, () => onSettled(() => {
2171
+ if (inflight === next) inflight = undefined;
2085
2172
  if (read() !== next) return;
2086
2173
  committing = true;
2087
2174
  try {
@@ -2096,7 +2183,9 @@ function createIntegration(history, match) {
2096
2183
  // the browser's own back/forward — so this is the one place the
2097
2184
  // navigation is declared. `read()` still holds the committed location
2098
2185
  // while a navigation is pending, which is the `from` a hop wants too.
2099
- OBSERVE ? OBSERVE.attribution.withOrigin(describeNavigation(match, next, untrack(read).value), commit) : commit();
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();
2100
2189
  }];
2101
2190
  history.init && onCleanup(history.init((value = history.get()) => {
2102
2191
  if (committing) return;
@@ -2107,6 +2196,7 @@ function createIntegration(history, match) {
2107
2196
  }));
2108
2197
  return {
2109
2198
  signal,
2199
+ inflight: () => inflight,
2110
2200
  utils: history.utils
2111
2201
  };
2112
2202
  }
@@ -2131,7 +2221,10 @@ function staticIntegration(url, utils) {
2131
2221
  value
2132
2222
  };
2133
2223
  return {
2134
- signal: [() => obj, next => Object.assign(obj, next)],
2224
+ signal: [() => obj, next => {
2225
+ const written = resolveLocationWrite(obj, next);
2226
+ written && Object.assign(obj, written);
2227
+ }],
2135
2228
  utils
2136
2229
  };
2137
2230
  }
@@ -2364,7 +2457,12 @@ function revalidate(key, force = true) {
2364
2457
  const now = Date.now();
2365
2458
  cacheKeyOp(key, entry => {
2366
2459
  force && (entry[0] = 0); //force cache miss
2367
- entry[4][1](now); // retrigger live signals
2460
+ // retrigger live signals. The version is the entry's fetch stamp, and
2461
+ // a signal write of an equal value is a no-op — an entry fetched within
2462
+ // this same millisecond (a mount whose redirect lands before the clock
2463
+ // ticks) would otherwise never be told to refetch, and the surviving
2464
+ // consumer paints stale. A sweep must notify unconditionally.
2465
+ entry[4][1](v => v === now ? now + 1 : now);
2368
2466
  });
2369
2467
  const keys = key === undefined ? undefined : Array.isArray(key) ? key : [key];
2370
2468
  for (const hook of revalidateHooks) hook(keys, force);
@@ -2394,7 +2492,15 @@ function query(fn, name) {
2394
2492
  }
2395
2493
  const cachedFn = (...args) => {
2396
2494
  const cache = getCache();
2397
- const intent = getIntent();
2495
+ // The intent is a fact about the moment of this read — which navigation
2496
+ // (if any) is asking — not a dependency of the value: `intent()` probes
2497
+ // `isPending`/`latest` of the router's location, and tracked here every
2498
+ // consumer memo would re-run as each navigation starts and lands. Under a
2499
+ // redirect that leaves a route section, the leaving section's memo is a
2500
+ // zombie the transaction is removing; dirtied through the probe's lane
2501
+ // channel it runs anyway (solid #3444), sees the entry the redirect just
2502
+ // invalidated, and fires the phantom refetch the sweep exists to avoid.
2503
+ const intent = untrack(getIntent);
2398
2504
  const inPreloadFn = getInPreloadFn();
2399
2505
  const owner = getOwner();
2400
2506
  const router = owner ? useRouter() : undefined;
@@ -2518,12 +2624,34 @@ function query(fn, name) {
2518
2624
  if (key == "set-cookie") e.response.headers.append("set-cookie", value);else e.response.headers.set(key, value);
2519
2625
  }
2520
2626
  }
2521
- const url = v.headers.get(LocationHeader);
2627
+ let url = v.headers.get(LocationHeader);
2628
+
2629
+ // A `"use server"` redirect reaches a client-side read masked: the
2630
+ // transport answers scripted callers with a 200, drops `Location`
2631
+ // and carries "<status> <absolute-url>" in REDIRECT_HEADER instead.
2632
+ // Decode it with the runtime's own reader, as action() does. The
2633
+ // carrier arrives RESOLVED to an absolute url, so same-origin vs
2634
+ // cross-origin is a real origin comparison: same-origin folds to
2635
+ // a path the soft branch below navigates under the router, any
2636
+ // other origin keeps its href and the document goes with it. This
2637
+ // stays synchronous on purpose — navigate runs in the same tick as
2638
+ // the `Location` branch would, so the transition semantics match.
2639
+ if (url === null && !isServer && v.headers.has(REDIRECT_HEADER)) {
2640
+ const carried = decodeRedirectHeaderValue(v.headers.get(REDIRECT_HEADER));
2641
+ if (carried) {
2642
+ const target = new URL(carried.url);
2643
+ url = target.origin === window.location.origin ? target.pathname + target.search + target.hash : target.href;
2644
+ }
2645
+ }
2522
2646
  if (url !== null) {
2523
2647
  // invalidate the redirect's revalidation keys before navigating so
2524
- // the destination's preloads see the miss and fetch fresh (#580 thread)
2525
- const keys = !isServer && v.headers.get(REVALIDATE_HEADER)?.split(",");
2526
- keys && cacheKeyOp(keys, entry => entry[0] = 0);
2648
+ // the destination's preloads see the miss and fetch fresh (#580 thread).
2649
+ // A read that redirects declares nothing by default (unlike an
2650
+ // action), so only a header present here sweeps: named keys their
2651
+ // matches, `*` everything.
2652
+ const declared = !isServer ? v.headers.get(REVALIDATE_HEADER) : null;
2653
+ const keys = declared !== null ? readRevalidateKeys(declared) : undefined;
2654
+ declared !== null && cacheKeyOp(keys, entry => entry[0] = 0);
2527
2655
 
2528
2656
  // client + server relative redirect
2529
2657
  const soft = navigate && url.startsWith("/");
@@ -2534,7 +2662,7 @@ function query(fn, name) {
2534
2662
  // sweep the live signals inside the same transition as the
2535
2663
  // navigation: surviving consumers (shared layouts) refetch and
2536
2664
  // hold the commit, so the destination never paints stale data
2537
- keys && revalidate(keys, false);
2665
+ declared !== null && revalidate(keys, false);
2538
2666
 
2539
2667
  // Hold the read pending on the client: the navigation unmounts this
2540
2668
  // consumer, and resolving `undefined` instead hands a missing value
@@ -2589,6 +2717,27 @@ query.set = (key, value) => {
2589
2717
  };
2590
2718
  query.delete = key => getCache().delete(key);
2591
2719
  query.clear = () => getCache().clear();
2720
+
2721
+ /**
2722
+ * The reserved `X-Revalidate` key meaning every entry — `revalidate: "*"`
2723
+ * on `@solidjs/web`'s response helpers (`REVALIDATE_ALL` there). The
2724
+ * host-independent spelling of "all", for hosts whose default is narrower
2725
+ * than this router's.
2726
+ */
2727
+ const REVALIDATE_ALL = "*";
2728
+
2729
+ /**
2730
+ * Reads an `X-Revalidate` header value into the keys the cache ops take.
2731
+ * Named keys are prefix-matched; an empty declaration (`revalidate: []`)
2732
+ * yields `[""]`, which `matchKey` matches to nothing; the reserved `*`
2733
+ * yields `undefined` — this router's own spelling of everything, the same
2734
+ * scope an action with no declaration gets, so an author who wrote "all"
2735
+ * explicitly lands on the same path as the default.
2736
+ */
2737
+ function readRevalidateKeys(value) {
2738
+ const keys = value.split(",");
2739
+ return keys.includes(REVALIDATE_ALL) ? undefined : keys;
2740
+ }
2592
2741
  function matchKey(key, keys) {
2593
2742
  for (let k of keys) {
2594
2743
  if (k && key.startsWith(k)) return true;
@@ -2676,7 +2825,7 @@ function handleFormAction(evt, router, actionBase) {
2676
2825
  * later real registration overrides it).
2677
2826
  */
2678
2827
  function createServerFormAction(url) {
2679
- const id = parseServerFunctionUrl(url);
2828
+ const id = parseServerFunctionActionUrl(url);
2680
2829
  if (!id) return undefined;
2681
2830
  // typecheck resolves the server half of the dual module; this path only
2682
2831
  // runs in the browser, where the client transport's signature applies
@@ -2935,7 +3084,7 @@ function setupFlightDataConsumer(router) {
2935
3084
  function applyResponseMetadata(metadata, navigate, flightData) {
2936
3085
  let keys;
2937
3086
  if (metadata) {
2938
- if (metadata.headers.has(REVALIDATE_HEADER)) keys = metadata.headers.get(REVALIDATE_HEADER).split(",");
3087
+ if (metadata.headers.has(REVALIDATE_HEADER)) keys = readRevalidateKeys(metadata.headers.get(REVALIDATE_HEADER));
2939
3088
  // The carrier delivers the target RESOLVED to an absolute url
2940
3089
  // (solidjs/solid#3102), so the soft/hard split is a real origin
2941
3090
  // comparison — never a guess from how the author spelled the target,
@@ -8,7 +8,7 @@ import { getRequestEvent, isServer } from "@solidjs/web";
8
8
  import { setupLinkClaims } from "../claims.js";
9
9
  import { setupNativeEvents } from "../data/events.js";
10
10
  import { createPathsProxy } from "../paths.js";
11
- import { createBranches, createRouterContext, getRouteMatches, mergeParams, registerFlightRouter, RouterContextObj, trackLazySubtrees, useOptionalContext } from "../routing.js";
11
+ import { createBranches, createRouterContext, getRouteMatches, mergeParams, registerFlightRouter, resolveLocationWrite, RouterContextObj, trackLazySubtrees, useOptionalContext } from "../routing.js";
12
12
  import { mockBase } from "../utils.js";
13
13
  import { Root, Routes } from "./components.jsx";
14
14
  import { browserHistory } from "./history.js";
@@ -40,19 +40,35 @@ export function defineRoute(route) {
40
40
  * Redirect depth comes from `_navigation` (1 = a navigation, n = its
41
41
  * (n - 1)th redirect hop, -1 = the browser moved: back/forward, hash).
42
42
  */
43
- function describeNavigation(match, next, from) {
44
- const pathname = new URL(next.value, mockBase).pathname;
45
- const matches = () => untrack(() => match(pathname));
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));
46
56
  const ref = {
47
57
  kind: "navigation",
48
- to: next.value,
58
+ get to() {
59
+ return to();
60
+ },
49
61
  from,
50
62
  get name() {
51
- const m = matches();
52
- return m.length ? m[m.length - 1].route.pattern || "/" : pathname;
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;
53
68
  },
54
69
  get params() {
55
- const m = matches();
70
+ const p = pathname();
71
+ const m = p === undefined ? [] : matches(p);
56
72
  return m.length ? mergeParams(m) : undefined;
57
73
  }
58
74
  };
@@ -63,6 +79,8 @@ function describeNavigation(match, next, from) {
63
79
  /** Wraps a history adapter in the integration signal the router core consumes. Must run under a reactive owner. */
64
80
  function createIntegration(history, match) {
65
81
  let committing = false;
82
+ // Written, not yet in history (see `RouterIntegration.inflight`).
83
+ let inflight;
66
84
  const wrap = (value) => (typeof value === "string" ? { value } : value);
67
85
  const [read, write] = createSignal(wrap(history.get()), {
68
86
  equals: (a, b) => a.value === b.value && a.state === b.state && a._navigation === b._navigation,
@@ -74,12 +92,23 @@ function createIntegration(history, match) {
74
92
  (next) => {
75
93
  if (sharedConfig.registry && !sharedConfig.done)
76
94
  sharedConfig.done = true;
95
+ // What the write resolved to, or undefined when there was nothing to
96
+ // write (see `resolveLocationWrite`).
97
+ let written;
77
98
  const commit = () => {
78
- write(next);
79
- if (next._navigation && next._navigation > 0) {
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;
80
107
  // Register out of band so a destination error boundary replacing the
81
108
  // Router subtree cannot suppress the winning history commit.
82
109
  runWithOwner(null, () => onSettled(() => {
110
+ if (inflight === next)
111
+ inflight = undefined;
83
112
  if (read() !== next)
84
113
  return;
85
114
  committing = true;
@@ -96,8 +125,10 @@ function createIntegration(history, match) {
96
125
  // the browser's own back/forward — so this is the one place the
97
126
  // navigation is declared. `read()` still holds the committed location
98
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.
99
130
  OBSERVE
100
- ? OBSERVE.attribution.withOrigin(describeNavigation(match, next, untrack(read).value), commit)
131
+ ? OBSERVE.attribution.withOrigin(describeNavigation(match, next, () => written, untrack(read).value), commit)
101
132
  : commit();
102
133
  }
103
134
  ];
@@ -107,7 +138,7 @@ function createIntegration(history, match) {
107
138
  return;
108
139
  signal[1]({ ...wrap(value), _navigation: -1 });
109
140
  }));
110
- return { signal, utils: history.utils };
141
+ return { signal, inflight: () => inflight, utils: history.utils };
111
142
  }
112
143
  /**
113
144
  * Server default: a static view of the request URL — no signal machinery, a
@@ -126,7 +157,16 @@ function staticIntegration(url, utils) {
126
157
  value = u.pathname + u.search;
127
158
  }
128
159
  const obj = { value };
129
- 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
+ };
130
170
  }
131
171
  export function createRouter(config) {
132
172
  const basePath = config.base || "";
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
@@ -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.24",
9
+ "version": "2.0.0-next.26",
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.8",
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.8",
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.8",
55
- "solid-js": "^2.0.0-rc.8"
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",