@solidjs/router 2.0.0-next.30 → 2.0.0-next.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -850,7 +850,7 @@ const isRouting = useIsRouting();
850
850
  return <div classList={{ "grey-out": isRouting() }}>...</div>;
851
851
  ```
852
852
 
853
- In Solid's dev and observe builds the router also declares every navigation to the attribution engine (`solid-js/attribution`): holds and re-runs caused by a navigation are named after the route pattern (`navigation to /users/:id`), timed from the user event that started it, and redirect hops fold onto the navigation they belong to. `attribution.navigations()` and `feedback().navigations` list them; nothing of this exists in production builds.
853
+ In Solid's dev and observe builds the router also declares every navigation to the attribution engine (`solid-js/attribution`): holds and re-runs caused by a navigation are named after the route pattern (`navigation to /users/:id`), timed from the user event that started it, and redirect hops fold onto the navigation they belong to. `attribution.history("navigation")` and `feedback().navigations` list them; nothing of this exists in production builds.
854
854
 
855
855
  ### useMatch
856
856
 
@@ -29,9 +29,10 @@ export const actions = /* #__PURE__ */ new Map();
29
29
  export function handleFormAction(evt, router, actionBase) {
30
30
  if (evt.defaultPrevented)
31
31
  return;
32
+ const form = evt.target;
32
33
  let actionRef = evt.submitter && evt.submitter.hasAttribute("formaction")
33
34
  ? evt.submitter.getAttribute("formaction")
34
- : evt.target.getAttribute("action");
35
+ : form.getAttribute("action");
35
36
  if (!actionRef)
36
37
  return;
37
38
  const serverAction = !actionRef.startsWith("https://action/");
@@ -42,7 +43,7 @@ export function handleFormAction(evt, router, actionBase) {
42
43
  if (!actionRef.startsWith(actionBase))
43
44
  return;
44
45
  }
45
- if (evt.target.method.toUpperCase() !== "POST")
46
+ if (form.method.toUpperCase() !== "POST")
46
47
  throw new Error("Only POST forms are supported for Actions");
47
48
  // A registry miss on a server-action url is a direct bind whose module
48
49
  // never loaded client-side (server components): the url is self-describing
@@ -54,10 +55,8 @@ export function handleFormAction(evt, router, actionBase) {
54
55
  const handler = actions.get(actionRef) || (serverAction && createServerFormAction(actionRef));
55
56
  if (handler) {
56
57
  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));
58
+ const data = new FormData(form, evt.submitter);
59
+ handler.call({ r: router, f: form }, form.enctype === "multipart/form-data" ? data : new URLSearchParams(data));
61
60
  }
62
61
  }
63
62
  /**
@@ -159,18 +158,22 @@ function actionImpl(fn, options = {}) {
159
158
  const runMutation = () => fn(...variables);
160
159
  const run = createSolidAction(async function* (context) {
161
160
  context.optimistic?.();
161
+ let value;
162
+ let error = false;
162
163
  try {
163
- const value = await context.call();
164
- yield;
165
- return { error: false, value };
164
+ value = await context.call();
166
165
  }
167
- catch (error) {
168
- yield;
169
- return { error: true, value: error };
166
+ catch (e) {
167
+ value = e;
168
+ error = true;
170
169
  }
170
+ const read = await readResponse(value, error);
171
+ yield;
172
+ // Apply inside the transition so the default revalidation's refetch and
173
+ // the release of the caller's optimistic writes commit as one frame (#619).
174
+ return applyResponse(read, router.navigatorFactory(), flightApplications !== flightApplicationsBefore);
171
175
  });
172
176
  form && setFormBusy(form, 1);
173
- let settled;
174
177
  let response;
175
178
  // The transport consumer is awaited before a single-flight mutation
176
179
  // resolves, so a counter delta over the call tells whether this action's
@@ -180,7 +183,7 @@ function actionImpl(fn, options = {}) {
180
183
  // which misses every response the server returned without flight data.
181
184
  const flightApplicationsBefore = flightApplications;
182
185
  try {
183
- settled = await settleActionResult(run({
186
+ response = await settleActionResult(run({
184
187
  call: runMutation,
185
188
  optimistic: submitHooks.size
186
189
  ? () => {
@@ -189,7 +192,6 @@ function actionImpl(fn, options = {}) {
189
192
  }
190
193
  : undefined
191
194
  }));
192
- response = await handleResponse(settled.value, settled.error, router.navigatorFactory(), flightApplications !== flightApplicationsBefore);
193
195
  }
194
196
  finally {
195
197
  form && setFormBusy(form, -1);
@@ -198,8 +200,8 @@ function actionImpl(fn, options = {}) {
198
200
  submission = {
199
201
  input: variables,
200
202
  url,
201
- result: response && response.data,
202
- error: response && response.error,
203
+ result: response?.data,
204
+ error: response?.error,
203
205
  clear() {
204
206
  router.submissions[1](entries => entries.filter(entry => entry !== submission));
205
207
  },
@@ -244,8 +246,7 @@ function toAction(invoke, url, boundArgs = [], base = url, submitHooks = new Map
244
246
  fn.with = function (...args) {
245
247
  const uri = new URL(url, mockBase);
246
248
  uri.searchParams.set("args", hashKey(args));
247
- const next = toAction(invoke, (uri.origin === "https://action" ? uri.origin : "") + uri.pathname + uri.search, [...boundArgs, ...args], base, submitHooks, settledHooks);
248
- return next;
249
+ return toAction(invoke, (uri.origin === "https://action" ? uri.origin : "") + uri.pathname + uri.search, [...boundArgs, ...args], base, submitHooks, settledHooks);
249
250
  };
250
251
  fn.onSubmit = function (hook) {
251
252
  const id = Symbol("actionOnSubmitHook");
@@ -362,8 +363,10 @@ function applyResponseMetadata(metadata, navigate, flightData) {
362
363
  cacheKeyOp(keys, entry => (entry[0] = 0));
363
364
  // set cache — and fan the payload out to other keyed stores (live query
364
365
  // channels adopt delivered values instead of reconnecting)
365
- flightData && Object.keys(flightData).forEach(k => query.set(k, flightData[k]));
366
- flightData && deliverFlightData(flightData);
366
+ if (flightData) {
367
+ Object.keys(flightData).forEach(k => query.set(k, flightData[k]));
368
+ deliverFlightData(flightData);
369
+ }
367
370
  // trigger revalidation inside the same transition as the navigation, so
368
371
  // the redirect commits atomically with fresh data: surviving consumers
369
372
  // (shared layouts) the flight payload didn't seed refetch and hold the
@@ -371,7 +374,7 @@ function applyResponseMetadata(metadata, navigate, flightData) {
371
374
  // are fresh again by now, so the sweep re-reads them from cache.
372
375
  revalidate(keys, false);
373
376
  }
374
- async function handleResponse(response, error, navigate, metadataHandled) {
377
+ async function readResponse(response, error) {
375
378
  let data;
376
379
  let flightData;
377
380
  let metadata;
@@ -397,6 +400,12 @@ async function handleResponse(response, error, navigate, metadataHandled) {
397
400
  return { error: response };
398
401
  else
399
402
  data = response;
403
+ return { data, flightData, metadata };
404
+ }
405
+ function applyResponse(read, navigate, metadataHandled) {
406
+ if ("error" in read)
407
+ return read;
408
+ const { data, flightData, metadata } = read;
400
409
  // The transport consumer applies metadata before returning a server
401
410
  // function's unwrapped value. Do not treat that value as a second plain
402
411
  // action response and invalidate the freshly seeded query cache again.
@@ -32,7 +32,7 @@ export function setupNativeEvents({ preload = true, explicitLinks = false, actio
32
32
  if (target || (!href && !a.hasAttribute("state")))
33
33
  return;
34
34
  const rel = (a.getAttribute("rel") || "").split(/\s+/);
35
- if (a.hasAttribute("download") || (rel && rel.includes("external")))
35
+ if (a.hasAttribute("download") || rel.includes("external"))
36
36
  return;
37
37
  const url = svg ? new URL(href, document.baseURI) : new URL(href);
38
38
  // Skip non-http(s) schemes (blob:, mailto:, tel:, data:, ...). blob: URLs
@@ -1,4 +1,4 @@
1
- import { createSignal, getObserver, getOwner, onCleanup, sharedConfig, untrack } from "solid-js";
1
+ import { createSignal, getObserver, getOwner, isHydratable, onCleanup, untrack } from "solid-js";
2
2
  // Everything server-function-shaped comes off the CORE entry: detection
3
3
  // (isServerFunction/getServerFunctionMetadata, registered-symbol reads) and
4
4
  // the late-bound RPC seam (getServerFunctionRPC). The transport itself —
@@ -9,7 +9,7 @@ import { createSignal, getObserver, getOwner, onCleanup, sharedConfig, untrack }
9
9
  // into the seam when a `'use server'` reference is created (compiled
10
10
  // output, module scope), so by the time a server function can reach
11
11
  // query() the seam is filled; plain-fetch apps read undefined forever.
12
- import { getRequestEvent, getServerFunctionMetadata, getServerFunctionRPC, isResponseEnvelope, isServer, isServerFunction, REVALIDATE_HEADER } from "@solidjs/web";
12
+ import { getHydrationWriter, getRequestEvent, getServerFunctionMetadata, getServerFunctionRPC, isResponseEnvelope, isServer, isServerFunction, REVALIDATE_HEADER, takeHydrationValue } from "@solidjs/web";
13
13
  // The redirect carrier's name and decoder are the exception: two pure,
14
14
  // dependency-free bindings off a `sideEffects: false` entry, so they
15
15
  // tree-shake to a few hundred bytes without dragging the codec in (the same
@@ -19,10 +19,34 @@ import { useRouter, getIntent, getInPreloadFn } from "../routing.js";
19
19
  const LocationHeader = "Location";
20
20
  const PRELOAD_TIMEOUT = 5000;
21
21
  const CACHE_TIMEOUT = 180000;
22
- // When this client booted. Flight-registry entries (sharedConfig.has/load)
22
+ const responseBodyHeaders = /* #__PURE__ */ new Set([
23
+ "content-type",
24
+ "content-length",
25
+ "content-encoding",
26
+ "transfer-encoding",
27
+ "content-disposition",
28
+ "content-language",
29
+ "content-location",
30
+ "content-range",
31
+ "accept-ranges",
32
+ "etag",
33
+ "last-modified"
34
+ ]);
35
+ const forwardedCookies = /* #__PURE__ */ new WeakMap();
36
+ // When this client booted. Flight-registry entries (takeHydrationValue)
23
37
  // hold values the server computed while rendering THIS page, so their age is
24
38
  // anchored here — not at whenever a late query() call happens to consume one.
25
39
  const bootTime = Date.now();
40
+ // Every hydratable read ships its entry: the writer keeps the first write
41
+ // per key, so a read skipped in a NoHydration zone never hides the entry
42
+ // from an island that reads the same key later.
43
+ function serializeForHydration(key, res) {
44
+ const writer = getHydrationWriter();
45
+ if (!writer || !writer.async || !isHydratable())
46
+ return;
47
+ const e = getRequestEvent();
48
+ (!e || !e.serverOnly) && writer.write(key, res);
49
+ }
26
50
  let cacheMap = new Map();
27
51
  // cleanup forward/back cache
28
52
  if (!isServer) {
@@ -156,13 +180,11 @@ export function query(fn, name) {
156
180
  }
157
181
  let res = cached[1];
158
182
  if (intent !== "preload") {
159
- res =
160
- "then" in cached[1]
161
- ? cached[1].then(handleResponse(false), handleResponse(true))
162
- : handleResponse(false)(cached[1]);
183
+ res = settle(res);
163
184
  !isServer && intent === "navigate" && cached[4][1](cached[0]); // update version
164
185
  }
165
186
  inPreloadFn && "then" in res && res.catch(() => { });
187
+ isServer && serializeForHydration(key, res);
166
188
  return res;
167
189
  }
168
190
  let res;
@@ -179,13 +201,17 @@ export function query(fn, name) {
179
201
  // active navigation (or its preload) accepts the payload only while a
180
202
  // real preload of the same age would still satisfy it — anything older
181
203
  // refetches instead of serving a minutes-old value on a fresh navigation.
182
- if (!isServer && sharedConfig.has && sharedConfig.has(key)) {
183
- const payloadAge = now - bootTime;
184
- if (!intent || payloadAge < (intent === "native" ? CACHE_TIMEOUT : PRELOAD_TIMEOUT)) {
204
+ if (!isServer &&
205
+ (!intent || now - bootTime < (intent === "native" ? CACHE_TIMEOUT : PRELOAD_TIMEOUT))) {
206
+ const entry = takeHydrationValue(key); // hydrating
207
+ if (entry) {
185
208
  adopted = true;
186
- res = sharedConfig.load(key); // hydrating
187
- // @ts-ignore at least until we add a delete method to sharedConfig
188
- delete globalThis._$HY.r[key];
209
+ res =
210
+ entry.status === "pending"
211
+ ? entry.promise
212
+ : entry.status === "resolved"
213
+ ? Promise.resolve(entry.value)
214
+ : Promise.reject(entry.error);
189
215
  }
190
216
  // A payload too old for this intent stays in the registry: it is
191
217
  // inert while the refetched entry below lives, but a later claim
@@ -201,6 +227,7 @@ export function query(fn, name) {
201
227
  // it would for a preload performed when the server fetched the data.
202
228
  const stamp = adopted ? bootTime : now;
203
229
  if (cached) {
230
+ isServer && forwardedCookies.delete(cached);
204
231
  cached[0] = stamp;
205
232
  cached[1] = res;
206
233
  cached[3] = intent;
@@ -225,23 +252,21 @@ export function query(fn, name) {
225
252
  if (e && e.router.dataOnly)
226
253
  return (e.router.data[key] = res);
227
254
  }
228
- if (intent !== "preload") {
229
- res =
230
- "then" in res
231
- ? res.then(handleResponse(false), handleResponse(true))
232
- : handleResponse(false)(res);
233
- }
255
+ if (intent !== "preload")
256
+ res = settle(res);
234
257
  inPreloadFn && "then" in res && res.catch(() => { });
235
- // serialize on server
236
- if (isServer &&
237
- sharedConfig.context &&
238
- sharedConfig.context.async &&
239
- !sharedConfig.context.noHydrate) {
240
- const e = getRequestEvent();
241
- (!e || !e.serverOnly) && sharedConfig.context.serialize(key, res);
242
- }
258
+ isServer && serializeForHydration(key, res);
243
259
  return res;
244
- function handleResponse(error) {
260
+ // a settled read: the response handling applied to the value or the promise
261
+ function settle(v) {
262
+ let cookies = isServer ? forwardedCookies.get(cached) : undefined;
263
+ if (isServer && !cookies)
264
+ forwardedCookies.set(cached, (cookies = new WeakSet()));
265
+ return "then" in v
266
+ ? v.then(handleResponse(false, cookies), handleResponse(true, cookies))
267
+ : handleResponse(false, cookies)(v);
268
+ }
269
+ function handleResponse(error, cookies) {
245
270
  return async (v) => {
246
271
  let enveloped;
247
272
  let hasEnveloped = false;
@@ -254,11 +279,22 @@ export function query(fn, name) {
254
279
  if (v instanceof Response) {
255
280
  const e = getRequestEvent();
256
281
  if (e) {
282
+ const headers = e.response.headers;
283
+ const skipCookies = cookies?.has(headers);
257
284
  for (const [key, value] of v.headers) {
258
- if (key == "set-cookie")
259
- e.response.headers.append("set-cookie", value);
260
- else
261
- e.response.headers.set(key, value);
285
+ // Keep metadata for the query's body off the document response.
286
+ if (responseBodyHeaders.has(key))
287
+ continue;
288
+ if (key === "set-cookie") {
289
+ if (!skipCookies)
290
+ headers.append(key, value);
291
+ }
292
+ else if (headers.get(key) !== value)
293
+ headers.set(key, value);
294
+ }
295
+ // Each cached result contributes its cookies once per document.
296
+ if (!skipCookies && v.headers.has("set-cookie")) {
297
+ cookies?.add(headers);
262
298
  }
263
299
  }
264
300
  let url = v.headers.get(LocationHeader);
@@ -347,6 +383,7 @@ query.set = (key, value) => {
347
383
  const now = Date.now();
348
384
  let cached = cache.get(key);
349
385
  if (cached) {
386
+ isServer && forwardedCookies.delete(cached);
350
387
  cached[0] = now;
351
388
  cached[1] = Promise.resolve(value);
352
389
  cached[2] = value;
@@ -400,7 +437,7 @@ export function hashKey(args) {
400
437
  .reduce((result, key) => {
401
438
  result[key] = val[key];
402
439
  return result;
403
- }, {})
440
+ }, Object.create(null))
404
441
  : val);
405
442
  }
406
443
  function isPlainObject(obj) {
package/dist/fs.d.ts CHANGED
@@ -35,7 +35,7 @@ export type FileRouteConfig<S extends string = string, T = unknown, F = undefine
35
35
  * `matchFilters` are validated against the pattern's params, and their
36
36
  * types (and any `search` schema) flow through the manifest into `paths`.
37
37
  */
38
- export declare function defineFileRoute<S extends string, T = unknown, const F = DefinedRouteFilters<S>, Sch extends StandardSchemaV1<any, any> | undefined = undefined>(path: S, config: {
38
+ export declare function defineFileRoute<S extends string, T = unknown, const F = DefinedRouteFilters<S>, Sch extends StandardSchemaV1<any, any> | undefined = undefined>(path: S, config?: {
39
39
  matchFilters?: (F & ValidFilters<F, S>) | undefined;
40
40
  preload?: ((args: RoutePreloadFuncArgs<RouteParams<S>>) => T) | undefined;
41
41
  /** Standard Schema validator for this route's search params; its input type flows into the typed path proxy. */
package/dist/fs.js CHANGED
@@ -20,7 +20,7 @@ import * as server from "./fsServer.js";
20
20
  * `matchFilters` are validated against the pattern's params, and their
21
21
  * types (and any `search` schema) flow through the manifest into `paths`.
22
22
  */
23
- export function defineFileRoute(path, config) {
23
+ export function defineFileRoute(path, config = {}) {
24
24
  return config;
25
25
  }
26
26
  /**