@solidjs/router 2.0.0-next.34 → 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 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,6 +203,7 @@ function installRouterIntegrations() {
152
203
  }
153
204
  else {
154
205
  setRouterFormHandler(handleFormAction);
206
+ setFormClaimHandler(claimBusyForm);
155
207
  provideFlightConsumer(setupFlightDataConsumer);
156
208
  }
157
209
  }
@@ -183,25 +235,90 @@ function actionImpl(fn, options = {}) {
183
235
  // flight-data consumer (see setupFlightDataConsumer) makes the transport
184
236
  // send the request header itself, so the mutation is just called.
185
237
  const runMutation = () => fn(...variables);
238
+ // The busy release, the submission record and the settled hooks wait for
239
+ // the action's transition to COMMIT, not just its body: the body's final
240
+ // slice can start reads (the default revalidation's refetch, a redirect's
241
+ // route data) that hold the transition with the old UI still on screen
242
+ // (#649). Which comes first varies — an unheld transition commits before
243
+ // the action's promise resolves, a held one after — so the outcome is
244
+ // captured inside the body and settle() runs once both are in.
245
+ let outcome;
246
+ let committed = false;
247
+ let settled = false;
248
+ const settle = () => {
249
+ if (settled || !committed || !outcome)
250
+ return;
251
+ settled = true;
252
+ const response = outcome.response;
253
+ release && release();
254
+ let submission;
255
+ submission = {
256
+ input: variables,
257
+ url,
258
+ result: response?.data,
259
+ error: response?.error,
260
+ clear() {
261
+ router.submissions[1](entries => entries.filter(entry => entry !== submission));
262
+ },
263
+ retry() {
264
+ submission.clear();
265
+ return current[invokeSymbol].call({ r: router, f: form }, variables, current);
266
+ }
267
+ };
268
+ // Book-keeping is intentional: only outcomes worth showing or retrying
269
+ // (a result or an error) enter the submissions list, so the typical void
270
+ // mutation leaves nothing behind. Settled hooks still see every
271
+ // completion — void, metadata-only, and redirects included — one
272
+ // `onSettled` per invocation (#580).
273
+ response && router.submissions[1](entries => [...entries, submission]);
274
+ // runs inside the scheduler's effect pass: a throwing hook must not
275
+ // abort the pass (or the hooks after it), so it is reported on its own
276
+ for (const hook of outcome.hooks) {
277
+ try {
278
+ hook(submission);
279
+ }
280
+ catch (e) {
281
+ queueMicrotask(() => {
282
+ throw e;
283
+ });
284
+ }
285
+ }
286
+ };
287
+ // The hooks that settle this submission are the ones registered when its
288
+ // body finished. The commit can dispose their owners (the page a redirect
289
+ // leaves) before it fires; they still see the submission they observed.
290
+ const finish = (response) => {
291
+ outcome || (outcome = { response, hooks: [...settledHooks.values()] });
292
+ settle();
293
+ };
186
294
  const run = createSolidAction(async function* (context) {
187
- context.optimistic?.();
188
- let value;
189
- let error = false;
190
295
  try {
191
- value = await context.call();
296
+ context.optimistic?.();
297
+ let value;
298
+ let error = false;
299
+ try {
300
+ value = await context.call();
301
+ }
302
+ catch (e) {
303
+ value = e;
304
+ error = true;
305
+ }
306
+ const read = await readResponse(value, error);
307
+ yield;
308
+ // Apply inside the transition so the default revalidation's refetch and
309
+ // the release of the caller's optimistic writes commit as one frame (#619).
310
+ const response = applyResponse(read, router.navigatorFactory(), flightApplications !== flightApplicationsBefore);
311
+ finish(response);
312
+ return response;
192
313
  }
193
314
  catch (e) {
194
- value = e;
195
- error = true;
315
+ // a failure outside the mutation (a submit hook, decoding the
316
+ // response, applying it) still settles, with the error recorded
317
+ finish({ error: e });
318
+ throw e;
196
319
  }
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
320
  });
203
- form && setFormBusy(form, 1);
204
- let response;
321
+ const release = form && markFormBusy(form);
205
322
  // The transport consumer is awaited before a single-flight mutation
206
323
  // resolves, so a counter delta over the call tells whether this action's
207
324
  // metadata was already applied. Overlapping mutations can cross-attribute
@@ -209,8 +326,9 @@ function actionImpl(fn, options = {}) {
209
326
  // a far smaller window than predicting from the function's identity,
210
327
  // which misses every response the server returned without flight data.
211
328
  const flightApplicationsBefore = flightApplications;
329
+ let pending;
212
330
  try {
213
- response = await settleActionResult(run({
331
+ pending = run({
214
332
  call: runMutation,
215
333
  optimistic: submitHooks.size
216
334
  ? () => {
@@ -218,33 +336,32 @@ function actionImpl(fn, options = {}) {
218
336
  hook(...variables);
219
337
  }
220
338
  : undefined
221
- }));
339
+ });
222
340
  }
223
- finally {
224
- form && setFormBusy(form, -1);
341
+ catch (e) {
342
+ // refused before a transition began: nothing will commit
343
+ release && release();
344
+ throw e;
225
345
  }
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);
346
+ // Registered unowned, synchronously after the invocation, this lands on
347
+ // the action's own transition — or the outer one a nested call joined, or
348
+ // the survivor of a merge — and fires at its commit, failures included.
349
+ // (Registered from the promise continuation it would fire too early: the
350
+ // transition has parked by then.)
351
+ onSettled(() => {
352
+ committed = true;
353
+ settle();
354
+ });
355
+ // The returned promise still means "the body finished": an outer action
356
+ // composing this one (`yield call()`) is the transition that has to
357
+ // commit, so it cannot wait for the commit.
358
+ try {
359
+ await settleActionResult(pending);
360
+ }
361
+ catch (e) {
362
+ finish({ error: e });
363
+ }
364
+ const response = outcome.response;
248
365
  if (response) {
249
366
  if (response.error && !form)
250
367
  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
  }
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) {
@@ -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
- // 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))));
944
+ const base = router.base.path();
945
+ const state = createMemo(() => matchLink(location, to(), base, options.end));
870
946
  return {
871
- active: () => state()[0],
872
- current: () => state()[1],
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 && matches(decodeURI(comparablePath(router.pendingTarget.value)))[0];
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
  };
@@ -3063,16 +3142,74 @@ const submitHooksSymbol = Symbol("routerActionSubmitHooks");
3063
3142
  const settledHooksSymbol = Symbol("routerActionSettledHooks");
3064
3143
  const invokeSymbol = Symbol("routerActionInvoke");
3065
3144
 
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");
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);
3076
3213
  }
3077
3214
  const actions = /* #__PURE__ */new Map();
3078
3215
 
@@ -3206,6 +3343,7 @@ function installRouterIntegrations() {
3206
3343
  provideFlashDecoder(cookieHeader => import('@solidjs/web/server-functions/server').then(m => m.decodeFlashCookie(cookieHeader)));
3207
3344
  } else {
3208
3345
  setRouterFormHandler(handleFormAction);
3346
+ setFormClaimHandler(claimBusyForm);
3209
3347
  provideFlightConsumer(setupFlightDataConsumer);
3210
3348
  }
3211
3349
  }
