@solidjs/router 2.0.0-next.33 → 2.0.0-next.35
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 +24 -7
- package/dist/claims.d.ts +14 -6
- package/dist/claims.js +64 -28
- package/dist/data/action.js +201 -54
- package/dist/data/events.js +2 -2
- package/dist/index.js +382 -128
- package/dist/routing.d.ts +10 -2
- package/dist/routing.js +40 -45
- package/dist/utils.d.ts +29 -1
- package/dist/utils.js +48 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -10,8 +10,57 @@ function normalizePath(path, omitSlash = false) {
|
|
|
10
10
|
return s ? omitSlash || /^[?#]/.test(s) ? s : "/" + s : "";
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Pathname stripped of search/hash and trailing slash, percent-encoded the
|
|
15
|
+
* way `URL` (and so `location.pathname`) reports it, lowercased — the form
|
|
16
|
+
* link matching compares. Raw and encoded spellings (`/café`, `/caf%C3%A9`)
|
|
17
|
+
* meet; escapes are never decoded, so `%2F` is not a path separator.
|
|
18
|
+
*/
|
|
19
|
+
const comparablePath = path => new URL(mockBase + normalizePath(path.split(/[?#]/, 1)[0])).pathname.toLowerCase().replace(/\/$/, "");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether a URL pathname is the router's base path or under it, on a segment
|
|
23
|
+
* boundary: base `/app` covers `/app`, `/app/` and `/app/x`, not `/apple`.
|
|
24
|
+
* Case-insensitive; no base (`""` or `/`) covers every path.
|
|
25
|
+
*/
|
|
26
|
+
function isUnderBase(pathname, base) {
|
|
27
|
+
const b = base.toLowerCase().replace(/\/+$/, "");
|
|
28
|
+
const p = pathname.toLowerCase();
|
|
29
|
+
return !b || !p || p === b || p.startsWith(b + "/");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A query string as an order-independent comparable string. */
|
|
33
|
+
const comparableQuery = search => {
|
|
34
|
+
const params = new URLSearchParams(search);
|
|
35
|
+
params.sort();
|
|
36
|
+
return params.toString();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The link-state rule shared by claimed anchors and `useLinkState`, given
|
|
41
|
+
* the location and a link's already-resolved target (path, optional query
|
|
42
|
+
* and hash):
|
|
43
|
+
*
|
|
44
|
+
* - `current` — same pathname and same query (parameter order and hash aside)
|
|
45
|
+
* - `active` — same pathname or one under it; the router's root (`base`, or
|
|
46
|
+
* `/` without one) only matches exactly, since it is a prefix of every
|
|
47
|
+
* page. `end` makes every link exact-only.
|
|
48
|
+
*/
|
|
49
|
+
function matchLink(location, target, base, end) {
|
|
50
|
+
if (target === undefined) return {
|
|
51
|
+
active: false,
|
|
52
|
+
current: false
|
|
53
|
+
};
|
|
54
|
+
const loc = comparablePath(location.pathname);
|
|
55
|
+
const path = comparablePath(target);
|
|
56
|
+
const exact = loc === path;
|
|
57
|
+
const hashless = target.split("#", 1)[0];
|
|
58
|
+
const q = hashless.indexOf("?");
|
|
59
|
+
return {
|
|
60
|
+
active: exact || !end && path !== "" && path !== comparablePath(base) && loc.startsWith(path + "/"),
|
|
61
|
+
current: exact && comparableQuery(location.search) === comparableQuery(q < 0 ? "" : hashless.slice(q))
|
|
62
|
+
};
|
|
63
|
+
}
|
|
15
64
|
function resolvePath(base, path, from) {
|
|
16
65
|
if (hasSchemeRegex.test(path)) {
|
|
17
66
|
return undefined;
|
|
@@ -179,15 +228,33 @@ function setFunctionName(obj, value) {
|
|
|
179
228
|
}
|
|
180
229
|
|
|
181
230
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
231
|
+
* Claimed forms are handed to this slot instead of the claims importing the
|
|
232
|
+
* action module: the action side installs it on first action creation (see
|
|
233
|
+
* data/action.ts), where form `aria-busy` state lives, so an app that never
|
|
234
|
+
* creates an action never pulls the data layer in through its claims.
|
|
235
|
+
*/
|
|
236
|
+
let formClaim;
|
|
237
|
+
function setFormClaimHandler(handler) {
|
|
238
|
+
formClaim = handler;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The compiler claims every `a[href]` and `form[action]` at creation, and the
|
|
243
|
+
* runtime re-claims on `href`/`action` writes and after a server-component
|
|
244
|
+
* morph changes the element. Forms go to the action layer's slot above, which
|
|
245
|
+
* re-applies `aria-busy` while their action is in flight. This consumer gives each router-managed anchor the link-state
|
|
246
|
+
* vocabulary without a wrapper component:
|
|
186
247
|
*
|
|
187
|
-
* - `aria-current="page"` — the location matches the link exactly
|
|
188
|
-
*
|
|
248
|
+
* - `aria-current="page"` — the location matches the link exactly, query
|
|
249
|
+
* included (parameter order aside)
|
|
250
|
+
* - `data-active` — pathname exact or prefix match (the router's root, its
|
|
251
|
+
* base path, exact only)
|
|
189
252
|
* - `data-pending` — the link is the target of an in-flight navigation
|
|
190
253
|
*
|
|
254
|
+
* The matching rule is `matchLink`, shared with `useLinkState`. The router
|
|
255
|
+
* only touches an `aria-current` it wrote itself: one the author set (a
|
|
256
|
+
* stepper's `"step"`, a static `"page"`) is left in place, current or not.
|
|
257
|
+
*
|
|
191
258
|
* Elements are claimed at creation, so late mounts (`<Show>`, `<For>`,
|
|
192
259
|
* portals) are correct immediately. One render effect (owned by the router)
|
|
193
260
|
* subscribes to the location and sweeps a registry of claimed anchors —
|
|
@@ -199,16 +266,16 @@ function setFunctionName(obj, value) {
|
|
|
199
266
|
*/
|
|
200
267
|
function setupLinkClaims(router, explicitLinks) {
|
|
201
268
|
const basePath = router.base.path();
|
|
202
|
-
// per-element record; `
|
|
203
|
-
//
|
|
269
|
+
// per-element record; `owned` is whether the `aria-current` on the element
|
|
270
|
+
// is the router's, so it never writes over or removes an authored one
|
|
204
271
|
const claimed = new WeakMap();
|
|
205
272
|
const registry = new Set();
|
|
206
273
|
function isSvg(el) {
|
|
207
274
|
return el.namespaceURI === "http://www.w3.org/2000/svg";
|
|
208
275
|
}
|
|
209
276
|
|
|
210
|
-
/** The
|
|
211
|
-
function
|
|
277
|
+
/** The anchor's resolved URL when the router manages it, else `undefined`. */
|
|
278
|
+
function managedUrl(a) {
|
|
212
279
|
if (explicitLinks && !a.hasAttribute("link")) return;
|
|
213
280
|
const svg = isSvg(a);
|
|
214
281
|
// claims fire at creation while the element is still in the template's
|
|
@@ -225,38 +292,55 @@ function setupLinkClaims(router, explicitLinks) {
|
|
|
225
292
|
} catch {
|
|
226
293
|
return;
|
|
227
294
|
}
|
|
228
|
-
if (url.origin !== window.location.origin ||
|
|
229
|
-
return
|
|
295
|
+
if (url.origin !== window.location.origin || !isUnderBase(url.pathname, basePath)) return;
|
|
296
|
+
return url;
|
|
230
297
|
}
|
|
231
298
|
function linkState(a) {
|
|
232
299
|
// read reactive sources unconditionally so the owning effect stays
|
|
233
300
|
// subscribed even while the anchor is not router-managed
|
|
234
|
-
const
|
|
301
|
+
const location = router.location;
|
|
235
302
|
const routing = router.isRouting();
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
const
|
|
303
|
+
const url = managedUrl(a);
|
|
304
|
+
const target = url && url.pathname + url.search;
|
|
305
|
+
// no per-anchor `end` opt-out like useLinkState has
|
|
306
|
+
const {
|
|
307
|
+
active,
|
|
308
|
+
current
|
|
309
|
+
} = matchLink(location, target, basePath);
|
|
240
310
|
// effects observe the committed location during a transition, so the
|
|
241
311
|
// in-flight target comes from pendingTarget — readable here because the
|
|
242
312
|
// isRouting write flushes after the target is assigned
|
|
243
|
-
const pending = routing && !!router.pendingTarget &&
|
|
313
|
+
const pending = routing && !!router.pendingTarget && matchLink({
|
|
314
|
+
pathname: router.pendingTarget.value,
|
|
315
|
+
search: ""
|
|
316
|
+
}, target, basePath).active;
|
|
244
317
|
return {
|
|
245
|
-
active
|
|
318
|
+
active,
|
|
246
319
|
pending,
|
|
247
|
-
|
|
320
|
+
current
|
|
248
321
|
};
|
|
249
322
|
}
|
|
250
323
|
function apply(a, rec, {
|
|
251
324
|
active,
|
|
252
325
|
pending,
|
|
253
|
-
|
|
326
|
+
current
|
|
254
327
|
}) {
|
|
255
328
|
active ? a.setAttribute("data-active", "") : a.removeAttribute("data-active");
|
|
256
329
|
pending ? a.setAttribute("data-pending", "") : a.removeAttribute("data-pending");
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
330
|
+
// Ownership is read against the element, not just the record. A
|
|
331
|
+
// server-component morph resets attributes to the server HTML, which
|
|
332
|
+
// never carries router link state, then re-claims: an owned value that
|
|
333
|
+
// went missing is re-applied, while a value the morph restored from the
|
|
334
|
+
// server HTML (or the author wrote since) is authored and left alone.
|
|
335
|
+
const value = a.getAttribute("aria-current");
|
|
336
|
+
if (rec.owned && value !== null && value !== "page") rec.owned = false;else if (current) {
|
|
337
|
+
if (value === null) {
|
|
338
|
+
a.setAttribute("aria-current", "page");
|
|
339
|
+
rec.owned = true;
|
|
340
|
+
}
|
|
341
|
+
} else if (rec.owned) {
|
|
342
|
+
if (value !== null) a.removeAttribute("aria-current");
|
|
343
|
+
rec.owned = false;
|
|
260
344
|
}
|
|
261
345
|
}
|
|
262
346
|
const refresh = (a, rec) => untrack(() => apply(a, rec, linkState(a)));
|
|
@@ -272,18 +356,20 @@ function setupLinkClaims(router, explicitLinks) {
|
|
|
272
356
|
// slot — lazy-route lookups miss and hydration leaves server nodes
|
|
273
357
|
// unclaimed. (The option is honored by the runtime but missing from the
|
|
274
358
|
// published EffectOptions type, hence the cast.)
|
|
275
|
-
createRenderEffect(() => (router.location.pathname, router.isRouting()), () => registry.forEach(a => refresh(a, claimed.get(a))), {
|
|
359
|
+
createRenderEffect(() => (router.location.pathname, router.location.search, router.isRouting()), () => registry.forEach(a => refresh(a, claimed.get(a))), {
|
|
276
360
|
transparent: true
|
|
277
361
|
});
|
|
278
362
|
onCleanup(registerElementClaim(node => {
|
|
279
|
-
|
|
363
|
+
const name = node.nodeName.toUpperCase();
|
|
364
|
+
if (name === "FORM") return formClaim && formClaim(node);
|
|
365
|
+
if (name !== "A") return;
|
|
280
366
|
const a = node;
|
|
281
367
|
// re-claim (href changed): the claiming write runs inside another
|
|
282
368
|
// effect, so refresh without leaking subscriptions into it
|
|
283
369
|
const existing = claimed.get(a);
|
|
284
370
|
if (existing) return refresh(a, existing);
|
|
285
371
|
const rec = {
|
|
286
|
-
|
|
372
|
+
owned: false
|
|
287
373
|
};
|
|
288
374
|
claimed.set(a, rec);
|
|
289
375
|
// claims fire during component setup, so an owner is present in
|
|
@@ -336,7 +422,7 @@ function setupNativeEvents({
|
|
|
336
422
|
// Skip non-http(s) schemes (blob:, mailto:, tel:, data:, ...). blob: URLs
|
|
337
423
|
// inherit the page origin, so the origin check below won't reject them. #382
|
|
338
424
|
if (url.protocol !== "https:" && url.protocol !== "http:") return;
|
|
339
|
-
if (url.origin !== window.location.origin ||
|
|
425
|
+
if (url.origin !== window.location.origin || !isUnderBase(url.pathname, basePath)) return;
|
|
340
426
|
return [a, url];
|
|
341
427
|
}
|
|
342
428
|
function handleAnchorClick(evt) {
|
|
@@ -855,27 +941,20 @@ const useLinkState = (href, options = {}) => {
|
|
|
855
941
|
const router = useRouter();
|
|
856
942
|
const location = router.location;
|
|
857
943
|
const to = useResolvedPath(() => String(href()));
|
|
858
|
-
|
|
859
|
-
const
|
|
860
|
-
const to_ = to();
|
|
861
|
-
return to_ === undefined ? undefined : comparablePath(to_);
|
|
862
|
-
});
|
|
863
|
-
const matches = loc => {
|
|
864
|
-
const path_ = path();
|
|
865
|
-
if (path_ === undefined) return [false, false];
|
|
866
|
-
const exact = loc === path_;
|
|
867
|
-
return [exact || !options.end && loc.startsWith(path_ + "/"), exact];
|
|
868
|
-
};
|
|
869
|
-
const state = createMemo(() => matches(decodeURI(comparablePath(location.pathname))));
|
|
944
|
+
const base = router.base.path();
|
|
945
|
+
const state = createMemo(() => matchLink(location, to(), base, options.end));
|
|
870
946
|
return {
|
|
871
|
-
active: () => state()
|
|
872
|
-
current: () => state()
|
|
947
|
+
active: createMemo(() => state().active),
|
|
948
|
+
current: createMemo(() => state().current),
|
|
873
949
|
// match the in-flight target explicitly (rather than active-while-routing)
|
|
874
950
|
// so the answer is the same from pure reads and from effects, which
|
|
875
951
|
// observe the committed location during a transition
|
|
876
952
|
pending: createMemo(() => {
|
|
877
953
|
state(); // location dependency: mid-flight target swaps recompute
|
|
878
|
-
return router.isRouting() && !!router.pendingTarget &&
|
|
954
|
+
return router.isRouting() && !!router.pendingTarget && matchLink({
|
|
955
|
+
pathname: router.pendingTarget.value,
|
|
956
|
+
search: ""
|
|
957
|
+
}, to(), base, options.end).active;
|
|
879
958
|
})
|
|
880
959
|
};
|
|
881
960
|
};
|
|
@@ -1190,6 +1269,17 @@ let flashDecoder;
|
|
|
1190
1269
|
function provideFlashDecoder(decoder) {
|
|
1191
1270
|
flashDecoder || (flashDecoder = decoder);
|
|
1192
1271
|
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* A request's flash cookie and its decode, shared by every router created
|
|
1275
|
+
* for that request. A server render can re-create the router — a suspension
|
|
1276
|
+
* under `<Errored>` retries from the nearest hole above it, which may sit
|
|
1277
|
+
* above the router — so state kept on one router instance would restart the
|
|
1278
|
+
* decode with every re-creation and the seeding read would never settle.
|
|
1279
|
+
* Keyed by the request event, so it lives exactly as long as the request.
|
|
1280
|
+
*/
|
|
1281
|
+
|
|
1282
|
+
const flashStates = new WeakMap();
|
|
1193
1283
|
let preloadIntent;
|
|
1194
1284
|
function getIntent() {
|
|
1195
1285
|
return preloadIntent || useOptionalContext(RouterContextObj)?.intent?.();
|
|
@@ -1228,63 +1318,68 @@ function createRouterContext(integration, branches, getContext, options = {}) {
|
|
|
1228
1318
|
// unread outcome must not haunt a later request's render. Only detection
|
|
1229
1319
|
// and clearing happen here (the runtime's isomorphic half); the raw header
|
|
1230
1320
|
// is stashed and decoding waits for the action-provided codec, read when
|
|
1231
|
-
// the lazily allocated submissions signal below first initializes.
|
|
1232
|
-
|
|
1321
|
+
// the lazily allocated submissions signal below first initializes. Both
|
|
1322
|
+
// happen once per request: a router re-created for the same request finds
|
|
1323
|
+
// the request's flash state (see `flashStates`) and neither clears again
|
|
1324
|
+
// nor decodes again.
|
|
1325
|
+
let flash;
|
|
1233
1326
|
if (isServer) {
|
|
1234
1327
|
const e = getRequestEvent();
|
|
1235
1328
|
if (e && !(e.router && e.router.submission)) {
|
|
1236
|
-
|
|
1237
|
-
if (
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1329
|
+
flash = flashStates.get(e);
|
|
1330
|
+
if (!flash) {
|
|
1331
|
+
const cookieHeader = e.request.headers.get("cookie");
|
|
1332
|
+
if (hasFlashCookie(cookieHeader)) {
|
|
1333
|
+
flashStates.set(e, flash = {
|
|
1334
|
+
cookieHeader
|
|
1335
|
+
});
|
|
1336
|
+
// one-shot: clear it even when unreadable so it can't haunt later renders
|
|
1337
|
+
if (e.response && e.response.headers) e.response.headers.append("Set-Cookie", clearFlashCookie());
|
|
1338
|
+
}
|
|
1241
1339
|
}
|
|
1242
1340
|
}
|
|
1243
1341
|
}
|
|
1244
1342
|
|
|
1245
|
-
// The decode, at most once per request: the decoder may answer with a
|
|
1246
|
-
// Promise (the cookie is encrypted; the runtime's decodeFlashCookie is
|
|
1247
|
-
// async), and this cache is what keeps the parked read's rerun from
|
|
1248
|
-
// restarting it — resumption finds the settled outcome and just reads it.
|
|
1249
|
-
// A decoder that rejects reads as "no flash", matching the runtime's own
|
|
1250
|
-
// malformed-cookie semantics.
|
|
1251
|
-
let flashDecode;
|
|
1252
|
-
|
|
1253
1343
|
// The seeding read, as a memo: NotReadyError must surface from a reactive
|
|
1254
1344
|
// node the graph can park and retry — never from router setup, which no
|
|
1255
|
-
// boundary guards — and the memo bounds the recompute to this function
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
1258
|
-
//
|
|
1345
|
+
// boundary guards — and the memo bounds the recompute to this function.
|
|
1346
|
+
// The decode runs at most once per request: the decoder may answer with a
|
|
1347
|
+
// Promise (the cookie is encrypted; the runtime's decodeFlashCookie is
|
|
1348
|
+
// async), and the request's cached state is what keeps a parked read's
|
|
1349
|
+
// rerun — or a re-created router's first read — from restarting it. A
|
|
1350
|
+
// decoder that rejects reads as "no flash", matching the runtime's own
|
|
1351
|
+
// malformed-cookie semantics. Created only when a flash cookie actually
|
|
1352
|
+
// arrived (server-only by construction: `flash` is only ever set there), and
|
|
1259
1353
|
// - `lazy`: server memos compute eagerly by default — deferred to first
|
|
1260
1354
|
// read, a request whose submissions are never read never decodes;
|
|
1261
1355
|
// - `transparent`: the memo exists on the server only, so its owner
|
|
1262
1356
|
// must not consume a hydration-id slot — the client, which seeds
|
|
1263
1357
|
// submissions as [] without ever creating this memo, would miss it
|
|
1264
1358
|
// and every sibling id would shift.
|
|
1265
|
-
const flashSubmission =
|
|
1359
|
+
const flashSubmission = flash !== undefined ? createMemo(() => {
|
|
1360
|
+
const state = flash;
|
|
1266
1361
|
if (!flashDecoder) return undefined;
|
|
1267
|
-
if (!
|
|
1268
|
-
const promise = flashDecoder(
|
|
1269
|
-
|
|
1362
|
+
if (!state.decode) {
|
|
1363
|
+
const promise = flashDecoder(state.cookieHeader).then(value => {
|
|
1364
|
+
state.decode = {
|
|
1270
1365
|
done: true,
|
|
1271
1366
|
value
|
|
1272
1367
|
};
|
|
1273
1368
|
}, () => {
|
|
1274
|
-
|
|
1369
|
+
state.decode = {
|
|
1275
1370
|
done: true,
|
|
1276
1371
|
value: undefined
|
|
1277
1372
|
};
|
|
1278
1373
|
});
|
|
1279
|
-
|
|
1374
|
+
state.decode = {
|
|
1280
1375
|
done: false,
|
|
1281
1376
|
promise
|
|
1282
1377
|
};
|
|
1283
1378
|
}
|
|
1284
1379
|
// SSR carries the Promise through NotReadyError so the parked
|
|
1285
1380
|
// reader can resume, exactly like the lazy matches above.
|
|
1286
|
-
if (!
|
|
1287
|
-
return
|
|
1381
|
+
if (!state.decode.done) throw new NotReadyError(state.decode.promise);
|
|
1382
|
+
return state.decode.value;
|
|
1288
1383
|
}, {
|
|
1289
1384
|
lazy: true,
|
|
1290
1385
|
transparent: true
|
|
@@ -3047,16 +3142,74 @@ const submitHooksSymbol = Symbol("routerActionSubmitHooks");
|
|
|
3047
3142
|
const settledHooksSymbol = Symbol("routerActionSettledHooks");
|
|
3048
3143
|
const invokeSymbol = Symbol("routerActionInvoke");
|
|
3049
3144
|
|
|
3050
|
-
// Forms submitted through delegation are marked `aria-busy`
|
|
3051
|
-
// action
|
|
3052
|
-
// (`data-active`/`data-pending`). Style with
|
|
3053
|
-
// A counter (not a boolean) keeps the
|
|
3054
|
-
//
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3145
|
+
// Forms submitted through delegation are marked `aria-busy` from submit until
|
|
3146
|
+
// the action's transition commits — the form half of the attribute vocabulary
|
|
3147
|
+
// links get (`data-active`/`data-pending`). Style with
|
|
3148
|
+
// `form[aria-busy] button { ... }`. A counter (not a boolean) keeps the
|
|
3149
|
+
// attribute through overlapping submissions.
|
|
3150
|
+
//
|
|
3151
|
+
// Busy state is keyed by the form's resolved `action` URL rather than the
|
|
3152
|
+
// element: a server-component morph strips the attribute, and a re-render can
|
|
3153
|
+
// replace the element, and the claim of either (claimBusyForm) re-applies it
|
|
3154
|
+
// from here. A form without an `action` (submitted through a button's
|
|
3155
|
+
// `formaction`) is never claimed, so it is keyed by the element itself.
|
|
3156
|
+
|
|
3157
|
+
const busyForms = /* #__PURE__ */new Map();
|
|
3158
|
+
// forms whose `aria-busy` the router wrote — an authored one is never
|
|
3159
|
+
// overwritten or removed (the same ownership rule as claimed `aria-current`)
|
|
3160
|
+
const ownedBusy = /* #__PURE__ */new WeakSet();
|
|
3161
|
+
function busyKey(form) {
|
|
3162
|
+
const action = form.getAttribute("action");
|
|
3163
|
+
if (action) {
|
|
3164
|
+
try {
|
|
3165
|
+
return new URL(action, document.baseURI).href;
|
|
3166
|
+
} catch {}
|
|
3167
|
+
}
|
|
3168
|
+
return form;
|
|
3169
|
+
}
|
|
3170
|
+
function showBusy(form) {
|
|
3171
|
+
const busy = busyForms.has(busyKey(form));
|
|
3172
|
+
// Ownership is read against the element: an owned value a morph stripped
|
|
3173
|
+
// is re-applied, one the author has rewritten since is theirs.
|
|
3174
|
+
const value = form.getAttribute("aria-busy");
|
|
3175
|
+
const owned = ownedBusy.has(form);
|
|
3176
|
+
if (owned && value !== null && value !== "true") ownedBusy.delete(form);else if (busy) {
|
|
3177
|
+
if (value === null) {
|
|
3178
|
+
form.setAttribute("aria-busy", "true");
|
|
3179
|
+
ownedBusy.add(form);
|
|
3180
|
+
}
|
|
3181
|
+
} else if (owned) {
|
|
3182
|
+
if (value !== null) form.removeAttribute("aria-busy");
|
|
3183
|
+
ownedBusy.delete(form);
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
/** Marks the form busy; the returned release is one-shot. */
|
|
3188
|
+
function markFormBusy(form) {
|
|
3189
|
+
const key = busyKey(form);
|
|
3190
|
+
let entry = busyForms.get(key);
|
|
3191
|
+
if (!entry) busyForms.set(key, entry = {
|
|
3192
|
+
count: 0,
|
|
3193
|
+
forms: new Set()
|
|
3194
|
+
});
|
|
3195
|
+
entry.count++;
|
|
3196
|
+
entry.forms.add(form);
|
|
3197
|
+
showBusy(form);
|
|
3198
|
+
let released = false;
|
|
3199
|
+
return () => {
|
|
3200
|
+
if (released) return;
|
|
3201
|
+
released = true;
|
|
3202
|
+
if (--entry.count > 0) return;
|
|
3203
|
+
busyForms.delete(key);
|
|
3204
|
+
entry.forms.forEach(showBusy);
|
|
3205
|
+
};
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
/** The claims' form slot: a (re-)claimed form whose action is busy shows it. */
|
|
3209
|
+
function claimBusyForm(form) {
|
|
3210
|
+
const entry = busyForms.get(busyKey(form));
|
|
3211
|
+
if (entry) entry.forms.add(form);
|
|
3212
|
+
showBusy(form);
|
|
3060
3213
|
}
|
|
3061
3214
|
const actions = /* #__PURE__ */new Map();
|
|
3062
3215
|
|
|
@@ -3086,7 +3239,7 @@ function handleFormAction(evt, router, actionBase) {
|
|
|
3086
3239
|
// stays a no-JS fallback.
|
|
3087
3240
|
// Client-only actions (`https://action/`) are their module's JS by
|
|
3088
3241
|
// definition, so a miss there falls through to native submission.
|
|
3089
|
-
const handler =
|
|
3242
|
+
const handler = findAction(actionRef) || serverAction && createServerFormAction(actionRef);
|
|
3090
3243
|
if (handler) {
|
|
3091
3244
|
evt.preventDefault();
|
|
3092
3245
|
const data = new FormData(form, evt.submitter);
|
|
@@ -3097,6 +3250,30 @@ function handleFormAction(evt, router, actionBase) {
|
|
|
3097
3250
|
}
|
|
3098
3251
|
}
|
|
3099
3252
|
|
|
3253
|
+
/**
|
|
3254
|
+
* Looks a rendered action url up in the registry. A server-rendered
|
|
3255
|
+
* `.with()` url is only registered if this client made the same binding
|
|
3256
|
+
* itself; otherwise its base action (the url without `?args`) is rebound to
|
|
3257
|
+
* the rendered arguments, so the submission runs through that action's
|
|
3258
|
+
* submit and settled hooks and is recorded under its base.
|
|
3259
|
+
*/
|
|
3260
|
+
function findAction(url) {
|
|
3261
|
+
const handler = actions.get(url);
|
|
3262
|
+
if (handler) return handler;
|
|
3263
|
+
const query = url.indexOf("?");
|
|
3264
|
+
if (query < 0) return undefined;
|
|
3265
|
+
const base = actions.get(url.slice(0, query));
|
|
3266
|
+
const args = new URLSearchParams(url.slice(query)).get("args");
|
|
3267
|
+
if (!base || args === null) return undefined;
|
|
3268
|
+
let bound;
|
|
3269
|
+
try {
|
|
3270
|
+
bound = JSON.parse(args);
|
|
3271
|
+
} catch {
|
|
3272
|
+
return undefined;
|
|
3273
|
+
}
|
|
3274
|
+
return Array.isArray(bound) ? base.with(...bound) : undefined;
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3100
3277
|
/**
|
|
3101
3278
|
* Synthesizes a router action for a server-rendered action url. The url
|
|
3102
3279
|
* carries everything an invocation needs — the function id in the path
|
|
@@ -3130,7 +3307,7 @@ function createServerFormAction(url) {
|
|
|
3130
3307
|
* the enctype conversion and the generic invocation happen here.
|
|
3131
3308
|
*/
|
|
3132
3309
|
function submitServerForm(router, url, form, data) {
|
|
3133
|
-
const handler =
|
|
3310
|
+
const handler = findAction(url) || createServerFormAction(url);
|
|
3134
3311
|
// not an address (`<endpoint>/<id>`) — not the server function convention;
|
|
3135
3312
|
// nothing can run it, resubmit natively (submit() bypasses the delegated
|
|
3136
3313
|
// handler)
|
|
@@ -3166,6 +3343,7 @@ function installRouterIntegrations() {
|
|
|
3166
3343
|
provideFlashDecoder(cookieHeader => import('@solidjs/web/server-functions/server').then(m => m.decodeFlashCookie(cookieHeader)));
|
|
3167
3344
|
} else {
|
|
3168
3345
|
setRouterFormHandler(handleFormAction);
|
|
3346
|
+
setFormClaimHandler(claimBusyForm);
|
|
3169
3347
|
provideFlightConsumer(setupFlightDataConsumer);
|
|
3170
3348
|
}
|
|
3171
3349
|
}
|
|
@@ -3198,24 +3376,94 @@ function actionImpl(fn, options = {}) {
|
|
|
3198
3376
|
// flight-data consumer (see setupFlightDataConsumer) makes the transport
|
|
3199
3377
|
// send the request header itself, so the mutation is just called.
|
|
3200
3378
|
const runMutation = () => fn(...variables);
|
|
3379
|
+
// The busy release, the submission record and the settled hooks wait for
|
|
3380
|
+
// the action's transition to COMMIT, not just its body: the body's final
|
|
3381
|
+
// slice can start reads (the default revalidation's refetch, a redirect's
|
|
3382
|
+
// route data) that hold the transition with the old UI still on screen
|
|
3383
|
+
// (#649). Which comes first varies — an unheld transition commits before
|
|
3384
|
+
// the action's promise resolves, a held one after — so the outcome is
|
|
3385
|
+
// captured inside the body and settle() runs once both are in.
|
|
3386
|
+
let outcome;
|
|
3387
|
+
let committed = false;
|
|
3388
|
+
let settled = false;
|
|
3389
|
+
const settle = () => {
|
|
3390
|
+
if (settled || !committed || !outcome) return;
|
|
3391
|
+
settled = true;
|
|
3392
|
+
const response = outcome.response;
|
|
3393
|
+
release && release();
|
|
3394
|
+
let submission;
|
|
3395
|
+
submission = {
|
|
3396
|
+
input: variables,
|
|
3397
|
+
url,
|
|
3398
|
+
result: response?.data,
|
|
3399
|
+
error: response?.error,
|
|
3400
|
+
clear() {
|
|
3401
|
+
router.submissions[1](entries => entries.filter(entry => entry !== submission));
|
|
3402
|
+
},
|
|
3403
|
+
retry() {
|
|
3404
|
+
submission.clear();
|
|
3405
|
+
return current[invokeSymbol].call({
|
|
3406
|
+
r: router,
|
|
3407
|
+
f: form
|
|
3408
|
+
}, variables, current);
|
|
3409
|
+
}
|
|
3410
|
+
};
|
|
3411
|
+
// Book-keeping is intentional: only outcomes worth showing or retrying
|
|
3412
|
+
// (a result or an error) enter the submissions list, so the typical void
|
|
3413
|
+
// mutation leaves nothing behind. Settled hooks still see every
|
|
3414
|
+
// completion — void, metadata-only, and redirects included — one
|
|
3415
|
+
// `onSettled` per invocation (#580).
|
|
3416
|
+
response && router.submissions[1](entries => [...entries, submission]);
|
|
3417
|
+
// runs inside the scheduler's effect pass: a throwing hook must not
|
|
3418
|
+
// abort the pass (or the hooks after it), so it is reported on its own
|
|
3419
|
+
for (const hook of outcome.hooks) {
|
|
3420
|
+
try {
|
|
3421
|
+
hook(submission);
|
|
3422
|
+
} catch (e) {
|
|
3423
|
+
queueMicrotask(() => {
|
|
3424
|
+
throw e;
|
|
3425
|
+
});
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
};
|
|
3429
|
+
// The hooks that settle this submission are the ones registered when its
|
|
3430
|
+
// body finished. The commit can dispose their owners (the page a redirect
|
|
3431
|
+
// leaves) before it fires; they still see the submission they observed.
|
|
3432
|
+
const finish = response => {
|
|
3433
|
+
outcome || (outcome = {
|
|
3434
|
+
response,
|
|
3435
|
+
hooks: [...settledHooks.values()]
|
|
3436
|
+
});
|
|
3437
|
+
settle();
|
|
3438
|
+
};
|
|
3201
3439
|
const run = action$1(async function* (context) {
|
|
3202
|
-
context.optimistic?.();
|
|
3203
|
-
let value;
|
|
3204
|
-
let error = false;
|
|
3205
3440
|
try {
|
|
3206
|
-
|
|
3441
|
+
context.optimistic?.();
|
|
3442
|
+
let value;
|
|
3443
|
+
let error = false;
|
|
3444
|
+
try {
|
|
3445
|
+
value = await context.call();
|
|
3446
|
+
} catch (e) {
|
|
3447
|
+
value = e;
|
|
3448
|
+
error = true;
|
|
3449
|
+
}
|
|
3450
|
+
const read = await readResponse(value, error);
|
|
3451
|
+
yield;
|
|
3452
|
+
// Apply inside the transition so the default revalidation's refetch and
|
|
3453
|
+
// the release of the caller's optimistic writes commit as one frame (#619).
|
|
3454
|
+
const response = applyResponse(read, router.navigatorFactory(), flightApplications !== flightApplicationsBefore);
|
|
3455
|
+
finish(response);
|
|
3456
|
+
return response;
|
|
3207
3457
|
} catch (e) {
|
|
3208
|
-
|
|
3209
|
-
error
|
|
3458
|
+
// a failure outside the mutation (a submit hook, decoding the
|
|
3459
|
+
// response, applying it) still settles, with the error recorded
|
|
3460
|
+
finish({
|
|
3461
|
+
error: e
|
|
3462
|
+
});
|
|
3463
|
+
throw e;
|
|
3210
3464
|
}
|
|
3211
|
-
const read = await readResponse(value, error);
|
|
3212
|
-
yield;
|
|
3213
|
-
// Apply inside the transition so the default revalidation's refetch and
|
|
3214
|
-
// the release of the caller's optimistic writes commit as one frame (#619).
|
|
3215
|
-
return applyResponse(read, router.navigatorFactory(), flightApplications !== flightApplicationsBefore);
|
|
3216
3465
|
});
|
|
3217
|
-
form &&
|
|
3218
|
-
let response;
|
|
3466
|
+
const release = form && markFormBusy(form);
|
|
3219
3467
|
// The transport consumer is awaited before a single-flight mutation
|
|
3220
3468
|
// resolves, so a counter delta over the call tells whether this action's
|
|
3221
3469
|
// metadata was already applied. Overlapping mutations can cross-attribute
|
|
@@ -3223,40 +3471,40 @@ function actionImpl(fn, options = {}) {
|
|
|
3223
3471
|
// a far smaller window than predicting from the function's identity,
|
|
3224
3472
|
// which misses every response the server returned without flight data.
|
|
3225
3473
|
const flightApplicationsBefore = flightApplications;
|
|
3474
|
+
let pending;
|
|
3226
3475
|
try {
|
|
3227
|
-
|
|
3476
|
+
pending = run({
|
|
3228
3477
|
call: runMutation,
|
|
3229
3478
|
optimistic: submitHooks.size ? () => {
|
|
3230
3479
|
for (const hook of submitHooks.values()) hook(...variables);
|
|
3231
3480
|
} : undefined
|
|
3232
|
-
})
|
|
3233
|
-
}
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
response
|
|
3259
|
-
for (const hook of settledHooks.values()) hook(submission);
|
|
3481
|
+
});
|
|
3482
|
+
} catch (e) {
|
|
3483
|
+
// refused before a transition began: nothing will commit
|
|
3484
|
+
release && release();
|
|
3485
|
+
throw e;
|
|
3486
|
+
}
|
|
3487
|
+
// Registered unowned, synchronously after the invocation, this lands on
|
|
3488
|
+
// the action's own transition — or the outer one a nested call joined, or
|
|
3489
|
+
// the survivor of a merge — and fires at its commit, failures included.
|
|
3490
|
+
// (Registered from the promise continuation it would fire too early: the
|
|
3491
|
+
// transition has parked by then.)
|
|
3492
|
+
onSettled(() => {
|
|
3493
|
+
committed = true;
|
|
3494
|
+
settle();
|
|
3495
|
+
});
|
|
3496
|
+
|
|
3497
|
+
// The returned promise still means "the body finished": an outer action
|
|
3498
|
+
// composing this one (`yield call()`) is the transition that has to
|
|
3499
|
+
// commit, so it cannot wait for the commit.
|
|
3500
|
+
try {
|
|
3501
|
+
await settleActionResult(pending);
|
|
3502
|
+
} catch (e) {
|
|
3503
|
+
finish({
|
|
3504
|
+
error: e
|
|
3505
|
+
});
|
|
3506
|
+
}
|
|
3507
|
+
const response = outcome.response;
|
|
3260
3508
|
if (response) {
|
|
3261
3509
|
if (response.error && !form) throw response.error;
|
|
3262
3510
|
return response.data;
|
|
@@ -3282,9 +3530,12 @@ function toAction(invoke, url, boundArgs = [], base = url, submitHooks = new Map
|
|
|
3282
3530
|
return url;
|
|
3283
3531
|
};
|
|
3284
3532
|
fn.with = function (...args) {
|
|
3533
|
+
const bound = [...boundArgs, ...args];
|
|
3285
3534
|
const uri = new URL(url, mockBase);
|
|
3286
|
-
|
|
3287
|
-
|
|
3535
|
+
// the server prepends `args` to the submitted arguments, so it must carry
|
|
3536
|
+
// the whole binding, not just this call's part of a chain
|
|
3537
|
+
uri.searchParams.set("args", hashKey(bound));
|
|
3538
|
+
return toAction(invoke, (uri.origin === "https://action" ? uri.origin : "") + uri.pathname + uri.search, bound, base, submitHooks, settledHooks);
|
|
3288
3539
|
};
|
|
3289
3540
|
fn.onSubmit = function (hook) {
|
|
3290
3541
|
const id = Symbol("actionOnSubmitHook");
|
|
@@ -3414,6 +3665,9 @@ function applyResponseMetadata(metadata, navigate, flightData) {
|
|
|
3414
3665
|
// are fresh again by now, so the sweep re-reads them from cache.
|
|
3415
3666
|
revalidate(keys, false);
|
|
3416
3667
|
}
|
|
3668
|
+
|
|
3669
|
+
/** What a run settles with: a result, an error, or nothing worth recording. */
|
|
3670
|
+
|
|
3417
3671
|
async function readResponse(response, error) {
|
|
3418
3672
|
let data;
|
|
3419
3673
|
let flightData;
|