@solidjs/router 0.17.0-next.5 → 0.17.0-next.6

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.
@@ -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>;
@@ -16,4 +16,16 @@ export declare const actions: Map<string, Action<any, any, any>>;
16
16
  export declare function useSubmissions<T extends Array<any>, U, V>(fn: Action<T, U, V>, filter?: (input: V) => boolean): Submission<V, NarrowResponse<U>>[];
17
17
  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
18
  export declare const action: ActionFactory;
19
+ /**
20
+ * Registers the router as the single-flight consumer of the server function
21
+ * transport. Subscribing is the opt-in: while registered, the transport
22
+ * sends the `X-Single-Flight` request header on mutations and delivers the
23
+ * folded payload here — fresh route data is seeded into the `query` cache
24
+ * and the envelope metadata (redirect `Location`, `X-Revalidate` keys) is
25
+ * applied, all before the action sees its plain return value. Called by the
26
+ * Router component on the client unless `singleFlight={false}`, which now
27
+ * simply means "never subscribe" — no consumer, no request header, no
28
+ * collection work on the server. Returns the unsubscribe function.
29
+ */
30
+ export declare function setupFlightDataConsumer(router: RouterContext): () => void;
19
31
  export {};
@@ -1,6 +1,6 @@
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";
3
+ import { decodeResponse, subscribeFlightData } from "@solidjs/web/server-functions";
4
4
  import { useRouter } from "../routing.js";
5
5
  import { mockBase, setFunctionName } from "../utils.js";
6
6
  import { cacheKeyOp, hashKey, revalidate, query } from "./query.js";
