@solidjs/router 0.17.0-next.5 → 1.0.0-next.10

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.
Files changed (51) hide show
  1. package/README.md +526 -716
  2. package/dist/claims.d.ts +21 -0
  3. package/dist/claims.js +115 -0
  4. package/dist/data/action.d.ts +28 -1
  5. package/dist/data/action.js +218 -52
  6. package/dist/data/events.d.ts +8 -0
  7. package/dist/data/events.js +23 -22
  8. package/dist/data/flash.d.ts +21 -0
  9. package/dist/data/flash.js +78 -0
  10. package/dist/data/flashCookie.d.ts +7 -0
  11. package/dist/data/flashCookie.js +20 -0
  12. package/dist/data/query.js +11 -4
  13. package/dist/data/serverForms.d.ts +1 -0
  14. package/dist/data/serverForms.js +5 -0
  15. package/dist/index.d.ts +5 -3
  16. package/dist/index.js +1548 -683
  17. package/dist/index.jsx +2 -2
  18. package/dist/lifecycle.d.ts +29 -4
  19. package/dist/lifecycle.js +40 -37
  20. package/dist/paths.d.ts +117 -0
  21. package/dist/paths.js +41 -0
  22. package/dist/routers/components.d.ts +10 -21
  23. package/dist/routers/components.jsx +29 -44
  24. package/dist/routers/factory.d.ts +66 -0
  25. package/dist/routers/factory.jsx +151 -0
  26. package/dist/routers/history.d.ts +26 -0
  27. package/dist/routers/history.js +180 -0
  28. package/dist/routers/index.d.ts +4 -11
  29. package/dist/routers/index.js +2 -6
  30. package/dist/routers/scrollRestoration.d.ts +33 -0
  31. package/dist/routers/scrollRestoration.js +135 -0
  32. package/dist/routing.d.ts +80 -44
  33. package/dist/routing.js +311 -108
  34. package/dist/server.d.ts +79 -0
  35. package/dist/server.js +284 -0
  36. package/dist/types.d.ts +75 -6
  37. package/dist/utils.d.ts +2 -0
  38. package/dist/utils.js +2 -0
  39. package/package.json +10 -8
  40. package/dist/components.d.ts +0 -31
  41. package/dist/components.jsx +0 -46
  42. package/dist/routers/HashRouter.d.ts +0 -9
  43. package/dist/routers/HashRouter.js +0 -41
  44. package/dist/routers/MemoryRouter.d.ts +0 -24
  45. package/dist/routers/MemoryRouter.js +0 -57
  46. package/dist/routers/Router.d.ts +0 -9
  47. package/dist/routers/Router.js +0 -45
  48. package/dist/routers/StaticRouter.d.ts +0 -6
  49. package/dist/routers/StaticRouter.js +0 -15
  50. package/dist/routers/createRouter.d.ts +0 -10
  51. package/dist/routers/createRouter.js +0 -40
