@solidjs/router 0.17.0-next.4 → 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,5 +1,6 @@
1
1
  import { $TRACK, action as createSolidAction, createMemo, onCleanup, getOwner } from "solid-js";
2
- import { isServer } from "@solidjs/web";
2
+ import { isResponseEnvelope, isServer } from "@solidjs/web";
3
+ import { decodeResponse, subscribeFlightData } from "@solidjs/web/server-functions";
3
4
  import { useRouter } from "../routing.js";
4
5
  import { mockBase, setFunctionName } from "../utils.js";
5
6
  import { cacheKeyOp, hashKey, revalidate, query } from "./query.js";
@@ -31,9 +32,10 @@ function actionImpl(fn, options = {}) {
31
32
  const form = this.f;
32
33
  const submitHooks = current[submitHooksSymbol];
33
34
  const settledHooks = current[settledHooksSymbol];
34
- const runMutation = () => (router.singleFlight && fn.withOptions
35
- ? fn.withOptions({ headers: { "X-Single-Flight": "true" } })
36
- : 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);
37
39
  const run = createSolidAction(async function* (context) {
38
40
  context.optimistic?.();
39
41
  try {
@@ -146,24 +148,34 @@ async function settleActionResult(result) {
146
148
  }
147
149
  return result;
148
150
  }
149
- async function handleResponse(response, error, navigate) {
150
- let data;
151
- let custom;
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) {
152
173
  let keys;
153
- let flightKeys;
154
- if (response instanceof Response) {
155
- if (response.headers.has("X-Revalidate"))
156
- keys = response.headers.get("X-Revalidate").split(",");
157
- if (response.customBody) {
158
- data = custom = await response.customBody();
159
- if (response.headers.has("X-Single-Flight")) {
160
- data = data._$value;
161
- delete custom._$value;
162
- flightKeys = Object.keys(custom);
163
- }
164
- }
165
- if (response.headers.has("Location")) {
166
- const locationUrl = response.headers.get("Location") || "/";
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") || "/";
167
179
  if (locationUrl.startsWith("http")) {
168
180
  window.location.href = locationUrl;
169
181
  }
@@ -172,15 +184,42 @@ async function handleResponse(response, error, navigate) {
172
184
  }
173
185
  }
174
186
  }
175
- else if (error)
176
- return { error: response };
177
- else
178
- data = response;
179
187
  // invalidate
180
188
  cacheKeyOp(keys, entry => (entry[0] = 0));
181
189
  // set cache
182
- flightKeys && flightKeys.forEach(k => query.set(k, custom[k]));
190
+ flightData && Object.keys(flightData).forEach(k => query.set(k, flightData[k]));
183
191
  // trigger revalidation
184
192
  await revalidate(keys, false);
193
+ }
194
+ async function handleResponse(response, error, navigate) {
195
+ let data;
196
+ let flightData;
197
+ let metadata;
198
+ if (isResponseEnvelope(response)) {
199
+ // client-only respond(): the value rides in memory beside the metadata
200
+ data = response.value;
201
+ metadata = response.response;
202
+ }
203
+ else if (response instanceof Response) {
204
+ metadata = response;
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.
210
+ if (response.body) {
211
+ data = await decodeResponse(response);
212
+ if (response.headers.has("X-Single-Flight")) {
213
+ const payload = data;
214
+ data = payload.value;
215
+ flightData = payload.data;
216
+ }
217
+ }
218
+ }
219
+ else if (error)
220
+ return { error: response };
221
+ else
222
+ data = response;
223
+ await applyResponseMetadata(metadata, navigate, flightData);
185
224
  return data != null ? { data } : undefined;
186
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,3 +1,2 @@
1
1
  export { action, useSubmissions, useAction, type Action } from "./action.js";
2
2
  export { query, revalidate, type CachedFunction } from "./query.js";
3
- export { redirect, reload, json } from "./response.js";
@@ -1,3 +1,2 @@
1
1
  export { action, useSubmissions, useAction } from "./action.js";
2
2
  export { query, revalidate } from "./query.js";
3
- export { redirect, reload, json } from "./response.js";
@@ -1,5 +1,6 @@
1
1
  import { createSignal, getObserver, getOwner, onCleanup, sharedConfig } from "solid-js";
2
- import { getRequestEvent, isServer } from "@solidjs/web";
2
+ import { getRequestEvent, isResponseEnvelope, isServer } from "@solidjs/web";
3
+ import { GET, decodeResponse, getServerFunctionMetadata, isServerFunction } from "@solidjs/web/server-functions";
3
4
  import { useNavigate, getIntent, getInPreloadFn } from "../routing.js";
4
5
  const LocationHeader = "Location";
5
6
  const PRELOAD_TIMEOUT = 5000;
@@ -42,9 +43,16 @@ export function cacheKeyOp(key, fn) {
42
43
  }
43
44
  }
44
45
  export function query(fn, name) {
45
- // prioritize GET for server functions
46
- if (fn.GET)
47
- 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
+ }
48
56
  const cachedFn = ((...args) => {
49
57
  const cache = getCache();
50
58
  const intent = getIntent();
@@ -143,6 +151,14 @@ export function query(fn, name) {
143
151
  return res;
144
152
  function handleResponse(error) {
145
153
  return async (v) => {
154
+ let enveloped;
155
+ let hasEnveloped = false;
156
+ if (isResponseEnvelope(v)) {
157
+ // respond(): the value rides in memory beside the metadata
158
+ enveloped = v.value;
159
+ hasEnveloped = true;
160
+ v = v.response;
161
+ }
146
162
  if (v instanceof Response) {
147
163
  const e = getRequestEvent();
148
164
  if (e) {
@@ -164,8 +180,15 @@ export function query(fn, name) {
164
180
  e.response.status = 302;
165
181
  return;
166
182
  }
167
- if (v.customBody)
168
- v = await v.customBody();
183
+ if (hasEnveloped)
184
+ v = enveloped;
185
+ else if (v.body) {
186
+ // responses the transport hands over whole (revalidation) carry a
187
+ // codec-encoded body; anything else (a raw user Response) stays whole
188
+ const decoded = await decodeResponse(v);
189
+ if (decoded !== undefined)
190
+ v = decoded;
191
+ }
169
192
  }
170
193
  if (error)
171
194
  throw v;
package/dist/index.d.ts CHANGED
@@ -4,4 +4,4 @@ export * from "./lifecycle.js";
4
4
  export { useHref, useIsRouting, useLocation, useMatch, useCurrentMatches, useNavigate, useParams, useResolvedPath, useSearchParams, useBeforeLeave, usePreloadRoute, RouterContextObj as RouterContext } from "./routing.js";
5
5
  export { mergeSearchString as _mergeSearchString } from "./utils.js";
6
6
  export * from "./data/index.js";
7
- export type { Location, LocationChange, SearchParams, MatchFilter, MatchFilters, NavigateOptions, Navigator, OutputMatch, Params, PathMatch, RouteSectionProps, RoutePreloadFunc, RoutePreloadFuncArgs, RouteDefinition, RouteDescription, RouteMatch, RouterIntegration, RouterUtils, SetParams, Submission, BeforeLeaveEventArgs, RouterResponseInit, CustomResponse } from "./types.js";
7
+ export type { Location, LocationChange, SearchParams, MatchFilter, MatchFilters, NavigateOptions, Navigator, OutputMatch, Params, PathMatch, RouteSectionProps, RoutePreloadFunc, RoutePreloadFuncArgs, RouteDefinition, RouteDescription, RouteMatch, RouterIntegration, RouterUtils, SetParams, Submission, BeforeLeaveEventArgs } from "./types.js";