@@ -32,9 +32,10 @@ function actionImpl(fn, options = {}) {
32
32
  const form = this.f;
33
33
  const submitHooks = current[submitHooksSymbol];
34
34
  const settledHooks = current[settledHooksSymbol];
35
- const runMutation = () => (router.singleFlight && fn.withOptions
36
- ? fn.withOptions({ headers: { "X-Single-Flight": "true" } })
37
- : fn)(...variables);
35
+ // Single-flight opt-in is no longer per call: the router's registered
36
+ // flight-data consumer (see setupFlightDataConsumer) makes the transport
37
+ // send the request header itself, so the mutation is just called.
38
+ const runMutation = () => fn(...variables);
38
39
  const run = createSolidAction(async function* (context) {
39
40
  context.optimistic?.();
40
41
  try {
@@ -147,11 +148,52 @@ async function settleActionResult(result) {
147
148
  }
148
149
  return result;
149
150
  }
151
+ /**
152
+ * Registers the router as the single-flight consumer of the server function
153
+ * transport. Subscribing is the opt-in: while registered, the transport
154
+ * sends the `X-Single-Flight` request header on mutations and delivers the
155
+ * folded payload here — fresh route data is seeded into the `query` cache
156
+ * and the envelope metadata (redirect `Location`, `X-Revalidate` keys) is
157
+ * applied, all before the action sees its plain return value. Called by the
158
+ * Router component on the client unless `singleFlight={false}`, which now
159
+ * simply means "never subscribe" — no consumer, no request header, no
160
+ * collection work on the server. Returns the unsubscribe function.
161
+ */
162
+ export function setupFlightDataConsumer(router) {
163
+ return subscribeFlightData((data, { response }) => applyResponseMetadata(response, router.navigatorFactory(), data));
164
+ }
165
+ /**
166
+ * Applies a server function response's integration metadata: `X-Revalidate`
167
+ * keys invalidate, `Location` navigates (hard for absolute urls), flight
168
+ * data seeds the query cache, and matching entries revalidate. Shared by
169
+ * the flight-data consumer and the action response path (which still sees
170
+ * metadata-bearing responses when no flight data was collected).
171
+ */
172
+ async function applyResponseMetadata(metadata, navigate, flightData) {
173
+ let keys;
174
+ if (metadata) {
175
+ if (metadata.headers.has("X-Revalidate"))
176
+ keys = metadata.headers.get("X-Revalidate").split(",");
177
+ if (metadata.headers.has("Location")) {
178
+ const locationUrl = metadata.headers.get("Location") || "/";
179
+ if (locationUrl.startsWith("http")) {
180
+ window.location.href = locationUrl;
181
+ }
182
+ else {
183
+ navigate(locationUrl);
184
+ }
185
+ }
186
+ }
187
+ // invalidate
188
+ cacheKeyOp(keys, entry => (entry[0] = 0));
189
+ // set cache
190
+ flightData && Object.keys(flightData).forEach(k => query.set(k, flightData[k]));
191
+ // trigger revalidation
192
+ await revalidate(keys, false);
193
+ }
150
194
  async function handleResponse(response, error, navigate) {
151
195
  let data;
152
- let custom;
153
- let keys;
154
- let flightKeys;
196
+ let flightData;
155
197
  let metadata;
156
198
  if (isResponseEnvelope(response)) {
157
199
  // client-only respond(): the value rides in memory beside the metadata
@@ -160,15 +202,17 @@ async function handleResponse(response, error, navigate) {
160
202
  }
161
203
  else if (response instanceof Response) {
162
204
  metadata = response;
163
- // responses the transport hands over whole (redirects, revalidation,
164
- // single-flight) carry a codec-encoded body the router decodes itself
205
+ // responses the transport hands over whole (redirects, revalidation)
206
+ // carry a codec-encoded body the router decodes itself. With the
207
+ // flight-data consumer registered single-flight payloads never reach
208
+ // this path, but a manually opted-in call (no consumer) still can —
209
+ // unwrap the standardized { value, data } shape for it too.
165
210
  if (response.body) {
166
211
  data = await decodeResponse(response);
167
212
  if (response.headers.has("X-Single-Flight")) {
168
- custom = data;
169
- data = custom._$value;
170
- delete custom._$value;
171
- flightKeys = Object.keys(custom);
213
+ const payload = data;
214
+ data = payload.value;
215
+ flightData = payload.data;
172
216
  }
173
217
  }
174
218
  }
@@ -176,24 +220,6 @@ async function handleResponse(response, error, navigate) {
176
220
  return { error: response };
177
221
  else
178
222
  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);
223
+ await applyResponseMetadata(metadata, navigate, flightData);
198
224
  return data != null ? { data } : undefined;
199
225
  }
@@ -0,0 +1,25 @@
1
+ export declare const FLASH_COOKIE = "flash";
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
+ /** The Set-Cookie value clearing the flash cookie after it has been read. */
17
+ export declare function clearFlashCookie(): string;
18
+ /** Whether a Cookie header carries a flash cookie (readable or not). */
19
+ export declare function hasFlashCookie(cookieHeader: string | null): boolean;
20
+ /**
21
+ * Decodes the flash cookie out of a request's Cookie header. Returns
22
+ * undefined when absent or unreadable (a malformed cookie must never take
23
+ * down SSR — it is cleared either way).
24
+ */
25
+ export declare function decodeFlashCookie(cookieHeader: string | null): FlashSubmission | undefined;
@@ -0,0 +1,86 @@
1
+ // The no-JS form convention's cookie codec. When a form posts to a server
2
+ // function without the client runtime (no instance header), the server
3
+ // handler redirects back carrying the outcome in a one-shot "flash" cookie;
4
+ // the next SSR pass reads it here and seeds the router's submission state so
5
+ // useSubmission() renders the result exactly as a scripted submission would.
6
+ // Both halves live in this module so the write (src/server.ts, the handler's
7
+ // handleNoJS) and the read (src/routing.ts, SSR initialization) can never
8
+ // drift apart.
9
+ export const FLASH_COOKIE = "flash";
10
+ // Form payloads have no JSON encoding, so entries are captured as pair
11
+ // arrays under a marker key ($f / $u) and revived to real FormData /
12
+ // URLSearchParams on the way out. File entries cannot ride a cookie and are
13
+ // dropped.
14
+ function encodeInputValue(value) {
15
+ if (value instanceof FormData)
16
+ return { $f: [...value.entries()].filter(([, v]) => typeof v === "string") };
17
+ if (value instanceof URLSearchParams)
18
+ return { $u: [...value.entries()] };
19
+ return value;
20
+ }
21
+ function decodeInputValue(value) {
22
+ if (value && typeof value === "object") {
23
+ if (Array.isArray(value.$f)) {
24
+ const form = new FormData();
25
+ for (const [k, v] of value.$f)
26
+ form.append(k, v);
27
+ return form;
28
+ }
29
+ if (Array.isArray(value.$u))
30
+ return new URLSearchParams(value.$u);
31
+ }
32
+ return value;
33
+ }
34
+ /**
35
+ * Encodes the outcome of a no-JS submission as a Set-Cookie value. `url` is
36
+ * the action url (pathname + search of the server function call) so the
37
+ * seeded submission matches `useSubmission` filters; `thrown` errors land on
38
+ * `error`, returned values on `result` (mirroring the scripted split).
39
+ */
40
+ export function encodeFlashCookie(url, result, input, thrown) {
41
+ const isError = result instanceof Error;
42
+ const payload = {
43
+ url,
44
+ result: isError ? result.message : result,
45
+ error: isError,
46
+ thrown: !!thrown,
47
+ input: input.map(encodeInputValue)
48
+ };
49
+ return `${FLASH_COOKIE}=${encodeURIComponent(JSON.stringify(payload))}; Secure; HttpOnly; Path=/`;
50
+ }
51
+ /** The Set-Cookie value clearing the flash cookie after it has been read. */
52
+ export function clearFlashCookie() {
53
+ return `${FLASH_COOKIE}=; Max-Age=0; Path=/`;
54
+ }
55
+ const FLASH_MATCHER = new RegExp(`(?:^|;\\s*)${FLASH_COOKIE}=([^;]+)`);
56
+ /** Whether a Cookie header carries a flash cookie (readable or not). */
57
+ export function hasFlashCookie(cookieHeader) {
58
+ return !!cookieHeader && FLASH_MATCHER.test(cookieHeader);
59
+ }
60
+ /**
61
+ * Decodes the flash cookie out of a request's Cookie header. Returns
62
+ * undefined when absent or unreadable (a malformed cookie must never take
63
+ * down SSR — it is cleared either way).
64
+ */
65
+ export function decodeFlashCookie(cookieHeader) {
66
+ if (!cookieHeader)
67
+ return;
68
+ const match = cookieHeader.match(FLASH_MATCHER);
69
+ if (!match)
70
+ return;
71
+ try {
72
+ const payload = JSON.parse(decodeURIComponent(match[1]));
73
+ if (!payload || !payload.result)
74
+ return;
75
+ const result = payload.error ? new Error(payload.result) : payload.result;
76
+ return {
77
+ input: Array.isArray(payload.input) ? payload.input.map(decodeInputValue) : [],
78
+ url: payload.url,
79
+ result: payload.thrown ? undefined : result,
80
+ error: payload.thrown ? result : undefined
81
+ };
82
+ }
83
+ catch (error) {
84
+ console.error(error);
85
+ }
86
+ }
@@ -1,6 +1,6 @@
1
1
  import { createSignal, getObserver, getOwner, onCleanup, sharedConfig } from "solid-js";
2
2
  import { getRequestEvent, isResponseEnvelope, isServer } from "@solidjs/web";
3
- import { decodeResponse } from "@solidjs/web/server-functions";
3
+ import { GET, decodeResponse, getServerFunctionMetadata, isServerFunction } from "@solidjs/web/server-functions";
4
4
  import { useNavigate, getIntent, getInPreloadFn } from "../routing.js";
5
5
  const LocationHeader = "Location";
6
6
  const PRELOAD_TIMEOUT = 5000;
@@ -43,9 +43,16 @@ export function cacheKeyOp(key, fn) {
43
43
  }
44
44
  }
45
45
  export function query(fn, name) {
46
- // prioritize GET for server functions
47
- if (fn.GET)
48
- fn = fn.GET;
46
+ // query implies GET: the router primitive is the declaration site, so a
47
+ // server function handed to query() is wrapped with core `GET(fn)` here,
48
+ // at query-creation (module scope) — the server half records the method
49
+ // declaration for dispatch, the client half swaps in the GET transport.
50
+ // An explicit `GET(fn)` already carries the declaration on the metadata
51
+ // channel (`getServerFunctionMetadata(fn)?.method === "GET"`) and passes
52
+ // through; non-server functions are untouched.
53
+ if (isServerFunction(fn) && !getServerFunctionMetadata(fn)?.method) {
54
+ fn = GET(fn);
55
+ }
49
56
  const cachedFn = ((...args) => {
50
57
  const cache = getCache();
51
58
  const intent = getIntent();