@@ -0,0 +1,21 @@
1
+ import type { RouterContext } from "./types.js";
2
+ /**
3
+ * The compiler claims every `a[href]` (and `form[action]`, which this handler
4
+ * ignores) at creation, and the runtime re-claims on `href` writes. This
5
+ * consumer gives each router-managed anchor the link-state vocabulary without
6
+ * a wrapper component:
7
+ *
8
+ * - `aria-current="page"` — the location matches the link exactly
9
+ * - `data-active` — exact or prefix match
10
+ * - `data-pending` — the link is the target of an in-flight navigation
11
+ *
12
+ * Elements are claimed at creation, so late mounts (`<Show>`, `<For>`,
13
+ * portals) are correct immediately. One render effect (owned by the router)
14
+ * subscribes to the location and sweeps a registry of claimed anchors —
15
+ * anchors themselves carry no reactive machinery, just a registry entry
16
+ * removed by their creating owner's cleanup. State is applied once at claim
17
+ * so it is correct before the next navigation; re-claims (an `href` write)
18
+ * are the same one-shot untracked refresh, reading the element's current
19
+ * `href` from the DOM.
20
+ */
21
+ export declare function setupLinkClaims(router: RouterContext, explicitLinks?: boolean): void;
package/dist/claims.js ADDED
@@ -0,0 +1,115 @@
1
+ import { registerElementClaim } from "@solidjs/web";
2
+ import { createRenderEffect, getOwner, onCleanup, untrack } from "solid-js";
3
+ import { comparablePath } from "./utils.js";
4
+ /**
5
+ * The compiler claims every `a[href]` (and `form[action]`, which this handler
6
+ * ignores) at creation, and the runtime re-claims on `href` writes. This
7
+ * consumer gives each router-managed anchor the link-state vocabulary without
8
+ * a wrapper component:
9
+ *
10
+ * - `aria-current="page"` — the location matches the link exactly
11
+ * - `data-active` — exact or prefix match
12
+ * - `data-pending` — the link is the target of an in-flight navigation
13
+ *
14
+ * Elements are claimed at creation, so late mounts (`<Show>`, `<For>`,
15
+ * portals) are correct immediately. One render effect (owned by the router)
16
+ * subscribes to the location and sweeps a registry of claimed anchors —
17
+ * anchors themselves carry no reactive machinery, just a registry entry
18
+ * removed by their creating owner's cleanup. State is applied once at claim
19
+ * so it is correct before the next navigation; re-claims (an `href` write)
20
+ * are the same one-shot untracked refresh, reading the element's current
21
+ * `href` from the DOM.
22
+ */
23
+ export function setupLinkClaims(router, explicitLinks) {
24
+ const basePath = router.base.path();
25
+ // per-element record; `current` remembers whether we set `aria-current`,
26
+ // so user-authored values (steppers, breadcrumbs) are never stripped
27
+ const claimed = new WeakMap();
28
+ const registry = new Set();
29
+ function isSvg(el) {
30
+ return el.namespaceURI === "http://www.w3.org/2000/svg";
31
+ }
32
+ /** The comparable pathname when the router manages this anchor, else `undefined`. */
33
+ function managedPath(a) {
34
+ if (explicitLinks && !a.hasAttribute("link"))
35
+ return;
36
+ const svg = isSvg(a);
37
+ // claims fire at creation while the element is still in the template's
38
+ // inert fragment, where the `href` property is not resolved — resolve the
39
+ // raw attribute against the live document instead
40
+ const href = svg ? a.href.baseVal : a.getAttribute("href");
41
+ const target = svg ? a.target.baseVal : a.target;
42
+ if (target || !href)
43
+ return;
44
+ const rel = (a.getAttribute("rel") || "").split(/\s+/);
45
+ if (a.hasAttribute("download") || rel.includes("external"))
46
+ return;
47
+ let url;
48
+ try {
49
+ url = new URL(href, document.baseURI);
50
+ }
51
+ catch {
52
+ return;
53
+ }
54
+ if (url.origin !== window.location.origin ||
55
+ (basePath && url.pathname && !url.pathname.toLowerCase().startsWith(basePath.toLowerCase())))
56
+ return;
57
+ return comparablePath(url.pathname);
58
+ }
59
+ function linkState(a) {
60
+ // read reactive sources unconditionally so the owning effect stays
61
+ // subscribed even while the anchor is not router-managed
62
+ const loc = decodeURI(comparablePath(router.location.pathname));
63
+ const routing = router.isRouting();
64
+ const path = managedPath(a);
65
+ // the root path is a prefix of everything, so it only matches exactly —
66
+ // there is no per-anchor `end` opt-out like useLinkState has
67
+ const matches = (target) => path !== undefined && (target === path || (path !== "" && target.startsWith(path + "/")));
68
+ // effects observe the committed location during a transition, so the
69
+ // in-flight target comes from pendingTarget — readable here because the
70
+ // isRouting write flushes after the target is assigned
71
+ const pending = routing && !!router.pendingTarget && matches(decodeURI(comparablePath(router.pendingTarget.value)));
72
+ return { active: matches(loc), pending, exact: path !== undefined && loc === path };
73
+ }
74
+ function apply(a, rec, { active, pending, exact }) {
75
+ active ? a.setAttribute("data-active", "") : a.removeAttribute("data-active");
76
+ pending ? a.setAttribute("data-pending", "") : a.removeAttribute("data-pending");
77
+ if (exact !== rec.current) {
78
+ exact ? a.setAttribute("aria-current", "page") : a.removeAttribute("aria-current");
79
+ rec.current = exact;
80
+ }
81
+ }
82
+ const refresh = (a, rec) => untrack(() => apply(a, rec, linkState(a)));
83
+ // The one subscription for every anchor: compute tracks the sources
84
+ // linkState derives from (the in-flight pendingTarget is readable in the
85
+ // effect phase because the isRouting write flushes after the target is
86
+ // assigned), the effect phase sweeps the registry untracked.
87
+ //
88
+ // `transparent` keeps the effect invisible to the hydration id scheme.
89
+ // This setup is client-only, so an id-consuming node here has no server
90
+ // counterpart and every subsequent hydration id would shift by one child
91
+ // slot — lazy-route lookups miss and hydration leaves server nodes
92
+ // unclaimed. (The option is honored by the runtime but missing from the
93
+ // published EffectOptions type, hence the cast.)
94
+ createRenderEffect(() => (router.location.pathname, router.isRouting()), () => registry.forEach(a => refresh(a, claimed.get(a))), { transparent: true });
95
+ onCleanup(registerElementClaim(node => {
96
+ if (node.nodeName.toUpperCase() !== "A")
97
+ return;
98
+ const a = node;
99
+ // re-claim (href changed): the claiming write runs inside another
100
+ // effect, so refresh without leaking subscriptions into it
101
+ const existing = claimed.get(a);
102
+ if (existing)
103
+ return refresh(a, existing);
104
+ const rec = { current: false };
105
+ claimed.set(a, rec);
106
+ // claims fire during component setup, so an owner is present in
107
+ // practice to bound the registry entry's lifetime; without one, state
108
+ // is still applied once at creation
109
+ if (getOwner()) {
110
+ registry.add(a);
111
+ onCleanup(() => registry.delete(a));
112
+ }
113
+ refresh(a, rec);
114
+ }));
115
+ }
@@ -1,5 +1,5 @@
1
1
  import { type JSX } from "@solidjs/web";
