@solidjs/router 2.0.0-next.24 → 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.
@@ -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
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
@@ -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
  }
@@ -2394,7 +2487,15 @@ function query(fn, name) {
2394
2487
  }
2395
2488
  const cachedFn = (...args) => {
2396
2489
  const cache = getCache();
2397
- 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);
2398
2499
  const inPreloadFn = getInPreloadFn();
2399
2500
  const owner = getOwner();
2400
2501
  const router = owner ? useRouter() : undefined;
@@ -2521,9 +2622,13 @@ function query(fn, name) {
2521
2622
  const url = v.headers.get(LocationHeader);
2522
2623
  if (url !== null) {
2523
2624
  // 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);
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);
2527
2632
 
2528
2633
  // client + server relative redirect
2529
2634
  const soft = navigate && url.startsWith("/");
@@ -2534,7 +2639,7 @@ function query(fn, name) {
2534
2639
  // sweep the live signals inside the same transition as the
2535
2640
  // navigation: surviving consumers (shared layouts) refetch and
2536
2641
  // hold the commit, so the destination never paints stale data
2537
- keys && revalidate(keys, false);
2642
+ declared !== null && revalidate(keys, false);
2538
2643
 
2539
2644
  // Hold the read pending on the client: the navigation unmounts this
2540
2645
  // consumer, and resolving `undefined` instead hands a missing value
@@ -2589,6 +2694,27 @@ query.set = (key, value) => {
2589
2694
  };
2590
2695
  query.delete = key => getCache().delete(key);
2591
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
+ }
2592
2718
  function matchKey(key, keys) {
2593
2719
  for (let k of keys) {
2594
2720
  if (k && key.startsWith(k)) return true;
@@ -2676,7 +2802,7 @@ function handleFormAction(evt, router, actionBase) {
2676
2802
  * later real registration overrides it).
2677
2803
  */
2678
2804
  function createServerFormAction(url) {
2679
- const id = parseServerFunctionUrl(url);
2805
+ const id = parseServerFunctionActionUrl(url);
2680
2806
  if (!id) return undefined;
2681
2807
  // typecheck resolves the server half of the dual module; this path only
2682
2808
  // runs in the browser, where the client transport's signature applies
@@ -2935,7 +3061,7 @@ function setupFlightDataConsumer(router) {
2935
3061
  function applyResponseMetadata(metadata, navigate, flightData) {
2936
3062
  let keys;
2937
3063
  if (metadata) {
2938
- 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));
2939
3065
  // The carrier delivers the target RESOLVED to an absolute url
2940
3066
  // (solidjs/solid#3102), so the soft/hard split is a real origin
2941
3067
  // 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.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.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",