@rsc-kit/core 0.18.0 → 0.19.0

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 (73) hide show
  1. package/dist/action.d.ts +16 -2
  2. package/dist/action.js +21 -12
  3. package/dist/action.js.map +1 -1
  4. package/dist/apiPrerender.js +24 -6
  5. package/dist/apiPrerender.js.map +1 -1
  6. package/dist/clientPackages.d.ts +30 -0
  7. package/dist/clientPackages.js +234 -0
  8. package/dist/clientPackages.js.map +1 -0
  9. package/dist/compress.d.ts +12 -0
  10. package/dist/compress.js +134 -0
  11. package/dist/compress.js.map +1 -0
  12. package/dist/compressRuntime.d.ts +8 -0
  13. package/dist/compressRuntime.js +62 -0
  14. package/dist/compressRuntime.js.map +1 -0
  15. package/dist/files.d.ts +20 -0
  16. package/dist/files.js +38 -0
  17. package/dist/files.js.map +1 -1
  18. package/dist/formSubmit.d.ts +1 -0
  19. package/dist/formSubmit.js +14 -0
  20. package/dist/formSubmit.js.map +1 -0
  21. package/dist/host.d.ts +23 -0
  22. package/dist/host.js +175 -31
  23. package/dist/host.js.map +1 -1
  24. package/dist/hostCalls.d.ts +15 -0
  25. package/dist/hostCalls.js +75 -8
  26. package/dist/hostCalls.js.map +1 -1
  27. package/dist/js/Form.d.ts +19 -2
  28. package/dist/js/Form.js +110 -83
  29. package/dist/js/Form.js.map +1 -1
  30. package/dist/js/LoadingBoundary.d.ts +5 -0
  31. package/dist/js/LoadingBoundary.js +19 -0
  32. package/dist/js/LoadingBoundary.js.map +1 -0
  33. package/dist/js/createViteRscApp.d.ts +1 -0
  34. package/dist/js/createViteRscApp.js +34 -5
  35. package/dist/js/createViteRscApp.js.map +1 -1
  36. package/dist/js/errors.d.ts +3 -1
  37. package/dist/js/errors.js +26 -3
  38. package/dist/js/errors.js.map +1 -1
  39. package/dist/js/fallbackReport.js +10 -8
  40. package/dist/js/fallbackReport.js.map +1 -1
  41. package/dist/js/formEncoding.d.ts +72 -3
  42. package/dist/js/formEncoding.js +284 -20
  43. package/dist/js/formEncoding.js.map +1 -1
  44. package/dist/js/navigate.d.ts +3 -0
  45. package/dist/js/navigate.js +56 -6
  46. package/dist/js/navigate.js.map +1 -1
  47. package/dist/js/updateStore.js +8 -2
  48. package/dist/js/updateStore.js.map +1 -1
  49. package/dist/js/useEvents.js +9 -7
  50. package/dist/js/useEvents.js.map +1 -1
  51. package/dist/js/usePolling.js +38 -37
  52. package/dist/js/usePolling.js.map +1 -1
  53. package/dist/openapi.d.ts +74 -0
  54. package/dist/openapi.js +172 -0
  55. package/dist/openapi.js.map +1 -0
  56. package/dist/prerender.js +1 -0
  57. package/dist/prerender.js.map +1 -1
  58. package/dist/redirect.d.ts +2 -2
  59. package/dist/redirect.js.map +1 -1
  60. package/dist/request.d.ts +56 -2
  61. package/dist/request.js +68 -4
  62. package/dist/request.js.map +1 -1
  63. package/dist/routeSchema.d.ts +49 -0
  64. package/dist/routeSchema.js.map +1 -1
  65. package/dist/routes.d.ts +15 -1
  66. package/dist/routes.js.map +1 -1
  67. package/dist/testing.d.ts +22 -0
  68. package/dist/testing.js +83 -5
  69. package/dist/testing.js.map +1 -1
  70. package/dist/vite.d.ts +80 -1
  71. package/dist/vite.js +625 -82
  72. package/dist/vite.js.map +1 -1
  73. package/package.json +7 -3
