@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 +2 -0
- package/dist/data/action.js +5 -6
- package/dist/data/query.d.ts +9 -0
- package/dist/data/query.js +87 -14
- package/dist/index.d.ts +1 -1
- package/dist/index.js +229 -80
- package/dist/routers/factory.jsx +53 -13
- package/dist/routing.d.ts +10 -1
- package/dist/routing.js +109 -58
- package/dist/types.d.ts +24 -1
- package/package.json +5 -5
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:
|
package/dist/data/action.js
CHANGED
|
@@ -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,
|
|
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 =
|
|
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)
|
|
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,
|
package/dist/data/query.d.ts
CHANGED
|
@@ -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;
|
package/dist/data/query.js
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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 = [
|
|
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 {
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
|
1353
|
-
//
|
|
1354
|
-
//
|
|
1355
|
-
//
|
|
1356
|
-
//
|
|
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
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
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
|
|
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 (
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
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
|
-
|
|
2048
|
-
|
|
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
|
|
2121
|
+
get to() {
|
|
2122
|
+
return to();
|
|
2123
|
+
},
|
|
2052
2124
|
from,
|
|
2053
2125
|
get name() {
|
|
2054
|
-
const
|
|
2055
|
-
|
|
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
|
|
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
|
-
|
|
2081
|
-
|
|
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
|
-
|
|
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 =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2526
|
-
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
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,
|
package/dist/routers/factory.jsx
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
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
|
|
58
|
+
get to() {
|
|
59
|
+
return to();
|
|
60
|
+
},
|
|
49
61
|
from,
|
|
50
62
|
get name() {
|
|
51
|
-
const
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
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 {
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
793
|
-
|
|
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
|
-
|
|
796
|
-
|
|
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 (
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
55
|
-
"solid-js": "^2.0.0-rc.
|
|
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",
|