@solidjs/router 2.0.0-next.33 → 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 +201 -54
- package/dist/data/events.js +2 -2
- package/dist/index.js +382 -128
- package/dist/routing.d.ts +10 -2
- package/dist/routing.js +40 -45
- 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
|
/**
|
|
@@ -52,13 +103,40 @@ export function handleFormAction(evt, router, actionBase) {
|
|
|
52
103
|
// stays a no-JS fallback.
|
|
53
104
|
// Client-only actions (`https://action/`) are their module's JS by
|
|
54
105
|
// definition, so a miss there falls through to native submission.
|
|
55
|
-
const handler =
|
|
106
|
+
const handler = findAction(actionRef) || (serverAction && createServerFormAction(actionRef));
|
|
56
107
|
if (handler) {
|
|
57
108
|
evt.preventDefault();
|
|
58
109
|
const data = new FormData(form, evt.submitter);
|
|
59
110
|
handler.call({ r: router, f: form }, form.enctype === "multipart/form-data" ? data : new URLSearchParams(data));
|
|
60
111
|
}
|
|
61
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Looks a rendered action url up in the registry. A server-rendered
|
|
115
|
+
* `.with()` url is only registered if this client made the same binding
|
|
116
|
+
* itself; otherwise its base action (the url without `?args`) is rebound to
|
|
117
|
+
* the rendered arguments, so the submission runs through that action's
|
|
118
|
+
* submit and settled hooks and is recorded under its base.
|
|
119
|
+
*/
|
|
120
|
+
function findAction(url) {
|
|
121
|
+
const handler = actions.get(url);
|
|
122
|
+
if (handler)
|
|
123
|
+
return handler;
|
|
124
|
+
const query = url.indexOf("?");
|
|
125
|
+
if (query < 0)
|
|
126
|
+
return undefined;
|
|
127
|
+
const base = actions.get(url.slice(0, query));
|
|
128
|
+
const args = new URLSearchParams(url.slice(query)).get("args");
|
|
129
|
+
if (!base || args === null)
|
|
130
|
+
return undefined;
|
|
131
|
+
let bound;
|
|
132
|
+
try {
|
|
133
|
+
bound = JSON.parse(args);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
return Array.isArray(bound) ? base.with(...bound) : undefined;
|
|
139
|
+
}
|
|
62
140
|
/**
|
|
63
141
|
* Synthesizes a router action for a server-rendered action url. The url
|
|
64
142
|
* carries everything an invocation needs — the function id in the path
|
|
@@ -90,7 +168,7 @@ function createServerFormAction(url) {
|
|
|
90
168
|
* the enctype conversion and the generic invocation happen here.
|
|
91
169
|
*/
|
|
92
170
|
export function submitServerForm(router, url, form, data) {
|
|
93
|
-
const handler =
|
|
171
|
+
const handler = findAction(url) || createServerFormAction(url);
|
|
94
172
|
// not an address (`<endpoint>/<id>`) — not the server function convention;
|
|
95
173
|
// nothing can run it, resubmit natively (submit() bypasses the delegated
|
|
96
174
|
// handler)
|
|
@@ -125,6 +203,7 @@ function installRouterIntegrations() {
|
|
|
125
203
|
}
|
|
126
204
|
else {
|
|
127
205
|
setRouterFormHandler(handleFormAction);
|
|
206
|
+
setFormClaimHandler(claimBusyForm);
|
|
128
207
|
provideFlightConsumer(setupFlightDataConsumer);
|
|
129
208
|
}
|
|
130
209
|
}
|
|
@@ -156,25 +235,90 @@ function actionImpl(fn, options = {}) {
|
|
|
156
235
|
// flight-data consumer (see setupFlightDataConsumer) makes the transport
|
|
157
236
|
// send the request header itself, so the mutation is just called.
|
|
158
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
|
+
};
|
|
159
294
|
const run = createSolidAction(async function* (context) {
|
|
160
|
-
context.optimistic?.();
|
|
161
|
-
let value;
|
|
162
|
-
let error = false;
|
|
163
295
|
try {
|
|
164
|
-
|
|
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;
|
|
165
313
|
}
|
|
166
314
|
catch (e) {
|
|
167
|
-
|
|
168
|
-
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;
|
|
169
319
|
}
|
|
170
|
-
const read = await readResponse(value, error);
|
|
171
|
-
yield;
|
|
172
|
-
// Apply inside the transition so the default revalidation's refetch and
|
|
173
|
-
// the release of the caller's optimistic writes commit as one frame (#619).
|
|
174
|
-
return applyResponse(read, router.navigatorFactory(), flightApplications !== flightApplicationsBefore);
|
|
175
320
|
});
|
|
176
|
-
form &&
|
|
177
|
-
let response;
|
|
321
|
+
const release = form && markFormBusy(form);
|
|
178
322
|
// The transport consumer is awaited before a single-flight mutation
|
|
179
323
|
// resolves, so a counter delta over the call tells whether this action's
|
|
180
324
|
// metadata was already applied. Overlapping mutations can cross-attribute
|
|
@@ -182,8 +326,9 @@ function actionImpl(fn, options = {}) {
|
|
|
182
326
|
// a far smaller window than predicting from the function's identity,
|
|
183
327
|
// which misses every response the server returned without flight data.
|
|
184
328
|
const flightApplicationsBefore = flightApplications;
|
|
329
|
+
let pending;
|
|
185
330
|
try {
|
|
186
|
-
|
|
331
|
+
pending = run({
|
|
187
332
|
call: runMutation,
|
|
188
333
|
optimistic: submitHooks.size
|
|
189
334
|
? () => {
|
|
@@ -191,33 +336,32 @@ function actionImpl(fn, options = {}) {
|
|
|
191
336
|
hook(...variables);
|
|
192
337
|
}
|
|
193
338
|
: undefined
|
|
194
|
-
})
|
|
339
|
+
});
|
|
195
340
|
}
|
|
196
|
-
|
|
197
|
-
|
|
341
|
+
catch (e) {
|
|
342
|
+
// refused before a transition began: nothing will commit
|
|
343
|
+
release && release();
|
|
344
|
+
throw e;
|
|
198
345
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
response && router.submissions[1](entries => [...entries, submission]);
|
|
219
|
-
for (const hook of settledHooks.values())
|
|
220
|
-
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;
|
|
221
365
|
if (response) {
|
|
222
366
|
if (response.error && !form)
|
|
223
367
|
throw response.error;
|
|
@@ -244,9 +388,12 @@ function toAction(invoke, url, boundArgs = [], base = url, submitHooks = new Map
|
|
|
244
388
|
return url;
|
|
245
389
|
};
|
|
246
390
|
fn.with = function (...args) {
|
|
391
|
+
const bound = [...boundArgs, ...args];
|
|
247
392
|
const uri = new URL(url, mockBase);
|
|
248
|
-
|
|
249
|
-
|
|
393
|
+
// the server prepends `args` to the submitted arguments, so it must carry
|
|
394
|
+
// the whole binding, not just this call's part of a chain
|
|
395
|
+
uri.searchParams.set("args", hashKey(bound));
|
|
396
|
+
return toAction(invoke, (uri.origin === "https://action" ? uri.origin : "") + uri.pathname + uri.search, bound, base, submitHooks, settledHooks);
|
|
250
397
|
};
|
|
251
398
|
fn.onSubmit = function (hook) {
|
|
252
399
|
const id = Symbol("actionOnSubmitHook");
|
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
|
}
|