@solidjs/router 2.0.0-next.17 → 2.0.0-next.18
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 +33 -1
- package/dist/data/action.js +4 -2
- package/dist/data/index.d.ts +1 -0
- package/dist/data/index.js +1 -0
- package/dist/data/liveQuery.d.ts +44 -0
- package/dist/data/liveQuery.js +377 -0
- package/dist/data/query.d.ts +6 -0
- package/dist/data/query.js +21 -1
- package/dist/index.js +438 -31
- package/dist/paths.d.ts +8 -0
- package/dist/paths.js +13 -1
- package/dist/routers/components.jsx +12 -8
- package/dist/routing.js +24 -6
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Explore the official [documentation](https://docs.solidjs.com/solid-router) for
|
|
|
20
20
|
- **Plain Anchors**: no link component — `<a>` elements get `aria-current`, `data-active`, and `data-pending` automatically via compiler-claimed anchors
|
|
21
21
|
- **Universal Rendering**: one factory for browser, hash, memory, and server rendering; history adapters are imports, so unused ones never enter your bundle
|
|
22
22
|
- **Preload Functions**: parallel data fetching following the render-as-you-fetch pattern, triggered eagerly on link hover/focus
|
|
23
|
-
- **Data APIs with Caching**: `query` and `action` with deduplication, revalidation, single-flight mutations, and progressive enhancement
|
|
23
|
+
- **Data APIs with Caching**: `query` and `action` with deduplication, revalidation, single-flight mutations, and progressive enhancement — plus experimental `liveQuery` for keyed queries over live streams
|
|
24
24
|
- **Typed Search Params**: opt-in per-route [Standard Schema](https://github.com/standard-schema/standard-schema) validation — `search.page` is a `number`, not `"2"`
|
|
25
25
|
|
|
26
26
|
## Table of Contents
|
|
@@ -519,6 +519,38 @@ 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
|
+
### `liveQuery` (experimental)
|
|
523
|
+
|
|
524
|
+
`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:
|
|
525
|
+
|
|
526
|
+
```tsx
|
|
527
|
+
const roomMessages = liveQuery(async function* (room: string) {
|
|
528
|
+
"use server";
|
|
529
|
+
yield await db.messages.list(room); // current state, immediately
|
|
530
|
+
for await (const change of db.messages.watch(room)) {
|
|
531
|
+
yield await db.messages.list(room); // current state again, on change
|
|
532
|
+
}
|
|
533
|
+
}, "messages");
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
Declaring it is what makes it live — no separate wrapper, and server functions are declared GET at creation just like `query`. Consumption is the same story as `query`: Solid primitives, no router-specific async wrapper. The value updates in place as yields arrive:
|
|
537
|
+
|
|
538
|
+
```tsx
|
|
539
|
+
const messages = createMemo(() => roomMessages(params.room));
|
|
540
|
+
return <For each={messages()}>{msg => <Message {...msg} />}</For>;
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
One connection per key (name + arguments) is shared by every consumer: late subscribers get the latest value immediately, delivery is latest-wins, and the connection closes when the last consumer leaves. A connected stream that dies transiently (network, 5xx) reconnects with exponential backoff while the latest value keeps serving; a definite rejection (4xx) or a first-connect failure surfaces to the consumer like any thrown error.
|
|
544
|
+
|
|
545
|
+
Live queries ride the router's existing machinery rather than adding their own:
|
|
546
|
+
|
|
547
|
+
- **Preload** — calling one in a preload function warms the connection, so navigation renders against an already-delivered value instead of holding the transition on connect.
|
|
548
|
+
- **SSR** — the document face renders the first value; hydration adopts it and reconnects. Server-side, consumers of a key within one request observe the same value.
|
|
549
|
+
- **Revalidation** — explicit `revalidate(key)` reconnects (the producer re-yields current state by contract). The post-mutation sweep leaves healthy connections alone: the stream is its own freshness mechanism.
|
|
550
|
+
- **Single-flight** — a mutation's flight payload pushes straight into open channels, so mutated state lands in the same round trip.
|
|
551
|
+
|
|
552
|
+
The callable carries the `query` conventions (`key`, `keyFor`) plus a reactive `status(...args)` read (`"idle" | "connecting" | "connected" | "reconnecting" | "closed"`) for surfacing connection state in UI.
|
|
553
|
+
|
|
522
554
|
### `action`
|
|
523
555
|
|
|
524
556
|
A router action is *an action with a URL* — Solid's mutation primitive plus URL addressability, submission tracking, and response handling. Data helpers come from the router; response helpers (`redirect`, `reload`) come from `@solidjs/web` — they're protocol-level and work without the router:
|
package/dist/data/action.js
CHANGED
|
@@ -7,7 +7,7 @@ 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, hashKey, revalidate, query } from "./query.js";
|
|
10
|
+
import { cacheKeyOp, deliverFlightData, hashKey, revalidate, query } from "./query.js";
|
|
11
11
|
const submitHooksSymbol = Symbol("routerActionSubmitHooks");
|
|
12
12
|
const settledHooksSymbol = Symbol("routerActionSettledHooks");
|
|
13
13
|
const invokeSymbol = Symbol("routerActionInvoke");
|
|
@@ -328,8 +328,10 @@ function applyResponseMetadata(metadata, navigate, flightData) {
|
|
|
328
328
|
}
|
|
329
329
|
// invalidate
|
|
330
330
|
cacheKeyOp(keys, entry => (entry[0] = 0));
|
|
331
|
-
// set cache
|
|
331
|
+
// set cache — and fan the payload out to other keyed stores (live query
|
|
332
|
+
// channels adopt delivered values instead of reconnecting)
|
|
332
333
|
flightData && Object.keys(flightData).forEach(k => query.set(k, flightData[k]));
|
|
334
|
+
flightData && deliverFlightData(flightData);
|
|
333
335
|
// trigger revalidation inside the same transition as the navigation, so
|
|
334
336
|
// the redirect commits atomically with fresh data: surviving consumers
|
|
335
337
|
// (shared layouts) the flight payload didn't seed refetch and hold the
|
package/dist/data/index.d.ts
CHANGED
package/dist/data/index.js
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type LiveQueryStatus = "idle" | "connecting" | "connected" | "reconnecting" | "closed";
|
|
2
|
+
export type LiveFunction<T extends (...args: any) => any> = T extends (...args: infer A) => infer R ? ((...args: A) => AsyncIterable<Awaited<R> extends AsyncIterable<infer V> ? V : Awaited<R>>) & {
|
|
3
|
+
keyFor: (...args: A) => string;
|
|
4
|
+
key: string;
|
|
5
|
+
status: (...args: A) => LiveQueryStatus;
|
|
6
|
+
} : never;
|
|
7
|
+
/**
|
|
8
|
+
* Declares a keyed live query over a value-shaped stream: an async-iterable
|
|
9
|
+
* producer whose yields are successive VALUES of one logical query, with the
|
|
10
|
+
* contract that the producer re-yields current state on every invocation.
|
|
11
|
+
* liveQuery IS the live layer — no separate declaration needed:
|
|
12
|
+
*
|
|
13
|
+
* - One connection per (name + args) key, shared by every consumer. Late
|
|
14
|
+
* subscribers receive the latest value immediately, delivery is
|
|
15
|
+
* latest-wins, and the connection closes when the last consumer leaves.
|
|
16
|
+
* - A connected stream that dies transiently (network, 5xx) reconnects with
|
|
17
|
+
* exponential backoff — the latest value keeps serving meanwhile. A
|
|
18
|
+
* definite rejection (4xx: the transport stamps HTTP statuses onto
|
|
19
|
+
* failures) ends the channel and surfaces the error to consumers, as does
|
|
20
|
+
* a first-connect failure.
|
|
21
|
+
* - Server functions are declared GET at creation (like `query`). Live
|
|
22
|
+
* queries participate in single-flight on the delivery side: a mutation's
|
|
23
|
+
* flight payload pushes straight into open channels (the mutation
|
|
24
|
+
* response is the round trip), while the post-mutation sweep leaves the
|
|
25
|
+
* healthy connection in place — the live stream stays authoritative.
|
|
26
|
+
* - Calling under preload intent warms the channel (a temporary hold keeps
|
|
27
|
+
* it open through the preload window), so navigation renders against an
|
|
28
|
+
* already-connected stream instead of holding the transition on connect
|
|
29
|
+
* plus first yield. Outside preload, calling remains free — only a real
|
|
30
|
+
* pull connects.
|
|
31
|
+
* - SSR: the document face renders the first value; hydration adopts it and
|
|
32
|
+
* reconnects. Channels are request-scoped on the server, so every
|
|
33
|
+
* consumer of a key in one render observes the same value. Explicit
|
|
34
|
+
* `revalidate(key)` reconnects (the producer re-yields current state on
|
|
35
|
+
* invocation), and `status(...args)` is a reactive read of the channel's
|
|
36
|
+
* wire state.
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* const roomMessages = liveQuery(getMessages, "messages");
|
|
40
|
+
* // in a component — one connection, however many consumers:
|
|
41
|
+
* const messages = createMemo(() => roomMessages(props.room));
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export declare function liveQuery<T extends (...args: any) => any>(fn: T, name: string): LiveFunction<T>;
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { createSignal } from "solid-js";
|
|
2
|
+
import { getRequestEvent, getServerFunctionMetadata, getServerFunctionRPC, isServer, isServerFunction } from "@solidjs/web";
|
|
3
|
+
import { getIntent } from "../routing.js";
|
|
4
|
+
import { hashKey, matchKey, registerFlightDataHook, registerRevalidateHook } from "./query.js";
|
|
5
|
+
// Matches query's preload lifetime: a channel warmed by preload intent
|
|
6
|
+
// stays held this long waiting for its navigation.
|
|
7
|
+
const PRELOAD_TIMEOUT = 5000;
|
|
8
|
+
// The live-source brand (registered symbol — no transport import needed):
|
|
9
|
+
// computations meeting a branded iterable apply live SSR policy (document
|
|
10
|
+
// face takes the first value; hydration re-runs the compute after adoption
|
|
11
|
+
// to reconnect). liveQuery writes it on both faces itself — the server-side
|
|
12
|
+
// resolved iterable and the client-side multicast iterable — so a producer
|
|
13
|
+
// needs no separate live() declaration to be live here.
|
|
14
|
+
const LIVE_SOURCE = Symbol.for("solid.LiveSource");
|
|
15
|
+
const channelMap = new Map();
|
|
16
|
+
// Status outlives the channel (an "idle"/"closed" read after teardown is
|
|
17
|
+
// meaningful), so signals live in their own keyed map.
|
|
18
|
+
const statusMap = new Map();
|
|
19
|
+
function statusSignal(key) {
|
|
20
|
+
let s = statusMap.get(key);
|
|
21
|
+
// ownedWrite: the first write happens synchronously inside whatever
|
|
22
|
+
// computation pulled the channel open — this is channel-owned state, not
|
|
23
|
+
// the computation's own.
|
|
24
|
+
if (!s)
|
|
25
|
+
statusMap.set(key, (s = createSignal("idle", { ownedWrite: true })));
|
|
26
|
+
return s;
|
|
27
|
+
}
|
|
28
|
+
function wake(ch) {
|
|
29
|
+
const waiters = [...ch.waiters];
|
|
30
|
+
ch.waiters.clear();
|
|
31
|
+
for (const w of waiters)
|
|
32
|
+
w();
|
|
33
|
+
}
|
|
34
|
+
const noStatus = () => { };
|
|
35
|
+
async function connect(ch) {
|
|
36
|
+
const gen = ch.gen;
|
|
37
|
+
const setStatus = ch.report ? statusSignal(ch.key)[1] : noStatus;
|
|
38
|
+
let attempts = 0;
|
|
39
|
+
setStatus(ch.version ? "reconnecting" : "connecting");
|
|
40
|
+
while (true) {
|
|
41
|
+
try {
|
|
42
|
+
const result = await ch.call();
|
|
43
|
+
if (gen !== ch.gen || ch.done) {
|
|
44
|
+
// superseded (reconnect) or torn down while connecting: the arrived
|
|
45
|
+
// stream must still be ended or its request leaks
|
|
46
|
+
result?.[Symbol.asyncIterator] && result[Symbol.asyncIterator]().return?.();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// A live()-declared producer's own retry loop erases deaths from the
|
|
50
|
+
// value stream and reports them through onstatus — forward those into
|
|
51
|
+
// the channel's status so both producer kinds read the same. Its
|
|
52
|
+
// "closed" is redundant with our own done handling below.
|
|
53
|
+
if (result !== null && typeof result === "object") {
|
|
54
|
+
try {
|
|
55
|
+
result.onstatus = (state) => {
|
|
56
|
+
if (gen === ch.gen && state !== "closed")
|
|
57
|
+
setStatus(state);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
catch { }
|
|
61
|
+
}
|
|
62
|
+
const it = result !== null && typeof result === "object" && result[Symbol.asyncIterator]
|
|
63
|
+
? result[Symbol.asyncIterator]()
|
|
64
|
+
: (async function* () {
|
|
65
|
+
yield result;
|
|
66
|
+
})();
|
|
67
|
+
ch.close = () => {
|
|
68
|
+
try {
|
|
69
|
+
const r = it.return && it.return();
|
|
70
|
+
if (r && typeof r.then === "function")
|
|
71
|
+
r.then(undefined, () => { });
|
|
72
|
+
}
|
|
73
|
+
catch { }
|
|
74
|
+
};
|
|
75
|
+
while (true) {
|
|
76
|
+
const r = await it.next();
|
|
77
|
+
if (gen !== ch.gen || ch.done)
|
|
78
|
+
return;
|
|
79
|
+
if (r.done)
|
|
80
|
+
break;
|
|
81
|
+
ch.latest = r.value;
|
|
82
|
+
ch.version++;
|
|
83
|
+
attempts = 0; // healthy value: backoff resets
|
|
84
|
+
setStatus("connected");
|
|
85
|
+
wake(ch);
|
|
86
|
+
}
|
|
87
|
+
ch.done = true;
|
|
88
|
+
setStatus("closed");
|
|
89
|
+
wake(ch);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (gen !== ch.gen || ch.done)
|
|
94
|
+
return;
|
|
95
|
+
const status = error?.status;
|
|
96
|
+
if (!ch.version || (typeof status === "number" && status >= 400 && status < 500)) {
|
|
97
|
+
// Terminal: never connected (fail like a normal call), or a
|
|
98
|
+
// definite rejection — the transport stamps HTTP statuses onto
|
|
99
|
+
// failures, and a 4xx means the server understood and refused;
|
|
100
|
+
// retrying cannot change the answer. Consumers rethrow (after
|
|
101
|
+
// draining the latest value), surfacing to error boundaries.
|
|
102
|
+
ch.error = error;
|
|
103
|
+
ch.done = true;
|
|
104
|
+
setStatus("closed");
|
|
105
|
+
wake(ch);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// A stream that had delivered died transiently (network, 5xx,
|
|
109
|
+
// severed stream): the channel IS the live layer — reconnect with
|
|
110
|
+
// backoff, latest value keeps serving meanwhile. (A live()-declared
|
|
111
|
+
// producer never reaches here; its transport retries.)
|
|
112
|
+
setStatus("reconnecting");
|
|
113
|
+
let timer, wakeUp;
|
|
114
|
+
await new Promise(resolve => {
|
|
115
|
+
wakeUp = () => resolve();
|
|
116
|
+
timer = setTimeout(wakeUp, Math.min(500 * 2 ** attempts++, 10000));
|
|
117
|
+
// connectivity returning wakes the sleep early
|
|
118
|
+
if (typeof addEventListener === "function")
|
|
119
|
+
addEventListener("online", wakeUp, { once: true });
|
|
120
|
+
});
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
// a timer-woken sleep leaves its once-listener behind otherwise —
|
|
123
|
+
// one leaked closure per backoff cycle until an online event fires
|
|
124
|
+
if (typeof removeEventListener === "function")
|
|
125
|
+
removeEventListener("online", wakeUp);
|
|
126
|
+
if (gen !== ch.gen || ch.done)
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function teardown(ch) {
|
|
132
|
+
// Deferred so a same-key resubscribe in the same tick (a memo re-running
|
|
133
|
+
// its compute) reuses the connection instead of thrashing it.
|
|
134
|
+
queueMicrotask(() => {
|
|
135
|
+
if (ch.count > 0 || ch.map.get(ch.key) !== ch)
|
|
136
|
+
return;
|
|
137
|
+
const ended = ch.done; // completed/failed on its own — "closed" persists
|
|
138
|
+
ch.done = true;
|
|
139
|
+
ch.close();
|
|
140
|
+
wake(ch);
|
|
141
|
+
if (ch.retain)
|
|
142
|
+
return; // request-scoped: latest stays replayable
|
|
143
|
+
ch.map.delete(ch.key);
|
|
144
|
+
if (ch.report && !ended)
|
|
145
|
+
statusSignal(ch.key)[1]("idle");
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function openChannel(map, key, call, report, retain) {
|
|
149
|
+
let ch = map.get(key);
|
|
150
|
+
if (!ch) {
|
|
151
|
+
ch = {
|
|
152
|
+
key,
|
|
153
|
+
map,
|
|
154
|
+
count: 0,
|
|
155
|
+
version: 0,
|
|
156
|
+
latest: undefined,
|
|
157
|
+
error: undefined,
|
|
158
|
+
done: false,
|
|
159
|
+
waiters: new Set(),
|
|
160
|
+
gen: 0,
|
|
161
|
+
close: () => { },
|
|
162
|
+
call,
|
|
163
|
+
report,
|
|
164
|
+
retain
|
|
165
|
+
};
|
|
166
|
+
map.set(key, ch);
|
|
167
|
+
connect(ch);
|
|
168
|
+
}
|
|
169
|
+
return ch;
|
|
170
|
+
}
|
|
171
|
+
// The consumer half: a branded iterable whose iterators subscribe to the
|
|
172
|
+
// channel lazily (first pull connects — a hydration trace or speculative
|
|
173
|
+
// call opens nothing), replay the latest value immediately, then follow
|
|
174
|
+
// with latest-wins delivery.
|
|
175
|
+
function subscriberIterable(open) {
|
|
176
|
+
return {
|
|
177
|
+
[LIVE_SOURCE]: true,
|
|
178
|
+
[Symbol.asyncIterator]() {
|
|
179
|
+
const ch = open();
|
|
180
|
+
ch.count++;
|
|
181
|
+
let seen = 0;
|
|
182
|
+
let released = false;
|
|
183
|
+
const release = () => {
|
|
184
|
+
if (released)
|
|
185
|
+
return;
|
|
186
|
+
released = true;
|
|
187
|
+
if (--ch.count === 0)
|
|
188
|
+
teardown(ch);
|
|
189
|
+
};
|
|
190
|
+
const pull = async () => {
|
|
191
|
+
while (true) {
|
|
192
|
+
if (ch.error && seen >= ch.version) {
|
|
193
|
+
release();
|
|
194
|
+
throw ch.error;
|
|
195
|
+
}
|
|
196
|
+
if (ch.version > seen) {
|
|
197
|
+
seen = ch.version;
|
|
198
|
+
return { done: false, value: ch.latest };
|
|
199
|
+
}
|
|
200
|
+
if (ch.done) {
|
|
201
|
+
release();
|
|
202
|
+
return { done: true, value: undefined };
|
|
203
|
+
}
|
|
204
|
+
await new Promise(resolve => ch.waiters.add(resolve));
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
return {
|
|
208
|
+
next: () => (released ? Promise.resolve({ done: true, value: undefined }) : pull()),
|
|
209
|
+
return(value) {
|
|
210
|
+
release();
|
|
211
|
+
return Promise.resolve({ done: true, value });
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function reconnectChannel(ch) {
|
|
218
|
+
ch.gen++;
|
|
219
|
+
ch.close();
|
|
220
|
+
ch.error = undefined;
|
|
221
|
+
ch.done = false;
|
|
222
|
+
connect(ch);
|
|
223
|
+
}
|
|
224
|
+
function push(ch, value) {
|
|
225
|
+
ch.latest = value;
|
|
226
|
+
ch.version++;
|
|
227
|
+
wake(ch);
|
|
228
|
+
}
|
|
229
|
+
// Live channels participate in both data protocols. Registered on first
|
|
230
|
+
// liveQuery() call, not at module scope: a side-effectful top level would
|
|
231
|
+
// anchor this module into graphs that never use it.
|
|
232
|
+
//
|
|
233
|
+
// - Explicit revalidate(key) (force) reconnects — the producer re-yields
|
|
234
|
+
// current state on invocation by contract.
|
|
235
|
+
// - The post-mutation sweep (force=false) does NOT reconnect: the
|
|
236
|
+
// connection is alive and is itself the freshness mechanism — a
|
|
237
|
+
// push-driven producer yields the mutated state on its own, and tearing
|
|
238
|
+
// down healthy connections on every mutation defeats the model.
|
|
239
|
+
// - Single-flight payloads deliver INTO open channels, exactly as they seed
|
|
240
|
+
// the query cache: the mutation response is the round trip, the channel
|
|
241
|
+
// adopts the value immediately, and the live stream stays authoritative
|
|
242
|
+
// for everything after.
|
|
243
|
+
let hooked = false;
|
|
244
|
+
function hookRevalidate() {
|
|
245
|
+
if (hooked)
|
|
246
|
+
return;
|
|
247
|
+
hooked = true;
|
|
248
|
+
registerRevalidateHook((keys, force) => {
|
|
249
|
+
if (!force)
|
|
250
|
+
return;
|
|
251
|
+
for (const [k, ch] of channelMap) {
|
|
252
|
+
if (keys === undefined || matchKey(k, keys))
|
|
253
|
+
reconnectChannel(ch);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
registerFlightDataHook(data => {
|
|
257
|
+
for (const k in data) {
|
|
258
|
+
const ch = channelMap.get(k);
|
|
259
|
+
if (!ch || ch.done)
|
|
260
|
+
continue;
|
|
261
|
+
const v = data[k];
|
|
262
|
+
if (v !== null && typeof v === "object" && v[Symbol.asyncIterator]) {
|
|
263
|
+
// a live key collected server-side arrives stream-shaped: adopt
|
|
264
|
+
// its yields for as long as it runs (it ends with the response
|
|
265
|
+
// body); the channel's own connection remains authoritative
|
|
266
|
+
(async () => {
|
|
267
|
+
try {
|
|
268
|
+
for await (const value of v) {
|
|
269
|
+
if (ch.done)
|
|
270
|
+
return;
|
|
271
|
+
push(ch, value);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch { }
|
|
275
|
+
})();
|
|
276
|
+
}
|
|
277
|
+
else
|
|
278
|
+
push(ch, v);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Declares a keyed live query over a value-shaped stream: an async-iterable
|
|
284
|
+
* producer whose yields are successive VALUES of one logical query, with the
|
|
285
|
+
* contract that the producer re-yields current state on every invocation.
|
|
286
|
+
* liveQuery IS the live layer — no separate declaration needed:
|
|
287
|
+
*
|
|
288
|
+
* - One connection per (name + args) key, shared by every consumer. Late
|
|
289
|
+
* subscribers receive the latest value immediately, delivery is
|
|
290
|
+
* latest-wins, and the connection closes when the last consumer leaves.
|
|
291
|
+
* - A connected stream that dies transiently (network, 5xx) reconnects with
|
|
292
|
+
* exponential backoff — the latest value keeps serving meanwhile. A
|
|
293
|
+
* definite rejection (4xx: the transport stamps HTTP statuses onto
|
|
294
|
+
* failures) ends the channel and surfaces the error to consumers, as does
|
|
295
|
+
* a first-connect failure.
|
|
296
|
+
* - Server functions are declared GET at creation (like `query`). Live
|
|
297
|
+
* queries participate in single-flight on the delivery side: a mutation's
|
|
298
|
+
* flight payload pushes straight into open channels (the mutation
|
|
299
|
+
* response is the round trip), while the post-mutation sweep leaves the
|
|
300
|
+
* healthy connection in place — the live stream stays authoritative.
|
|
301
|
+
* - Calling under preload intent warms the channel (a temporary hold keeps
|
|
302
|
+
* it open through the preload window), so navigation renders against an
|
|
303
|
+
* already-connected stream instead of holding the transition on connect
|
|
304
|
+
* plus first yield. Outside preload, calling remains free — only a real
|
|
305
|
+
* pull connects.
|
|
306
|
+
* - SSR: the document face renders the first value; hydration adopts it and
|
|
307
|
+
* reconnects. Channels are request-scoped on the server, so every
|
|
308
|
+
* consumer of a key in one render observes the same value. Explicit
|
|
309
|
+
* `revalidate(key)` reconnects (the producer re-yields current state on
|
|
310
|
+
* invocation), and `status(...args)` is a reactive read of the channel's
|
|
311
|
+
* wire state.
|
|
312
|
+
*
|
|
313
|
+
* ```ts
|
|
314
|
+
* const roomMessages = liveQuery(getMessages, "messages");
|
|
315
|
+
* // in a component — one connection, however many consumers:
|
|
316
|
+
* const messages = createMemo(() => roomMessages(props.room));
|
|
317
|
+
* ```
|
|
318
|
+
*/
|
|
319
|
+
export function liveQuery(fn, name) {
|
|
320
|
+
hookRevalidate();
|
|
321
|
+
// liveQuery implies GET, exactly as query does: reads belong on the GET
|
|
322
|
+
// transport (cacheable URLs, no single-flight enveloping). An explicit
|
|
323
|
+
// GET(fn) or live(GET(fn)) declaration passes through untouched.
|
|
324
|
+
if (isServerFunction(fn) && !getServerFunctionMetadata(fn)?.method) {
|
|
325
|
+
const rpc = getServerFunctionRPC();
|
|
326
|
+
if (rpc)
|
|
327
|
+
fn = rpc.GET(fn);
|
|
328
|
+
}
|
|
329
|
+
const liveFn = ((...args) => {
|
|
330
|
+
const key = name + hashKey(args);
|
|
331
|
+
if (isServer) {
|
|
332
|
+
const e = getRequestEvent();
|
|
333
|
+
if (!e) {
|
|
334
|
+
// No request scope (static render, tests): hand the source through
|
|
335
|
+
// whole, branded, so the signals layer applies live SSR policy
|
|
336
|
+
// (first value, then client takeover).
|
|
337
|
+
const result = fn(...args);
|
|
338
|
+
const brand = (r) => {
|
|
339
|
+
if (r !== null && typeof r === "object" && r[Symbol.asyncIterator])
|
|
340
|
+
r[LIVE_SOURCE] = true;
|
|
341
|
+
return r;
|
|
342
|
+
};
|
|
343
|
+
return result !== null && typeof result === "object" && typeof result.then === "function"
|
|
344
|
+
? result.then(brand)
|
|
345
|
+
: brand(result);
|
|
346
|
+
}
|
|
347
|
+
// Request-scoped channels: two consumers of one key during one render
|
|
348
|
+
// must observe the SAME first value (query's request cache gives its
|
|
349
|
+
// reads the same guarantee). Channels live in the event, retained
|
|
350
|
+
// past teardown so a later consumer replays the settled value instead
|
|
351
|
+
// of reinvoking; the producer still closes with its last consumer.
|
|
352
|
+
const router = (e.router || (e.router = {}));
|
|
353
|
+
const channels = router.liveChannels || (router.liveChannels = new Map());
|
|
354
|
+
return subscriberIterable(() => openChannel(channels, key, () => fn(...args), false, true));
|
|
355
|
+
}
|
|
356
|
+
const open = () => openChannel(channelMap, key, () => fn(...args), true, false);
|
|
357
|
+
// Preload warms the channel, exactly as calling a query in a preload
|
|
358
|
+
// function warms its cache: connect now, hold a temporary consumer slot
|
|
359
|
+
// so refcounting doesn't close it before navigation renders. When the
|
|
360
|
+
// real consumer arrives it inherits the live connection (first pull
|
|
361
|
+
// replays the already-arrived value — the transition doesn't hold);
|
|
362
|
+
// when navigation never comes, the hold lapses and teardown closes it.
|
|
363
|
+
if (getIntent() === "preload") {
|
|
364
|
+
const ch = open();
|
|
365
|
+
ch.count++;
|
|
366
|
+
setTimeout(() => {
|
|
367
|
+
if (--ch.count === 0)
|
|
368
|
+
teardown(ch);
|
|
369
|
+
}, PRELOAD_TIMEOUT);
|
|
370
|
+
}
|
|
371
|
+
return subscriberIterable(open);
|
|
372
|
+
});
|
|
373
|
+
liveFn.keyFor = (...args) => name + hashKey(args);
|
|
374
|
+
liveFn.key = name;
|
|
375
|
+
liveFn.status = (...args) => statusSignal(name + hashKey(args))[0]();
|
|
376
|
+
return liveFn;
|
|
377
|
+
}
|
package/dist/data/query.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { CacheEntry, NarrowResponse } from "../types.js";
|
|
2
|
+
export declare function registerRevalidateHook(hook: (keys: string[] | void, force: boolean) => void): void;
|
|
3
|
+
export declare function registerFlightDataHook(hook: (data: Record<string, any>) => void): void;
|
|
4
|
+
/** Fans a single-flight payload out to registered keyed stores (the query
|
|
5
|
+
* cache itself is seeded by the caller via query.set). */
|
|
6
|
+
export declare function deliverFlightData(data: Record<string, any>): void;
|
|
2
7
|
/**
|
|
3
8
|
* Revalidates the given cache entry/entries.
|
|
4
9
|
*/
|
|
@@ -18,4 +23,5 @@ export declare namespace query {
|
|
|
18
23
|
export var clear: () => void;
|
|
19
24
|
export { _a as delete };
|
|
20
25
|
}
|
|
26
|
+
export declare function matchKey(key: string, keys: string[]): boolean;
|
|
21
27
|
export declare function hashKey<T extends Array<any>>(args: T): string;
|
package/dist/data/query.js
CHANGED
|
@@ -38,6 +38,23 @@ function getCache() {
|
|
|
38
38
|
throw new Error("Cannot find cache context");
|
|
39
39
|
return (req.router || (req.router = {})).cache || (req.router.cache = new Map());
|
|
40
40
|
}
|
|
41
|
+
// Other keyed stores participating in the data protocols (live query
|
|
42
|
+
// channels) register here — revalidate() stays the one invalidation verb,
|
|
43
|
+
// and single-flight payloads reach every keyed store, not just the cache.
|
|
44
|
+
const revalidateHooks = [];
|
|
45
|
+
export function registerRevalidateHook(hook) {
|
|
46
|
+
revalidateHooks.push(hook);
|
|
47
|
+
}
|
|
48
|
+
const flightHooks = [];
|
|
49
|
+
export function registerFlightDataHook(hook) {
|
|
50
|
+
flightHooks.push(hook);
|
|
51
|
+
}
|
|
52
|
+
/** Fans a single-flight payload out to registered keyed stores (the query
|
|
53
|
+
* cache itself is seeded by the caller via query.set). */
|
|
54
|
+
export function deliverFlightData(data) {
|
|
55
|
+
for (const hook of flightHooks)
|
|
56
|
+
hook(data);
|
|
57
|
+
}
|
|
41
58
|
/**
|
|
42
59
|
* Revalidates the given cache entry/entries.
|
|
43
60
|
*/
|
|
@@ -47,6 +64,9 @@ export function revalidate(key, force = true) {
|
|
|
47
64
|
force && (entry[0] = 0); //force cache miss
|
|
48
65
|
entry[4][1](now); // retrigger live signals
|
|
49
66
|
});
|
|
67
|
+
const keys = key === undefined ? undefined : Array.isArray(key) ? key : [key];
|
|
68
|
+
for (const hook of revalidateHooks)
|
|
69
|
+
hook(keys, force);
|
|
50
70
|
}
|
|
51
71
|
export function cacheKeyOp(key, fn) {
|
|
52
72
|
key && !Array.isArray(key) && (key = [key]);
|
|
@@ -291,7 +311,7 @@ query.set = (key, value) => {
|
|
|
291
311
|
};
|
|
292
312
|
query.delete = (key) => getCache().delete(key);
|
|
293
313
|
query.clear = () => getCache().clear();
|
|
294
|
-
function matchKey(key, keys) {
|
|
314
|
+
export function matchKey(key, keys) {
|
|
295
315
|
for (let k of keys) {
|
|
296
316
|
if (k && key.startsWith(k))
|
|
297
317
|
return true;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getOwner, runWithOwner, createMemo, createRenderEffect, onCleanup, untrack, createContext, createSignal, useContext, NotReadyError, isPending, DEV, flush, createComponent, createRoot, createEffect, sharedConfig, getObserver, $TRACK, action as action$1 } from 'solid-js';
|
|
1
|
+
import { getOwner, runWithOwner, createMemo, createRenderEffect, onCleanup, untrack, createContext, createSignal, useContext, NotReadyError, isPending, DEV, flush, createComponent, createRoot, Show, createEffect, sharedConfig, 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
3
|
import { subscribeFlightData, decodeResponsePayload, createServerReference } from '@solidjs/web/server-functions';
|
|
4
4
|
import { decodeFlashCookie } from '@solidjs/web/server-functions/server';
|
|
@@ -475,6 +475,15 @@ const int = s => /^-?\d+$/.test(s);
|
|
|
475
475
|
|
|
476
476
|
const encodeParam = value => String(value).split("/").map(encodeURIComponent).join("/");
|
|
477
477
|
|
|
478
|
+
/**
|
|
479
|
+
* The global Href brand (`@solidjs/web` reads the same registry symbol).
|
|
480
|
+
* On a paths node it holds the logical path — the routable pathname before
|
|
481
|
+
* `renderPath` decorates it for display (eg. hash mode's `#` prefix) — so
|
|
482
|
+
* `navigate()` and `redirect()` can route the node while `toString()` keeps
|
|
483
|
+
* yielding the display href for the DOM.
|
|
484
|
+
*/
|
|
485
|
+
const HREF = Symbol.for("solid.Href");
|
|
486
|
+
|
|
478
487
|
/**
|
|
479
488
|
* Creates the runtime path proxy. It is instance-scoped: `renderPath` comes
|
|
480
489
|
* from the router's history adapter (eg. hash routing prefixes `#`), and
|
|
@@ -500,7 +509,7 @@ function createPathsProxy(renderPath = p => p, base = "") {
|
|
|
500
509
|
return new Proxy(build, {
|
|
501
510
|
get(_, prop) {
|
|
502
511
|
if (prop === "toString") return () => toHref(pathname);
|
|
503
|
-
if (typeof prop === "symbol") return prop === Symbol.toPrimitive ? () => toHref(pathname) : undefined;
|
|
512
|
+
if (typeof prop === "symbol") return prop === Symbol.toPrimitive ? () => toHref(pathname) : prop === HREF ? pathname || "/" : undefined;
|
|
504
513
|
return node(`${pathname}/${prop}`);
|
|
505
514
|
}
|
|
506
515
|
});
|
|
@@ -508,13 +517,6 @@ function createPathsProxy(renderPath = p => p, base = "") {
|
|
|
508
517
|
return node(normalizePath(base));
|
|
509
518
|
}
|
|
510
519
|
|
|
511
|
-
// Flash-cookie detection/clearing are cookie utilities on the CORE entry
|
|
512
|
-
// (they live beside the cookie codec) — routing is in every app's eager
|
|
513
|
-
// graph and must never import the server-functions package, whose client
|
|
514
|
-
// half is the fetch transport + the seroval codec. The type below is
|
|
515
|
-
// erased at build; only the server-only action path imports that module's
|
|
516
|
-
// values (behind isServer, tree-shaken from client bundles).
|
|
517
|
-
|
|
518
520
|
const MAX_REDIRECTS = 100;
|
|
519
521
|
|
|
520
522
|
/** Consider this API opaque and internal. It is likely to change in the future. */
|
|
@@ -817,12 +819,17 @@ const encodeSegment = s => encodeURIComponent(s).replace(/%(2B|40|3A|24|26|2C|3B
|
|
|
817
819
|
const lazyBoundaries = new WeakMap();
|
|
818
820
|
// Module scope: boundary resolution is global, deterministic state (same
|
|
819
821
|
// thunk -> same routes), shared by every factory instance and the server's
|
|
820
|
-
// flight collector.
|
|
822
|
+
// flight collector. The counter is the source of truth; the client mirrors
|
|
823
|
+
// it into a signal for subscription. Server render is pure (signal writes
|
|
824
|
+
// are inert as of solid 2.0.0-rc.3): there the plain counter serves reads,
|
|
825
|
+
// and re-runs come from the parked computation's promise retry, not from
|
|
826
|
+
// reactivity.
|
|
827
|
+
let lazyTreeCounter = 0;
|
|
821
828
|
const [lazyTreeVersion, setLazyTreeVersion] = createSignal(0);
|
|
822
829
|
|
|
823
830
|
/** Reactive read of the lazy-subtree version — recompile compiled branches when it changes. */
|
|
824
831
|
function trackLazySubtrees() {
|
|
825
|
-
return lazyTreeVersion();
|
|
832
|
+
return isServer ? lazyTreeCounter : lazyTreeVersion();
|
|
826
833
|
}
|
|
827
834
|
function getLazyBoundary(thunk) {
|
|
828
835
|
let record = lazyBoundaries.get(thunk);
|
|
@@ -862,7 +869,8 @@ function resolveLazySubtree(record) {
|
|
|
862
869
|
return record.promise ||= Promise.resolve(record.thunk()).then(m => {
|
|
863
870
|
const routes = Array.isArray(m) ? m : m.default || m.routes || [];
|
|
864
871
|
record.resolved = routes;
|
|
865
|
-
|
|
872
|
+
lazyTreeCounter++;
|
|
873
|
+
isServer || setLazyTreeVersion(lazyTreeCounter);
|
|
866
874
|
return record.resolved;
|
|
867
875
|
}, e => {
|
|
868
876
|
// ?? Error(): a held undefined would read as "no failure" and refire
|
|
@@ -1241,8 +1249,18 @@ function createRouterContext(integration, branches, getContext, options = {}) {
|
|
|
1241
1249
|
}
|
|
1242
1250
|
return;
|
|
1243
1251
|
}
|
|
1244
|
-
//
|
|
1245
|
-
|
|
1252
|
+
// A paths node carries its logical path under the Href brand — read
|
|
1253
|
+
// that rather than coercing: toString() renders the *display* href
|
|
1254
|
+
// (eg. hash mode's `#` prefix), which is for the DOM, not for routing.
|
|
1255
|
+
// Foreign Href-branded values without the slot still coerce.
|
|
1256
|
+
if (typeof to !== "string") to = to[HREF] || to.toString();
|
|
1257
|
+
// Display hrefs can still arrive as plain strings: terminating paths
|
|
1258
|
+
// calls type as `string`, and redirect Location headers round-trip
|
|
1259
|
+
// through here. Under hash mode those start with `#` — a spelling no
|
|
1260
|
+
// logical path uses — so map them back through the integration's
|
|
1261
|
+
// parser, exactly like the anchor click handler does. Elsewhere
|
|
1262
|
+
// parsePath is identity and `#...` keeps its URL meaning below.
|
|
1263
|
+
if (to[0] === "#") to = parsePath(to);
|
|
1246
1264
|
const {
|
|
1247
1265
|
replace,
|
|
1248
1266
|
resolve,
|
|
@@ -1556,18 +1574,26 @@ function Routes(props) {
|
|
|
1556
1574
|
return memo(outlet);
|
|
1557
1575
|
}
|
|
1558
1576
|
const createOutlet = child => {
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1577
|
+
// Keyed on the context's identity (#588). `when` compiles to a lazy prop
|
|
1578
|
+
// getter, so child() is tracked inside Show's memo — the outlet function
|
|
1579
|
+
// reads nothing reactive and Show owns the subtree. Levels whose context is
|
|
1580
|
+
// reference-reused (parent layouts) keep their subtree across navigations;
|
|
1581
|
+
// a changed context must re-create the provider, not just re-render under
|
|
1582
|
+
// it: a context value is registered once at provider creation
|
|
1583
|
+
// (setContext(provider, props.value)), so non-keyed reuse would leave new
|
|
1584
|
+
// route content reading the previous level's context.
|
|
1585
|
+
return () => createComponent$1(Show, {
|
|
1586
|
+
get when() {
|
|
1587
|
+
return child();
|
|
1588
|
+
},
|
|
1589
|
+
keyed: true,
|
|
1590
|
+
children: c => createComponent$1(RouteContextObj, {
|
|
1591
|
+
value: c,
|
|
1592
|
+
get children() {
|
|
1593
|
+
return c.outlet();
|
|
1594
|
+
}
|
|
1595
|
+
})
|
|
1596
|
+
});
|
|
1571
1597
|
};
|
|
1572
1598
|
|
|
1573
1599
|
// for data only mode with single flight mutations
|
|
@@ -2187,7 +2213,7 @@ const useBeforeLeave = listener => {
|
|
|
2187
2213
|
};
|
|
2188
2214
|
|
|
2189
2215
|
const LocationHeader = "Location";
|
|
2190
|
-
const PRELOAD_TIMEOUT = 5000;
|
|
2216
|
+
const PRELOAD_TIMEOUT$1 = 5000;
|
|
2191
2217
|
const CACHE_TIMEOUT = 180000;
|
|
2192
2218
|
// When this client booted. Flight-registry entries (sharedConfig.has/load)
|
|
2193
2219
|
// hold values the server computed while rendering THIS page, so their age is
|
|
@@ -2213,6 +2239,23 @@ function getCache() {
|
|
|
2213
2239
|
return (req.router || (req.router = {})).cache || (req.router.cache = new Map());
|
|
2214
2240
|
}
|
|
2215
2241
|
|
|
2242
|
+
// Other keyed stores participating in the data protocols (live query
|
|
2243
|
+
// channels) register here — revalidate() stays the one invalidation verb,
|
|
2244
|
+
// and single-flight payloads reach every keyed store, not just the cache.
|
|
2245
|
+
const revalidateHooks = [];
|
|
2246
|
+
function registerRevalidateHook(hook) {
|
|
2247
|
+
revalidateHooks.push(hook);
|
|
2248
|
+
}
|
|
2249
|
+
const flightHooks = [];
|
|
2250
|
+
function registerFlightDataHook(hook) {
|
|
2251
|
+
flightHooks.push(hook);
|
|
2252
|
+
}
|
|
2253
|
+
/** Fans a single-flight payload out to registered keyed stores (the query
|
|
2254
|
+
* cache itself is seeded by the caller via query.set). */
|
|
2255
|
+
function deliverFlightData(data) {
|
|
2256
|
+
for (const hook of flightHooks) hook(data);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2216
2259
|
/**
|
|
2217
2260
|
* Revalidates the given cache entry/entries.
|
|
2218
2261
|
*/
|
|
@@ -2222,6 +2265,8 @@ function revalidate(key, force = true) {
|
|
|
2222
2265
|
force && (entry[0] = 0); //force cache miss
|
|
2223
2266
|
entry[4][1](now); // retrigger live signals
|
|
2224
2267
|
});
|
|
2268
|
+
const keys = key === undefined ? undefined : Array.isArray(key) ? key : [key];
|
|
2269
|
+
for (const hook of revalidateHooks) hook(keys, force);
|
|
2225
2270
|
}
|
|
2226
2271
|
function cacheKeyOp(key, fn) {
|
|
2227
2272
|
key && !Array.isArray(key) && (key = [key]);
|
|
@@ -2275,7 +2320,7 @@ function query(fn, name) {
|
|
|
2275
2320
|
tracking = true;
|
|
2276
2321
|
onCleanup(() => cached[4].count--);
|
|
2277
2322
|
}
|
|
2278
|
-
if (cached && cached[0] && (isServer || intent === "native" || cached[4].count || Date.now() - cached[0] < PRELOAD_TIMEOUT)) {
|
|
2323
|
+
if (cached && cached[0] && (isServer || intent === "native" || cached[4].count || Date.now() - cached[0] < PRELOAD_TIMEOUT$1)) {
|
|
2279
2324
|
if (tracking) {
|
|
2280
2325
|
cached[4].count++;
|
|
2281
2326
|
cached[4][0](); // track
|
|
@@ -2307,7 +2352,7 @@ function query(fn, name) {
|
|
|
2307
2352
|
// refetches instead of serving a minutes-old value on a fresh navigation.
|
|
2308
2353
|
if (!isServer && sharedConfig.has && sharedConfig.has(key)) {
|
|
2309
2354
|
const payloadAge = now - bootTime;
|
|
2310
|
-
if (!intent || payloadAge < (intent === "native" ? CACHE_TIMEOUT : PRELOAD_TIMEOUT)) {
|
|
2355
|
+
if (!intent || payloadAge < (intent === "native" ? CACHE_TIMEOUT : PRELOAD_TIMEOUT$1)) {
|
|
2311
2356
|
adopted = true;
|
|
2312
2357
|
res = sharedConfig.load(key); // hydrating
|
|
2313
2358
|
// @ts-ignore at least until we add a delete method to sharedConfig
|
|
@@ -2789,8 +2834,10 @@ function applyResponseMetadata(metadata, navigate, flightData) {
|
|
|
2789
2834
|
}
|
|
2790
2835
|
// invalidate
|
|
2791
2836
|
cacheKeyOp(keys, entry => entry[0] = 0);
|
|
2792
|
-
// set cache
|
|
2837
|
+
// set cache — and fan the payload out to other keyed stores (live query
|
|
2838
|
+
// channels adopt delivered values instead of reconnecting)
|
|
2793
2839
|
flightData && Object.keys(flightData).forEach(k => query.set(k, flightData[k]));
|
|
2840
|
+
flightData && deliverFlightData(flightData);
|
|
2794
2841
|
// trigger revalidation inside the same transition as the navigation, so
|
|
2795
2842
|
// the redirect commits atomically with fresh data: surviving consumers
|
|
2796
2843
|
// (shared layouts) the flight payload didn't seed refetch and hold the
|
|
@@ -2830,6 +2877,366 @@ async function handleResponse(response, error, navigate, metadataHandled) {
|
|
|
2830
2877
|
} : undefined;
|
|
2831
2878
|
}
|
|
2832
2879
|
|
|
2880
|
+
// Matches query's preload lifetime: a channel warmed by preload intent
|
|
2881
|
+
// stays held this long waiting for its navigation.
|
|
2882
|
+
const PRELOAD_TIMEOUT = 5000;
|
|
2883
|
+
|
|
2884
|
+
// The live-source brand (registered symbol — no transport import needed):
|
|
2885
|
+
// computations meeting a branded iterable apply live SSR policy (document
|
|
2886
|
+
// face takes the first value; hydration re-runs the compute after adoption
|
|
2887
|
+
// to reconnect). liveQuery writes it on both faces itself — the server-side
|
|
2888
|
+
// resolved iterable and the client-side multicast iterable — so a producer
|
|
2889
|
+
// needs no separate live() declaration to be live here.
|
|
2890
|
+
const LIVE_SOURCE = Symbol.for("solid.LiveSource");
|
|
2891
|
+
const channelMap = new Map();
|
|
2892
|
+
// Status outlives the channel (an "idle"/"closed" read after teardown is
|
|
2893
|
+
// meaningful), so signals live in their own keyed map.
|
|
2894
|
+
const statusMap = new Map();
|
|
2895
|
+
function statusSignal(key) {
|
|
2896
|
+
let s = statusMap.get(key);
|
|
2897
|
+
// ownedWrite: the first write happens synchronously inside whatever
|
|
2898
|
+
// computation pulled the channel open — this is channel-owned state, not
|
|
2899
|
+
// the computation's own.
|
|
2900
|
+
if (!s) statusMap.set(key, s = createSignal("idle", {
|
|
2901
|
+
ownedWrite: true
|
|
2902
|
+
}));
|
|
2903
|
+
return s;
|
|
2904
|
+
}
|
|
2905
|
+
function wake(ch) {
|
|
2906
|
+
const waiters = [...ch.waiters];
|
|
2907
|
+
ch.waiters.clear();
|
|
2908
|
+
for (const w of waiters) w();
|
|
2909
|
+
}
|
|
2910
|
+
const noStatus = () => {};
|
|
2911
|
+
async function connect(ch) {
|
|
2912
|
+
const gen = ch.gen;
|
|
2913
|
+
const setStatus = ch.report ? statusSignal(ch.key)[1] : noStatus;
|
|
2914
|
+
let attempts = 0;
|
|
2915
|
+
setStatus(ch.version ? "reconnecting" : "connecting");
|
|
2916
|
+
while (true) {
|
|
2917
|
+
try {
|
|
2918
|
+
const result = await ch.call();
|
|
2919
|
+
if (gen !== ch.gen || ch.done) {
|
|
2920
|
+
// superseded (reconnect) or torn down while connecting: the arrived
|
|
2921
|
+
// stream must still be ended or its request leaks
|
|
2922
|
+
result?.[Symbol.asyncIterator] && result[Symbol.asyncIterator]().return?.();
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
// A live()-declared producer's own retry loop erases deaths from the
|
|
2926
|
+
// value stream and reports them through onstatus — forward those into
|
|
2927
|
+
// the channel's status so both producer kinds read the same. Its
|
|
2928
|
+
// "closed" is redundant with our own done handling below.
|
|
2929
|
+
if (result !== null && typeof result === "object") {
|
|
2930
|
+
try {
|
|
2931
|
+
result.onstatus = state => {
|
|
2932
|
+
if (gen === ch.gen && state !== "closed") setStatus(state);
|
|
2933
|
+
};
|
|
2934
|
+
} catch {}
|
|
2935
|
+
}
|
|
2936
|
+
const it = result !== null && typeof result === "object" && result[Symbol.asyncIterator] ? result[Symbol.asyncIterator]() : async function* () {
|
|
2937
|
+
yield result;
|
|
2938
|
+
}();
|
|
2939
|
+
ch.close = () => {
|
|
2940
|
+
try {
|
|
2941
|
+
const r = it.return && it.return();
|
|
2942
|
+
if (r && typeof r.then === "function") r.then(undefined, () => {});
|
|
2943
|
+
} catch {}
|
|
2944
|
+
};
|
|
2945
|
+
while (true) {
|
|
2946
|
+
const r = await it.next();
|
|
2947
|
+
if (gen !== ch.gen || ch.done) return;
|
|
2948
|
+
if (r.done) break;
|
|
2949
|
+
ch.latest = r.value;
|
|
2950
|
+
ch.version++;
|
|
2951
|
+
attempts = 0; // healthy value: backoff resets
|
|
2952
|
+
setStatus("connected");
|
|
2953
|
+
wake(ch);
|
|
2954
|
+
}
|
|
2955
|
+
ch.done = true;
|
|
2956
|
+
setStatus("closed");
|
|
2957
|
+
wake(ch);
|
|
2958
|
+
return;
|
|
2959
|
+
} catch (error) {
|
|
2960
|
+
if (gen !== ch.gen || ch.done) return;
|
|
2961
|
+
const status = error?.status;
|
|
2962
|
+
if (!ch.version || typeof status === "number" && status >= 400 && status < 500) {
|
|
2963
|
+
// Terminal: never connected (fail like a normal call), or a
|
|
2964
|
+
// definite rejection — the transport stamps HTTP statuses onto
|
|
2965
|
+
// failures, and a 4xx means the server understood and refused;
|
|
2966
|
+
// retrying cannot change the answer. Consumers rethrow (after
|
|
2967
|
+
// draining the latest value), surfacing to error boundaries.
|
|
2968
|
+
ch.error = error;
|
|
2969
|
+
ch.done = true;
|
|
2970
|
+
setStatus("closed");
|
|
2971
|
+
wake(ch);
|
|
2972
|
+
return;
|
|
2973
|
+
}
|
|
2974
|
+
// A stream that had delivered died transiently (network, 5xx,
|
|
2975
|
+
// severed stream): the channel IS the live layer — reconnect with
|
|
2976
|
+
// backoff, latest value keeps serving meanwhile. (A live()-declared
|
|
2977
|
+
// producer never reaches here; its transport retries.)
|
|
2978
|
+
setStatus("reconnecting");
|
|
2979
|
+
let timer, wakeUp;
|
|
2980
|
+
await new Promise(resolve => {
|
|
2981
|
+
wakeUp = () => resolve();
|
|
2982
|
+
timer = setTimeout(wakeUp, Math.min(500 * 2 ** attempts++, 10000));
|
|
2983
|
+
// connectivity returning wakes the sleep early
|
|
2984
|
+
if (typeof addEventListener === "function") addEventListener("online", wakeUp, {
|
|
2985
|
+
once: true
|
|
2986
|
+
});
|
|
2987
|
+
});
|
|
2988
|
+
clearTimeout(timer);
|
|
2989
|
+
// a timer-woken sleep leaves its once-listener behind otherwise —
|
|
2990
|
+
// one leaked closure per backoff cycle until an online event fires
|
|
2991
|
+
if (typeof removeEventListener === "function") removeEventListener("online", wakeUp);
|
|
2992
|
+
if (gen !== ch.gen || ch.done) return;
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
function teardown(ch) {
|
|
2997
|
+
// Deferred so a same-key resubscribe in the same tick (a memo re-running
|
|
2998
|
+
// its compute) reuses the connection instead of thrashing it.
|
|
2999
|
+
queueMicrotask(() => {
|
|
3000
|
+
if (ch.count > 0 || ch.map.get(ch.key) !== ch) return;
|
|
3001
|
+
const ended = ch.done; // completed/failed on its own — "closed" persists
|
|
3002
|
+
ch.done = true;
|
|
3003
|
+
ch.close();
|
|
3004
|
+
wake(ch);
|
|
3005
|
+
if (ch.retain) return; // request-scoped: latest stays replayable
|
|
3006
|
+
ch.map.delete(ch.key);
|
|
3007
|
+
if (ch.report && !ended) statusSignal(ch.key)[1]("idle");
|
|
3008
|
+
});
|
|
3009
|
+
}
|
|
3010
|
+
function openChannel(map, key, call, report, retain) {
|
|
3011
|
+
let ch = map.get(key);
|
|
3012
|
+
if (!ch) {
|
|
3013
|
+
ch = {
|
|
3014
|
+
key,
|
|
3015
|
+
map,
|
|
3016
|
+
count: 0,
|
|
3017
|
+
version: 0,
|
|
3018
|
+
latest: undefined,
|
|
3019
|
+
error: undefined,
|
|
3020
|
+
done: false,
|
|
3021
|
+
waiters: new Set(),
|
|
3022
|
+
gen: 0,
|
|
3023
|
+
close: () => {},
|
|
3024
|
+
call,
|
|
3025
|
+
report,
|
|
3026
|
+
retain
|
|
3027
|
+
};
|
|
3028
|
+
map.set(key, ch);
|
|
3029
|
+
connect(ch);
|
|
3030
|
+
}
|
|
3031
|
+
return ch;
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
// The consumer half: a branded iterable whose iterators subscribe to the
|
|
3035
|
+
// channel lazily (first pull connects — a hydration trace or speculative
|
|
3036
|
+
// call opens nothing), replay the latest value immediately, then follow
|
|
3037
|
+
// with latest-wins delivery.
|
|
3038
|
+
function subscriberIterable(open) {
|
|
3039
|
+
return {
|
|
3040
|
+
[LIVE_SOURCE]: true,
|
|
3041
|
+
[Symbol.asyncIterator]() {
|
|
3042
|
+
const ch = open();
|
|
3043
|
+
ch.count++;
|
|
3044
|
+
let seen = 0;
|
|
3045
|
+
let released = false;
|
|
3046
|
+
const release = () => {
|
|
3047
|
+
if (released) return;
|
|
3048
|
+
released = true;
|
|
3049
|
+
if (--ch.count === 0) teardown(ch);
|
|
3050
|
+
};
|
|
3051
|
+
const pull = async () => {
|
|
3052
|
+
while (true) {
|
|
3053
|
+
if (ch.error && seen >= ch.version) {
|
|
3054
|
+
release();
|
|
3055
|
+
throw ch.error;
|
|
3056
|
+
}
|
|
3057
|
+
if (ch.version > seen) {
|
|
3058
|
+
seen = ch.version;
|
|
3059
|
+
return {
|
|
3060
|
+
done: false,
|
|
3061
|
+
value: ch.latest
|
|
3062
|
+
};
|
|
3063
|
+
}
|
|
3064
|
+
if (ch.done) {
|
|
3065
|
+
release();
|
|
3066
|
+
return {
|
|
3067
|
+
done: true,
|
|
3068
|
+
value: undefined
|
|
3069
|
+
};
|
|
3070
|
+
}
|
|
3071
|
+
await new Promise(resolve => ch.waiters.add(resolve));
|
|
3072
|
+
}
|
|
3073
|
+
};
|
|
3074
|
+
return {
|
|
3075
|
+
next: () => released ? Promise.resolve({
|
|
3076
|
+
done: true,
|
|
3077
|
+
value: undefined
|
|
3078
|
+
}) : pull(),
|
|
3079
|
+
return(value) {
|
|
3080
|
+
release();
|
|
3081
|
+
return Promise.resolve({
|
|
3082
|
+
done: true,
|
|
3083
|
+
value
|
|
3084
|
+
});
|
|
3085
|
+
}
|
|
3086
|
+
};
|
|
3087
|
+
}
|
|
3088
|
+
};
|
|
3089
|
+
}
|
|
3090
|
+
function reconnectChannel(ch) {
|
|
3091
|
+
ch.gen++;
|
|
3092
|
+
ch.close();
|
|
3093
|
+
ch.error = undefined;
|
|
3094
|
+
ch.done = false;
|
|
3095
|
+
connect(ch);
|
|
3096
|
+
}
|
|
3097
|
+
function push(ch, value) {
|
|
3098
|
+
ch.latest = value;
|
|
3099
|
+
ch.version++;
|
|
3100
|
+
wake(ch);
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
// Live channels participate in both data protocols. Registered on first
|
|
3104
|
+
// liveQuery() call, not at module scope: a side-effectful top level would
|
|
3105
|
+
// anchor this module into graphs that never use it.
|
|
3106
|
+
//
|
|
3107
|
+
// - Explicit revalidate(key) (force) reconnects — the producer re-yields
|
|
3108
|
+
// current state on invocation by contract.
|
|
3109
|
+
// - The post-mutation sweep (force=false) does NOT reconnect: the
|
|
3110
|
+
// connection is alive and is itself the freshness mechanism — a
|
|
3111
|
+
// push-driven producer yields the mutated state on its own, and tearing
|
|
3112
|
+
// down healthy connections on every mutation defeats the model.
|
|
3113
|
+
// - Single-flight payloads deliver INTO open channels, exactly as they seed
|
|
3114
|
+
// the query cache: the mutation response is the round trip, the channel
|
|
3115
|
+
// adopts the value immediately, and the live stream stays authoritative
|
|
3116
|
+
// for everything after.
|
|
3117
|
+
let hooked = false;
|
|
3118
|
+
function hookRevalidate() {
|
|
3119
|
+
if (hooked) return;
|
|
3120
|
+
hooked = true;
|
|
3121
|
+
registerRevalidateHook((keys, force) => {
|
|
3122
|
+
if (!force) return;
|
|
3123
|
+
for (const [k, ch] of channelMap) {
|
|
3124
|
+
if (keys === undefined || matchKey(k, keys)) reconnectChannel(ch);
|
|
3125
|
+
}
|
|
3126
|
+
});
|
|
3127
|
+
registerFlightDataHook(data => {
|
|
3128
|
+
for (const k in data) {
|
|
3129
|
+
const ch = channelMap.get(k);
|
|
3130
|
+
if (!ch || ch.done) continue;
|
|
3131
|
+
const v = data[k];
|
|
3132
|
+
if (v !== null && typeof v === "object" && v[Symbol.asyncIterator]) {
|
|
3133
|
+
// a live key collected server-side arrives stream-shaped: adopt
|
|
3134
|
+
// its yields for as long as it runs (it ends with the response
|
|
3135
|
+
// body); the channel's own connection remains authoritative
|
|
3136
|
+
(async () => {
|
|
3137
|
+
try {
|
|
3138
|
+
for await (const value of v) {
|
|
3139
|
+
if (ch.done) return;
|
|
3140
|
+
push(ch, value);
|
|
3141
|
+
}
|
|
3142
|
+
} catch {}
|
|
3143
|
+
})();
|
|
3144
|
+
} else push(ch, v);
|
|
3145
|
+
}
|
|
3146
|
+
});
|
|
3147
|
+
}
|
|
3148
|
+
/**
|
|
3149
|
+
* Declares a keyed live query over a value-shaped stream: an async-iterable
|
|
3150
|
+
* producer whose yields are successive VALUES of one logical query, with the
|
|
3151
|
+
* contract that the producer re-yields current state on every invocation.
|
|
3152
|
+
* liveQuery IS the live layer — no separate declaration needed:
|
|
3153
|
+
*
|
|
3154
|
+
* - One connection per (name + args) key, shared by every consumer. Late
|
|
3155
|
+
* subscribers receive the latest value immediately, delivery is
|
|
3156
|
+
* latest-wins, and the connection closes when the last consumer leaves.
|
|
3157
|
+
* - A connected stream that dies transiently (network, 5xx) reconnects with
|
|
3158
|
+
* exponential backoff — the latest value keeps serving meanwhile. A
|
|
3159
|
+
* definite rejection (4xx: the transport stamps HTTP statuses onto
|
|
3160
|
+
* failures) ends the channel and surfaces the error to consumers, as does
|
|
3161
|
+
* a first-connect failure.
|
|
3162
|
+
* - Server functions are declared GET at creation (like `query`). Live
|
|
3163
|
+
* queries participate in single-flight on the delivery side: a mutation's
|
|
3164
|
+
* flight payload pushes straight into open channels (the mutation
|
|
3165
|
+
* response is the round trip), while the post-mutation sweep leaves the
|
|
3166
|
+
* healthy connection in place — the live stream stays authoritative.
|
|
3167
|
+
* - Calling under preload intent warms the channel (a temporary hold keeps
|
|
3168
|
+
* it open through the preload window), so navigation renders against an
|
|
3169
|
+
* already-connected stream instead of holding the transition on connect
|
|
3170
|
+
* plus first yield. Outside preload, calling remains free — only a real
|
|
3171
|
+
* pull connects.
|
|
3172
|
+
* - SSR: the document face renders the first value; hydration adopts it and
|
|
3173
|
+
* reconnects. Channels are request-scoped on the server, so every
|
|
3174
|
+
* consumer of a key in one render observes the same value. Explicit
|
|
3175
|
+
* `revalidate(key)` reconnects (the producer re-yields current state on
|
|
3176
|
+
* invocation), and `status(...args)` is a reactive read of the channel's
|
|
3177
|
+
* wire state.
|
|
3178
|
+
*
|
|
3179
|
+
* ```ts
|
|
3180
|
+
* const roomMessages = liveQuery(getMessages, "messages");
|
|
3181
|
+
* // in a component — one connection, however many consumers:
|
|
3182
|
+
* const messages = createMemo(() => roomMessages(props.room));
|
|
3183
|
+
* ```
|
|
3184
|
+
*/
|
|
3185
|
+
function liveQuery(fn, name) {
|
|
3186
|
+
hookRevalidate();
|
|
3187
|
+
// liveQuery implies GET, exactly as query does: reads belong on the GET
|
|
3188
|
+
// transport (cacheable URLs, no single-flight enveloping). An explicit
|
|
3189
|
+
// GET(fn) or live(GET(fn)) declaration passes through untouched.
|
|
3190
|
+
if (isServerFunction(fn) && !getServerFunctionMetadata(fn)?.method) {
|
|
3191
|
+
const rpc = getServerFunctionRPC();
|
|
3192
|
+
if (rpc) fn = rpc.GET(fn);
|
|
3193
|
+
}
|
|
3194
|
+
const liveFn = (...args) => {
|
|
3195
|
+
const key = name + hashKey(args);
|
|
3196
|
+
if (isServer) {
|
|
3197
|
+
const e = getRequestEvent();
|
|
3198
|
+
if (!e) {
|
|
3199
|
+
// No request scope (static render, tests): hand the source through
|
|
3200
|
+
// whole, branded, so the signals layer applies live SSR policy
|
|
3201
|
+
// (first value, then client takeover).
|
|
3202
|
+
const result = fn(...args);
|
|
3203
|
+
const brand = r => {
|
|
3204
|
+
if (r !== null && typeof r === "object" && r[Symbol.asyncIterator]) r[LIVE_SOURCE] = true;
|
|
3205
|
+
return r;
|
|
3206
|
+
};
|
|
3207
|
+
return result !== null && typeof result === "object" && typeof result.then === "function" ? result.then(brand) : brand(result);
|
|
3208
|
+
}
|
|
3209
|
+
// Request-scoped channels: two consumers of one key during one render
|
|
3210
|
+
// must observe the SAME first value (query's request cache gives its
|
|
3211
|
+
// reads the same guarantee). Channels live in the event, retained
|
|
3212
|
+
// past teardown so a later consumer replays the settled value instead
|
|
3213
|
+
// of reinvoking; the producer still closes with its last consumer.
|
|
3214
|
+
const router = e.router || (e.router = {});
|
|
3215
|
+
const channels = router.liveChannels || (router.liveChannels = new Map());
|
|
3216
|
+
return subscriberIterable(() => openChannel(channels, key, () => fn(...args), false, true));
|
|
3217
|
+
}
|
|
3218
|
+
const open = () => openChannel(channelMap, key, () => fn(...args), true, false);
|
|
3219
|
+
// Preload warms the channel, exactly as calling a query in a preload
|
|
3220
|
+
// function warms its cache: connect now, hold a temporary consumer slot
|
|
3221
|
+
// so refcounting doesn't close it before navigation renders. When the
|
|
3222
|
+
// real consumer arrives it inherits the live connection (first pull
|
|
3223
|
+
// replays the already-arrived value — the transition doesn't hold);
|
|
3224
|
+
// when navigation never comes, the hold lapses and teardown closes it.
|
|
3225
|
+
if (getIntent() === "preload") {
|
|
3226
|
+
const ch = open();
|
|
3227
|
+
ch.count++;
|
|
3228
|
+
setTimeout(() => {
|
|
3229
|
+
if (--ch.count === 0) teardown(ch);
|
|
3230
|
+
}, PRELOAD_TIMEOUT);
|
|
3231
|
+
}
|
|
3232
|
+
return subscriberIterable(open);
|
|
3233
|
+
};
|
|
3234
|
+
liveFn.keyFor = (...args) => name + hashKey(args);
|
|
3235
|
+
liveFn.key = name;
|
|
3236
|
+
liveFn.status = (...args) => statusSignal(name + hashKey(args))[0]();
|
|
3237
|
+
return liveFn;
|
|
3238
|
+
}
|
|
3239
|
+
|
|
2833
3240
|
// The delegation fallback's lazy entry (see data/events.ts). Nothing imports
|
|
2834
3241
|
// this module statically — it exists so the dynamic import has a target that
|
|
2835
3242
|
// bundlers can keep as a split point: router-only apps never load the action
|
|
@@ -2840,4 +3247,4 @@ var serverForms = /*#__PURE__*/Object.freeze({
|
|
|
2840
3247
|
submitServerForm: submitServerForm
|
|
2841
3248
|
});
|
|
2842
3249
|
|
|
2843
|
-
export { RouterContextObj as RouterContext, mergeSearchString as _mergeSearchString, action, browserHistory, createBeforeLeave, createRouter, defineRoute, defineRoutes, hashHistory, int, memoryHistory, query, revalidate, useAction, useBeforeLeave, useHref, useIsRouting, useLinkState, useLocation, useMatch, useNavigate, useParams, usePreloadRoute, useResolvedPath, useRouteMatches, useSearchParams, useSubmissions };
|
|
3250
|
+
export { RouterContextObj as RouterContext, mergeSearchString as _mergeSearchString, action, browserHistory, createBeforeLeave, createRouter, defineRoute, defineRoutes, hashHistory, int, liveQuery, memoryHistory, query, revalidate, useAction, useBeforeLeave, useHref, useIsRouting, useLinkState, useLocation, useMatch, useNavigate, useParams, usePreloadRoute, useResolvedPath, useRouteMatches, useSearchParams, useSubmissions };
|
package/dist/paths.d.ts
CHANGED
|
@@ -108,6 +108,14 @@ type MultiPathContrib<Ps extends readonly string[], Def, PAcc extends Params> =
|
|
|
108
108
|
export type RoutePaths<R extends readonly RouteDefinition[]> = number extends R["length"] ? any : PathEnd<DefaultSearchTypes, {}> & TuplePaths<R, {}>;
|
|
109
109
|
/** Extracts the params record a paths node binds, as runtime (string-valued) params. */
|
|
110
110
|
export type PathParamsOf<N> = N extends TypedPath<infer P> ? Flat<P> : Params;
|
|
111
|
+
/**
|
|
112
|
+
* The global Href brand (`@solidjs/web` reads the same registry symbol).
|
|
113
|
+
* On a paths node it holds the logical path — the routable pathname before
|
|
114
|
+
* `renderPath` decorates it for display (eg. hash mode's `#` prefix) — so
|
|
115
|
+
* `navigate()` and `redirect()` can route the node while `toString()` keeps
|
|
116
|
+
* yielding the display href for the DOM.
|
|
117
|
+
*/
|
|
118
|
+
export declare const HREF: unique symbol;
|
|
111
119
|
/**
|
|
112
120
|
* Creates the runtime path proxy. It is instance-scoped: `renderPath` comes
|
|
113
121
|
* from the router's history adapter (eg. hash routing prefixes `#`), and
|
package/dist/paths.js
CHANGED
|
@@ -5,6 +5,14 @@ export const int = ((s) => /^-?\d+$/.test(s));
|
|
|
5
5
|
// Runtime
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
const encodeParam = (value) => String(value).split("/").map(encodeURIComponent).join("/");
|
|
8
|
+
/**
|
|
9
|
+
* The global Href brand (`@solidjs/web` reads the same registry symbol).
|
|
10
|
+
* On a paths node it holds the logical path — the routable pathname before
|
|
11
|
+
* `renderPath` decorates it for display (eg. hash mode's `#` prefix) — so
|
|
12
|
+
* `navigate()` and `redirect()` can route the node while `toString()` keeps
|
|
13
|
+
* yielding the display href for the DOM.
|
|
14
|
+
*/
|
|
15
|
+
export const HREF = Symbol.for("solid.Href");
|
|
8
16
|
/**
|
|
9
17
|
* Creates the runtime path proxy. It is instance-scoped: `renderPath` comes
|
|
10
18
|
* from the router's history adapter (eg. hash routing prefixes `#`), and
|
|
@@ -32,7 +40,11 @@ export function createPathsProxy(renderPath = p => p, base = "") {
|
|
|
32
40
|
if (prop === "toString")
|
|
33
41
|
return () => toHref(pathname);
|
|
34
42
|
if (typeof prop === "symbol")
|
|
35
|
-
return prop === Symbol.toPrimitive
|
|
43
|
+
return prop === Symbol.toPrimitive
|
|
44
|
+
? () => toHref(pathname)
|
|
45
|
+
: prop === HREF
|
|
46
|
+
? pathname || "/"
|
|
47
|
+
: undefined;
|
|
36
48
|
return node(`${pathname}/${prop}`);
|
|
37
49
|
}
|
|
38
50
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*@refresh skip*/
|
|
2
|
-
import { createMemo, createRoot, getOwner, onCleanup, runWithOwner, untrack } from "solid-js";
|
|
2
|
+
import { createMemo, createRoot, getOwner, onCleanup, runWithOwner, untrack, Show } from "solid-js";
|
|
3
3
|
import { getRequestEvent, isServer } from "@solidjs/web";
|
|
4
4
|
import { createRouteContext, getIntent, getRouteMatches, resolveLazySubtree, RouteContextObj, setInPreloadFn, unresolvedLazyMatches } from "../routing.js";
|
|
5
5
|
export function Root(props) {
|
|
@@ -104,13 +104,17 @@ export function Routes(props) {
|
|
|
104
104
|
return <>{outlet()}</>;
|
|
105
105
|
}
|
|
106
106
|
const createOutlet = (child) => {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
107
|
+
// Keyed on the context's identity (#588). `when` compiles to a lazy prop
|
|
108
|
+
// getter, so child() is tracked inside Show's memo — the outlet function
|
|
109
|
+
// reads nothing reactive and Show owns the subtree. Levels whose context is
|
|
110
|
+
// reference-reused (parent layouts) keep their subtree across navigations;
|
|
111
|
+
// a changed context must re-create the provider, not just re-render under
|
|
112
|
+
// it: a context value is registered once at provider creation
|
|
113
|
+
// (setContext(provider, props.value)), so non-keyed reuse would leave new
|
|
114
|
+
// route content reading the previous level's context.
|
|
115
|
+
return () => (<Show when={child()} keyed>
|
|
116
|
+
{c => <RouteContextObj value={c}>{c.outlet()}</RouteContextObj>}
|
|
117
|
+
</Show>);
|
|
114
118
|
};
|
|
115
119
|
// for data only mode with single flight mutations
|
|
116
120
|
function dataOnly(event, routerState, branches) {
|
package/dist/routing.js
CHANGED
|
@@ -5,6 +5,7 @@ import { DEV } from "solid-js";
|
|
|
5
5
|
import { createComponent, createContext, createMemo, createSignal, isPending, NotReadyError, untrack, useContext } from "solid-js";
|
|
6
6
|
import { clearFlashCookie, getRequestEvent, hasFlashCookie, isServer } from "@solidjs/web";
|
|
7
7
|
import { mockBase, comparablePath, createMemoObject, extractSearchParams, invariant, resolvePath, createMatcher, joinPaths, scoreRoute, mergeSearchString, expandOptionals } from "./utils.js";
|
|
8
|
+
import { HREF } from "./paths.js";
|
|
8
9
|
const MAX_REDIRECTS = 100;
|
|
9
10
|
/** Consider this API opaque and internal. It is likely to change in the future. */
|
|
10
11
|
export const RouterContextObj = createContext();
|
|
@@ -262,15 +263,20 @@ const encodeSegment = (s) => encodeURIComponent(s).replace(/%(2B|40|3A|24|26|2C|
|
|
|
262
263
|
const lazyBoundaries = new WeakMap();
|
|
263
264
|
// Module scope: boundary resolution is global, deterministic state (same
|
|
264
265
|
// thunk -> same routes), shared by every factory instance and the server's
|
|
265
|
-
// flight collector.
|
|
266
|
+
// flight collector. The counter is the source of truth; the client mirrors
|
|
267
|
+
// it into a signal for subscription. Server render is pure (signal writes
|
|
268
|
+
// are inert as of solid 2.0.0-rc.3): there the plain counter serves reads,
|
|
269
|
+
// and re-runs come from the parked computation's promise retry, not from
|
|
270
|
+
// reactivity.
|
|
271
|
+
let lazyTreeCounter = 0;
|
|
266
272
|
const [lazyTreeVersion, setLazyTreeVersion] = createSignal(0);
|
|
267
273
|
/** Reactive read of the lazy-subtree version — recompile compiled branches when it changes. */
|
|
268
274
|
export function trackLazySubtrees() {
|
|
269
|
-
return lazyTreeVersion();
|
|
275
|
+
return isServer ? lazyTreeCounter : lazyTreeVersion();
|
|
270
276
|
}
|
|
271
277
|
/** Non-reactive read, for cache-busting outside the reactive graph (server collectors). */
|
|
272
278
|
export function peekLazySubtrees() {
|
|
273
|
-
return
|
|
279
|
+
return lazyTreeCounter;
|
|
274
280
|
}
|
|
275
281
|
function getLazyBoundary(thunk) {
|
|
276
282
|
let record = lazyBoundaries.get(thunk);
|
|
@@ -314,7 +320,8 @@ export function resolveLazySubtree(record) {
|
|
|
314
320
|
m.routes ||
|
|
315
321
|
[];
|
|
316
322
|
record.resolved = routes;
|
|
317
|
-
|
|
323
|
+
lazyTreeCounter++;
|
|
324
|
+
isServer || setLazyTreeVersion(lazyTreeCounter);
|
|
318
325
|
return record.resolved;
|
|
319
326
|
}, e => {
|
|
320
327
|
// ?? Error(): a held undefined would read as "no failure" and refire
|
|
@@ -694,9 +701,20 @@ export function createRouterContext(integration, branches, getContext, options =
|
|
|
694
701
|
}
|
|
695
702
|
return;
|
|
696
703
|
}
|
|
697
|
-
//
|
|
704
|
+
// A paths node carries its logical path under the Href brand — read
|
|
705
|
+
// that rather than coercing: toString() renders the *display* href
|
|
706
|
+
// (eg. hash mode's `#` prefix), which is for the DOM, not for routing.
|
|
707
|
+
// Foreign Href-branded values without the slot still coerce.
|
|
698
708
|
if (typeof to !== "string")
|
|
699
|
-
to = to.toString();
|
|
709
|
+
to = to[HREF] || to.toString();
|
|
710
|
+
// Display hrefs can still arrive as plain strings: terminating paths
|
|
711
|
+
// calls type as `string`, and redirect Location headers round-trip
|
|
712
|
+
// through here. Under hash mode those start with `#` — a spelling no
|
|
713
|
+
// logical path uses — so map them back through the integration's
|
|
714
|
+
// parser, exactly like the anchor click handler does. Elsewhere
|
|
715
|
+
// parsePath is identity and `#...` keeps its URL meaning below.
|
|
716
|
+
if (to[0] === "#")
|
|
717
|
+
to = parsePath(to);
|
|
700
718
|
const { replace, resolve, scroll, state: nextState } = {
|
|
701
719
|
replace: false,
|
|
702
720
|
resolve: true,
|
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.18",
|
|
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.28",
|
|
41
|
-
"@solidjs/web": "^2.0.0-rc.
|
|
41
|
+
"@solidjs/web": "^2.0.0-rc.3",
|
|
42
42
|
"@types/jest": "^29.5.14",
|
|
43
43
|
"@types/node": "^22.10.0",
|
|
44
|
-
"babel-preset-solid": "^2.0.0-rc.
|
|
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.3",
|
|
49
49
|
"typescript": "^5.7.2",
|
|
50
50
|
"vite": "^6.0.0",
|
|
51
51
|
"vitest": "^2.1.6"
|
|
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.3",
|
|
55
|
+
"solid-js": "^2.0.0-rc.3"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {
|
|
58
58
|
"build": "rm -rf dist && tsc && rollup -c",
|