@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 +24 -7
- package/dist/claims.d.ts +14 -6
- package/dist/claims.js +64 -28
- package/dist/data/action.js +167 -50
- package/dist/data/events.js +2 -2
- package/dist/index.js +308 -97
- package/dist/routing.d.ts +10 -2
- package/dist/routing.js +7 -17
- package/dist/utils.d.ts +29 -1
- package/dist/utils.js +48 -2
- package/package.json +1 -1
package/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
|
-
|
|
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
|
|
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]`
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
3
|
+
import { isUnderBase, matchLink } from "./utils.js";
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
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; `
|
|
26
|
-
//
|
|
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
|
|
33
|
-
function
|
|
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
|
|
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
|
|
78
|
+
const location = router.location;
|
|
63
79
|
const routing = router.isRouting();
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
const
|
|
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 &&
|
|
72
|
-
|
|
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,
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
package/dist/data/action.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
//
|
|
13
|
-
// (
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
error
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
341
|
+
catch (e) {
|
|
342
|
+
// refused before a transition began: nothing will commit
|
|
343
|
+
release && release();
|
|
344
|
+
throw e;
|
|
225
345
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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;
|
package/dist/data/events.js
CHANGED
|
@@ -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
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Pathname stripped of search/hash and trailing slash, percent-encoded the
|
|
15
|
+
* way `URL` (and so `location.pathname`) reports it, lowercased — the form
|
|
16
|
+
* link matching compares. Raw and encoded spellings (`/café`, `/caf%C3%A9`)
|
|
17
|
+
* meet; escapes are never decoded, so `%2F` is not a path separator.
|
|
18
|
+
*/
|
|
19
|
+
const comparablePath = path => new URL(mockBase + normalizePath(path.split(/[?#]/, 1)[0])).pathname.toLowerCase().replace(/\/$/, "");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether a URL pathname is the router's base path or under it, on a segment
|
|
23
|
+
* boundary: base `/app` covers `/app`, `/app/` and `/app/x`, not `/apple`.
|
|
24
|
+
* Case-insensitive; no base (`""` or `/`) covers every path.
|
|
25
|
+
*/
|
|
26
|
+
function isUnderBase(pathname, base) {
|
|
27
|
+
const b = base.toLowerCase().replace(/\/+$/, "");
|
|
28
|
+
const p = pathname.toLowerCase();
|
|
29
|
+
return !b || !p || p === b || p.startsWith(b + "/");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A query string as an order-independent comparable string. */
|
|
33
|
+
const comparableQuery = search => {
|
|
34
|
+
const params = new URLSearchParams(search);
|
|
35
|
+
params.sort();
|
|
36
|
+
return params.toString();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The link-state rule shared by claimed anchors and `useLinkState`, given
|
|
41
|
+
* the location and a link's already-resolved target (path, optional query
|
|
42
|
+
* and hash):
|
|
43
|
+
*
|
|
44
|
+
* - `current` — same pathname and same query (parameter order and hash aside)
|
|
45
|
+
* - `active` — same pathname or one under it; the router's root (`base`, or
|
|
46
|
+
* `/` without one) only matches exactly, since it is a prefix of every
|
|
47
|
+
* page. `end` makes every link exact-only.
|
|
48
|
+
*/
|
|
49
|
+
function matchLink(location, target, base, end) {
|
|
50
|
+
if (target === undefined) return {
|
|
51
|
+
active: false,
|
|
52
|
+
current: false
|
|
53
|
+
};
|
|
54
|
+
const loc = comparablePath(location.pathname);
|
|
55
|
+
const path = comparablePath(target);
|
|
56
|
+
const exact = loc === path;
|
|
57
|
+
const hashless = target.split("#", 1)[0];
|
|
58
|
+
const q = hashless.indexOf("?");
|
|
59
|
+
return {
|
|
60
|
+
active: exact || !end && path !== "" && path !== comparablePath(base) && loc.startsWith(path + "/"),
|
|
61
|
+
current: exact && comparableQuery(location.search) === comparableQuery(q < 0 ? "" : hashless.slice(q))
|
|
62
|
+
};
|
|
63
|
+
}
|
|
15
64
|
function resolvePath(base, path, from) {
|
|
16
65
|
if (hasSchemeRegex.test(path)) {
|
|
17
66
|
return undefined;
|
|
@@ -179,15 +228,33 @@ function setFunctionName(obj, value) {
|
|
|
179
228
|
}
|
|
180
229
|
|
|
181
230
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
231
|
+
* Claimed forms are handed to this slot instead of the claims importing the
|
|
232
|
+
* action module: the action side installs it on first action creation (see
|
|
233
|
+
* data/action.ts), where form `aria-busy` state lives, so an app that never
|
|
234
|
+
* creates an action never pulls the data layer in through its claims.
|
|
235
|
+
*/
|
|
236
|
+
let formClaim;
|
|
237
|
+
function setFormClaimHandler(handler) {
|
|
238
|
+
formClaim = handler;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The compiler claims every `a[href]` and `form[action]` at creation, and the
|
|
243
|
+
* runtime re-claims on `href`/`action` writes and after a server-component
|
|
244
|
+
* morph changes the element. Forms go to the action layer's slot above, which
|
|
245
|
+
* re-applies `aria-busy` while their action is in flight. This consumer gives each router-managed anchor the link-state
|
|
246
|
+
* vocabulary without a wrapper component:
|
|
186
247
|
*
|
|
187
|
-
* - `aria-current="page"` — the location matches the link exactly
|
|
188
|
-
*
|
|
248
|
+
* - `aria-current="page"` — the location matches the link exactly, query
|
|
249
|
+
* included (parameter order aside)
|
|
250
|
+
* - `data-active` — pathname exact or prefix match (the router's root, its
|
|
251
|
+
* base path, exact only)
|
|
189
252
|
* - `data-pending` — the link is the target of an in-flight navigation
|
|
190
253
|
*
|
|
254
|
+
* The matching rule is `matchLink`, shared with `useLinkState`. The router
|
|
255
|
+
* only touches an `aria-current` it wrote itself: one the author set (a
|
|
256
|
+
* stepper's `"step"`, a static `"page"`) is left in place, current or not.
|
|
257
|
+
*
|
|
191
258
|
* Elements are claimed at creation, so late mounts (`<Show>`, `<For>`,
|
|
192
259
|
* portals) are correct immediately. One render effect (owned by the router)
|
|
193
260
|
* subscribes to the location and sweeps a registry of claimed anchors —
|
|
@@ -199,16 +266,16 @@ function setFunctionName(obj, value) {
|
|
|
199
266
|
*/
|
|
200
267
|
function setupLinkClaims(router, explicitLinks) {
|
|
201
268
|
const basePath = router.base.path();
|
|
202
|
-
// per-element record; `
|
|
203
|
-
//
|
|
269
|
+
// per-element record; `owned` is whether the `aria-current` on the element
|
|
270
|
+
// is the router's, so it never writes over or removes an authored one
|
|
204
271
|
const claimed = new WeakMap();
|
|
205
272
|
const registry = new Set();
|
|
206
273
|
function isSvg(el) {
|
|
207
274
|
return el.namespaceURI === "http://www.w3.org/2000/svg";
|
|
208
275
|
}
|
|
209
276
|
|
|
210
|
-
/** The
|
|
211
|
-
function
|
|
277
|
+
/** The anchor's resolved URL when the router manages it, else `undefined`. */
|
|
278
|
+
function managedUrl(a) {
|
|
212
279
|
if (explicitLinks && !a.hasAttribute("link")) return;
|
|
213
280
|
const svg = isSvg(a);
|
|
214
281
|
// claims fire at creation while the element is still in the template's
|
|
@@ -225,38 +292,55 @@ function setupLinkClaims(router, explicitLinks) {
|
|
|
225
292
|
} catch {
|
|
226
293
|
return;
|
|
227
294
|
}
|
|
228
|
-
if (url.origin !== window.location.origin ||
|
|
229
|
-
return
|
|
295
|
+
if (url.origin !== window.location.origin || !isUnderBase(url.pathname, basePath)) return;
|
|
296
|
+
return url;
|
|
230
297
|
}
|
|
231
298
|
function linkState(a) {
|
|
232
299
|
// read reactive sources unconditionally so the owning effect stays
|
|
233
300
|
// subscribed even while the anchor is not router-managed
|
|
234
|
-
const
|
|
301
|
+
const location = router.location;
|
|
235
302
|
const routing = router.isRouting();
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
const
|
|
303
|
+
const url = managedUrl(a);
|
|
304
|
+
const target = url && url.pathname + url.search;
|
|
305
|
+
// no per-anchor `end` opt-out like useLinkState has
|
|
306
|
+
const {
|
|
307
|
+
active,
|
|
308
|
+
current
|
|
309
|
+
} = matchLink(location, target, basePath);
|
|
240
310
|
// effects observe the committed location during a transition, so the
|
|
241
311
|
// in-flight target comes from pendingTarget — readable here because the
|
|
242
312
|
// isRouting write flushes after the target is assigned
|
|
243
|
-
const pending = routing && !!router.pendingTarget &&
|
|
313
|
+
const pending = routing && !!router.pendingTarget && matchLink({
|
|
314
|
+
pathname: router.pendingTarget.value,
|
|
315
|
+
search: ""
|
|
316
|
+
}, target, basePath).active;
|
|
244
317
|
return {
|
|
245
|
-
active
|
|
318
|
+
active,
|
|
246
319
|
pending,
|
|
247
|
-
|
|
320
|
+
current
|
|
248
321
|
};
|
|
249
322
|
}
|
|
250
323
|
function apply(a, rec, {
|
|
251
324
|
active,
|
|
252
325
|
pending,
|
|
253
|
-
|
|
326
|
+
current
|
|
254
327
|
}) {
|
|
255
328
|
active ? a.setAttribute("data-active", "") : a.removeAttribute("data-active");
|
|
256
329
|
pending ? a.setAttribute("data-pending", "") : a.removeAttribute("data-pending");
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
330
|
+
// Ownership is read against the element, not just the record. A
|
|
331
|
+
// server-component morph resets attributes to the server HTML, which
|
|
332
|
+
// never carries router link state, then re-claims: an owned value that
|
|
333
|
+
// went missing is re-applied, while a value the morph restored from the
|
|
334
|
+
// server HTML (or the author wrote since) is authored and left alone.
|
|
335
|
+
const value = a.getAttribute("aria-current");
|
|
336
|
+
if (rec.owned && value !== null && value !== "page") rec.owned = false;else if (current) {
|
|
337
|
+
if (value === null) {
|
|
338
|
+
a.setAttribute("aria-current", "page");
|
|
339
|
+
rec.owned = true;
|
|
340
|
+
}
|
|
341
|
+
} else if (rec.owned) {
|
|
342
|
+
if (value !== null) a.removeAttribute("aria-current");
|
|
343
|
+
rec.owned = false;
|
|
260
344
|
}
|
|
261
345
|
}
|
|
262
346
|
const refresh = (a, rec) => untrack(() => apply(a, rec, linkState(a)));
|
|
@@ -272,18 +356,20 @@ function setupLinkClaims(router, explicitLinks) {
|
|
|
272
356
|
// slot — lazy-route lookups miss and hydration leaves server nodes
|
|
273
357
|
// unclaimed. (The option is honored by the runtime but missing from the
|
|
274
358
|
// published EffectOptions type, hence the cast.)
|
|
275
|
-
createRenderEffect(() => (router.location.pathname, router.isRouting()), () => registry.forEach(a => refresh(a, claimed.get(a))), {
|
|
359
|
+
createRenderEffect(() => (router.location.pathname, router.location.search, router.isRouting()), () => registry.forEach(a => refresh(a, claimed.get(a))), {
|
|
276
360
|
transparent: true
|
|
277
361
|
});
|
|
278
362
|
onCleanup(registerElementClaim(node => {
|
|
279
|
-
|
|
363
|
+
const name = node.nodeName.toUpperCase();
|
|
364
|
+
if (name === "FORM") return formClaim && formClaim(node);
|
|
365
|
+
if (name !== "A") return;
|
|
280
366
|
const a = node;
|
|
281
367
|
// re-claim (href changed): the claiming write runs inside another
|
|
282
368
|
// effect, so refresh without leaking subscriptions into it
|
|
283
369
|
const existing = claimed.get(a);
|
|
284
370
|
if (existing) return refresh(a, existing);
|
|
285
371
|
const rec = {
|
|
286
|
-
|
|
372
|
+
owned: false
|
|
287
373
|
};
|
|
288
374
|
claimed.set(a, rec);
|
|
289
375
|
// claims fire during component setup, so an owner is present in
|
|
@@ -336,7 +422,7 @@ function setupNativeEvents({
|
|
|
336
422
|
// Skip non-http(s) schemes (blob:, mailto:, tel:, data:, ...). blob: URLs
|
|
337
423
|
// inherit the page origin, so the origin check below won't reject them. #382
|
|
338
424
|
if (url.protocol !== "https:" && url.protocol !== "http:") return;
|
|
339
|
-
if (url.origin !== window.location.origin ||
|
|
425
|
+
if (url.origin !== window.location.origin || !isUnderBase(url.pathname, basePath)) return;
|
|
340
426
|
return [a, url];
|
|
341
427
|
}
|
|
342
428
|
function handleAnchorClick(evt) {
|
|
@@ -855,27 +941,20 @@ const useLinkState = (href, options = {}) => {
|
|
|
855
941
|
const router = useRouter();
|
|
856
942
|
const location = router.location;
|
|
857
943
|
const to = useResolvedPath(() => String(href()));
|
|
858
|
-
|
|
859
|
-
const
|
|
860
|
-
const to_ = to();
|
|
861
|
-
return to_ === undefined ? undefined : comparablePath(to_);
|
|
862
|
-
});
|
|
863
|
-
const matches = loc => {
|
|
864
|
-
const path_ = path();
|
|
865
|
-
if (path_ === undefined) return [false, false];
|
|
866
|
-
const exact = loc === path_;
|
|
867
|
-
return [exact || !options.end && loc.startsWith(path_ + "/"), exact];
|
|
868
|
-
};
|
|
869
|
-
const state = createMemo(() => matches(decodeURI(comparablePath(location.pathname))));
|
|
944
|
+
const base = router.base.path();
|
|
945
|
+
const state = createMemo(() => matchLink(location, to(), base, options.end));
|
|
870
946
|
return {
|
|
871
|
-
active: () => state()
|
|
872
|
-
current: () => state()
|
|
947
|
+
active: createMemo(() => state().active),
|
|
948
|
+
current: createMemo(() => state().current),
|
|
873
949
|
// match the in-flight target explicitly (rather than active-while-routing)
|
|
874
950
|
// so the answer is the same from pure reads and from effects, which
|
|
875
951
|
// observe the committed location during a transition
|
|
876
952
|
pending: createMemo(() => {
|
|
877
953
|
state(); // location dependency: mid-flight target swaps recompute
|
|
878
|
-
return router.isRouting() && !!router.pendingTarget &&
|
|
954
|
+
return router.isRouting() && !!router.pendingTarget && matchLink({
|
|
955
|
+
pathname: router.pendingTarget.value,
|
|
956
|
+
search: ""
|
|
957
|
+
}, to(), base, options.end).active;
|
|
879
958
|
})
|
|
880
959
|
};
|
|
881
960
|
};
|
|
@@ -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`
|
|
3067
|
-
// action
|
|
3068
|
-
// (`data-active`/`data-pending`). Style with
|
|
3069
|
-
// A counter (not a boolean) keeps the
|
|
3070
|
-
//
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3249
|
-
error
|
|
3458
|
+
// a failure outside the mutation (a submit hook, decoding the
|
|
3459
|
+
// response, applying it) still settles, with the error recorded
|
|
3460
|
+
finish({
|
|
3461
|
+
error: e
|
|
3462
|
+
});
|
|
3463
|
+
throw e;
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
}
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
response
|
|
3299
|
-
for (const hook of settledHooks.values()) hook(submission);
|
|
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
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* The location's pathname matches this link's or lives under it; the query
|
|
170
|
+
* is ignored. A link to the router's root (`/`, under the router's `base`)
|
|
171
|
+
* is exact-only, as is every link with `end`.
|
|
172
|
+
* Styling: `data-active`.
|
|
173
|
+
*/
|
|
169
174
|
active: () => boolean;
|
|
170
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* The location matches this link exactly: same pathname and same query,
|
|
177
|
+
* parameter order and hash aside — what `aria-current="page"` reflects.
|
|
178
|
+
*/
|
|
171
179
|
current: () => boolean;
|
|
172
180
|
/** This link is the target of an in-flight navigation. Styling: `data-pending`. */
|
|
173
181
|
pending: () => boolean;
|
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,
|
|
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
|
-
|
|
231
|
-
const
|
|
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()
|
|
245
|
-
current: () => state()
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
10
|
-
|
|
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;
|