@solidjs/router 2.0.0-next.34 → 2.0.0-next.36
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 +171 -51
- package/dist/data/events.js +2 -2
- package/dist/index.js +322 -98
- package/dist/paths.d.ts +23 -3
- package/dist/routing.d.ts +10 -2
- package/dist/routing.js +7 -17
- 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) {
|
|
@@ -458,6 +544,14 @@ const int = s => /^-?\d+$/.test(s);
|
|
|
458
544
|
* untyped, definitionally.
|
|
459
545
|
*/
|
|
460
546
|
|
|
547
|
+
/**
|
|
548
|
+
* Sibling routes that share a param prefix (`/posts/:id`, `/posts/:id/edit`)
|
|
549
|
+
* each contribute a call at that node, and a call on an intersection of
|
|
550
|
+
* function types resolves to its first overload only. So the call reads its
|
|
551
|
+
* result back through `this` — the whole intersection — where the
|
|
552
|
+
* continuations of every sibling with the same arity have merged.
|
|
553
|
+
*/
|
|
554
|
+
|
|
461
555
|
/**
|
|
462
556
|
* The type of a router instance's `paths` proxy for a given route tree.
|
|
463
557
|
* Requires the tree to be a literal tuple (`as const` or a `const` type
|
|
@@ -855,27 +949,20 @@ const useLinkState = (href, options = {}) => {
|
|
|
855
949
|
const router = useRouter();
|
|
856
950
|
const location = router.location;
|
|
857
951
|
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))));
|
|
952
|
+
const base = router.base.path();
|
|
953
|
+
const state = createMemo(() => matchLink(location, to(), base, options.end));
|
|
870
954
|
return {
|
|
871
|
-
active: () => state()
|
|
872
|
-
current: () => state()
|
|
955
|
+
active: createMemo(() => state().active),
|
|
956
|
+
current: createMemo(() => state().current),
|
|
873
957
|
// match the in-flight target explicitly (rather than active-while-routing)
|
|
874
958
|
// so the answer is the same from pure reads and from effects, which
|
|
875
959
|
// observe the committed location during a transition
|
|
876
960
|
pending: createMemo(() => {
|
|
877
961
|
state(); // location dependency: mid-flight target swaps recompute
|
|
878
|
-
return router.isRouting() && !!router.pendingTarget &&
|
|
962
|
+
return router.isRouting() && !!router.pendingTarget && matchLink({
|
|
963
|
+
pathname: router.pendingTarget.value,
|
|
964
|
+
search: ""
|
|
965
|
+
}, to(), base, options.end).active;
|
|
879
966
|
})
|
|
880
967
|
};
|
|
881
968
|
};
|
|
@@ -3063,16 +3150,74 @@ const submitHooksSymbol = Symbol("routerActionSubmitHooks");
|
|
|
3063
3150
|
const settledHooksSymbol = Symbol("routerActionSettledHooks");
|
|
3064
3151
|
const invokeSymbol = Symbol("routerActionInvoke");
|
|
3065
3152
|
|
|
3066
|
-
// Forms submitted through delegation are marked `aria-busy`
|
|
3067
|
-
// action
|
|
3068
|
-
// (`data-active`/`data-pending`). Style with
|
|
3069
|
-
// A counter (not a boolean) keeps the
|
|
3070
|
-
//
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3153
|
+
// Forms submitted through delegation are marked `aria-busy` from submit until
|
|
3154
|
+
// the action's transition commits — the form half of the attribute vocabulary
|
|
3155
|
+
// links get (`data-active`/`data-pending`). Style with
|
|
3156
|
+
// `form[aria-busy] button { ... }`. A counter (not a boolean) keeps the
|
|
3157
|
+
// attribute through overlapping submissions.
|
|
3158
|
+
//
|
|
3159
|
+
// Busy state is keyed by the form's resolved `action` URL rather than the
|
|
3160
|
+
// element: a server-component morph strips the attribute, and a re-render can
|
|
3161
|
+
// replace the element, and the claim of either (claimBusyForm) re-applies it
|
|
3162
|
+
// from here. A form without an `action` (submitted through a button's
|
|
3163
|
+
// `formaction`) is never claimed, so it is keyed by the element itself.
|
|
3164
|
+
|
|
3165
|
+
const busyForms = /* #__PURE__ */new Map();
|
|
3166
|
+
// forms whose `aria-busy` the router wrote — an authored one is never
|
|
3167
|
+
// overwritten or removed (the same ownership rule as claimed `aria-current`)
|
|
3168
|
+
const ownedBusy = /* #__PURE__ */new WeakSet();
|
|
3169
|
+
function busyKey(form) {
|
|
3170
|
+
const action = form.getAttribute("action");
|
|
3171
|
+
if (action) {
|
|
3172
|
+
try {
|
|
3173
|
+
return new URL(action, document.baseURI).href;
|
|
3174
|
+
} catch {}
|
|
3175
|
+
}
|
|
3176
|
+
return form;
|
|
3177
|
+
}
|
|
3178
|
+
function showBusy(form) {
|
|
3179
|
+
const busy = busyForms.has(busyKey(form));
|
|
3180
|
+
// Ownership is read against the element: an owned value a morph stripped
|
|
3181
|
+
// is re-applied, one the author has rewritten since is theirs.
|
|
3182
|
+
const value = form.getAttribute("aria-busy");
|
|
3183
|
+
const owned = ownedBusy.has(form);
|
|
3184
|
+
if (owned && value !== null && value !== "true") ownedBusy.delete(form);else if (busy) {
|
|
3185
|
+
if (value === null) {
|
|
3186
|
+
form.setAttribute("aria-busy", "true");
|
|
3187
|
+
ownedBusy.add(form);
|
|
3188
|
+
}
|
|
3189
|
+
} else if (owned) {
|
|
3190
|
+
if (value !== null) form.removeAttribute("aria-busy");
|
|
3191
|
+
ownedBusy.delete(form);
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
/** Marks the form busy; the returned release is one-shot. */
|
|
3196
|
+
function markFormBusy(form) {
|
|
3197
|
+
const key = busyKey(form);
|
|
3198
|
+
let entry = busyForms.get(key);
|
|
3199
|
+
if (!entry) busyForms.set(key, entry = {
|
|
3200
|
+
count: 0,
|
|
3201
|
+
forms: new Set()
|
|
3202
|
+
});
|
|
3203
|
+
entry.count++;
|
|
3204
|
+
entry.forms.add(form);
|
|
3205
|
+
showBusy(form);
|
|
3206
|
+
let released = false;
|
|
3207
|
+
return () => {
|
|
3208
|
+
if (released) return;
|
|
3209
|
+
released = true;
|
|
3210
|
+
if (--entry.count > 0) return;
|
|
3211
|
+
busyForms.delete(key);
|
|
3212
|
+
entry.forms.forEach(showBusy);
|
|
3213
|
+
};
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
/** The claims' form slot: a (re-)claimed form whose action is busy shows it. */
|
|
3217
|
+
function claimBusyForm(form) {
|
|
3218
|
+
const entry = busyForms.get(busyKey(form));
|
|
3219
|
+
if (entry) entry.forms.add(form);
|
|
3220
|
+
showBusy(form);
|
|
3076
3221
|
}
|
|
3077
3222
|
const actions = /* #__PURE__ */new Map();
|
|
3078
3223
|
|
|
@@ -3206,12 +3351,18 @@ function installRouterIntegrations() {
|
|
|
3206
3351
|
provideFlashDecoder(cookieHeader => import('@solidjs/web/server-functions/server').then(m => m.decodeFlashCookie(cookieHeader)));
|
|
3207
3352
|
} else {
|
|
3208
3353
|
setRouterFormHandler(handleFormAction);
|
|
3354
|
+
setFormClaimHandler(claimBusyForm);
|
|
3209
3355
|
provideFlightConsumer(setupFlightDataConsumer);
|
|
3210
3356
|
}
|
|
3211
3357
|
}
|
|
3212
3358
|
function useSubmissions(fn, filter) {
|
|
3213
3359
|
const router = useRouter();
|
|
3214
|
-
|
|
3360
|
+
// Submissions are immutable records, so the same records in the same order
|
|
3361
|
+
// are the same list: another action's submissions changing re-runs the
|
|
3362
|
+
// filter but must not re-run this list's readers.
|
|
3363
|
+
const subs = createMemo(() => router.submissions[0]().filter(s => s.url === fn.base && (!filter || filter(s.input))), {
|
|
3364
|
+
equals: (a, b) => a.length === b.length && a.every((s, i) => s === b[i])
|
|
3365
|
+
});
|
|
3215
3366
|
return new Proxy([], {
|
|
3216
3367
|
get(_, property) {
|
|
3217
3368
|
if (property === $TRACK) return subs();
|
|
@@ -3238,24 +3389,94 @@ function actionImpl(fn, options = {}) {
|
|
|
3238
3389
|
// flight-data consumer (see setupFlightDataConsumer) makes the transport
|
|
3239
3390
|
// send the request header itself, so the mutation is just called.
|
|
3240
3391
|
const runMutation = () => fn(...variables);
|
|
3392
|
+
// The busy release, the submission record and the settled hooks wait for
|
|
3393
|
+
// the action's transition to COMMIT, not just its body: the body's final
|
|
3394
|
+
// slice can start reads (the default revalidation's refetch, a redirect's
|
|
3395
|
+
// route data) that hold the transition with the old UI still on screen
|
|
3396
|
+
// (#649). Which comes first varies — an unheld transition commits before
|
|
3397
|
+
// the action's promise resolves, a held one after — so the outcome is
|
|
3398
|
+
// captured inside the body and settle() runs once both are in.
|
|
3399
|
+
let outcome;
|
|
3400
|
+
let committed = false;
|
|
3401
|
+
let settled = false;
|
|
3402
|
+
const settle = () => {
|
|
3403
|
+
if (settled || !committed || !outcome) return;
|
|
3404
|
+
settled = true;
|
|
3405
|
+
const response = outcome.response;
|
|
3406
|
+
release && release();
|
|
3407
|
+
let submission;
|
|
3408
|
+
submission = {
|
|
3409
|
+
input: variables,
|
|
3410
|
+
url,
|
|
3411
|
+
result: response?.data,
|
|
3412
|
+
error: response?.error,
|
|
3413
|
+
clear() {
|
|
3414
|
+
router.submissions[1](entries => entries.filter(entry => entry !== submission));
|
|
3415
|
+
},
|
|
3416
|
+
retry() {
|
|
3417
|
+
submission.clear();
|
|
3418
|
+
return current[invokeSymbol].call({
|
|
3419
|
+
r: router,
|
|
3420
|
+
f: form
|
|
3421
|
+
}, variables, current);
|
|
3422
|
+
}
|
|
3423
|
+
};
|
|
3424
|
+
// Book-keeping is intentional: only outcomes worth showing or retrying
|
|
3425
|
+
// (a result or an error) enter the submissions list, so the typical void
|
|
3426
|
+
// mutation leaves nothing behind. Settled hooks still see every
|
|
3427
|
+
// completion — void, metadata-only, and redirects included — one
|
|
3428
|
+
// `onSettled` per invocation (#580).
|
|
3429
|
+
response && router.submissions[1](entries => [...entries, submission]);
|
|
3430
|
+
// runs inside the scheduler's effect pass: a throwing hook must not
|
|
3431
|
+
// abort the pass (or the hooks after it), so it is reported on its own
|
|
3432
|
+
for (const hook of outcome.hooks) {
|
|
3433
|
+
try {
|
|
3434
|
+
hook(submission);
|
|
3435
|
+
} catch (e) {
|
|
3436
|
+
queueMicrotask(() => {
|
|
3437
|
+
throw e;
|
|
3438
|
+
});
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
};
|
|
3442
|
+
// The hooks that settle this submission are the ones registered when its
|
|
3443
|
+
// body finished. The commit can dispose their owners (the page a redirect
|
|
3444
|
+
// leaves) before it fires; they still see the submission they observed.
|
|
3445
|
+
const finish = response => {
|
|
3446
|
+
outcome || (outcome = {
|
|
3447
|
+
response,
|
|
3448
|
+
hooks: [...settledHooks.values()]
|
|
3449
|
+
});
|
|
3450
|
+
settle();
|
|
3451
|
+
};
|
|
3241
3452
|
const run = action$1(async function* (context) {
|
|
3242
|
-
context.optimistic?.();
|
|
3243
|
-
let value;
|
|
3244
|
-
let error = false;
|
|
3245
3453
|
try {
|
|
3246
|
-
|
|
3454
|
+
context.optimistic?.();
|
|
3455
|
+
let value;
|
|
3456
|
+
let error = false;
|
|
3457
|
+
try {
|
|
3458
|
+
value = await context.call();
|
|
3459
|
+
} catch (e) {
|
|
3460
|
+
value = e;
|
|
3461
|
+
error = true;
|
|
3462
|
+
}
|
|
3463
|
+
const read = await readResponse(value, error);
|
|
3464
|
+
yield;
|
|
3465
|
+
// Apply inside the transition so the default revalidation's refetch and
|
|
3466
|
+
// the release of the caller's optimistic writes commit as one frame (#619).
|
|
3467
|
+
const response = applyResponse(read, router.navigatorFactory(), flightApplications !== flightApplicationsBefore);
|
|
3468
|
+
finish(response);
|
|
3469
|
+
return response;
|
|
3247
3470
|
} catch (e) {
|
|
3248
|
-
|
|
3249
|
-
error
|
|
3471
|
+
// a failure outside the mutation (a submit hook, decoding the
|
|
3472
|
+
// response, applying it) still settles, with the error recorded
|
|
3473
|
+
finish({
|
|
3474
|
+
error: e
|
|
3475
|
+
});
|
|
3476
|
+
throw e;
|
|
3250
3477
|
}
|
|
3251
|
-
const read = await readResponse(value, error);
|
|
3252
|
-
yield;
|
|
3253
|
-
// Apply inside the transition so the default revalidation's refetch and
|
|
3254
|
-
// the release of the caller's optimistic writes commit as one frame (#619).
|
|
3255
|
-
return applyResponse(read, router.navigatorFactory(), flightApplications !== flightApplicationsBefore);
|
|
3256
3478
|
});
|
|
3257
|
-
form &&
|
|
3258
|
-
let response;
|
|
3479
|
+
const release = form && markFormBusy(form);
|
|
3259
3480
|
// The transport consumer is awaited before a single-flight mutation
|
|
3260
3481
|
// resolves, so a counter delta over the call tells whether this action's
|
|
3261
3482
|
// metadata was already applied. Overlapping mutations can cross-attribute
|
|
@@ -3263,40 +3484,40 @@ function actionImpl(fn, options = {}) {
|
|
|
3263
3484
|
// a far smaller window than predicting from the function's identity,
|
|
3264
3485
|
// which misses every response the server returned without flight data.
|
|
3265
3486
|
const flightApplicationsBefore = flightApplications;
|
|
3487
|
+
let pending;
|
|
3266
3488
|
try {
|
|
3267
|
-
|
|
3489
|
+
pending = run({
|
|
3268
3490
|
call: runMutation,
|
|
3269
3491
|
optimistic: submitHooks.size ? () => {
|
|
3270
3492
|
for (const hook of submitHooks.values()) hook(...variables);
|
|
3271
3493
|
} : undefined
|
|
3272
|
-
})
|
|
3273
|
-
}
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
response
|
|
3299
|
-
for (const hook of settledHooks.values()) hook(submission);
|
|
3494
|
+
});
|
|
3495
|
+
} catch (e) {
|
|
3496
|
+
// refused before a transition began: nothing will commit
|
|
3497
|
+
release && release();
|
|
3498
|
+
throw e;
|
|
3499
|
+
}
|
|
3500
|
+
// Registered unowned, synchronously after the invocation, this lands on
|
|
3501
|
+
// the action's own transition — or the outer one a nested call joined, or
|
|
3502
|
+
// the survivor of a merge — and fires at its commit, failures included.
|
|
3503
|
+
// (Registered from the promise continuation it would fire too early: the
|
|
3504
|
+
// transition has parked by then.)
|
|
3505
|
+
onSettled(() => {
|
|
3506
|
+
committed = true;
|
|
3507
|
+
settle();
|
|
3508
|
+
});
|
|
3509
|
+
|
|
3510
|
+
// The returned promise still means "the body finished": an outer action
|
|
3511
|
+
// composing this one (`yield call()`) is the transition that has to
|
|
3512
|
+
// commit, so it cannot wait for the commit.
|
|
3513
|
+
try {
|
|
3514
|
+
await settleActionResult(pending);
|
|
3515
|
+
} catch (e) {
|
|
3516
|
+
finish({
|
|
3517
|
+
error: e
|
|
3518
|
+
});
|
|
3519
|
+
}
|
|
3520
|
+
const response = outcome.response;
|
|
3300
3521
|
if (response) {
|
|
3301
3522
|
if (response.error && !form) throw response.error;
|
|
3302
3523
|
return response.data;
|
|
@@ -3457,6 +3678,9 @@ function applyResponseMetadata(metadata, navigate, flightData) {
|
|
|
3457
3678
|
// are fresh again by now, so the sweep re-reads them from cache.
|
|
3458
3679
|
revalidate(keys, false);
|
|
3459
3680
|
}
|
|
3681
|
+
|
|
3682
|
+
/** What a run settles with: a result, an error, or nothing worth recording. */
|
|
3683
|
+
|
|
3460
3684
|
async function readResponse(response, error) {
|
|
3461
3685
|
let data;
|
|
3462
3686
|
let flightData;
|
package/dist/paths.d.ts
CHANGED
|
@@ -82,9 +82,29 @@ type TuplePaths<R extends readonly unknown[], PAcc extends Params> = R extends r
|
|
|
82
82
|
...infer T extends readonly unknown[]
|
|
83
83
|
] ? RouteContrib<H, PAcc> & TuplePaths<T, PAcc> : {};
|
|
84
84
|
type PathLeaf<Sch extends SearchTypes, C, PAcc extends Params> = PathEnd<Sch, Flat<PAcc>> & ChildPaths<C, PAcc>;
|
|
85
|
-
|
|
85
|
+
declare const PARAM_NEXT: unique symbol;
|
|
86
|
+
/**
|
|
87
|
+
* Sibling routes that share a param prefix (`/posts/:id`, `/posts/:id/edit`)
|
|
88
|
+
* each contribute a call at that node, and a call on an intersection of
|
|
89
|
+
* function types resolves to its first overload only. So the call reads its
|
|
90
|
+
* result back through `this` — the whole intersection — where the
|
|
91
|
+
* continuations of every sibling with the same arity have merged.
|
|
92
|
+
*/
|
|
93
|
+
interface ParamCall<A extends readonly unknown[]> {
|
|
94
|
+
(...args: A): this extends {
|
|
95
|
+
readonly [PARAM_NEXT]: {
|
|
96
|
+
[K in A["length"]]: infer N;
|
|
97
|
+
};
|
|
98
|
+
} ? N : never;
|
|
99
|
+
}
|
|
100
|
+
type ParamCallTo<A extends readonly unknown[], N> = ParamCall<A> & {
|
|
101
|
+
readonly [PARAM_NEXT]: {
|
|
102
|
+
[K in A["length"]]: N;
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
type PathNode<Segs extends readonly string[], F, Sch extends SearchTypes, C, PAcc extends Params> = Segs extends readonly [infer H extends string, ...infer R extends readonly string[]] ? H extends `:${infer N}?` ? ParamCallTo<[ParamArg<N, F>], PathNode<R, F, Sch, C, PAcc & {
|
|
86
106
|
[K in N]?: string;
|
|
87
|
-
}
|
|
107
|
+
}>> & PathNode<R, F, Sch, C, PAcc & {
|
|
88
108
|
[K in N]?: string;
|
|
89
109
|
}> : H extends `:${string}` | `*${string}` ? ParamCallNode<Segs, F, Sch, C, PAcc> : {
|
|
90
110
|
[K in H]: PathNode<R, F, Sch, C, PAcc>;
|
|
@@ -93,7 +113,7 @@ type ParamCallNode<Segs extends readonly string[], F, Sch extends SearchTypes, C
|
|
|
93
113
|
args: infer A extends readonly unknown[];
|
|
94
114
|
params: infer P2 extends Params;
|
|
95
115
|
rest: infer R2 extends readonly string[];
|
|
96
|
-
} ?
|
|
116
|
+
} ? ParamCallTo<A, PathNode<R2, F, Sch, C, PAcc & P2>> & (R2 extends readonly [] ? {
|
|
97
117
|
(...args: [...A, Sch["input"], string?]): string;
|
|
98
118
|
} : {}) & TypedPath<Flat<PAcc & P2>> : never;
|
|
99
119
|
type RouteContrib<Def, PAcc extends Params> = Def extends {
|
package/dist/routing.d.ts
CHANGED
|
@@ -165,9 +165,17 @@ export declare function useSearchParams<T extends SearchParams>(): [
|
|
|
165
165
|
(params: SetSearchParams, options?: Partial<NavigateOptions>) => void
|
|
166
166
|
];
|
|
167
167
|
export interface LinkState {
|
|
168
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* The location's pathname matches this link's or lives under it; the query
|
|
170
|
+
* is ignored. A link to the router's root (`/`, under the router's `base`)
|
|
171
|
+
* is exact-only, as is every link with `end`.
|
|
172
|
+
* Styling: `data-active`.
|
|
173
|
+
*/
|
|
169
174
|
active: () => boolean;
|
|
170
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* The location matches this link exactly: same pathname and same query,
|
|
177
|
+
* parameter order and hash aside — what `aria-current="page"` reflects.
|
|
178
|
+
*/
|
|
171
179
|
current: () => boolean;
|
|
172
180
|
/** This link is the target of an in-flight navigation. Styling: `data-pending`. */
|
|
173
181
|
pending: () => boolean;
|