@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/dist/index.js CHANGED
@@ -10,8 +10,57 @@ function normalizePath(path, omitSlash = false) {
10
10
  return s ? omitSlash || /^[?#]/.test(s) ? s : "/" + s : "";
11
11
  }
12
12
 
13
- /** Pathname stripped of search/hash and trailing slash, lowercased — the form link matching compares. */
14
- const comparablePath = path => normalizePath(path.split(/[?#]/, 1)[0]).toLowerCase().replace(/\/$/, "");
13
+ /**
14
+ * Pathname stripped of search/hash and trailing slash, percent-encoded the
15
+ * way `URL` (and so `location.pathname`) reports it, lowercased — the form
16
+ * link matching compares. Raw and encoded spellings (`/café`, `/caf%C3%A9`)
17
+ * meet; escapes are never decoded, so `%2F` is not a path separator.
18
+ */
19
+ const comparablePath = path => new URL(mockBase + normalizePath(path.split(/[?#]/, 1)[0])).pathname.toLowerCase().replace(/\/$/, "");
20
+
21
+ /**
22
+ * Whether a URL pathname is the router's base path or under it, on a segment
23
+ * boundary: base `/app` covers `/app`, `/app/` and `/app/x`, not `/apple`.
24
+ * Case-insensitive; no base (`""` or `/`) covers every path.
25
+ */
26
+ function isUnderBase(pathname, base) {
27
+ const b = base.toLowerCase().replace(/\/+$/, "");
28
+ const p = pathname.toLowerCase();
29
+ return !b || !p || p === b || p.startsWith(b + "/");
30
+ }
31
+
32
+ /** A query string as an order-independent comparable string. */
33
+ const comparableQuery = search => {
34
+ const params = new URLSearchParams(search);
35
+ params.sort();
36
+ return params.toString();
37
+ };
38
+
39
+ /**
40
+ * The link-state rule shared by claimed anchors and `useLinkState`, given
41
+ * the location and a link's already-resolved target (path, optional query
42
+ * and hash):
43
+ *
44
+ * - `current` — same pathname and same query (parameter order and hash aside)
45
+ * - `active` — same pathname or one under it; the router's root (`base`, or
46
+ * `/` without one) only matches exactly, since it is a prefix of every
47
+ * page. `end` makes every link exact-only.
48
+ */
49
+ function matchLink(location, target, base, end) {
50
+ if (target === undefined) return {
51
+ active: false,
52
+ current: false
53
+ };
54
+ const loc = comparablePath(location.pathname);
55
+ const path = comparablePath(target);
56
+ const exact = loc === path;
57
+ const hashless = target.split("#", 1)[0];
58
+ const q = hashless.indexOf("?");
59
+ return {
60
+ active: exact || !end && path !== "" && path !== comparablePath(base) && loc.startsWith(path + "/"),
61
+ current: exact && comparableQuery(location.search) === comparableQuery(q < 0 ? "" : hashless.slice(q))
62
+ };
63
+ }
15
64
  function resolvePath(base, path, from) {
16
65
  if (hasSchemeRegex.test(path)) {
17
66
  return undefined;
@@ -179,15 +228,33 @@ function setFunctionName(obj, value) {
179
228
  }
180
229
 
181
230
  /**
182
- * The compiler claims every `a[href]` (and `form[action]`, which this handler
183
- * ignores) at creation, and the runtime re-claims on `href` writes. This
184
- * consumer gives each router-managed anchor the link-state vocabulary without
185
- * a wrapper component:
231
+ * Claimed forms are handed to this slot instead of the claims importing the
232
+ * action module: the action side installs it on first action creation (see
233
+ * data/action.ts), where form `aria-busy` state lives, so an app that never
234
+ * creates an action never pulls the data layer in through its claims.
235
+ */
236
+ let formClaim;
237
+ function setFormClaimHandler(handler) {
238
+ formClaim = handler;
239
+ }
240
+
241
+ /**
242
+ * The compiler claims every `a[href]` and `form[action]` at creation, and the
243
+ * runtime re-claims on `href`/`action` writes and after a server-component
244
+ * morph changes the element. Forms go to the action layer's slot above, which
245
+ * re-applies `aria-busy` while their action is in flight. This consumer gives each router-managed anchor the link-state
246
+ * vocabulary without a wrapper component:
186
247
  *
187
- * - `aria-current="page"` — the location matches the link exactly
188
- * - `data-active` — exact or prefix match
248
+ * - `aria-current="page"` — the location matches the link exactly, query
249
+ * included (parameter order aside)
250
+ * - `data-active` — pathname exact or prefix match (the router's root, its
251
+ * base path, exact only)
189
252
  * - `data-pending` — the link is the target of an in-flight navigation
190
253
  *
254
+ * The matching rule is `matchLink`, shared with `useLinkState`. The router
255
+ * only touches an `aria-current` it wrote itself: one the author set (a
256
+ * stepper's `"step"`, a static `"page"`) is left in place, current or not.
257
+ *
191
258
  * Elements are claimed at creation, so late mounts (`<Show>`, `<For>`,
192
259
  * portals) are correct immediately. One render effect (owned by the router)
193
260
  * subscribes to the location and sweeps a registry of claimed anchors —
@@ -199,16 +266,16 @@ function setFunctionName(obj, value) {
199
266
  */
200
267
  function setupLinkClaims(router, explicitLinks) {
201
268
  const basePath = router.base.path();
202
- // per-element record; `current` remembers whether we set `aria-current`,
203
- // so user-authored values (steppers, breadcrumbs) are never stripped
269
+ // per-element record; `owned` is whether the `aria-current` on the element
270
+ // is the router's, so it never writes over or removes an authored one
204
271
  const claimed = new WeakMap();
205
272
  const registry = new Set();
206
273
  function isSvg(el) {
207
274
  return el.namespaceURI === "http://www.w3.org/2000/svg";
208
275
  }
209
276
 
210
- /** The comparable pathname when the router manages this anchor, else `undefined`. */
211
- function managedPath(a) {
277
+ /** The anchor's resolved URL when the router manages it, else `undefined`. */
278
+ function managedUrl(a) {
212
279
  if (explicitLinks && !a.hasAttribute("link")) return;
213
280
  const svg = isSvg(a);
214
281
  // claims fire at creation while the element is still in the template's
@@ -225,38 +292,55 @@ function setupLinkClaims(router, explicitLinks) {
225
292
  } catch {
226
293
  return;
227
294
  }
228
- if (url.origin !== window.location.origin || basePath && url.pathname && !url.pathname.toLowerCase().startsWith(basePath.toLowerCase())) return;
229
- return comparablePath(url.pathname);
295
+ if (url.origin !== window.location.origin || !isUnderBase(url.pathname, basePath)) return;
296
+ return url;
230
297
  }
231
298
  function linkState(a) {
232
299
  // read reactive sources unconditionally so the owning effect stays
233
300
  // subscribed even while the anchor is not router-managed
234
- const loc = decodeURI(comparablePath(router.location.pathname));
301
+ const location = router.location;
235
302
  const routing = router.isRouting();
236
- const path = managedPath(a);
237
- // the root path is a prefix of everything, so it only matches exactly —
238
- // there is no per-anchor `end` opt-out like useLinkState has
239
- const matches = target => path !== undefined && (target === path || path !== "" && target.startsWith(path + "/"));
303
+ const url = managedUrl(a);
304
+ const target = url && url.pathname + url.search;
305
+ // no per-anchor `end` opt-out like useLinkState has
306
+ const {
307
+ active,
308
+ current
309
+ } = matchLink(location, target, basePath);
240
310
  // effects observe the committed location during a transition, so the
241
311
  // in-flight target comes from pendingTarget — readable here because the
242
312
  // isRouting write flushes after the target is assigned
243
- const pending = routing && !!router.pendingTarget && matches(decodeURI(comparablePath(router.pendingTarget.value)));
313
+ const pending = routing && !!router.pendingTarget && matchLink({
314
+ pathname: router.pendingTarget.value,
315
+ search: ""
316
+ }, target, basePath).active;
244
317
  return {
245
- active: matches(loc),
318
+ active,
246
319
  pending,
247
- exact: path !== undefined && loc === path
320
+ current
248
321
  };
249
322
  }
250
323
  function apply(a, rec, {
251
324
  active,
252
325
  pending,
253
- exact
326
+ current
254
327
  }) {
255
328
  active ? a.setAttribute("data-active", "") : a.removeAttribute("data-active");
256
329
  pending ? a.setAttribute("data-pending", "") : a.removeAttribute("data-pending");
257
- if (exact !== rec.current) {
258
- exact ? a.setAttribute("aria-current", "page") : a.removeAttribute("aria-current");
259
- rec.current = exact;
330
+ // Ownership is read against the element, not just the record. A
331
+ // server-component morph resets attributes to the server HTML, which
332
+ // never carries router link state, then re-claims: an owned value that
333
+ // went missing is re-applied, while a value the morph restored from the
334
+ // server HTML (or the author wrote since) is authored and left alone.
335
+ const value = a.getAttribute("aria-current");
336
+ if (rec.owned && value !== null && value !== "page") rec.owned = false;else if (current) {
337
+ if (value === null) {
338
+ a.setAttribute("aria-current", "page");
339
+ rec.owned = true;
340
+ }
341
+ } else if (rec.owned) {
342
+ if (value !== null) a.removeAttribute("aria-current");
343
+ rec.owned = false;
260
344
  }
261
345
  }
262
346
  const refresh = (a, rec) => untrack(() => apply(a, rec, linkState(a)));
@@ -272,18 +356,20 @@ function setupLinkClaims(router, explicitLinks) {
272
356
  // slot — lazy-route lookups miss and hydration leaves server nodes
273
357
  // unclaimed. (The option is honored by the runtime but missing from the
274
358
  // published EffectOptions type, hence the cast.)
275
- createRenderEffect(() => (router.location.pathname, router.isRouting()), () => registry.forEach(a => refresh(a, claimed.get(a))), {
359
+ createRenderEffect(() => (router.location.pathname, router.location.search, router.isRouting()), () => registry.forEach(a => refresh(a, claimed.get(a))), {
276
360
  transparent: true
277
361
  });
278
362
  onCleanup(registerElementClaim(node => {
279
- if (node.nodeName.toUpperCase() !== "A") return;
363
+ const name = node.nodeName.toUpperCase();
364
+ if (name === "FORM") return formClaim && formClaim(node);
365
+ if (name !== "A") return;
280
366
  const a = node;
281
367
  // re-claim (href changed): the claiming write runs inside another
282
368
  // effect, so refresh without leaking subscriptions into it
283
369
  const existing = claimed.get(a);
284
370
  if (existing) return refresh(a, existing);
285
371
  const rec = {
286
- current: false
372
+ owned: false
287
373
  };
288
374
  claimed.set(a, rec);
289
375
  // claims fire during component setup, so an owner is present in
@@ -336,7 +422,7 @@ function setupNativeEvents({
336
422
  // Skip non-http(s) schemes (blob:, mailto:, tel:, data:, ...). blob: URLs
337
423
  // inherit the page origin, so the origin check below won't reject them. #382
338
424
  if (url.protocol !== "https:" && url.protocol !== "http:") return;
339
- if (url.origin !== window.location.origin || basePath && url.pathname && !url.pathname.toLowerCase().startsWith(basePath.toLowerCase())) return;
425
+ if (url.origin !== window.location.origin || !isUnderBase(url.pathname, basePath)) return;
340
426
  return [a, url];
341
427
  }
342
428
  function handleAnchorClick(evt) {
@@ -855,27 +941,20 @@ const useLinkState = (href, options = {}) => {
855
941
  const router = useRouter();
856
942
  const location = router.location;
857
943
  const to = useResolvedPath(() => String(href()));
858
- // trailing slashes are ignored so `/route` and `/route/` share state
859
- const path = createMemo(() => {
860
- const to_ = to();
861
- return to_ === undefined ? undefined : comparablePath(to_);
862
- });
863
- const matches = loc => {
864
- const path_ = path();
865
- if (path_ === undefined) return [false, false];
866
- const exact = loc === path_;
867
- return [exact || !options.end && loc.startsWith(path_ + "/"), exact];
868
- };
869
- const state = createMemo(() => matches(decodeURI(comparablePath(location.pathname))));
944
+ const base = router.base.path();
945
+ const state = createMemo(() => matchLink(location, to(), base, options.end));
870
946
  return {
871
- active: () => state()[0],
872
- current: () => state()[1],
947
+ active: createMemo(() => state().active),
948
+ current: createMemo(() => state().current),
873
949
  // match the in-flight target explicitly (rather than active-while-routing)
874
950
  // so the answer is the same from pure reads and from effects, which
875
951
  // observe the committed location during a transition
876
952
  pending: createMemo(() => {
877
953
  state(); // location dependency: mid-flight target swaps recompute
878
- return router.isRouting() && !!router.pendingTarget && matches(decodeURI(comparablePath(router.pendingTarget.value)))[0];
954
+ return router.isRouting() && !!router.pendingTarget && matchLink({
955
+ pathname: router.pendingTarget.value,
956
+ search: ""
957
+ }, to(), base, options.end).active;
879
958
  })
880
959
  };
881
960
  };
@@ -1190,6 +1269,17 @@ let flashDecoder;
1190
1269
  function provideFlashDecoder(decoder) {
1191
1270
  flashDecoder || (flashDecoder = decoder);
1192
1271
  }
1272
+
1273
+ /**
1274
+ * A request's flash cookie and its decode, shared by every router created
1275
+ * for that request. A server render can re-create the router — a suspension
1276
+ * under `<Errored>` retries from the nearest hole above it, which may sit
1277
+ * above the router — so state kept on one router instance would restart the
1278
+ * decode with every re-creation and the seeding read would never settle.
1279
+ * Keyed by the request event, so it lives exactly as long as the request.
1280
+ */
1281
+
1282
+ const flashStates = new WeakMap();
1193
1283
  let preloadIntent;
1194
1284
  function getIntent() {
1195
1285
  return preloadIntent || useOptionalContext(RouterContextObj)?.intent?.();
@@ -1228,63 +1318,68 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1228
1318
  // unread outcome must not haunt a later request's render. Only detection
1229
1319
  // and clearing happen here (the runtime's isomorphic half); the raw header
1230
1320
  // is stashed and decoding waits for the action-provided codec, read when
1231
- // the lazily allocated submissions signal below first initializes.
1232
- let flashCookieHeader;
1321
+ // the lazily allocated submissions signal below first initializes. Both
1322
+ // happen once per request: a router re-created for the same request finds
1323
+ // the request's flash state (see `flashStates`) and neither clears again
1324
+ // nor decodes again.
1325
+ let flash;
1233
1326
  if (isServer) {
1234
1327
  const e = getRequestEvent();
1235
1328
  if (e && !(e.router && e.router.submission)) {
1236
- const cookieHeader = e.request.headers.get("cookie");
1237
- if (hasFlashCookie(cookieHeader)) {
1238
- flashCookieHeader = cookieHeader;
1239
- // one-shot: clear it even when unreadable so it can't haunt later renders
1240
- if (e.response && e.response.headers) e.response.headers.append("Set-Cookie", clearFlashCookie());
1329
+ flash = flashStates.get(e);
1330
+ if (!flash) {
1331
+ const cookieHeader = e.request.headers.get("cookie");
1332
+ if (hasFlashCookie(cookieHeader)) {
1333
+ flashStates.set(e, flash = {
1334
+ cookieHeader
1335
+ });
1336
+ // one-shot: clear it even when unreadable so it can't haunt later renders
1337
+ if (e.response && e.response.headers) e.response.headers.append("Set-Cookie", clearFlashCookie());
1338
+ }
1241
1339
  }
1242
1340
  }
1243
1341
  }
1244
1342
 
1245
- // The decode, at most once per request: the decoder may answer with a
1246
- // Promise (the cookie is encrypted; the runtime's decodeFlashCookie is
1247
- // async), and this cache is what keeps the parked read's rerun from
1248
- // restarting it — resumption finds the settled outcome and just reads it.
1249
- // A decoder that rejects reads as "no flash", matching the runtime's own
1250
- // malformed-cookie semantics.
1251
- let flashDecode;
1252
-
1253
1343
  // The seeding read, as a memo: NotReadyError must surface from a reactive
1254
1344
  // node the graph can park and retry — never from router setup, which no
1255
- // boundary guards — and the memo bounds the recompute to this function;
1256
- // a parked reader resumes into the settled cache above, never a second
1257
- // decode. Created only when a flash cookie actually arrived (server-only
1258
- // by construction: flashCookieHeader is only ever set there), and
1345
+ // boundary guards — and the memo bounds the recompute to this function.
1346
+ // The decode runs at most once per request: the decoder may answer with a
1347
+ // Promise (the cookie is encrypted; the runtime's decodeFlashCookie is
1348
+ // async), and the request's cached state is what keeps a parked read's
1349
+ // rerun — or a re-created router's first read — from restarting it. A
1350
+ // decoder that rejects reads as "no flash", matching the runtime's own
1351
+ // malformed-cookie semantics. Created only when a flash cookie actually
1352
+ // arrived (server-only by construction: `flash` is only ever set there), and
1259
1353
  // - `lazy`: server memos compute eagerly by default — deferred to first
1260
1354
  // read, a request whose submissions are never read never decodes;
1261
1355
  // - `transparent`: the memo exists on the server only, so its owner
1262
1356
  // must not consume a hydration-id slot — the client, which seeds
1263
1357
  // submissions as [] without ever creating this memo, would miss it
1264
1358
  // and every sibling id would shift.
1265
- const flashSubmission = flashCookieHeader !== undefined ? createMemo(() => {
1359
+ const flashSubmission = flash !== undefined ? createMemo(() => {
1360
+ const state = flash;
1266
1361
  if (!flashDecoder) return undefined;
1267
- if (!flashDecode) {
1268
- const promise = flashDecoder(flashCookieHeader).then(value => {
1269
- flashDecode = {
1362
+ if (!state.decode) {
1363
+ const promise = flashDecoder(state.cookieHeader).then(value => {
1364
+ state.decode = {
1270
1365
  done: true,
1271
1366
  value
1272
1367
  };
1273
1368
  }, () => {
1274
- flashDecode = {
1369
+ state.decode = {
1275
1370
  done: true,
1276
1371
  value: undefined
1277
1372
  };
1278
1373
  });
1279
- flashDecode = {
1374
+ state.decode = {
1280
1375
  done: false,
1281
1376
  promise
1282
1377
  };
1283
1378
  }
1284
1379
  // SSR carries the Promise through NotReadyError so the parked
1285
1380
  // reader can resume, exactly like the lazy matches above.
1286
- if (!flashDecode.done) throw new NotReadyError(flashDecode.promise);
1287
- return flashDecode.value;
1381
+ if (!state.decode.done) throw new NotReadyError(state.decode.promise);
1382
+ return state.decode.value;
1288
1383
  }, {
1289
1384
  lazy: true,
1290
1385
  transparent: true
@@ -3047,16 +3142,74 @@ const submitHooksSymbol = Symbol("routerActionSubmitHooks");
3047
3142
  const settledHooksSymbol = Symbol("routerActionSettledHooks");
3048
3143
  const invokeSymbol = Symbol("routerActionInvoke");
3049
3144
 
3050
- // Forms submitted through delegation are marked `aria-busy` while their
3051
- // action is in flight — the form half of the attribute vocabulary links get
3052
- // (`data-active`/`data-pending`). Style with `form[aria-busy] button { ... }`.
3053
- // A counter (not a boolean) keeps the attribute through overlapping
3054
- // submissions from the same form.
3055
- const busyForms = /* #__PURE__ */new WeakMap();
3056
- function setFormBusy(form, delta) {
3057
- const count = (busyForms.get(form) || 0) + delta;
3058
- busyForms.set(form, count);
3059
- count > 0 ? form.setAttribute("aria-busy", "true") : form.removeAttribute("aria-busy");
3145
+ // Forms submitted through delegation are marked `aria-busy` from submit until
3146
+ // the action's transition commits — the form half of the attribute vocabulary
3147
+ // links get (`data-active`/`data-pending`). Style with
3148
+ // `form[aria-busy] button { ... }`. A counter (not a boolean) keeps the
3149
+ // attribute through overlapping submissions.
3150
+ //
3151
+ // Busy state is keyed by the form's resolved `action` URL rather than the
3152
+ // element: a server-component morph strips the attribute, and a re-render can
3153
+ // replace the element, and the claim of either (claimBusyForm) re-applies it
3154
+ // from here. A form without an `action` (submitted through a button's
3155
+ // `formaction`) is never claimed, so it is keyed by the element itself.
3156
+
3157
+ const busyForms = /* #__PURE__ */new Map();
3158
+ // forms whose `aria-busy` the router wrote — an authored one is never
3159
+ // overwritten or removed (the same ownership rule as claimed `aria-current`)
3160
+ const ownedBusy = /* #__PURE__ */new WeakSet();
3161
+ function busyKey(form) {
3162
+ const action = form.getAttribute("action");
3163
+ if (action) {
3164
+ try {
3165
+ return new URL(action, document.baseURI).href;
3166
+ } catch {}
3167
+ }
3168
+ return form;
3169
+ }
3170
+ function showBusy(form) {
3171
+ const busy = busyForms.has(busyKey(form));
3172
+ // Ownership is read against the element: an owned value a morph stripped
3173
+ // is re-applied, one the author has rewritten since is theirs.
3174
+ const value = form.getAttribute("aria-busy");
3175
+ const owned = ownedBusy.has(form);
3176
+ if (owned && value !== null && value !== "true") ownedBusy.delete(form);else if (busy) {
3177
+ if (value === null) {
3178
+ form.setAttribute("aria-busy", "true");
3179
+ ownedBusy.add(form);
3180
+ }
3181
+ } else if (owned) {
3182
+ if (value !== null) form.removeAttribute("aria-busy");
3183
+ ownedBusy.delete(form);
3184
+ }
3185
+ }
3186
+
3187
+ /** Marks the form busy; the returned release is one-shot. */
3188
+ function markFormBusy(form) {
3189
+ const key = busyKey(form);
3190
+ let entry = busyForms.get(key);
3191
+ if (!entry) busyForms.set(key, entry = {
3192
+ count: 0,
3193
+ forms: new Set()
3194
+ });
3195
+ entry.count++;
3196
+ entry.forms.add(form);
3197
+ showBusy(form);
3198
+ let released = false;
3199
+ return () => {
3200
+ if (released) return;
3201
+ released = true;
3202
+ if (--entry.count > 0) return;
3203
+ busyForms.delete(key);
3204
+ entry.forms.forEach(showBusy);
3205
+ };
3206
+ }
3207
+
3208
+ /** The claims' form slot: a (re-)claimed form whose action is busy shows it. */
3209
+ function claimBusyForm(form) {
3210
+ const entry = busyForms.get(busyKey(form));
3211
+ if (entry) entry.forms.add(form);
3212
+ showBusy(form);
3060
3213
  }
3061
3214
  const actions = /* #__PURE__ */new Map();
3062
3215
 
@@ -3086,7 +3239,7 @@ function handleFormAction(evt, router, actionBase) {
3086
3239
  // stays a no-JS fallback.
3087
3240
  // Client-only actions (`https://action/`) are their module's JS by
3088
3241
  // definition, so a miss there falls through to native submission.
3089
- const handler = actions.get(actionRef) || serverAction && createServerFormAction(actionRef);
3242
+ const handler = findAction(actionRef) || serverAction && createServerFormAction(actionRef);
3090
3243
  if (handler) {
3091
3244
  evt.preventDefault();
3092
3245
  const data = new FormData(form, evt.submitter);
@@ -3097,6 +3250,30 @@ function handleFormAction(evt, router, actionBase) {
3097
3250
  }
3098
3251
  }
3099
3252
 
3253
+ /**
3254
+ * Looks a rendered action url up in the registry. A server-rendered
3255
+ * `.with()` url is only registered if this client made the same binding
3256
+ * itself; otherwise its base action (the url without `?args`) is rebound to
3257
+ * the rendered arguments, so the submission runs through that action's
3258
+ * submit and settled hooks and is recorded under its base.
3259
+ */
3260
+ function findAction(url) {
3261
+ const handler = actions.get(url);
3262
+ if (handler) return handler;
3263
+ const query = url.indexOf("?");
3264
+ if (query < 0) return undefined;
3265
+ const base = actions.get(url.slice(0, query));
3266
+ const args = new URLSearchParams(url.slice(query)).get("args");
3267
+ if (!base || args === null) return undefined;
3268
+ let bound;
3269
+ try {
3270
+ bound = JSON.parse(args);
3271
+ } catch {
3272
+ return undefined;
3273
+ }
3274
+ return Array.isArray(bound) ? base.with(...bound) : undefined;
3275
+ }
3276
+
3100
3277
  /**
3101
3278
  * Synthesizes a router action for a server-rendered action url. The url
3102
3279
  * carries everything an invocation needs — the function id in the path
@@ -3130,7 +3307,7 @@ function createServerFormAction(url) {
3130
3307
  * the enctype conversion and the generic invocation happen here.
3131
3308
  */
3132
3309
  function submitServerForm(router, url, form, data) {
3133
- const handler = actions.get(url) || createServerFormAction(url);
3310
+ const handler = findAction(url) || createServerFormAction(url);
3134
3311
  // not an address (`<endpoint>/<id>`) — not the server function convention;
3135
3312
  // nothing can run it, resubmit natively (submit() bypasses the delegated
3136
3313
  // handler)
@@ -3166,6 +3343,7 @@ function installRouterIntegrations() {
3166
3343
  provideFlashDecoder(cookieHeader => import('@solidjs/web/server-functions/server').then(m => m.decodeFlashCookie(cookieHeader)));
3167
3344
  } else {
3168
3345
  setRouterFormHandler(handleFormAction);
3346
+ setFormClaimHandler(claimBusyForm);
3169
3347
  provideFlightConsumer(setupFlightDataConsumer);
3170
3348
  }
3171
3349
  }
@@ -3198,24 +3376,94 @@ function actionImpl(fn, options = {}) {
3198
3376
  // flight-data consumer (see setupFlightDataConsumer) makes the transport
3199
3377
  // send the request header itself, so the mutation is just called.
3200
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
+ };
3201
3439
  const run = action$1(async function* (context) {
3202
- context.optimistic?.();
3203
- let value;
3204
- let error = false;
3205
3440
  try {
3206
- value = await context.call();
3441
+ context.optimistic?.();
3442
+ let value;
3443
+ let error = false;
3444
+ try {
3445
+ value = await context.call();
3446
+ } catch (e) {
3447
+ value = e;
3448
+ error = true;
3449
+ }
3450
+ const read = await readResponse(value, error);
3451
+ yield;
3452
+ // Apply inside the transition so the default revalidation's refetch and
3453
+ // the release of the caller's optimistic writes commit as one frame (#619).
3454
+ const response = applyResponse(read, router.navigatorFactory(), flightApplications !== flightApplicationsBefore);
3455
+ finish(response);
3456
+ return response;
3207
3457
  } catch (e) {
3208
- value = e;
3209
- error = true;
3458
+ // a failure outside the mutation (a submit hook, decoding the
3459
+ // response, applying it) still settles, with the error recorded
3460
+ finish({
3461
+ error: e
3462
+ });
3463
+ throw e;
3210
3464
  }
3211
- const read = await readResponse(value, error);
3212
- yield;
3213
- // Apply inside the transition so the default revalidation's refetch and
3214
- // the release of the caller's optimistic writes commit as one frame (#619).
3215
- return applyResponse(read, router.navigatorFactory(), flightApplications !== flightApplicationsBefore);
3216
3465
  });
3217
- form && setFormBusy(form, 1);
3218
- let response;
3466
+ const release = form && markFormBusy(form);
3219
3467
  // The transport consumer is awaited before a single-flight mutation
3220
3468
  // resolves, so a counter delta over the call tells whether this action's
3221
3469
  // metadata was already applied. Overlapping mutations can cross-attribute
@@ -3223,40 +3471,40 @@ function actionImpl(fn, options = {}) {
3223
3471
  // a far smaller window than predicting from the function's identity,
3224
3472
  // which misses every response the server returned without flight data.
3225
3473
  const flightApplicationsBefore = flightApplications;
3474
+ let pending;
3226
3475
  try {
3227
- response = await settleActionResult(run({
3476
+ pending = run({
3228
3477
  call: runMutation,
3229
3478
  optimistic: submitHooks.size ? () => {
3230
3479
  for (const hook of submitHooks.values()) hook(...variables);
3231
3480
  } : undefined
3232
- }));
3233
- } finally {
3234
- form && setFormBusy(form, -1);
3235
- }
3236
- let submission;
3237
- submission = {
3238
- input: variables,
3239
- url,
3240
- result: response?.data,
3241
- error: response?.error,
3242
- clear() {
3243
- router.submissions[1](entries => entries.filter(entry => entry !== submission));
3244
- },
3245
- retry() {
3246
- submission.clear();
3247
- return current[invokeSymbol].call({
3248
- r: router,
3249
- f: form
3250
- }, variables, current);
3251
- }
3252
- };
3253
- // Book-keeping is intentional: only outcomes worth showing or retrying
3254
- // (a result or an error) enter the submissions list, so the typical void
3255
- // mutation leaves nothing behind. Settled hooks still see every
3256
- // completion — void, metadata-only, and redirects included — one
3257
- // `onSettled` per invocation (#580).
3258
- response && router.submissions[1](entries => [...entries, submission]);
3259
- 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;
3260
3508
  if (response) {
3261
3509
  if (response.error && !form) throw response.error;
3262
3510
  return response.data;
@@ -3282,9 +3530,12 @@ function toAction(invoke, url, boundArgs = [], base = url, submitHooks = new Map
3282
3530
  return url;
3283
3531
  };
3284
3532
  fn.with = function (...args) {
3533
+ const bound = [...boundArgs, ...args];
3285
3534
  const uri = new URL(url, mockBase);
3286
- uri.searchParams.set("args", hashKey(args));
3287
- return toAction(invoke, (uri.origin === "https://action" ? uri.origin : "") + uri.pathname + uri.search, [...boundArgs, ...args], base, submitHooks, settledHooks);
3535
+ // the server prepends `args` to the submitted arguments, so it must carry
3536
+ // the whole binding, not just this call's part of a chain
3537
+ uri.searchParams.set("args", hashKey(bound));
3538
+ return toAction(invoke, (uri.origin === "https://action" ? uri.origin : "") + uri.pathname + uri.search, bound, base, submitHooks, settledHooks);
3288
3539
  };
3289
3540
  fn.onSubmit = function (hook) {
3290
3541
  const id = Symbol("actionOnSubmitHook");
@@ -3414,6 +3665,9 @@ function applyResponseMetadata(metadata, navigate, flightData) {
3414
3665
  // are fresh again by now, so the sweep re-reads them from cache.
3415
3666
  revalidate(keys, false);
3416
3667
  }
3668
+
3669
+ /** What a run settles with: a result, an error, or nothing worth recording. */
3670
+
3417
3671
  async function readResponse(response, error) {
3418
3672
  let data;
3419
3673
  let flightData;