@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/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
- /** Pathname stripped of search/hash and trailing slash, lowercased — the form link matching compares. */
14
- const comparablePath = path => normalizePath(path.split(/[?#]/, 1)[0]).toLowerCase().replace(/\/$/, "");
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
- * The compiler claims every `a[href]` (and `form[action]`, which this handler
183
- * ignores) at creation, and the runtime re-claims on `href` writes. This
184
- * consumer gives each router-managed anchor the link-state vocabulary without
185
- * a wrapper component:
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
- * - `data-active` — exact or prefix match
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; `current` remembers whether we set `aria-current`,
203
- // so user-authored values (steppers, breadcrumbs) are never stripped
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 comparable pathname when the router manages this anchor, else `undefined`. */
211
- function managedPath(a) {
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 || basePath && url.pathname && !url.pathname.toLowerCase().startsWith(basePath.toLowerCase())) return;
229
- return comparablePath(url.pathname);
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 loc = decodeURI(comparablePath(router.location.pathname));
301
+ const location = router.location;
235
302
  const routing = router.isRouting();
236
- const path = managedPath(a);
237
- // the root path is a prefix of everything, so it only matches exactly —
238
- // there is no per-anchor `end` opt-out like useLinkState has
239
- const matches = target => path !== undefined && (target === path || path !== "" && target.startsWith(path + "/"));
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 && matches(decodeURI(comparablePath(router.pendingTarget.value)));
313
+ const pending = routing && !!router.pendingTarget && matchLink({
314
+ pathname: router.pendingTarget.value,
315
+ search: ""
316
+ }, target, basePath).active;
244
317
  return {
245
- active: matches(loc),
318
+ active,
246
319
  pending,
247
- exact: path !== undefined && loc === path
320
+ current
248
321
  };
249
322
  }
250
323
  function apply(a, rec, {
251
324
  active,
252
325
  pending,
253
- exact
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
- if (exact !== rec.current) {
258
- exact ? a.setAttribute("aria-current", "page") : a.removeAttribute("aria-current");
259
- rec.current = exact;
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
- if (node.nodeName.toUpperCase() !== "A") return;
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
- current: false
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 || basePath && url.pathname && !url.pathname.toLowerCase().startsWith(basePath.toLowerCase())) return;
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
- // trailing slashes are ignored so `/route` and `/route/` share state
859
- const path = createMemo(() => {
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()[0],
872
- current: () => state()[1],
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 && matches(decodeURI(comparablePath(router.pendingTarget.value)))[0];
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` while their
3067
- // action is in flight — the form half of the attribute vocabulary links get
3068
- // (`data-active`/`data-pending`). Style with `form[aria-busy] button { ... }`.
3069
- // A counter (not a boolean) keeps the attribute through overlapping
3070
- // submissions from the same form.
3071
- const busyForms = /* #__PURE__ */new WeakMap();
3072
- function setFormBusy(form, delta) {
3073
- const count = (busyForms.get(form) || 0) + delta;
3074
- busyForms.set(form, count);
3075
- count > 0 ? form.setAttribute("aria-busy", "true") : form.removeAttribute("aria-busy");
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
- const subs = createMemo(() => router.submissions[0]().filter(s => s.url === fn.base && (!filter || filter(s.input))));
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
- value = await context.call();
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
- value = e;
3249
- error = true;
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 && setFormBusy(form, 1);
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
- response = await settleActionResult(run({
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
- } finally {
3274
- form && setFormBusy(form, -1);
3275
- }
3276
- let submission;
3277
- submission = {
3278
- input: variables,
3279
- url,
3280
- result: response?.data,
3281
- error: response?.error,
3282
- clear() {
3283
- router.submissions[1](entries => entries.filter(entry => entry !== submission));
3284
- },
3285
- retry() {
3286
- submission.clear();
3287
- return current[invokeSymbol].call({
3288
- r: router,
3289
- f: form
3290
- }, variables, current);
3291
- }
3292
- };
3293
- // Book-keeping is intentional: only outcomes worth showing or retrying
3294
- // (a result or an error) enter the submissions list, so the typical void
3295
- // mutation leaves nothing behind. Settled hooks still see every
3296
- // completion — void, metadata-only, and redirects included — one
3297
- // `onSettled` per invocation (#580).
3298
- response && router.submissions[1](entries => [...entries, submission]);
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
- 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}?` ? ((arg: ParamArg<N, F>) => PathNode<R, F, Sch, C, PAcc & {
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
- }>) & PathNode<R, F, Sch, C, PAcc & {
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
- } ? ((...args: A) => PathNode<R2, F, Sch, C, PAcc & P2>) & (R2 extends readonly [] ? {
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
- /** The location matches this link or lives under it (exact-only when `end`). Styling: `data-active`. */
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
- /** The location matches this link exactly — what `aria-current="page"` reflects. */
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;