2
- import type { Submission, NarrowResponse } from "../types.js";
2
+ import type { RouterContext, Submission, NarrowResponse } from "../types.js";
3
3
  export type Action<T extends Array<any>, U, V = T> = (T extends [FormData | URLSearchParams] | [] ? JSX.SerializableAttributeValue : unknown) & ((...vars: T) => Promise<NarrowResponse<U>>) & {
4
4
  url: string;
5
5
  with<A extends any[], B extends any[]>(this: (this: any, ...args: [...A, ...B]) => Promise<NarrowResponse<U>>, ...args: A): Action<B, U, V>;
@@ -13,7 +13,34 @@ type ActionFactory = {
13
13
  }): Action<T, U>;
14
14
  };
15
15
  export declare const actions: Map<string, Action<any, any, any>>;
16
+ /**
17
+ * The document-delegation submit handler for router actions. Lives here —
18
+ * not in events.ts — so the router's event wiring holds no static reference
19
+ * to the action module; `installRouterIntegrations` slots it in when the
20
+ * first action is created on the client.
21
+ */
22
+ export declare function handleFormAction(evt: SubmitEvent, router: RouterContext, actionBase: string): void;
23
+ /**
24
+ * Entry point for delegation's lazy fallback (data/events.ts): when no form
25
+ * handler was ever installed — no action module in the client graph at all —
26
+ * the router intercepts posts to server-action urls synchronously and loads
27
+ * this module to run them. The FormData was captured at submit time; only
28
+ * the enctype conversion and the generic invocation happen here.
29
+ */
30
+ export declare function submitServerForm(router: RouterContext, url: string, form: HTMLFormElement, data: FormData): void;
16
31
  export declare function useSubmissions<T extends Array<any>, U, V>(fn: Action<T, U, V>, filter?: (input: V) => boolean): Submission<V, NarrowResponse<U>>[];
17
32
  export declare function useAction<T extends Array<any>, U, V>(action: Action<T, U, V>): (...args: Parameters<Action<T, U, V>>) => Promise<NarrowResponse<U>>;
18
33
  export declare const action: ActionFactory;
34
+ /**
35
+ * Registers the router as the single-flight consumer of the server function
36
+ * transport. Subscribing is the opt-in: while registered, the transport
37
+ * sends the `X-Single-Flight` request header on mutations and delivers the
38
+ * folded payload here — fresh route data is seeded into the `query` cache
39
+ * and the envelope metadata (redirect `Location`, `X-Revalidate` keys) is
40
+ * applied, all before the action sees its plain return value. Called by the
41
+ * Router component on the client unless `singleFlight={false}`, which now
42
+ * simply means "never subscribe" — no consumer, no request header, no
43
+ * collection work on the server. Returns the unsubscribe function.
44
+ */
45
+ export declare function setupFlightDataConsumer(router: RouterContext): () => void;
19
46
  export {};