@@ -3238,24 +3376,94 @@ function actionImpl(fn, options = {}) {
3238
3376
  // flight-data consumer (see setupFlightDataConsumer) makes the transport
3239
3377
  // send the request header itself, so the mutation is just called.
3240
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
+ };
3241
3439
  const run = action$1(async function* (context) {
3242
- context.optimistic?.();
3243
- let value;
3244
- let error = false;
3245
3440
  try {
3246
- value = await context.call();
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;
3247
3457
  } catch (e) {
3248
- value = e;
3249
- error = true;
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;
3250
3464
  }
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
3465
  });
3257
- form && setFormBusy(form, 1);
3258
- let response;
3466
+ const release = form && markFormBusy(form);
3259
3467
  // The transport consumer is awaited before a single-flight mutation
3260
3468
  // resolves, so a counter delta over the call tells whether this action's
3261
3469
  // metadata was already applied. Overlapping mutations can cross-attribute
@@ -3263,40 +3471,40 @@ function actionImpl(fn, options = {}) {
3263
3471
  // a far smaller window than predicting from the function's identity,
3264
3472
  // which misses every response the server returned without flight data.
3265
3473
  const flightApplicationsBefore = flightApplications;
3474
+ let pending;
3266
3475
  try {
3267
- response = await settleActionResult(run({
3476
+ pending = run({
3268
3477
  call: runMutation,
3269
3478
  optimistic: submitHooks.size ? () => {
3270
3479
  for (const hook of submitHooks.values()) hook(...variables);
3271
3480
  } : 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);
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;
3300
3508
  if (response) {
3301
3509
  if (response.error && !form) throw response.error;
3302
3510
  return response.data;
@@ -3457,6 +3665,9 @@ function applyResponseMetadata(metadata, navigate, flightData) {
3457
3665
  // are fresh again by now, so the sweep re-reads them from cache.
3458
3666
  revalidate(keys, false);
3459
3667
  }
3668
+
3669
+ /** What a run settles with: a result, an error, or nothing worth recording. */
3670
+
3460
3671
  async function readResponse(response, error) {
3461
3672
  let data;
3462
3673
  let flightData;
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;
package/dist/routing.js CHANGED
@@ -4,7 +4,7 @@ import { runWithOwner } from "solid-js";
4
4
  import { DEV } from "solid-js";
5
5
  import { createComponent, createContext, createMemo, createSignal, getOwner, isPending, latest, NotReadyError, untrack, useContext } from "solid-js";
6
6
  import { clearFlashCookie, getRequestEvent, hasFlashCookie, isServer } from "@solidjs/web";
7
- import { mockBase, comparablePath, createMemoObject, extractSearchParams, invariant, resolvePath, createMatcher, joinPaths, scoreRoute, mergeSearchString, expandOptionals, validateSearch } from "./utils.js";
7
+ import { mockBase, createMemoObject, extractSearchParams, invariant, matchLink, resolvePath, createMatcher, joinPaths, scoreRoute, mergeSearchString, expandOptionals, validateSearch } from "./utils.js";
8
8
  import { HREF } from "./paths.js";
9
9
  import { serverRouteOf, serverRouteArgs, serverRouteArgsEqual } from "./serverRouteShared.js";
10
10
  const MAX_REDIRECTS = 100;
@@ -227,22 +227,11 @@ export const useLinkState = (href, options = {}) => {
227
227
  const router = useRouter();
228
228
  const location = router.location;
229
229
  const to = useResolvedPath(() => String(href()));
230
- // trailing slashes are ignored so `/route` and `/route/` share state
231
- const path = createMemo(() => {
232
- const to_ = to();
233
- return to_ === undefined ? undefined : comparablePath(to_);
234
- });
235
- const matches = (loc) => {
236
- const path_ = path();
237
- if (path_ === undefined)
238
- return [false, false];
239
- const exact = loc === path_;
240
- return [exact || (!options.end && loc.startsWith(path_ + "/")), exact];
241
- };
242
- const state = createMemo(() => matches(decodeURI(comparablePath(location.pathname))));
230
+ const base = router.base.path();
231
+ const state = createMemo(() => matchLink(location, to(), base, options.end));
243
232
  return {
244
- active: () => state()[0],
245
- current: () => state()[1],
233
+ active: createMemo(() => state().active),
234
+ current: createMemo(() => state().current),
246
235
  // match the in-flight target explicitly (rather than active-while-routing)
247
236
  // so the answer is the same from pure reads and from effects, which
248
237
  // observe the committed location during a transition
@@ -250,7 +239,8 @@ export const useLinkState = (href, options = {}) => {
250
239
  state(); // location dependency: mid-flight target swaps recompute
251
240
  return (router.isRouting() &&
252
241
  !!router.pendingTarget &&
253
- matches(decodeURI(comparablePath(router.pendingTarget.value)))[0]);
242
+ matchLink({ pathname: router.pendingTarget.value, search: "" }, to(), base, options.end)
243
+ .active);
254
244
  })
255
245
  };
256
246
  };
package/dist/utils.d.ts CHANGED
@@ -1,8 +1,36 @@
1
1
  import type { StandardSchemaV1, MatchFilters, PathMatch, RouteDescription, SearchParams, SetSearchParams } from "./types.js";
2
2
  export declare const mockBase = "http://sr";
3
3
  export declare function normalizePath(path: string, omitSlash?: boolean): string;
4
- /** Pathname stripped of search/hash and trailing slash, lowercased — the form link matching compares. */
4
+ /**
5
+ * Pathname stripped of search/hash and trailing slash, percent-encoded the
6
+ * way `URL` (and so `location.pathname`) reports it, lowercased — the form
7
+ * link matching compares. Raw and encoded spellings (`/café`, `/caf%C3%A9`)
8
+ * meet; escapes are never decoded, so `%2F` is not a path separator.
9
+ */
5
10
  export declare const comparablePath: (path: string) => string;
11
+ /**
12
+ * Whether a URL pathname is the router's base path or under it, on a segment
13
+ * boundary: base `/app` covers `/app`, `/app/` and `/app/x`, not `/apple`.
14
+ * Case-insensitive; no base (`""` or `/`) covers every path.
15
+ */
16
+ export declare function isUnderBase(pathname: string, base: string): boolean;
17
+ /**
18
+ * The link-state rule shared by claimed anchors and `useLinkState`, given
19
+ * the location and a link's already-resolved target (path, optional query
20
+ * and hash):
21
+ *
22
+ * - `current` — same pathname and same query (parameter order and hash aside)
23
+ * - `active` — same pathname or one under it; the router's root (`base`, or
24
+ * `/` without one) only matches exactly, since it is a prefix of every
25
+ * page. `end` makes every link exact-only.
26
+ */
27
+ export declare function matchLink(location: {
28
+ pathname: string;
29
+ search: string;
30
+ }, target: string | undefined, base: string, end?: boolean): {
31
+ active: boolean;
32
+ current: boolean;
33
+ };
6
34
  export declare function resolvePath(base: string, path: string, from?: string): string | undefined;
7
35
  export declare function invariant<T>(value: T | null | undefined, message: string): T;
8
36
  export declare function joinPaths(from: string, to: string): string;
package/dist/utils.js CHANGED
@@ -6,8 +6,54 @@ export function normalizePath(path, omitSlash = false) {
6
6
  const s = path.replace(trimPathRegex, "$1");
7
7
  return s ? (omitSlash || /^[?#]/.test(s) ? s : "/" + s) : "";
8
8
  }
9
- /** Pathname stripped of search/hash and trailing slash, lowercased — the form link matching compares. */
10
- export const comparablePath = (path) => normalizePath(path.split(/[?#]/, 1)[0]).toLowerCase().replace(/\/$/, "");
9
+ /**
10
+ * Pathname stripped of search/hash and trailing slash, percent-encoded the
11
+ * way `URL` (and so `location.pathname`) reports it, lowercased — the form
12
+ * link matching compares. Raw and encoded spellings (`/café`, `/caf%C3%A9`)
13
+ * meet; escapes are never decoded, so `%2F` is not a path separator.
14
+ */
15
+ export const comparablePath = (path) => new URL(mockBase + normalizePath(path.split(/[?#]/, 1)[0])).pathname
16
+ .toLowerCase()
17
+ .replace(/\/$/, "");
18
+ /**
19
+ * Whether a URL pathname is the router's base path or under it, on a segment
20
+ * boundary: base `/app` covers `/app`, `/app/` and `/app/x`, not `/apple`.
21
+ * Case-insensitive; no base (`""` or `/`) covers every path.
22
+ */
23
+ export function isUnderBase(pathname, base) {
24
+ const b = base.toLowerCase().replace(/\/+$/, "");
25
+ const p = pathname.toLowerCase();
26
+ return !b || !p || p === b || p.startsWith(b + "/");
27
+ }
28
+ /** A query string as an order-independent comparable string. */
29
+ const comparableQuery = (search) => {
30
+ const params = new URLSearchParams(search);
31
+ params.sort();
32
+ return params.toString();
33
+ };
34
+ /**
35
+ * The link-state rule shared by claimed anchors and `useLinkState`, given
36
+ * the location and a link's already-resolved target (path, optional query
37
+ * and hash):
38
+ *
39
+ * - `current` — same pathname and same query (parameter order and hash aside)
40
+ * - `active` — same pathname or one under it; the router's root (`base`, or
41
+ * `/` without one) only matches exactly, since it is a prefix of every
42
+ * page. `end` makes every link exact-only.
43
+ */
44
+ export function matchLink(location, target, base, end) {
45
+ if (target === undefined)
46
+ return { active: false, current: false };
47
+ const loc = comparablePath(location.pathname);
48
+ const path = comparablePath(target);
49
+ const exact = loc === path;
50
+ const hashless = target.split("#", 1)[0];
51
+ const q = hashless.indexOf("?");
52
+ return {
53
+ active: exact || (!end && path !== "" && path !== comparablePath(base) && loc.startsWith(path + "/")),
54
+ current: exact && comparableQuery(location.search) === comparableQuery(q < 0 ? "" : hashless.slice(q))
55
+ };
56
+ }
11
57
  export function resolvePath(base, path, from) {
12
58
  if (hasSchemeRegex.test(path)) {
13
59
  return undefined;
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "Ryan Turnquist"
7
7
  ],
8
8
  "license": "MIT",
9
- "version": "2.0.0-next.34",
9
+ "version": "2.0.0-next.35",
10
10
  "homepage": "https://github.com/solidjs/solid-router#readme",
11
11
  "repository": {
12
12
  "type": "git",