@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 CHANGED
@@ -522,16 +522,21 @@ Active and pending state is styled with CSS — one vocabulary for every kind of
522
522
  ```css
523
523
  nav a[aria-current="page"] {
524
524
  font-weight: 600;
525
- } /* exact match */
525
+ } /* exact match, query included */
526
526
  nav a[data-active] {
527
527
  color: var(--accent);
528
- } /* exact or prefix match */
528
+ } /* exact or prefix match on the path */
529
529
  a[data-pending] {
530
530
  opacity: 0.6;
531
531
  } /* target of in-flight navigation */
532
532
  ```
533
533
 
534
- (The root path only ever matches exactly, so `href={paths()}` doesn't light up on every page.)
534
+ One rule decides both, for anchors and `useLinkState` alike:
535
+
536
+ - **current** (`aria-current="page"`) — same path and same query, ignoring parameter order and the hash. On `/?filter=active`, `<a href="/?filter=active">` is current and `<a href="/">` is not.
537
+ - **active** (`data-active`, and `data-pending` for the in-flight target) — the path only, exact or prefix, so both of those links are active there. The root path (the router's `base`, when it has one) only ever matches exactly, so `href={paths()}` doesn't light up on every page.
538
+
539
+ An `aria-current` you write yourself (`aria-current="step"` in a stepper, say) is yours: the router never overwrites or removes it, and only manages the attribute on links where it set it.
535
540
 
536
541
  For component-library links that need reactive state beyond CSS, `useLinkState` is the programmatic counterpart of the attribute vocabulary:
537
542
 
@@ -672,7 +677,7 @@ const updateUser = action(async (form: FormData) => {
672
677
  <button type="submit" formaction={updateUser}>Save</button>
673
678
  ```
674
679
 
675
- Actions only work with POST requests, so put `method="post"` on your form. Submitting forms get `aria-busy="true"` automatically while the action (including its revalidation) is in flight — the same CSS story as links:
680
+ Actions only work with POST requests, so put `method="post"` on your form. Submitting forms get `aria-busy="true"` automatically from submit until the action's result is on screen — the mutation, then its revalidation or redirect, until that update commits — the same CSS story as links:
676
681
 
677
682
  ```css
678
683
  form[aria-busy] button {
@@ -681,6 +686,8 @@ form[aria-busy] button {
681
686
  }
682
687
  ```
683
688
 
689
+ Busy state belongs to the form's `action` URL, not the element: a form re-rendered or replaced mid-flight (a server-component morph, a keyed re-mount) still shows it. An `aria-busy` you set yourself is left alone.
690
+
684
691
  Forms work without JavaScript: a real POST, a redirect back, and the result seeded into submission state through a one-shot flash cookie. Single-flight mutations are on by default — the mutation response carries the refreshed route data in the same round trip.
685
692
 
686
693
  Delegation doesn't require the action's module on the client either. A form bound directly to a server action in a server-only module (a server component) renders a plain `action="/_server?id=...&args=..."` — a self-describing URL. On submit, the router synthesizes the invocation from it: the form data posts to that URL through the server-function transport, `.with()` arguments ride along in the query string, and submissions, `aria-busy`, redirects, revalidation, and single-flight data flow through the normal pipeline. The handler loads lazily on first such submit, so router-only bundles don't carry the data layer. The no-JS POST above remains the fallback only for clients that actually have no JavaScript. (Client-only actions — `action(fn, "name")` without `use server` — are their module's JS by definition and still require it on the client.)
@@ -704,7 +711,7 @@ const addTodo = action(async todo => {
704
711
  });
705
712
  ```
706
713
 
707
- `onSubmit(...)` registers a listener in the current reactive owner — multiple components can register against the same action, and hooks are removed when their owner is disposed. `onSettled(...)` works the same way for observing completed submissions.
714
+ `onSubmit(...)` registers a listener in the current reactive owner — multiple components can register against the same action, and hooks are removed when their owner is disposed. `onSettled(...)` works the same way for observing completed submissions. A submission settles when its result is on screen: the hooks run, the record enters `useSubmissions`, and `aria-busy` clears once the action's update commits — after any revalidation or redirect it triggered, which may be later than the promise from `useAction` resolves. The hooks that run are the ones registered when the action finished: a hook whose owner that commit unmounts (the page a redirect leaves) still sees the submission, while a hook registered later, or removed with its owner before then, does not.
708
715
 
709
716
  The preferred pattern is returning values and letting the client interpret the result; thrown errors are still captured on `Submission.error` as an escape hatch.
710
717
 
@@ -894,7 +901,17 @@ preload(paths.users(2).settings, { preloadData: true });
894
901
 
895
902
  ### useLinkState
896
903
 
897
- Reactive `active`/`current`/`pending` state for [custom link components](#links).
904
+ Reactive `active`/`current`/`pending` state for [custom link components](#links), matched by the same rule as plain anchors:
905
+
906
+ - `current()` — same path and same query as the location, ignoring parameter order and the hash (what `aria-current="page"` reflects)
907
+ - `active()` — the location's path is the link's path or lives under it, query ignored (`data-active`); a root link (`/`, which resolves to the router's `base`) is exact-only
908
+ - `pending()` — the link's path is the target of an in-flight navigation (`data-pending`)
909
+
910
+ Pass `{ end: true }` to make `active` (and `pending`) exact-path for any link.
911
+
912
+ ```tsx
913
+ const link = useLinkState(() => props.href, { end: true });
914
+ ```
898
915
 
899
916
  ### useBeforeLeave
900
917
 
@@ -1014,7 +1031,7 @@ Route props map 1:1 onto definition keys (`path`, `component`, `preload`, `match
1014
1031
 
1015
1032
  - `<A href replace noScroll state>` → `<a href replace noscroll state>` (attributes, all lowercase)
1016
1033
  - `activeClass` / `inactiveClass` → CSS attribute selectors on `[data-active]` / `[aria-current="page"]`
1017
- - `end` → style exact matches with `[aria-current="page"]` instead of `[data-active]`; the root path already only matches exactly
1034
+ - `end` → style exact matches with `[aria-current="page"]` (which also compares the query) instead of `[data-active]`; the root path already only matches exactly
1018
1035
  - Route-relative hrefs → typed `paths`; `useResolvedPath` / `useHref` remain for manual resolution
1019
1036
  - Custom link components → `useLinkState`
1020
1037
 
package/dist/claims.d.ts CHANGED
@@ -1,14 +1,22 @@
1
1
  import type { RouterContext } from "./types.js";
2
+ export declare function setFormClaimHandler(handler: ((form: HTMLFormElement) => void) | undefined): void;
2
3
  /**
3
- * The compiler claims every `a[href]` (and `form[action]`, which this handler
4
- * ignores) at creation, and the runtime re-claims on `href` writes. This
5
- * consumer gives each router-managed anchor the link-state vocabulary without
6
- * a wrapper component:
4
+ * The compiler claims every `a[href]` and `form[action]` at creation, and the
5
+ * runtime re-claims on `href`/`action` writes and after a server-component
6
+ * morph changes the element. Forms go to the action layer's slot above, which
7
+ * re-applies `aria-busy` while their action is in flight. This consumer gives each router-managed anchor the link-state
8
+ * vocabulary without a wrapper component:
7
9
  *
8
- * - `aria-current="page"` — the location matches the link exactly
9
- * - `data-active` — exact or prefix match
10
+ * - `aria-current="page"` — the location matches the link exactly, query
11
+ * included (parameter order aside)
12
+ * - `data-active` — pathname exact or prefix match (the router's root, its
13
+ * base path, exact only)
10
14
  * - `data-pending` — the link is the target of an in-flight navigation
11
15
  *
16
+ * The matching rule is `matchLink`, shared with `useLinkState`. The router
17
+ * only touches an `aria-current` it wrote itself: one the author set (a
18
+ * stepper's `"step"`, a static `"page"`) is left in place, current or not.
19
+ *
12
20
  * Elements are claimed at creation, so late mounts (`<Show>`, `<For>`,
13
21
  * portals) are correct immediately. One render effect (owned by the router)
14
22
  * subscribes to the location and sweeps a registry of claimed anchors —
package/dist/claims.js CHANGED
@@ -1,16 +1,33 @@
1
1
  import { registerElementClaim } from "@solidjs/web";
2
2
  import { createRenderEffect, getOwner, onCleanup, untrack } from "solid-js";
3
- import { comparablePath } from "./utils.js";
3
+ import { isUnderBase, matchLink } from "./utils.js";
4
4
  /**
5
- * The compiler claims every `a[href]` (and `form[action]`, which this handler
6
- * ignores) at creation, and the runtime re-claims on `href` writes. This
7
- * consumer gives each router-managed anchor the link-state vocabulary without
8
- * a wrapper component:
5
+ * Claimed forms are handed to this slot instead of the claims importing the
6
+ * action module: the action side installs it on first action creation (see
7
+ * data/action.ts), where form `aria-busy` state lives, so an app that never
8
+ * creates an action never pulls the data layer in through its claims.
9
+ */
10
+ let formClaim;
11
+ export function setFormClaimHandler(handler) {
12
+ formClaim = handler;
13
+ }
14
+ /**
15
+ * The compiler claims every `a[href]` and `form[action]` at creation, and the
16
+ * runtime re-claims on `href`/`action` writes and after a server-component
17
+ * morph changes the element. Forms go to the action layer's slot above, which
18
+ * re-applies `aria-busy` while their action is in flight. This consumer gives each router-managed anchor the link-state
19
+ * vocabulary without a wrapper component:
9
20
  *
10
- * - `aria-current="page"` — the location matches the link exactly
11
- * - `data-active` — exact or prefix match
21
+ * - `aria-current="page"` — the location matches the link exactly, query
22
+ * included (parameter order aside)
23
+ * - `data-active` — pathname exact or prefix match (the router's root, its
24
+ * base path, exact only)
12
25
  * - `data-pending` — the link is the target of an in-flight navigation
13
26
  *
27
+ * The matching rule is `matchLink`, shared with `useLinkState`. The router
28
+ * only touches an `aria-current` it wrote itself: one the author set (a
29
+ * stepper's `"step"`, a static `"page"`) is left in place, current or not.
30
+ *
14
31
  * Elements are claimed at creation, so late mounts (`<Show>`, `<For>`,
15
32
  * portals) are correct immediately. One render effect (owned by the router)
16
33
  * subscribes to the location and sweeps a registry of claimed anchors —
@@ -22,15 +39,15 @@ import { comparablePath } from "./utils.js";
22
39
  */
23
40
  export function setupLinkClaims(router, explicitLinks) {
24
41
  const basePath = router.base.path();
25
- // per-element record; `current` remembers whether we set `aria-current`,
26
- // so user-authored values (steppers, breadcrumbs) are never stripped
42
+ // per-element record; `owned` is whether the `aria-current` on the element
43
+ // is the router's, so it never writes over or removes an authored one
27
44
  const claimed = new WeakMap();
28
45
  const registry = new Set();
29
46
  function isSvg(el) {
30
47
  return el.namespaceURI === "http://www.w3.org/2000/svg";
31
48
  }
32
- /** The comparable pathname when the router manages this anchor, else `undefined`. */
33
- function managedPath(a) {
49
+ /** The anchor's resolved URL when the router manages it, else `undefined`. */
50
+ function managedUrl(a) {
34
51
  if (explicitLinks && !a.hasAttribute("link"))
35
52
  return;
36
53
  const svg = isSvg(a);
@@ -51,32 +68,48 @@ export function setupLinkClaims(router, explicitLinks) {
51
68
  catch {
52
69
  return;
53
70
  }
54
- if (url.origin !== window.location.origin ||
55
- (basePath && url.pathname && !url.pathname.toLowerCase().startsWith(basePath.toLowerCase())))
71
+ if (url.origin !== window.location.origin || !isUnderBase(url.pathname, basePath))
56
72
  return;
57
- return comparablePath(url.pathname);
73
+ return url;
58
74
  }
59
75
  function linkState(a) {
60
76
  // read reactive sources unconditionally so the owning effect stays
61
77
  // subscribed even while the anchor is not router-managed
62
- const loc = decodeURI(comparablePath(router.location.pathname));
78
+ const location = router.location;
63
79
  const routing = router.isRouting();
64
- const path = managedPath(a);
65
- // the root path is a prefix of everything, so it only matches exactly —
66
- // there is no per-anchor `end` opt-out like useLinkState has
67
- const matches = (target) => path !== undefined && (target === path || (path !== "" && target.startsWith(path + "/")));
80
+ const url = managedUrl(a);
81
+ const target = url && url.pathname + url.search;
82
+ // no per-anchor `end` opt-out like useLinkState has
83
+ const { active, current } = matchLink(location, target, basePath);
68
84
  // effects observe the committed location during a transition, so the
69
85
  // in-flight target comes from pendingTarget — readable here because the
70
86
  // isRouting write flushes after the target is assigned
71
- const pending = routing && !!router.pendingTarget && matches(decodeURI(comparablePath(router.pendingTarget.value)));
72
- return { active: matches(loc), pending, exact: path !== undefined && loc === path };
87
+ const pending = routing &&
88
+ !!router.pendingTarget &&
89
+ matchLink({ pathname: router.pendingTarget.value, search: "" }, target, basePath).active;
90
+ return { active, pending, current };
73
91
  }
74
- function apply(a, rec, { active, pending, exact }) {
92
+ function apply(a, rec, { active, pending, current }) {
75
93
  active ? a.setAttribute("data-active", "") : a.removeAttribute("data-active");
76
94
  pending ? a.setAttribute("data-pending", "") : a.removeAttribute("data-pending");
77
- if (exact !== rec.current) {
78
- exact ? a.setAttribute("aria-current", "page") : a.removeAttribute("aria-current");
79
- rec.current = exact;
95
+ // Ownership is read against the element, not just the record. A
96
+ // server-component morph resets attributes to the server HTML, which
97
+ // never carries router link state, then re-claims: an owned value that
98
+ // went missing is re-applied, while a value the morph restored from the
99
+ // server HTML (or the author wrote since) is authored and left alone.
100
+ const value = a.getAttribute("aria-current");
101
+ if (rec.owned && value !== null && value !== "page")
102
+ rec.owned = false;
103
+ else if (current) {
104
+ if (value === null) {
105
+ a.setAttribute("aria-current", "page");
106
+ rec.owned = true;
107
+ }
108
+ }
109
+ else if (rec.owned) {
110
+ if (value !== null)
111
+ a.removeAttribute("aria-current");
112
+ rec.owned = false;
80
113
  }
81
114
  }
82
115
  const refresh = (a, rec) => untrack(() => apply(a, rec, linkState(a)));
@@ -91,9 +124,12 @@ export function setupLinkClaims(router, explicitLinks) {
91
124
  // slot — lazy-route lookups miss and hydration leaves server nodes
92
125
  // unclaimed. (The option is honored by the runtime but missing from the
93
126
  // published EffectOptions type, hence the cast.)
94
- createRenderEffect(() => (router.location.pathname, router.isRouting()), () => registry.forEach(a => refresh(a, claimed.get(a))), { transparent: true });
127
+ createRenderEffect(() => (router.location.pathname, router.location.search, router.isRouting()), () => registry.forEach(a => refresh(a, claimed.get(a))), { transparent: true });
95
128
  onCleanup(registerElementClaim(node => {
96
- if (node.nodeName.toUpperCase() !== "A")
129
+ const name = node.nodeName.toUpperCase();
130
+ if (name === "FORM")
131
+ return formClaim && formClaim(node);
132
+ if (name !== "A")
97
133
  return;
98
134
  const a = node;
99
135
  // re-claim (href changed): the claiming write runs inside another
@@ -101,7 +137,7 @@ export function setupLinkClaims(router, explicitLinks) {
101
137
  const existing = claimed.get(a);
102
138
  if (existing)
103
139
  return refresh(a, existing);
104
- const rec = { current: false };
140
+ const rec = { owned: false };
105
141
  claimed.set(a, rec);
106
142
  // claims fire during component setup, so an owner is present in
107
143
  // practice to bound the registry entry's lifetime; without one, state
@@ -1,6 +1,7 @@
1
- import { $TRACK, action as createSolidAction, createMemo, onCleanup, getOwner } from "solid-js";
1
+ import { $TRACK, action as createSolidAction, createMemo, onCleanup, onSettled, getOwner } from "solid-js";
2
2
  import { isResponseEnvelope, isServer, REVALIDATE_HEADER } from "@solidjs/web";
3
3
  import { createServerReference, decodeRedirectHeaderValue, decodeResponsePayload, parseServerFunctionActionUrl, REDIRECT_HEADER, subscribeFlightData } from "@solidjs/web/server-functions";
4
+ import { setFormClaimHandler } from "../claims.js";
4
5
  import { provideFlashDecoder, provideFlightConsumer, useRouter } from "../routing.js";
5
6
  import { setRouterFormHandler } from "./events.js";
6
7
  import { mockBase, setFunctionName } from "../utils.js";
@@ -8,16 +9,66 @@ import { cacheKeyOp, deliverFlightData, hashKey, readRevalidateKeys, revalidate,
8
9
  const submitHooksSymbol = Symbol("routerActionSubmitHooks");
9
10
  const settledHooksSymbol = Symbol("routerActionSettledHooks");
10
11
  const invokeSymbol = Symbol("routerActionInvoke");
11
- // Forms submitted through delegation are marked `aria-busy` while their
12
- // action is in flight — the form half of the attribute vocabulary links get
13
- // (`data-active`/`data-pending`). Style with `form[aria-busy] button { ... }`.
14
- // A counter (not a boolean) keeps the attribute through overlapping
15
- // submissions from the same form.
16
- const busyForms = /* #__PURE__ */ new WeakMap();
17
- function setFormBusy(form, delta) {
18
- const count = (busyForms.get(form) || 0) + delta;
19
- busyForms.set(form, count);
20
- count > 0 ? form.setAttribute("aria-busy", "true") : form.removeAttribute("aria-busy");
12
+ const busyForms = /* #__PURE__ */ new Map();
13
+ // forms whose `aria-busy` the router wrote — an authored one is never
14
+ // overwritten or removed (the same ownership rule as claimed `aria-current`)
15
+ const ownedBusy = /* #__PURE__ */ new WeakSet();
16
+ function busyKey(form) {
17
+ const action = form.getAttribute("action");
18
+ if (action) {
19
+ try {
20
+ return new URL(action, document.baseURI).href;
21
+ }
22
+ catch { }
23
+ }
24
+ return form;
25
+ }
26
+ function showBusy(form) {
27
+ const busy = busyForms.has(busyKey(form));
28
+ // Ownership is read against the element: an owned value a morph stripped
29
+ // is re-applied, one the author has rewritten since is theirs.
30
+ const value = form.getAttribute("aria-busy");
31
+ const owned = ownedBusy.has(form);
32
+ if (owned && value !== null && value !== "true")
33
+ ownedBusy.delete(form);
34
+ else if (busy) {
35
+ if (value === null) {
36
+ form.setAttribute("aria-busy", "true");
37
+ ownedBusy.add(form);
38
+ }
39
+ }
40
+ else if (owned) {
41
+ if (value !== null)
42
+ form.removeAttribute("aria-busy");
43
+ ownedBusy.delete(form);
44
+ }
45
+ }
46
+ /** Marks the form busy; the returned release is one-shot. */
47
+ function markFormBusy(form) {
48
+ const key = busyKey(form);
49
+ let entry = busyForms.get(key);
50
+ if (!entry)
51
+ busyForms.set(key, (entry = { count: 0, forms: new Set() }));
52
+ entry.count++;
53
+ entry.forms.add(form);
54
+ showBusy(form);
55
+ let released = false;
56
+ return () => {
57
+ if (released)
58
+ return;
59
+ released = true;
60
+ if (--entry.count > 0)
61
+ return;
62
+ busyForms.delete(key);
63
+ entry.forms.forEach(showBusy);
64
+ };
65
+ }
66
+ /** The claims' form slot: a (re-)claimed form whose action is busy shows it. */
67
+ function claimBusyForm(form) {
68
+ const entry = busyForms.get(busyKey(form));
69
+ if (entry)
70
+ entry.forms.add(form);
71
+ showBusy(form);
21
72
  }
22
73
  export const actions = /* #__PURE__ */ new Map();
23
74
  /**
@@ -152,12 +203,16 @@ function installRouterIntegrations() {
152
203
  }
153
204
  else {
154
205
  setRouterFormHandler(handleFormAction);
206
+ setFormClaimHandler(claimBusyForm);
155
207
  provideFlightConsumer(setupFlightDataConsumer);
156
208
  }
157
209
  }
158
210
  export function useSubmissions(fn, filter) {
159
211
  const router = useRouter();
160
- const subs = createMemo(() => router.submissions[0]().filter(s => s.url === fn.base && (!filter || filter(s.input))));
212
+ // Submissions are immutable records, so the same records in the same order
213
+ // are the same list: another action's submissions changing re-runs the
214
+ // filter but must not re-run this list's readers.
215
+ const subs = createMemo(() => router.submissions[0]().filter(s => s.url === fn.base && (!filter || filter(s.input))), { equals: (a, b) => a.length === b.length && a.every((s, i) => s === b[i]) });
161
216
  return new Proxy([], {
162
217
  get(_, property) {
163
218
  if (property === $TRACK)
@@ -183,25 +238,90 @@ function actionImpl(fn, options = {}) {
183
238
  // flight-data consumer (see setupFlightDataConsumer) makes the transport
184
239
  // send the request header itself, so the mutation is just called.
185
240
  const runMutation = () => fn(...variables);
241
+ // The busy release, the submission record and the settled hooks wait for
242
+ // the action's transition to COMMIT, not just its body: the body's final
243
+ // slice can start reads (the default revalidation's refetch, a redirect's
244
+ // route data) that hold the transition with the old UI still on screen
245
+ // (#649). Which comes first varies — an unheld transition commits before
246
+ // the action's promise resolves, a held one after — so the outcome is
247
+ // captured inside the body and settle() runs once both are in.
248
+ let outcome;
249
+ let committed = false;
250
+ let settled = false;
251
+ const settle = () => {
252
+ if (settled || !committed || !outcome)
253
+ return;
254
+ settled = true;
255
+ const response = outcome.response;
256
+ release && release();
257
+ let submission;
258
+ submission = {
259
+ input: variables,
260
+ url,
261
+ result: response?.data,
262
+ error: response?.error,
263
+ clear() {
264
+ router.submissions[1](entries => entries.filter(entry => entry !== submission));
265
+ },
266
+ retry() {
267
+ submission.clear();
268
+ return current[invokeSymbol].call({ r: router, f: form }, variables, current);
269
+ }
270
+ };
271
+ // Book-keeping is intentional: only outcomes worth showing or retrying
272
+ // (a result or an error) enter the submissions list, so the typical void
273
+ // mutation leaves nothing behind. Settled hooks still see every
274
+ // completion — void, metadata-only, and redirects included — one
275
+ // `onSettled` per invocation (#580).
276
+ response && router.submissions[1](entries => [...entries, submission]);
277
+ // runs inside the scheduler's effect pass: a throwing hook must not
278
+ // abort the pass (or the hooks after it), so it is reported on its own
279
+ for (const hook of outcome.hooks) {
280
+ try {
281
+ hook(submission);
282
+ }
283
+ catch (e) {
284
+ queueMicrotask(() => {
285
+ throw e;
286
+ });
287
+ }
288
+ }
289
+ };
290
+ // The hooks that settle this submission are the ones registered when its
291
+ // body finished. The commit can dispose their owners (the page a redirect
292
+ // leaves) before it fires; they still see the submission they observed.
293
+ const finish = (response) => {
294
+ outcome || (outcome = { response, hooks: [...settledHooks.values()] });
295
+ settle();
296
+ };
186
297
  const run = createSolidAction(async function* (context) {
187
- context.optimistic?.();
188
- let value;
189
- let error = false;
190
298
  try {
191
- value = await context.call();
299
+ context.optimistic?.();
300
+ let value;
301
+ let error = false;
302
+ try {
303
+ value = await context.call();
304
+ }
305
+ catch (e) {
306
+ value = e;
307
+ error = true;
308
+ }
309
+ const read = await readResponse(value, error);
310
+ yield;
311
+ // Apply inside the transition so the default revalidation's refetch and
312
+ // the release of the caller's optimistic writes commit as one frame (#619).
313
+ const response = applyResponse(read, router.navigatorFactory(), flightApplications !== flightApplicationsBefore);
314
+ finish(response);
315
+ return response;
192
316
  }
193
317
  catch (e) {
194
- value = e;
195
- error = true;
318
+ // a failure outside the mutation (a submit hook, decoding the
319
+ // response, applying it) still settles, with the error recorded
320
+ finish({ error: e });
321
+ throw e;
196
322
  }
197
- const read = await readResponse(value, error);
198
- yield;
199
- // Apply inside the transition so the default revalidation's refetch and
200
- // the release of the caller's optimistic writes commit as one frame (#619).
201
- return applyResponse(read, router.navigatorFactory(), flightApplications !== flightApplicationsBefore);
202
323
  });
203
- form && setFormBusy(form, 1);
204
- let response;
324
+ const release = form && markFormBusy(form);
205
325
  // The transport consumer is awaited before a single-flight mutation
206
326
  // resolves, so a counter delta over the call tells whether this action's
207
327
  // metadata was already applied. Overlapping mutations can cross-attribute
@@ -209,8 +329,9 @@ function actionImpl(fn, options = {}) {
209
329
  // a far smaller window than predicting from the function's identity,
210
330
  // which misses every response the server returned without flight data.
211
331
  const flightApplicationsBefore = flightApplications;
332
+ let pending;
212
333
  try {
213
- response = await settleActionResult(run({
334
+ pending = run({
214
335
  call: runMutation,
215
336
  optimistic: submitHooks.size
216
337
  ? () => {
@@ -218,33 +339,32 @@ function actionImpl(fn, options = {}) {
218
339
  hook(...variables);
219
340
  }
220
341
  : undefined
221
- }));
342
+ });
222
343
  }
223
- finally {
224
- form && setFormBusy(form, -1);
344
+ catch (e) {
345
+ // refused before a transition began: nothing will commit
346
+ release && release();
347
+ throw e;
225
348
  }
226
- let submission;
227
- submission = {
228
- input: variables,
229
- url,
230
- result: response?.data,
231
- error: response?.error,
232
- clear() {
233
- router.submissions[1](entries => entries.filter(entry => entry !== submission));
234
- },
235
- retry() {
236
- submission.clear();
237
- return current[invokeSymbol].call({ r: router, f: form }, variables, current);
238
- }
239
- };
240
- // Book-keeping is intentional: only outcomes worth showing or retrying
241
- // (a result or an error) enter the submissions list, so the typical void
242
- // mutation leaves nothing behind. Settled hooks still see every
243
- // completion — void, metadata-only, and redirects included — one
244
- // `onSettled` per invocation (#580).
245
- response && router.submissions[1](entries => [...entries, submission]);
246
- for (const hook of settledHooks.values())
247
- hook(submission);
349
+ // Registered unowned, synchronously after the invocation, this lands on
350
+ // the action's own transition — or the outer one a nested call joined, or
351
+ // the survivor of a merge — and fires at its commit, failures included.
352
+ // (Registered from the promise continuation it would fire too early: the
353
+ // transition has parked by then.)
354
+ onSettled(() => {
355
+ committed = true;
356
+ settle();
357
+ });
358
+ // The returned promise still means "the body finished": an outer action
359
+ // composing this one (`yield call()`) is the transition that has to
360
+ // commit, so it cannot wait for the commit.
361
+ try {
362
+ await settleActionResult(pending);
363
+ }
364
+ catch (e) {
365
+ finish({ error: e });
366
+ }
367
+ const response = outcome.response;
248
368
  if (response) {
249
369
  if (response.error && !form)
250
370
  throw response.error;
@@ -1,5 +1,6 @@
1
1
  import { delegateEvents } from "@solidjs/web";
2
2
  import { onCleanup } from "solid-js";
3
+ import { isUnderBase } from "../utils.js";
3
4
  let formHandler;
4
5
  export function setRouterFormHandler(handler) {
5
6
  formHandler = handler;
@@ -39,8 +40,7 @@ export function setupNativeEvents({ preload = true, explicitLinks = false, actio
39
40
  // inherit the page origin, so the origin check below won't reject them. #382
40
41
  if (url.protocol !== "https:" && url.protocol !== "http:")
41
42
  return;
42
- if (url.origin !== window.location.origin ||
43
- (basePath && url.pathname && !url.pathname.toLowerCase().startsWith(basePath.toLowerCase())))
43
+ if (url.origin !== window.location.origin || !isUnderBase(url.pathname, basePath))
44
44
  return;
45
45
  return [a, url];
46
46
  }