@@ -1,13 +1,119 @@
1
1
  import { $TRACK, action as createSolidAction, createMemo, onCleanup, getOwner } from "solid-js";
2
2
  import { isResponseEnvelope, isServer } from "@solidjs/web";
3
- import { decodeResponse } from "@solidjs/web/server-functions";
4
- import { useRouter } from "../routing.js";
3
+ import { createServerReference, decodeResponse, subscribeFlightData } from "@solidjs/web/server-functions";
4
+ import { provideFlashDecoder, provideFlightConsumer, useRouter } from "../routing.js";
5
+ import { setRouterFormHandler } from "./events.js";
6
+ import { decodeFlashCookie } from "./flash.js";
5
7
  import { mockBase, setFunctionName } from "../utils.js";
6
8
  import { cacheKeyOp, hashKey, revalidate, query } from "./query.js";
7
9
  const submitHooksSymbol = Symbol("routerActionSubmitHooks");
8
10
  const settledHooksSymbol = Symbol("routerActionSettledHooks");
9
11
  const invokeSymbol = Symbol("routerActionInvoke");
12
+ // Forms submitted through delegation are marked `aria-busy` while their
13
+ // action is in flight — the form half of the attribute vocabulary links get
14
+ // (`data-active`/`data-pending`). Style with `form[aria-busy] button { ... }`.
15
+ // A counter (not a boolean) keeps the attribute through overlapping
16
+ // submissions from the same form.
17
+ const busyForms = /* #__PURE__ */ new WeakMap();
18
+ function setFormBusy(form, delta) {
19
+ const count = (busyForms.get(form) || 0) + delta;
20
+ busyForms.set(form, count);
21
+ count > 0 ? form.setAttribute("aria-busy", "true") : form.removeAttribute("aria-busy");
22
+ }
10
23
  export const actions = /* #__PURE__ */ new Map();