@@ -1 +1 @@
1
- {"version":3,"file":"fallbackReport.js","sourceRoot":"","sources":["../../src/js/fallbackReport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,MAAM,eAAe,GAAG,yCAAyC,CAAC;AAElE,MAAM,UAAU,eAAe,CAC7B,cAAyC;IAEzC,MAAM,MAAM,GAAG,CAAC,cAAc,IAAI,EAAE,CAAC;SAClC,KAAK,CAAC,IAAI,CAAC;SACX,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;IAC1C,MAAM,EAAE,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,mBAAmB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;IAEtE,IAAI,EAAE,KAAK,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IAE5B,OAAO,MAAM;SACV,KAAK,CAAC,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC;SACrB,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;AAChD,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,SAAS,GACb,8EAA8E,CAAC;AAEjF,MAAM,UAAU,mBAAmB,CAAC,KAAc;IAChD,IAAI,KAAK,YAAY,YAAY;QAAE,OAAO,KAAK,CAAC,IAAI,KAAK,YAAY,CAAC;IAEtE,MAAM,OAAO,GACX,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,SAAS,IAAI,KAAK;QAC/D,CAAC,CAAC,MAAM,CAAE,KAA8B,CAAC,OAAO,CAAC;QACjD,CAAC,CAAC,OAAO,KAAK,KAAK,QAAQ;YACzB,CAAC,CAAC,KAAK;YACP,CAAC,CAAC,EAAE,CAAC;IAEX,OAAO,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AACjC,CAAC","sourcesContent":["/**\n * Whether a read React caught at a boundary fell through to a loading.tsx.\n *\n * A component that reads the query string under a <Suspense> the developer\n * wrote is the designed path: the fallback is stored, the browser fills it,\n * and nothing needs saying. The same read with nothing closer than a\n * loading.tsx - a boundary the engine put there for the whole segment - is\n * worth a line, because the whole segment shows the fallback until the query\n * arrives, and a boundary closer to the read would keep the rest painted.\n *\n * The component stack says which: frames run from the component outward,\n * and the engine's segment boundaries render through SegmentBoundary or\n * SlotBoundary, so the frame just outside the nearest Suspense tells whose\n * it is. Only development has the stack; production reports nothing here,\n * and the build's own note or warning on the route is the record.\n */\nconst ENGINE_BOUNDARY = /^\\s*at (SegmentBoundary|SlotBoundary)\\b/;\n\nexport function caughtByLoading(\n componentStack: string | null | undefined,\n): boolean {\n const frames = (componentStack ?? \"\")\n .split(\"\\n\")\n .filter((line) => /^\\s*at /.test(line));\n const at = frames.findIndex((line) => /^\\s*at Suspense\\b/.test(line));\n\n if (at === -1) return false;\n\n return frames\n .slice(at + 1, at + 3)\n .some((line) => ENGINE_BOUNDARY.test(line));\n}\n\n/**\n * Whether a render error is the consumer cancelling, not the app failing.\n *\n * React's server renderer reports an abort as an error, and the reason it\n * gives when the stream was simply cancelled - a browser that left the page\n * mid-stream, a prefetch abandoned, a proxy that closed - is its own fixed\n * message. Logged, it reads as a fault in the page, and the page had none:\n * \"[rsc-kit:ssr] Error: The render was aborted by the server without a\n * reason.\" on a dev console, every so often, for nothing. The same message\n * from the payload renderer means the same thing.\n */\nconst CANCELLED =\n /^The render was aborted by the server (?:without a reason|with a promise)\\.$/;\n\nexport function cancelledByConsumer(error: unknown): boolean {\n if (error instanceof DOMException) return error.name === \"AbortError\";\n\n const message =\n typeof error === \"object\" && error !== null && \"message\" in error\n ? String((error as { message: unknown }).message)\n : typeof error === \"string\"\n ? error\n : \"\";\n\n return CANCELLED.test(message);\n}\n"]}
1
+ {"version":3,"file":"fallbackReport.js","sourceRoot":"","sources":["../../src/js/fallbackReport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,eAAe,GAAG,0BAA0B,CAAC;AAEnD,MAAM,UAAU,eAAe,CAC7B,cAAyC;IAEzC,MAAM,MAAM,GAAG,CAAC,cAAc,IAAI,EAAE,CAAC;SAClC,KAAK,CAAC,IAAI,CAAC;SACX,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;IAC1C,MAAM,EAAE,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,mBAAmB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;IAEtE,IAAI,EAAE,KAAK,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IAE5B,OAAO,eAAe,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;AACpD,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,SAAS,GACb,8EAA8E,CAAC;AAEjF,MAAM,UAAU,mBAAmB,CAAC,KAAc;IAChD,IAAI,KAAK,YAAY,YAAY;QAAE,OAAO,KAAK,CAAC,IAAI,KAAK,YAAY,CAAC;IAEtE,MAAM,OAAO,GACX,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,SAAS,IAAI,KAAK;QAC/D,CAAC,CAAC,MAAM,CAAE,KAA8B,CAAC,OAAO,CAAC;QACjD,CAAC,CAAC,OAAO,KAAK,KAAK,QAAQ;YACzB,CAAC,CAAC,KAAK;YACP,CAAC,CAAC,EAAE,CAAC;IAEX,OAAO,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AACjC,CAAC","sourcesContent":["/**\n * Whether a read React caught at a boundary fell through to a loading.tsx.\n *\n * A component that reads the query string under a <Suspense> the developer\n * wrote is the designed path: the fallback is stored, the browser fills it,\n * and nothing needs saying. The same read with nothing closer than a\n * loading.tsx - a boundary the engine put there for the whole segment - is\n * worth a line, because the whole segment shows the fallback until the query\n * arrives, and a boundary closer to the read would keep the rest painted.\n *\n * The component stack says which: frames run from the component outward,\n * and the engine renders the boundary a loading.tsx becomes through a\n * component named for it, so the frame just outside the nearest Suspense\n * is LoadingBoundary exactly when the boundary is the engine's. Only that\n * frame, and only that name: the check used to accept SegmentBoundary two\n * frames out, which held for a first render and misfired on a PPR resume,\n * where React leaves server components out of the stack and a developer's\n * own <Suspense> at the top of a page sat directly under the segment\n * boundary - a warning on every request that nothing could make go away.\n */\nconst ENGINE_BOUNDARY = /^\\s*at LoadingBoundary\\b/;\n\nexport function caughtByLoading(\n componentStack: string | null | undefined,\n): boolean {\n const frames = (componentStack ?? \"\")\n .split(\"\\n\")\n .filter((line) => /^\\s*at /.test(line));\n const at = frames.findIndex((line) => /^\\s*at Suspense\\b/.test(line));\n\n if (at === -1) return false;\n\n return ENGINE_BOUNDARY.test(frames[at + 1] ?? \"\");\n}\n\n/**\n * Whether a render error is the consumer cancelling, not the app failing.\n *\n * React's server renderer reports an abort as an error, and the reason it\n * gives when the stream was simply cancelled - a browser that left the page\n * mid-stream, a prefetch abandoned, a proxy that closed - is its own fixed\n * message. Logged, it reads as a fault in the page, and the page had none:\n * \"[rsc-kit:ssr] Error: The render was aborted by the server without a\n * reason.\" on a dev console, every so often, for nothing. The same message\n * from the payload renderer means the same thing.\n */\nconst CANCELLED =\n /^The render was aborted by the server (?:without a reason|with a promise)\\.$/;\n\nexport function cancelledByConsumer(error: unknown): boolean {\n if (error instanceof DOMException) return error.name === \"AbortError\";\n\n const message =\n typeof error === \"object\" && error !== null && \"message\" in error\n ? String((error as { message: unknown }).message)\n : typeof error === \"string\"\n ? error\n : \"\";\n\n return CANCELLED.test(message);\n}\n"]}
@@ -1,8 +1,77 @@
1
1
  /**
2
2
  * Serialize form state into FormData for a server action.
3
3
  *
4
- * The contract between a form and the action it calls — booleans become "1"/"0" so PHP sees something truthy, arrays repeat under
5
- * `key[]`, Files pass through untouched for native uploads, and null/undefined
6
- * are dropped rather than sent as the string "null".
4
+ * The contract between a form and the action it calls: booleans become "1"/"0"
5
+ * so PHP sees something truthy, Files pass through untouched for native
6
+ * uploads, null/undefined are dropped rather than sent as the string "null",
7
+ * and a nested value nests - `{ items: [{ name }] }` is `items[0][name]`,
8
+ * `{ address: { city } }` is `address[city]`, a list of scalars repeats under
9
+ * `key[]`. Every one of those decodes back to what was given.
7
10
  */
8
11
  export declare function buildFormData(data: Record<string, unknown>): FormData;
12
+ /**
13
+ * A FormData as the object a schema expects.
14
+ *
15
+ * Four things beyond copying entries across, and each of them was a bug or a
16
+ * gap someone would meet on their first non-trivial form:
17
+ *
18
+ * A repeated name is an array. Three checkboxes sharing a name, a multiple
19
+ * select, a list of tags - this used to keep the LAST one and drop the rest
20
+ * silently, so a schema validated an object the person had not submitted.
21
+ *
22
+ * A name ending in `[]` is always an array, even with one value selected.
23
+ * Otherwise a list of checkboxes is a string when one is ticked and an array
24
+ * when two are, and no schema can describe both. It is also what `useForm`
25
+ * writes when it serialises an array, so the two round-trip.
26
+ *
27
+ * Nested names nest. `address.city` and `items[0].name` build the object they
28
+ * describe, which is the shape the schema was written against - and the shape
29
+ * whose validation errors come back keyed the same way, because Standard
30
+ * Schema issue paths are joined with dots too.
31
+ *
32
+ * Files are kept. They were dropped for being non-strings, which meant a schema
33
+ * checking an upload was handed undefined and refused a file that was there.
34
+ */
35
+ export declare function formDataToObject<T extends Record<string, unknown> = Record<string, unknown>>(formData: FormData): T;
36
+ type JsonSchema = {
37
+ type?: string | string[];
38
+ properties?: Record<string, JsonSchema>;
39
+ items?: JsonSchema | JsonSchema[];
40
+ required?: string[];
41
+ anyOf?: JsonSchema[];
42
+ oneOf?: JsonSchema[];
43
+ const?: unknown;
44
+ enum?: unknown[];
45
+ };
46
+ /**
47
+ * A form's values as the types its schema means.
48
+ *
49
+ * Everything a form posts is a string or a file, and a control that is off
50
+ * is not there at all - so a schema written for the shape it wants, `boolean`
51
+ * or `number`, refused a form that was perfectly filled in, and the guide
52
+ * asked for `z.enum(['on', '1']).optional().transform(...)` per checkbox. The
53
+ * schema knows what it means; this reads the form the way the schema does:
54
+ *
55
+ * - a `boolean` that is absent is `false` (an unchecked box posts nothing);
56
+ * `"on"`, `"1"`, `"true"` are true, `"0"`, `"off"`, `"false"` are false
57
+ * - a `number` or `integer` given as a numeric string is the number; an
58
+ * empty string for one that is not required is absent
59
+ * - an `array` given one value is that value in a list, and one given
60
+ * nothing - no checkbox ticked - is the empty list
61
+ * - an object nests, a list of objects nests per item, and a union takes the
62
+ * branch its discriminator names
63
+ *
64
+ * Nothing else is touched: a string stays the string it was, a file stays a
65
+ * file, and a value the schema does not describe is passed through as posted.
66
+ */
67
+ export declare function coerceToSchema(value: unknown, schema: JsonSchema | null | undefined): unknown;
68
+ /**
69
+ * A FormData as the object a schema expects, typed the way the schema means.
70
+ *
71
+ * What `<Form>` validates and what an action decodes: the same call, so a
72
+ * form that passes in the browser passes on the server. Without a schema, or
73
+ * with one that cannot describe itself, the values are the strings the form
74
+ * posted.
75
+ */
76
+ export declare function decodeFormData<T extends Record<string, unknown> = Record<string, unknown>>(formData: FormData, schema?: unknown): T;
77
+ export {};
@@ -1,37 +1,301 @@
1
- // Turning an object back into FormData.
1
+ // Turning an object into FormData, and FormData back into an object.
2
2
  //
3
3
  // The one place the encoding decisions live, so a value sent through `<Form>`'s
4
- // `transform` is encoded exactly as one sent any other way. It used to exist
5
- // twice — here and inline in Form.tsx — which is two places for a boolean to
6
- // start meaning something different.
4
+ // `transform` is encoded exactly as one sent any other way, and - the part
5
+ // that mattered - the object an ACTION decodes is the same object the FORM
6
+ // validated. The decoder lived in Form.tsx and understood nested names;
7
+ // the action had a flat one of its own. A form with `items[0].name` passed
8
+ // validation in the browser and failed the same schema on the server, and
9
+ // nothing said why. Both sides call this now.
7
10
  /**
8
11
  * Serialize form state into FormData for a server action.
9
12
  *
10
- * The contract between a form and the action it calls — booleans become "1"/"0" so PHP sees something truthy, arrays repeat under
11
- * `key[]`, Files pass through untouched for native uploads, and null/undefined
12
- * are dropped rather than sent as the string "null".
13
+ * The contract between a form and the action it calls: booleans become "1"/"0"
14
+ * so PHP sees something truthy, Files pass through untouched for native
15
+ * uploads, null/undefined are dropped rather than sent as the string "null",
16
+ * and a nested value nests - `{ items: [{ name }] }` is `items[0][name]`,
17
+ * `{ address: { city } }` is `address[city]`, a list of scalars repeats under
18
+ * `key[]`. Every one of those decodes back to what was given.
13
19
  */
14
20
  export function buildFormData(data) {
15
21
  const formData = new FormData();
16
- for (const [key, val] of Object.entries(data)) {
17
- if (val === null || val === undefined) {
22
+ for (const [key, val] of Object.entries(data))
23
+ appendValue(formData, key, val);
24
+ return formData;
25
+ }
26
+ function appendValue(formData, key, val) {
27
+ if (val === null || val === undefined)
28
+ return;
29
+ if (val instanceof File || val instanceof Blob) {
30
+ formData.append(key, val);
31
+ }
32
+ else if (typeof val === "boolean") {
33
+ formData.append(key, val ? "1" : "0");
34
+ }
35
+ else if (Array.isArray(val)) {
36
+ // Scalars repeat under key[]; objects and nested lists take an index, so
37
+ // each element's fields stay together on the way back.
38
+ val.forEach((item, i) => {
39
+ if (item !== null && typeof item === "object" && !(item instanceof File) && !(item instanceof Blob)) {
40
+ appendValue(formData, `${key}[${i}]`, item);
41
+ }
42
+ else {
43
+ appendValue(formData, `${key}[]`, item);
44
+ }
45
+ });
46
+ }
47
+ else if (typeof val === "object" && !(val instanceof Date)) {
48
+ for (const [prop, inner] of Object.entries(val)) {
49
+ appendValue(formData, `${key}[${prop}]`, inner);
50
+ }
51
+ }
52
+ else if (val instanceof Date) {
53
+ formData.append(key, val.toISOString());
54
+ }
55
+ else {
56
+ formData.append(key, String(val));
57
+ }
58
+ }
59
+ /**
60
+ * The pieces of a field name: `items[0].name` is items, 0, name.
61
+ *
62
+ * Both spellings, because both are in use and a form should not care which one
63
+ * a person reached for: `items[0].name` and `items[0][name]` are the same
64
+ * field. A trailing `[]` is a piece of its own - see below.
65
+ */
66
+ function pathOf(name) {
67
+ return name
68
+ .replace(/\[(\w*)\]/g, ".$1")
69
+ .split(".")
70
+ .filter((piece, index, all) => piece !== "" || index === all.length - 1);
71
+ }
72
+ /** Whether a piece names an array index rather than a property. */
73
+ const isIndex = (piece) => /^\d+$/.test(piece);
74
+ /**
75
+ * Put one value at one path, making the containers it passes through.
76
+ *
77
+ * Whether a container is an array or an object is decided by the NEXT piece, so
78
+ * `items[0].name` makes an array holding an object without being told which is
79
+ * which.
80
+ */
81
+ function place(root, path, value) {
82
+ let node = root;
83
+ for (let i = 0; i < path.length - 1; i++) {
84
+ const key = path[i];
85
+ const container = node;
86
+ if (container[key] === undefined || typeof container[key] !== "object") {
87
+ // An index makes an array, and so does the empty piece a trailing `[]`
88
+ // leaves - `tags[]` has to reach an array to be pushed into, and building
89
+ // an object there is how this first went wrong.
90
+ const next = path[i + 1];
91
+ container[key] = isIndex(next) || next === "" ? [] : {};
92
+ }
93
+ node = container[key];
94
+ }
95
+ const last = path[path.length - 1];
96
+ // The empty piece a trailing `[]` leaves: push rather than assign, so
97
+ // `tags[]` twice is two entries rather than one overwriting the other.
98
+ if (last === "")
99
+ node.push(value);
100
+ else
101
+ node[last] = value;
102
+ }
103
+ /**
104
+ * A FormData as the object a schema expects.
105
+ *
106
+ * Four things beyond copying entries across, and each of them was a bug or a
107
+ * gap someone would meet on their first non-trivial form:
108
+ *
109
+ * A repeated name is an array. Three checkboxes sharing a name, a multiple
110
+ * select, a list of tags - this used to keep the LAST one and drop the rest
111
+ * silently, so a schema validated an object the person had not submitted.
112
+ *
113
+ * A name ending in `[]` is always an array, even with one value selected.
114
+ * Otherwise a list of checkboxes is a string when one is ticked and an array
115
+ * when two are, and no schema can describe both. It is also what `useForm`
116
+ * writes when it serialises an array, so the two round-trip.
117
+ *
118
+ * Nested names nest. `address.city` and `items[0].name` build the object they
119
+ * describe, which is the shape the schema was written against - and the shape
120
+ * whose validation errors come back keyed the same way, because Standard
121
+ * Schema issue paths are joined with dots too.
122
+ *
123
+ * Files are kept. They were dropped for being non-strings, which meant a schema
124
+ * checking an upload was handed undefined and refused a file that was there.
125
+ */
126
+ export function formDataToObject(formData) {
127
+ const obj = {};
128
+ for (const name of new Set(formData.keys())) {
129
+ const all = formData.getAll(name);
130
+ const path = pathOf(name);
131
+ // A plain name used more than once is the array case, and it has no
132
+ // brackets to say so - `tags` twice is `['a', 'b']`.
133
+ if (path.length === 1 && path[0] !== "" && all.length > 1) {
134
+ obj[path[0]] = all;
18
135
  continue;
19
136
  }
20
- if (val instanceof File) {
21
- formData.append(key, val);
137
+ for (const value of all)
138
+ place(obj, path, value);
139
+ }
140
+ return obj;
141
+ }
142
+ /**
143
+ * What a schema is asked for. A leaf JSON Schema cannot say - a Date, a
144
+ * Map, a custom check - is `{}` rather than a refusal of the whole schema:
145
+ * Zod reads `unrepresentable` from here, and a form whose schema has one
146
+ * `z.date()` beside twenty fields still reads the twenty the way they mean.
147
+ * A library that does not know the option ignores it.
148
+ */
149
+ const JSON_SCHEMA_OPTIONS = { target: "draft-2020-12", libraryOptions: { unrepresentable: "any" } };
150
+ const jsonSchemas = new WeakMap();
151
+ /**
152
+ * The JSON Schema a Standard Schema describes itself with, if it can.
153
+ *
154
+ * Zod 4 and ArkType can; Valibot needs its separate converter and answers
155
+ * nothing here, in which case the form's values are handed to the schema as
156
+ * posted and the schema has to read strings.
157
+ */
158
+ function jsonSchemaOf(schema) {
159
+ if (schema === null || typeof schema !== "object")
160
+ return null;
161
+ const cached = jsonSchemas.get(schema);
162
+ if (cached !== undefined)
163
+ return cached;
164
+ let json = null;
165
+ const produce = schema["~standard"]?.jsonSchema?.input;
166
+ try {
167
+ if (typeof produce === "function") {
168
+ json = produce(JSON_SCHEMA_OPTIONS);
169
+ }
170
+ }
171
+ catch (error) {
172
+ json = null;
173
+ // A schema that has the method and still refused: every field of this
174
+ // form now arrives as the string it was posted, and a z.boolean() in it
175
+ // fails on "on" - which looks like the form's fault. Said once, in
176
+ // development, with the library's own reason.
177
+ if (typeof produce === "function" && import.meta.env?.DEV) {
178
+ console.warn("[rsc-kit] this form's schema cannot describe itself as JSON Schema, so its values are " +
179
+ "validated as the strings the form posted: " +
180
+ (error instanceof Error ? error.message : String(error)));
22
181
  }
23
- else if (typeof val === "boolean") {
24
- formData.append(key, val ? "1" : "0");
182
+ }
183
+ jsonSchemas.set(schema, json);
184
+ return json;
185
+ }
186
+ const hasType = (schema, type) => Array.isArray(schema.type) ? schema.type.includes(type) : schema.type === type;
187
+ /**
188
+ * Which branch of a union the value is for.
189
+ *
190
+ * A discriminated union says so with a `const` on the discriminator, which
191
+ * is what a form posts as a string and can be matched before coercion.
192
+ * Otherwise the first branch whose type the value could be.
193
+ */
194
+ function branchFor(branches, value) {
195
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
196
+ const record = value;
197
+ const byDiscriminator = branches.find((branch) => Object.entries(branch.properties ?? {}).some(([key, prop]) => prop.const !== undefined && record[key] === String(prop.const)));
198
+ if (byDiscriminator)
199
+ return byDiscriminator;
200
+ }
201
+ return (branches.find((branch) => typeof value === "string"
202
+ ? hasType(branch, "string") || hasType(branch, "number") || hasType(branch, "integer") || hasType(branch, "boolean")
203
+ : Array.isArray(value)
204
+ ? hasType(branch, "array")
205
+ : hasType(branch, "object")) ?? null);
206
+ }
207
+ /**
208
+ * A form's values as the types its schema means.
209
+ *
210
+ * Everything a form posts is a string or a file, and a control that is off
211
+ * is not there at all - so a schema written for the shape it wants, `boolean`
212
+ * or `number`, refused a form that was perfectly filled in, and the guide
213
+ * asked for `z.enum(['on', '1']).optional().transform(...)` per checkbox. The
214
+ * schema knows what it means; this reads the form the way the schema does:
215
+ *
216
+ * - a `boolean` that is absent is `false` (an unchecked box posts nothing);
217
+ * `"on"`, `"1"`, `"true"` are true, `"0"`, `"off"`, `"false"` are false
218
+ * - a `number` or `integer` given as a numeric string is the number; an
219
+ * empty string for one that is not required is absent
220
+ * - an `array` given one value is that value in a list, and one given
221
+ * nothing - no checkbox ticked - is the empty list
222
+ * - an object nests, a list of objects nests per item, and a union takes the
223
+ * branch its discriminator names
224
+ *
225
+ * Nothing else is touched: a string stays the string it was, a file stays a
226
+ * file, and a value the schema does not describe is passed through as posted.
227
+ */
228
+ export function coerceToSchema(value, schema) {
229
+ if (!schema)
230
+ return value;
231
+ const branches = schema.anyOf ?? schema.oneOf;
232
+ if (branches) {
233
+ const branch = branchFor(branches, value);
234
+ return branch ? coerceToSchema(value, branch) : value;
235
+ }
236
+ if (hasType(schema, "boolean")) {
237
+ if (value === undefined)
238
+ return false;
239
+ if (typeof value === "string") {
240
+ const v = value.trim().toLowerCase();
241
+ if (v === "on" || v === "1" || v === "true")
242
+ return true;
243
+ if (v === "" || v === "0" || v === "off" || v === "false")
244
+ return false;
25
245
  }
26
- else if (Array.isArray(val)) {
27
- for (const item of val) {
28
- formData.append(`${key}[]`, String(item));
29
- }
246
+ return value;
247
+ }
248
+ if (hasType(schema, "number") || hasType(schema, "integer")) {
249
+ if (typeof value === "string") {
250
+ const v = value.trim();
251
+ if (v === "")
252
+ return undefined;
253
+ if (/^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i.test(v))
254
+ return Number(v);
30
255
  }
31
- else {
32
- formData.append(key, String(val));
256
+ return value;
257
+ }
258
+ if (hasType(schema, "array")) {
259
+ // A list with nothing in it posts nothing at all: no checkbox ticked, a
260
+ // list the person emptied. That is an empty list, not a missing field.
261
+ if (value === undefined)
262
+ return [];
263
+ const list = Array.isArray(value) ? value : [value];
264
+ const items = Array.isArray(schema.items) ? null : schema.items;
265
+ return items ? list.map((item) => coerceToSchema(item, items)) : list;
266
+ }
267
+ if (hasType(schema, "object") || schema.properties) {
268
+ if (value === null || typeof value !== "object" || Array.isArray(value))
269
+ return value;
270
+ const record = { ...value };
271
+ const required = schema.required ?? [];
272
+ for (const [key, prop] of Object.entries(schema.properties ?? {})) {
273
+ // A control left blank posts "", and a field the schema does not
274
+ // require is then absent, whatever its type: z.email().optional()
275
+ // would refuse "" and the person typed nothing; a union with a
276
+ // boolean branch would read "" as false and write a value nobody
277
+ // chose. Decided before coercion, so no branch gets to. A required
278
+ // field keeps its "" so the schema can say it is required.
279
+ const coerced = record[key] === "" && !required.includes(key) ? undefined : coerceToSchema(record[key], prop);
280
+ if (coerced === undefined)
281
+ delete record[key];
282
+ else
283
+ record[key] = coerced;
33
284
  }
285
+ return record;
34
286
  }
35
- return formData;
287
+ return value;
288
+ }
289
+ /**
290
+ * A FormData as the object a schema expects, typed the way the schema means.
291
+ *
292
+ * What `<Form>` validates and what an action decodes: the same call, so a
293
+ * form that passes in the browser passes on the server. Without a schema, or
294
+ * with one that cannot describe itself, the values are the strings the form
295
+ * posted.
296
+ */
297
+ export function decodeFormData(formData, schema) {
298
+ const values = formDataToObject(formData);
299
+ return coerceToSchema(values, jsonSchemaOf(schema));
36
300
  }
37
301
  //# sourceMappingURL=formEncoding.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"formEncoding.js","sourceRoot":"","sources":["../../src/js/formEncoding.ts"],"names":[],"mappings":"AAAA,wCAAwC;AACxC,EAAE;AACF,gFAAgF;AAChF,6EAA6E;AAC7E,6EAA6E;AAC7E,qCAAqC;AAErC;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,IAA6B;IACzD,MAAM,QAAQ,GAAG,IAAI,QAAQ,EAAE,CAAC;IAEhC,KAAK,MAAM,CAAC,GAAG,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAC9C,IAAI,GAAG,KAAK,IAAI,IAAI,GAAG,KAAK,SAAS,EAAE,CAAC;YACtC,SAAS;QACX,CAAC;QACD,IAAI,GAAG,YAAY,IAAI,EAAE,CAAC;YACxB,QAAQ,CAAC,MAAM,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QAC5B,CAAC;aAAM,IAAI,OAAO,GAAG,KAAK,SAAS,EAAE,CAAC;YACpC,QAAQ,CAAC,MAAM,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;QACxC,CAAC;aAAM,IAAI,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;YAC9B,KAAK,MAAM,IAAI,IAAI,GAAG,EAAE,CAAC;gBACvB,QAAQ,CAAC,MAAM,CAAC,GAAG,GAAG,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;YAC5C,CAAC;QACH,CAAC;aAAM,CAAC;YACN,QAAQ,CAAC,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;QACpC,CAAC;IACH,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC","sourcesContent":["// Turning an object back into FormData.\n//\n// The one place the encoding decisions live, so a value sent through `<Form>`'s\n// `transform` is encoded exactly as one sent any other way. It used to exist\n// twice — here and inline in Form.tsx — which is two places for a boolean to\n// start meaning something different.\n\n/**\n * Serialize form state into FormData for a server action.\n *\n * The contract between a form and the action it calls — booleans become \"1\"/\"0\" so PHP sees something truthy, arrays repeat under\n * `key[]`, Files pass through untouched for native uploads, and null/undefined\n * are dropped rather than sent as the string \"null\".\n */\nexport function buildFormData(data: Record<string, unknown>): FormData {\n const formData = new FormData();\n\n for (const [key, val] of Object.entries(data)) {\n if (val === null || val === undefined) {\n continue;\n }\n if (val instanceof File) {\n formData.append(key, val);\n } else if (typeof val === \"boolean\") {\n formData.append(key, val ? \"1\" : \"0\");\n } else if (Array.isArray(val)) {\n for (const item of val) {\n formData.append(`${key}[]`, String(item));\n }\n } else {\n formData.append(key, String(val));\n }\n }\n\n return formData;\n}\n"]}
1
+ {"version":3,"file":"formEncoding.js","sourceRoot":"","sources":["../../src/js/formEncoding.ts"],"names":[],"mappings":"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","sourcesContent":["// Turning an object into FormData, and FormData back into an object.\n//\n// The one place the encoding decisions live, so a value sent through `<Form>`'s\n// `transform` is encoded exactly as one sent any other way, and - the part\n// that mattered - the object an ACTION decodes is the same object the FORM\n// validated. The decoder lived in Form.tsx and understood nested names;\n// the action had a flat one of its own. A form with `items[0].name` passed\n// validation in the browser and failed the same schema on the server, and\n// nothing said why. Both sides call this now.\n\n/**\n * Serialize form state into FormData for a server action.\n *\n * The contract between a form and the action it calls: booleans become \"1\"/\"0\"\n * so PHP sees something truthy, Files pass through untouched for native\n * uploads, null/undefined are dropped rather than sent as the string \"null\",\n * and a nested value nests - `{ items: [{ name }] }` is `items[0][name]`,\n * `{ address: { city } }` is `address[city]`, a list of scalars repeats under\n * `key[]`. Every one of those decodes back to what was given.\n */\nexport function buildFormData(data: Record<string, unknown>): FormData {\n const formData = new FormData();\n\n for (const [key, val] of Object.entries(data)) appendValue(formData, key, val);\n\n return formData;\n}\n\nfunction appendValue(formData: FormData, key: string, val: unknown): void {\n if (val === null || val === undefined) return;\n\n if (val instanceof File || val instanceof Blob) {\n formData.append(key, val);\n } else if (typeof val === \"boolean\") {\n formData.append(key, val ? \"1\" : \"0\");\n } else if (Array.isArray(val)) {\n // Scalars repeat under key[]; objects and nested lists take an index, so\n // each element's fields stay together on the way back.\n val.forEach((item, i) => {\n if (item !== null && typeof item === \"object\" && !(item instanceof File) && !(item instanceof Blob)) {\n appendValue(formData, `${key}[${i}]`, item);\n } else {\n appendValue(formData, `${key}[]`, item);\n }\n });\n } else if (typeof val === \"object\" && !(val instanceof Date)) {\n for (const [prop, inner] of Object.entries(val as Record<string, unknown>)) {\n appendValue(formData, `${key}[${prop}]`, inner);\n }\n } else if (val instanceof Date) {\n formData.append(key, val.toISOString());\n } else {\n formData.append(key, String(val));\n }\n}\n\n/**\n * The pieces of a field name: `items[0].name` is items, 0, name.\n *\n * Both spellings, because both are in use and a form should not care which one\n * a person reached for: `items[0].name` and `items[0][name]` are the same\n * field. A trailing `[]` is a piece of its own - see below.\n */\nfunction pathOf(name: string): string[] {\n return name\n .replace(/\\[(\\w*)\\]/g, \".$1\")\n .split(\".\")\n .filter((piece, index, all) => piece !== \"\" || index === all.length - 1);\n}\n\n/** Whether a piece names an array index rather than a property. */\nconst isIndex = (piece: string): boolean => /^\\d+$/.test(piece);\n\n/**\n * Put one value at one path, making the containers it passes through.\n *\n * Whether a container is an array or an object is decided by the NEXT piece, so\n * `items[0].name` makes an array holding an object without being told which is\n * which.\n */\nfunction place(\n root: Record<string, unknown>,\n path: string[],\n value: unknown,\n): void {\n let node: Record<string, unknown> | unknown[] = root;\n\n for (let i = 0; i < path.length - 1; i++) {\n const key = path[i];\n const container = node as Record<string, unknown>;\n\n if (container[key] === undefined || typeof container[key] !== \"object\") {\n // An index makes an array, and so does the empty piece a trailing `[]`\n // leaves - `tags[]` has to reach an array to be pushed into, and building\n // an object there is how this first went wrong.\n const next = path[i + 1];\n\n container[key] = isIndex(next) || next === \"\" ? [] : {};\n }\n\n node = container[key] as Record<string, unknown> | unknown[];\n }\n\n const last = path[path.length - 1];\n\n // The empty piece a trailing `[]` leaves: push rather than assign, so\n // `tags[]` twice is two entries rather than one overwriting the other.\n if (last === \"\") (node as unknown[]).push(value);\n else (node as Record<string, unknown>)[last] = value;\n}\n\n/**\n * A FormData as the object a schema expects.\n *\n * Four things beyond copying entries across, and each of them was a bug or a\n * gap someone would meet on their first non-trivial form:\n *\n * A repeated name is an array. Three checkboxes sharing a name, a multiple\n * select, a list of tags - this used to keep the LAST one and drop the rest\n * silently, so a schema validated an object the person had not submitted.\n *\n * A name ending in `[]` is always an array, even with one value selected.\n * Otherwise a list of checkboxes is a string when one is ticked and an array\n * when two are, and no schema can describe both. It is also what `useForm`\n * writes when it serialises an array, so the two round-trip.\n *\n * Nested names nest. `address.city` and `items[0].name` build the object they\n * describe, which is the shape the schema was written against - and the shape\n * whose validation errors come back keyed the same way, because Standard\n * Schema issue paths are joined with dots too.\n *\n * Files are kept. They were dropped for being non-strings, which meant a schema\n * checking an upload was handed undefined and refused a file that was there.\n */\nexport function formDataToObject<T extends Record<string, unknown> = Record<string, unknown>>(\n formData: FormData,\n): T {\n const obj: Record<string, unknown> = {};\n\n for (const name of new Set(formData.keys())) {\n const all = formData.getAll(name);\n const path = pathOf(name);\n\n // A plain name used more than once is the array case, and it has no\n // brackets to say so - `tags` twice is `['a', 'b']`.\n if (path.length === 1 && path[0] !== \"\" && all.length > 1) {\n obj[path[0]] = all;\n continue;\n }\n\n for (const value of all) place(obj, path, value);\n }\n\n return obj as T;\n}\n\n// ── Coercing what a form posts to what a schema means ─────────────────────\n\ntype JsonSchema = {\n type?: string | string[];\n properties?: Record<string, JsonSchema>;\n items?: JsonSchema | JsonSchema[];\n required?: string[];\n anyOf?: JsonSchema[];\n oneOf?: JsonSchema[];\n const?: unknown;\n enum?: unknown[];\n};\n\ntype WithJsonSchema = {\n \"~standard\"?: {\n jsonSchema?: {\n input?: (options: { target: string; libraryOptions?: Record<string, unknown> }) => unknown;\n };\n };\n};\n\n/**\n * What a schema is asked for. A leaf JSON Schema cannot say - a Date, a\n * Map, a custom check - is `{}` rather than a refusal of the whole schema:\n * Zod reads `unrepresentable` from here, and a form whose schema has one\n * `z.date()` beside twenty fields still reads the twenty the way they mean.\n * A library that does not know the option ignores it.\n */\nconst JSON_SCHEMA_OPTIONS = { target: \"draft-2020-12\", libraryOptions: { unrepresentable: \"any\" } };\n\nconst jsonSchemas = new WeakMap<object, JsonSchema | null>();\n\n/**\n * The JSON Schema a Standard Schema describes itself with, if it can.\n *\n * Zod 4 and ArkType can; Valibot needs its separate converter and answers\n * nothing here, in which case the form's values are handed to the schema as\n * posted and the schema has to read strings.\n */\nfunction jsonSchemaOf(schema: unknown): JsonSchema | null {\n if (schema === null || typeof schema !== \"object\") return null;\n\n const cached = jsonSchemas.get(schema);\n\n if (cached !== undefined) return cached;\n\n let json: JsonSchema | null = null;\n\n const produce = (schema as WithJsonSchema)[\"~standard\"]?.jsonSchema?.input;\n\n try {\n if (typeof produce === \"function\") {\n json = produce(JSON_SCHEMA_OPTIONS) as JsonSchema;\n }\n } catch (error) {\n json = null;\n\n // A schema that has the method and still refused: every field of this\n // form now arrives as the string it was posted, and a z.boolean() in it\n // fails on \"on\" - which looks like the form's fault. Said once, in\n // development, with the library's own reason.\n if (typeof produce === \"function\" && import.meta.env?.DEV) {\n console.warn(\n \"[rsc-kit] this form's schema cannot describe itself as JSON Schema, so its values are \" +\n \"validated as the strings the form posted: \" +\n (error instanceof Error ? error.message : String(error)),\n );\n }\n }\n\n jsonSchemas.set(schema, json);\n\n return json;\n}\n\nconst hasType = (schema: JsonSchema, type: string): boolean =>\n Array.isArray(schema.type) ? schema.type.includes(type) : schema.type === type;\n\n/**\n * Which branch of a union the value is for.\n *\n * A discriminated union says so with a `const` on the discriminator, which\n * is what a form posts as a string and can be matched before coercion.\n * Otherwise the first branch whose type the value could be.\n */\nfunction branchFor(branches: JsonSchema[], value: unknown): JsonSchema | null {\n if (value !== null && typeof value === \"object\" && !Array.isArray(value)) {\n const record = value as Record<string, unknown>;\n const byDiscriminator = branches.find((branch) =>\n Object.entries(branch.properties ?? {}).some(\n ([key, prop]) => prop.const !== undefined && record[key] === String(prop.const),\n ),\n );\n\n if (byDiscriminator) return byDiscriminator;\n }\n\n return (\n branches.find((branch) =>\n typeof value === \"string\"\n ? hasType(branch, \"string\") || hasType(branch, \"number\") || hasType(branch, \"integer\") || hasType(branch, \"boolean\")\n : Array.isArray(value)\n ? hasType(branch, \"array\")\n : hasType(branch, \"object\"),\n ) ?? null\n );\n}\n\n/**\n * A form's values as the types its schema means.\n *\n * Everything a form posts is a string or a file, and a control that is off\n * is not there at all - so a schema written for the shape it wants, `boolean`\n * or `number`, refused a form that was perfectly filled in, and the guide\n * asked for `z.enum(['on', '1']).optional().transform(...)` per checkbox. The\n * schema knows what it means; this reads the form the way the schema does:\n *\n * - a `boolean` that is absent is `false` (an unchecked box posts nothing);\n * `\"on\"`, `\"1\"`, `\"true\"` are true, `\"0\"`, `\"off\"`, `\"false\"` are false\n * - a `number` or `integer` given as a numeric string is the number; an\n * empty string for one that is not required is absent\n * - an `array` given one value is that value in a list, and one given\n * nothing - no checkbox ticked - is the empty list\n * - an object nests, a list of objects nests per item, and a union takes the\n * branch its discriminator names\n *\n * Nothing else is touched: a string stays the string it was, a file stays a\n * file, and a value the schema does not describe is passed through as posted.\n */\nexport function coerceToSchema(value: unknown, schema: JsonSchema | null | undefined): unknown {\n if (!schema) return value;\n\n const branches = schema.anyOf ?? schema.oneOf;\n\n if (branches) {\n const branch = branchFor(branches, value);\n\n return branch ? coerceToSchema(value, branch) : value;\n }\n\n if (hasType(schema, \"boolean\")) {\n if (value === undefined) return false;\n if (typeof value === \"string\") {\n const v = value.trim().toLowerCase();\n\n if (v === \"on\" || v === \"1\" || v === \"true\") return true;\n if (v === \"\" || v === \"0\" || v === \"off\" || v === \"false\") return false;\n }\n\n return value;\n }\n\n if (hasType(schema, \"number\") || hasType(schema, \"integer\")) {\n if (typeof value === \"string\") {\n const v = value.trim();\n\n if (v === \"\") return undefined;\n if (/^[-+]?(\\d+\\.?\\d*|\\.\\d+)(e[-+]?\\d+)?$/i.test(v)) return Number(v);\n }\n\n return value;\n }\n\n if (hasType(schema, \"array\")) {\n // A list with nothing in it posts nothing at all: no checkbox ticked, a\n // list the person emptied. That is an empty list, not a missing field.\n if (value === undefined) return [];\n\n const list = Array.isArray(value) ? value : [value];\n const items = Array.isArray(schema.items) ? null : schema.items;\n\n return items ? list.map((item) => coerceToSchema(item, items)) : list;\n }\n\n if (hasType(schema, \"object\") || schema.properties) {\n if (value === null || typeof value !== \"object\" || Array.isArray(value)) return value;\n\n const record = { ...(value as Record<string, unknown>) };\n const required = schema.required ?? [];\n\n for (const [key, prop] of Object.entries(schema.properties ?? {})) {\n // A control left blank posts \"\", and a field the schema does not\n // require is then absent, whatever its type: z.email().optional()\n // would refuse \"\" and the person typed nothing; a union with a\n // boolean branch would read \"\" as false and write a value nobody\n // chose. Decided before coercion, so no branch gets to. A required\n // field keeps its \"\" so the schema can say it is required.\n const coerced =\n record[key] === \"\" && !required.includes(key) ? undefined : coerceToSchema(record[key], prop);\n\n if (coerced === undefined) delete record[key];\n else record[key] = coerced;\n }\n\n return record;\n }\n\n return value;\n}\n\n/**\n * A FormData as the object a schema expects, typed the way the schema means.\n *\n * What `<Form>` validates and what an action decodes: the same call, so a\n * form that passes in the browser passes on the server. Without a schema, or\n * with one that cannot describe itself, the values are the strings the form\n * posted.\n */\nexport function decodeFormData<T extends Record<string, unknown> = Record<string, unknown>>(\n formData: FormData,\n schema?: unknown,\n): T {\n const values = formDataToObject(formData);\n\n return coerceToSchema(values, jsonSchemaOf(schema)) as T;\n}\n"]}
@@ -46,6 +46,9 @@ export declare function setNavigateHandler(fn: (tree: ReactNode, key: string, se
46
46
  export declare function setRestoreHandler(fn: (key: string, maxAge?: number) => boolean): void;
47
47
  export declare function setDeserializer(fn: Deserializer): void;
48
48
  export declare function setCallServer(fn: CallServerFn): void;
49
+ export declare function setApiRoutes(patterns: string[]): void;
50
+ /** Whether a url is one a route.ts answers. */
51
+ export declare function isApiRoute(url: string): boolean;
49
52
  export declare function setInterceptManifest(entries: InterceptEntry[]): void;
50
53
  export declare function renderTree(tree: ReactNode): void;
51
54
  /**
@@ -201,6 +201,33 @@ export function setDeserializer(fn) {
201
201
  export function setCallServer(fn) {
202
202
  callServerFn = fn;
203
203
  }
204
+ /**
205
+ * The urls a route.ts answers, so a link to one is treated as the anchor it
206
+ * is: no prefetch - a hover must not run a route - and a full navigation
207
+ * rather than a payload fetch, since the answer is a Response, not a page.
208
+ * Baked into the generated browser entry from the route tree.
209
+ */
210
+ let apiRoutePatterns = [];
211
+ export function setApiRoutes(patterns) {
212
+ apiRoutePatterns = patterns.map((pattern) => new RegExp("^" +
213
+ pattern
214
+ .split("/")
215
+ .map((part) => part.startsWith("[...") ? ".+" : part.startsWith("[") ? "[^/]+" : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
216
+ .join("/") +
217
+ "/?$"));
218
+ }
219
+ /** Whether a url is one a route.ts answers. */
220
+ export function isApiRoute(url) {
221
+ if (apiRoutePatterns.length === 0)
222
+ return false;
223
+ try {
224
+ const { pathname } = new URL(url, window.location.origin);
225
+ return apiRoutePatterns.some((pattern) => pattern.test(pathname));
226
+ }
227
+ catch {
228
+ return false;
229
+ }
230
+ }
204
231
  export function setInterceptManifest(entries) {
205
232
  interceptManifest = entries;
206
233
  }
@@ -406,6 +433,12 @@ export async function navigate(url, opts) {
406
433
  window.location.href = url;
407
434
  return;
408
435
  }
436
+ // A route.ts answers with a Response, not a page: a download, a redirect
437
+ // that decides where someone belongs, a sign-out. The browser goes there.
438
+ if (isApiRoute(url)) {
439
+ window.location.href = url;
440
+ return;
441
+ }
409
442
  // Hash-only URLs — let the browser handle scrolling natively
410
443
  if (url.startsWith("#")) {
411
444
  window.location.hash = url;
@@ -413,11 +446,13 @@ export async function navigate(url, opts) {
413
446
  }
414
447
  // Abort any in-flight navigation
415
448
  activeController?.abort();
416
- // If the initial HTML stream is still loading (Suspense completions streaming),
417
- // stop it so the single-threaded PHP server can handle the new request.
418
- if (document.readyState === "loading") {
419
- window.stop();
420
- }
449
+ // Not window.stop(). It used to be called here while the document was
450
+ // still loading, to free a single-threaded server for the new request -
451
+ // and it cancels every load the page has in flight: the client chunks a
452
+ // just-decoded tree still needs, the stylesheet, the boundaries still
453
+ // streaming. A navigation that left the page before the runtime had
454
+ // finished arriving rendered a document with nothing in it. The server
455
+ // has workers; the page keeps loading.
421
456
  const controller = new AbortController();
422
457
  activeController = controller;
423
458
  // Check if this URL matches an intercept pattern.
@@ -565,7 +600,19 @@ export async function navigate(url, opts) {
565
600
  }
566
601
  treePromise = deserializeResponse(response);
567
602
  }
568
- const tree = await treePromise;
603
+ let tree;
604
+ try {
605
+ tree = await treePromise;
606
+ }
607
+ catch (error) {
608
+ // A navigation another one overtook: its request was aborted, and
609
+ // the decoder reports that as a failure of the payload. It is not one
610
+ // - nothing of it was going to be shown - so it ends here, quietly,
611
+ // rather than as a rejection nobody is waiting on.
612
+ if (controller.signal.aborted)
613
+ return;
614
+ throw error;
615
+ }
569
616
  if (reused) {
570
617
  segmentDepth = reused.segmentDepth;
571
618
  nextLayouts = reused.layouts;
@@ -771,6 +818,9 @@ function restoreScroll(positions) {
771
818
  apply(1);
772
819
  }
773
820
  export function prefetch(url, cacheForMs) {
821
+ // Never a route.ts: fetching one runs it, and a hover is not a click.
822
+ if (isApiRoute(url))
823
+ return;
774
824
  if (isExternalUrl(url))
775
825
  return;
776
826
  const ttl = cacheForMs ?? DEFAULT_PREFETCH_TTL;