24
+ /**
25
+ * The document-delegation submit handler for router actions. Lives here —
26
+ * not in events.ts — so the router's event wiring holds no static reference
27
+ * to the action module; `installRouterIntegrations` slots it in when the
28
+ * first action is created on the client.
29
+ */
30
+ export function handleFormAction(evt, router, actionBase) {
31
+ if (evt.defaultPrevented)
32
+ return;
33
+ let actionRef = evt.submitter && evt.submitter.hasAttribute("formaction")
34
+ ? evt.submitter.getAttribute("formaction")
35
+ : evt.target.getAttribute("action");
36
+ if (!actionRef)
37
+ return;
38
+ const serverAction = !actionRef.startsWith("https://action/");
39
+ if (serverAction) {
40
+ // normalize server actions
41
+ const url = new URL(actionRef, mockBase);
42
+ actionRef = router.parsePath(url.pathname + url.search);
43
+ if (!actionRef.startsWith(actionBase))
44
+ return;
45
+ }
46
+ if (evt.target.method.toUpperCase() !== "POST")
47
+ throw new Error("Only POST forms are supported for Actions");
48
+ // A registry miss on a server-action url is a direct bind whose module
49
+ // never loaded client-side (server components): the url is self-describing
50
+ // (`?id`, bound `?args`), so a generic invocation is synthesized from it —
51
+ // delegation alone is sufficient, the no-JS path stays a no-JS fallback.
52
+ // Client-only actions (`https://action/`) are their module's JS by
53
+ // definition, so a miss there falls through to native submission.
54
+ const handler = actions.get(actionRef) || (serverAction && createServerFormAction(actionRef));
55
+ if (handler) {
56
+ evt.preventDefault();
57
+ const data = new FormData(evt.target, evt.submitter);
58
+ handler.call({ r: router, f: evt.target }, evt.target.enctype === "multipart/form-data"
59
+ ? data
60
+ : new URLSearchParams(data));
61
+ }
62
+ }
63
+ /**
64
+ * Synthesizes a router action for a server-rendered action url. The url
65
+ * carries everything an invocation needs — the function id and any bound
66
+ * `.with()` arguments (plain JSON in `?args`, which the server prepends for
67
+ * natural-encoding bodies exactly as it does for no-JS posts) — so the
68
+ * FormData is posted to it verbatim through the server-function transport:
69
+ * submissions, `aria-busy`, redirects, revalidation, and single-flight all
70
+ * flow through the normal action machinery. Registered under the url, so
71
+ * repeat submits reuse it (and a later real registration overrides it).
72
+ */
73
+ function createServerFormAction(url) {
74
+ const id = new URL(url, mockBase).searchParams.get("id");
75
+ if (!id)
76
+ return undefined;
77
+ // typecheck resolves the server half of the dual module; this path only
78
+ // runs in the browser, where the client transport's signature applies
79
+ const stub = createServerReference(id, undefined, url);
80
+ const caller = Object.assign((form) => stub(form), { url });
81
+ return actionImpl(caller);
82
+ }
83
+ /**
84
+ * Entry point for delegation's lazy fallback (data/events.ts): when no form
85
+ * handler was ever installed — no action module in the client graph at all —
86
+ * the router intercepts posts to server-action urls synchronously and loads
87
+ * this module to run them. The FormData was captured at submit time; only
88
+ * the enctype conversion and the generic invocation happen here.
89
+ */
90
+ export function submitServerForm(router, url, form, data) {
91
+ const handler = actions.get(url) || createServerFormAction(url);
92
+ // no `?id` — not the server function convention; nothing can run it,
93
+ // resubmit natively (submit() bypasses the delegated handler)
94
+ if (!handler)
95
+ return form.submit();
96
+ handler.call({ r: router, f: form }, form.enctype === "multipart/form-data" ? data : new URLSearchParams(data));
97
+ }
98
+ // Wires the action layer into the router's slots exactly once, triggered by
99
+ // the first action creation. Not an import side effect — with
100
+ // `sideEffects: false`, module evaluation only happens when action() is
101
+ // actually used, which is precisely when the wiring is wanted: no action in
102
+ // the graph means no form interception, no single-flight subscription (the
103
+ // server is never asked to collect), and no flash cookies to decode. On the
104
+ // server, actions are created at module scope, so the flash decoder is
105
+ // always installed before useSubmission can read the submissions signal.
106
+ let integrationsInstalled = false;
107
+ function installRouterIntegrations() {
108
+ if (integrationsInstalled)
109
+ return;
110
+ integrationsInstalled = true;
111
+ provideFlashDecoder(decodeFlashCookie);
112
+ if (!isServer) {
113
+ setRouterFormHandler(handleFormAction);
114
+ provideFlightConsumer(setupFlightDataConsumer);
115
+ }
116
+ }
11
117
  export function useSubmissions(fn, filter) {
12
118
  const router = useRouter();
13
119
  const subs = createMemo(() => router.submissions[0]().filter(s => s.url === fn.base && (!filter || filter(s.input))));
@@ -32,9 +138,10 @@ function actionImpl(fn, options = {}) {
32
138
  const form = this.f;
33
139
  const submitHooks = current[submitHooksSymbol];
34
140
  const settledHooks = current[settledHooksSymbol];
35
- const runMutation = () => (router.singleFlight && fn.withOptions
36
- ? fn.withOptions({ headers: { "X-Single-Flight": "true" } })
37
- : fn)(...variables);
141
+ // Single-flight opt-in is no longer per call: the router's registered
142
+ // flight-data consumer (see setupFlightDataConsumer) makes the transport
143
+ // send the request header itself, so the mutation is just called.
144
+ const runMutation = () => fn(...variables);
38
145
  const run = createSolidAction(async function* (context) {
39
146
  context.optimistic?.();
40
147
  try {
@@ -47,24 +154,37 @@ function actionImpl(fn, options = {}) {
47
154
  return { error: true, value: error };
48
155
  }
49
156
  });
50
- const settled = await settleActionResult(run({
51
- call: runMutation,
52
- optimistic: submitHooks.size
53
- ? () => {
54
- for (const hook of submitHooks.values())
55
- hook(...variables);
56
- }
57
- : undefined
58
- }));
59
- const response = await handleResponse(settled.value, settled.error, router.navigatorFactory());
60
- if (!response)
61
- return undefined;
157
+ form && setFormBusy(form, 1);
158
+ let settled;
159
+ let response;
160
+ // The transport consumer is awaited before a single-flight mutation
161
+ // resolves, so a counter delta over the call tells whether this action's
162
+ // metadata was already applied. Overlapping mutations can cross-attribute
163
+ // a run (skipping one default revalidation another pass just covered) —
164
+ // a far smaller window than predicting from the function's identity,
165
+ // which misses every response the server returned without flight data.
166
+ const flightApplicationsBefore = flightApplications;
167
+ try {
168
+ settled = await settleActionResult(run({
169
+ call: runMutation,
170
+ optimistic: submitHooks.size
171
+ ? () => {
172
+ for (const hook of submitHooks.values())
173
+ hook(...variables);
174
+ }
175
+ : undefined
176
+ }));
177
+ response = await handleResponse(settled.value, settled.error, router.navigatorFactory(), flightApplications !== flightApplicationsBefore);
178
+ }
179
+ finally {
180
+ form && setFormBusy(form, -1);
181
+ }
62
182
  let submission;
63
183
  submission = {
64
184
  input: variables,
65
185
  url,
66
- result: response.data,
67
- error: response.error,
186
+ result: response && response.data,
187
+ error: response && response.error,
68
188
  clear() {
69
189
  router.submissions[1](entries => entries.filter(entry => entry !== submission));
70
190
  },
@@ -73,12 +193,20 @@ function actionImpl(fn, options = {}) {
73
193
  return current[invokeSymbol].call({ r: router, f: form }, variables, current);
74
194
  }
75
195
  };
76
- router.submissions[1](entries => [...entries, submission]);
196
+ // Book-keeping is intentional: only outcomes worth showing or retrying
197
+ // (a result or an error) enter the submissions list, so the typical void
198
+ // mutation leaves nothing behind. Settled hooks still see every
199
+ // completion — void, metadata-only, and redirects included — one
200
+ // `onSettled` per invocation (#580).
201
+ response && router.submissions[1](entries => [...entries, submission]);
77
202
  for (const hook of settledHooks.values())
78
203
  hook(submission);
79
- if (response.error && !form)
80
- throw response.error;
81
- return response.data;
204
+ if (response) {
205
+ if (response.error && !form)
206
+ throw response.error;
207
+ return response.data;
208
+ }
209
+ return undefined;
82
210
  }
83
211
  const o = typeof options === "string" ? { name: options } : options;
84
212
  const name = o.name || (!isServer ? String(hashString(fn.toString())) : undefined);
@@ -121,6 +249,7 @@ function toAction(invoke, url, boundArgs = [], base = url, submitHooks = new Map
121
249
  fn[submitHooksSymbol] = submitHooks;
122
250
  fn[settledHooksSymbol] = settledHooks;
123
251
  fn[invokeSymbol] = invoke;
252
+ installRouterIntegrations();
124
253
  if (!isServer) {
125
254
  actions.set(url, fn);
126
255
  // Only remove the registration if it still belongs to this instance —
@@ -147,11 +276,60 @@ async function settleActionResult(result) {
147
276
  }
148
277
  return result;
149
278
  }
150
- async function handleResponse(response, error, navigate) {
151
- let data;
152
- let custom;
279
+ // Invocation count of the flight-data consumer. An action compares it across
280
+ // its mutation call to learn whether the transport already applied this
281
+ // response's metadata (and so the default revalidation pass must not run
282
+ // again and wipe the freshly seeded cache).
283
+ let flightApplications = 0;
284
+ /**
285
+ * Registers the router as the single-flight consumer of the server function
286
+ * transport. Subscribing is the opt-in: while registered, the transport
287
+ * sends the `X-Single-Flight` request header on mutations and delivers the
288
+ * folded payload here — fresh route data is seeded into the `query` cache
289
+ * and the envelope metadata (redirect `Location`, `X-Revalidate` keys) is
290
+ * applied, all before the action sees its plain return value. Called by the
291
+ * Router component on the client unless `singleFlight={false}`, which now
292
+ * simply means "never subscribe" — no consumer, no request header, no
293
+ * collection work on the server. Returns the unsubscribe function.
294
+ */
295
+ export function setupFlightDataConsumer(router) {
296
+ return subscribeFlightData((data, { response }) => {
297
+ flightApplications++;
298
+ return applyResponseMetadata(response, router.navigatorFactory(), data);
299
+ });
300
+ }
301
+ /**
302
+ * Applies a server function response's integration metadata: `X-Revalidate`
303
+ * keys invalidate, `Location` navigates (hard for absolute urls), flight
304
+ * data seeds the query cache, and matching entries revalidate. Shared by
305
+ * the flight-data consumer and the action response path (which still sees
306
+ * metadata-bearing responses when no flight data was collected).
307
+ */
308
+ function applyResponseMetadata(metadata, navigate, flightData) {
153
309
  let keys;
154
- let flightKeys;
310
+ if (metadata) {
311
+ if (metadata.headers.has("X-Revalidate"))
312
+ keys = metadata.headers.get("X-Revalidate").split(",");
313
+ if (metadata.headers.has("Location")) {
314
+ const locationUrl = metadata.headers.get("Location") || "/";
315
+ if (locationUrl.startsWith("http")) {
316
+ window.location.href = locationUrl;
317
+ }
318
+ else {
319
+ navigate(locationUrl);
320
+ }
321
+ }
322
+ }
323
+ // invalidate
324
+ cacheKeyOp(keys, entry => (entry[0] = 0));
325
+ // set cache
326
+ flightData && Object.keys(flightData).forEach(k => query.set(k, flightData[k]));
327
+ // trigger revalidation
328
+ revalidate(keys, false);
329
+ }
330
+ async function handleResponse(response, error, navigate, metadataHandled) {
331
+ let data;
332
+ let flightData;
155
333
  let metadata;
156
334
  if (isResponseEnvelope(response)) {
157
335
  // client-only respond(): the value rides in memory beside the metadata
@@ -160,15 +338,17 @@ async function handleResponse(response, error, navigate) {
160
338
  }
161
339
  else if (response instanceof Response) {
162
340
  metadata = response;
163
- // responses the transport hands over whole (redirects, revalidation,
164
- // single-flight) carry a codec-encoded body the router decodes itself
341
+ // responses the transport hands over whole (redirects, revalidation)
342
+ // carry a codec-encoded body the router decodes itself. With the
343
+ // flight-data consumer registered single-flight payloads never reach
344
+ // this path, but a manually opted-in call (no consumer) still can —
345
+ // unwrap the standardized { value, data } shape for it too.
165
346
  if (response.body) {
166
347
  data = await decodeResponse(response);
167
348
  if (response.headers.has("X-Single-Flight")) {
168
- custom = data;
169
- data = custom._$value;
170
- delete custom._$value;
171
- flightKeys = Object.keys(custom);
349
+ const payload = data;
350
+ data = payload.value;
351
+ flightData = payload.data;
172
352
  }
173
353
  }
174
354
  }
@@ -176,24 +356,10 @@ async function handleResponse(response, error, navigate) {
176
356
  return { error: response };
177
357
  else
178
358
  data = response;
179
- if (metadata) {
180
- if (metadata.headers.has("X-Revalidate"))
181
- keys = metadata.headers.get("X-Revalidate").split(",");
182
- if (metadata.headers.has("Location")) {
183
- const locationUrl = metadata.headers.get("Location") || "/";
184
- if (locationUrl.startsWith("http")) {
185
- window.location.href = locationUrl;
186
- }
187
- else {
188
- navigate(locationUrl);
189
- }
190
- }
191
- }
192
- // invalidate
193
- cacheKeyOp(keys, entry => (entry[0] = 0));
194
- // set cache
195
- flightKeys && flightKeys.forEach(k => query.set(k, custom[k]));
196
- // trigger revalidation
197
- await revalidate(keys, false);
359
+ // The transport consumer applies metadata before returning a server
360
+ // function's unwrapped value. Do not treat that value as a second plain
361
+ // action response and invalidate the freshly seeded query cache again.
362
+ if (!metadataHandled || metadata || flightData)
363
+ applyResponseMetadata(metadata, navigate, flightData);
198
364
  return data != null ? { data } : undefined;
199
365
  }
@@ -1,4 +1,12 @@
1
1
  import type { RouterContext } from "../types.js";
2
+ /**
3
+ * The submit delegation consults this slot instead of importing the action
4
+ * module: the action side installs its handler on first action creation
5
+ * (see data/action.ts), so an app that never creates an action never pulls
6
+ * the data layer into its bundle through the router's event wiring.
7
+ */
8
+ export type RouterFormHandler = (evt: SubmitEvent, router: RouterContext, actionBase: string) => void;
9
+ export declare function setRouterFormHandler(handler: RouterFormHandler | undefined): void;
2
10
  type NativeEventConfig = {
3
11
  preload?: boolean;
4
12
  explicitLinks?: boolean;
@@ -1,7 +1,9 @@
1
1
  import { delegateEvents } from "@solidjs/web";
2
2
  import { onCleanup } from "solid-js";
3
- import { actions } from "./action.js";
4
- import { mockBase } from "../utils.js";
3
+ let formHandler;
4
+ export function setRouterFormHandler(handler) {
5
+ formHandler = handler;
6
+ }
5
7
  export function setupNativeEvents({ preload = true, explicitLinks = false, actionBase = "/_server", transformUrl } = {}) {
6
8
  return (router) => {
7
9
  const basePath = router.base.path();
@@ -76,30 +78,29 @@ export function setupNativeEvents({ preload = true, explicitLinks = false, actio
76
78
  }, 20);
77
79
  }
78
80
  function handleFormSubmit(evt) {
81
+ if (formHandler)
82
+ return formHandler(evt, router, actionBase);
83
+ // No form handler means no action module in the client graph at all
84
+ // (e.g. server components binding forms straight to server functions).
85
+ // A POST to a url under actionBase is self-describing, so delegation
86
+ // is still sufficient: intercept synchronously — the no-JS treatment
87
+ // is reserved for clients with no JS — capture the FormData, and load
88
+ // the handler lazily. Apps that never submit one never load it.
79
89
  if (evt.defaultPrevented)
80
90
  return;
81
- let actionRef = evt.submitter && evt.submitter.hasAttribute("formaction")
91
+ const form = evt.target;
92
+ const ref = evt.submitter && evt.submitter.hasAttribute("formaction")
82
93
  ? evt.submitter.getAttribute("formaction")
83
- : evt.target.getAttribute("action");
84
- if (!actionRef)
94
+ : form.getAttribute("action");
95
+ if (!ref || ref.startsWith("https://action/"))
85
96
  return;
86
- if (!actionRef.startsWith("https://action/")) {
87
- // normalize server actions
88
- const url = new URL(actionRef, mockBase);
89
- actionRef = router.parsePath(url.pathname + url.search);
90
- if (!actionRef.startsWith(actionBase))
91
- return;
92
- }
93
- if (evt.target.method.toUpperCase() !== "POST")
94
- throw new Error("Only POST forms are supported for Actions");
95
- const handler = actions.get(actionRef);
96
- if (handler) {
97
- evt.preventDefault();
98
- const data = new FormData(evt.target, evt.submitter);
99
- handler.call({ r: router, f: evt.target }, evt.target.enctype === "multipart/form-data"
100
- ? data
101
- : new URLSearchParams(data));
102
- }
97
+ const url = new URL(ref, document.baseURI);
98
+ const path = router.parsePath(url.pathname + url.search);
99
+ if (!path.startsWith(actionBase) || form.method.toUpperCase() !== "POST")
100
+ return;
101
+ evt.preventDefault();
102
+ const data = new FormData(form, evt.submitter);
103
+ import("./serverForms.js").then(m => m.submitServerForm(router, path, form, data));
103
104
  }
104
105
  // ensure delegated event run first
105
106
  delegateEvents(["click", "submit"]);
@@ -0,0 +1,21 @@
1
+ export { FLASH_COOKIE, hasFlashCookie, clearFlashCookie } from "./flashCookie.js";
2
+ /** What rides the cookie: a Submission minus its lifecycle methods. */
3
+ export interface FlashSubmission {
4
+ input: any[];
5
+ url: string;
6
+ result?: any;
7
+ error?: any;
8
+ }
9
+ /**
10
+ * Encodes the outcome of a no-JS submission as a Set-Cookie value. `url` is
11
+ * the action url (pathname + search of the server function call) so the
12
+ * seeded submission matches `useSubmission` filters; `thrown` errors land on
13
+ * `error`, returned values on `result` (mirroring the scripted split).
14
+ */
15
+ export declare function encodeFlashCookie(url: string, result: any, input: any[], thrown?: boolean): string;
16
+ /**
17
+ * Decodes the flash cookie out of a request's Cookie header. Returns
18
+ * undefined when absent or unreadable (a malformed cookie must never take
19
+ * down SSR — it is cleared either way).
20
+ */
21
+ export declare function decodeFlashCookie(cookieHeader: string | null): FlashSubmission | undefined;