@uniflowed/router 0.0.0-alpha.14 → 0.0.0-alpha.16

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/action.js CHANGED
@@ -32,6 +32,61 @@
32
32
  // above the page — and it is not a substitute for the action authorizing
33
33
  // itself; `internal/action-endpoint.js` says why in the paragraph that matters.
34
34
  //
35
+ // # A form
36
+ //
37
+ // The same reference is what React 19's form APIs take, because a reference is
38
+ // an ordinary async function and that is all they ask for:
39
+ //
40
+ // // app/counter/_components/Note.js
41
+ // "use client";
42
+ // import { useActionState } from "@uniflowed/react";
43
+ // import { useFormStatus } from "react-dom";
44
+ // import { submitNote } from "../_actions/notes.js";
45
+ //
46
+ // component Save() {
47
+ // const { pending } = useFormStatus();
48
+ // return <button disabled={pending}>Save</button>;
49
+ // }
50
+ //
51
+ // component Note() {
52
+ // const [saved, save] = useActionState(submitNote, null);
53
+ // return (
54
+ // <form action={save}>
55
+ // <input name="note" />
56
+ // <Save />
57
+ // <output>{saved}</output>
58
+ // </form>
59
+ // );
60
+ // }
61
+ //
62
+ // `<form action={save}>` hands the reference a `FormData`, `useActionState`
63
+ // hands it the previous state and then the `FormData`, and both cross under
64
+ // the grammar in `internal/action-wire.js` — one form per call, beside the
65
+ // values rather than inside one, entries of strings and nothing else. Flow
66
+ // still reads `submitNote`'s own declaration, so a form action whose first
67
+ // parameter is not the state it was given `null` for is a `uf check` error.
68
+ //
69
+ // **This needs the page to be hydrated.** React's progressive enhancement —
70
+ // the form that submits before its JavaScript has arrived — works through a
71
+ // `$$FORM_ACTION` property that turns the submit into a *native* form post,
72
+ // and a native form post is `multipart/form-data`: the content type this
73
+ // endpoint refuses, deliberately, as one of the three things standing between
74
+ // it and a cross-site call. Supporting the pre-hydration submit would mean
75
+ // accepting that content type, so a reference carries no `$$FORM_ACTION`, and
76
+ // React writes the form it writes for any client action —
77
+ // `action="javascript:throw new Error('React form unexpectedly submitted.')"`
78
+ // — so a submit before hydration throws in the page rather than posting
79
+ // anywhere. Nothing reaches a server that was not meant to; what is missing is
80
+ // the submit working at all. See ubugeeei-prod/uf#252.
81
+ //
82
+ // The refusal is why the property is withheld, not what would stop it: no
83
+ // request is made, so nothing is refused. Nor would a native form post aimed
84
+ // at a page by hand be refused as multipart — `createActionDispatcher` reads
85
+ // the `uf-action` header before it looks at the method or the content type and
86
+ // returns `null` when it is absent, so the request is not an action call at
87
+ // all and falls through to the route handlers. The `415` answers a request
88
+ // that claims to be an action, which is the only kind that reaches it.
89
+ //
35
90
  // # What Flow checks, and where
36
91
  //
37
92
  // Flow reads `app/_actions/clicks.js`, not the reference the bundler
@@ -51,12 +106,13 @@
51
106
  import {
52
107
  ACTION_CONTENT_TYPE,
53
108
  ACTION_HEADER,
109
+ type ActionArgument,
54
110
  type ActionValue,
55
111
  decodeActionResult,
56
112
  encodeActionArguments,
57
113
  } from "./internal/action-wire.js";
58
114
 
59
- export type { ActionValue } from "./internal/action-wire.js";
115
+ export type { ActionArgument, ActionValue } from "./internal/action-wire.js";
60
116
  export {
61
117
  ACTION_CONTENT_TYPE,
62
118
  ACTION_HEADER,
@@ -65,17 +121,22 @@ export {
65
121
  MAX_ACTION_BODY_BYTES,
66
122
  MAX_ACTION_DEPTH,
67
123
  MAX_ACTION_VALUES,
124
+ MAX_FORM_ENTRIES,
125
+ MAX_FORM_NAME_LENGTH,
68
126
  } from "./internal/action-wire.js";
69
127
 
70
128
  /**
71
129
  * A function that can be a server action.
72
130
  *
73
- * Both halves of the signature are the wire grammar: an action is called with
74
- * values that can cross and answers with one that can. `void` is a result and
75
- * not an argument, because JSON has no `undefined` and an action declared to
76
- * take one would be taking something the caller cannot send.
131
+ * Both halves of the signature are the wire grammar, and they are not the same
132
+ * half: an action is called with values that can cross *or* the one form a
133
+ * submit produces, and answers with a value that can cross. `void` is a result
134
+ * and not an argument, because JSON has no `undefined` and an action declared
135
+ * to take one would be taking something the caller cannot send; a `FormData`
136
+ * is an argument and not a result, because a form is something a browser
137
+ * submits and not something a server answers with.
77
138
  */
78
- export type ServerActionFunction = (...args: Array<ActionValue>) => Promise<ActionValue | void>;
139
+ export type ServerActionFunction = (...args: Array<ActionArgument>) => Promise<ActionValue | void>;
79
140
 
80
141
  /**
81
142
  * An action's arguments, held against what a wire can carry.
@@ -95,8 +156,38 @@ export type ServerActionFunction = (...args: Array<ActionValue>) => Promise<Acti
95
156
  *
96
157
  * `ServerActionName` is every action's name, so `ServerActionArgs` of it is
97
158
  * every action's argument list, and one line checks all of them.
159
+ *
160
+ * The bound is [`ActionArgument`] and not [`ActionValue`], which is the whole
161
+ * of what makes `<form action={fn}>` type-check: a form action's parameter is
162
+ * a `FormData`, and a `FormData` crosses as an argument and only as one.
163
+ *
164
+ * # What this does not catch, and why
165
+ *
166
+ * The bound is element-wise, so it holds each argument against the grammar and
167
+ * says nothing about the list as a whole. The wire has one rule that is about
168
+ * the list: a call carries at most one form, because the envelope names the
169
+ * form's position once. So `(a: FormData, b: FormData)` type-checks here and
170
+ * throws `ActionValueError` at `encodeActionArguments` — a rule enforced at
171
+ * run time that the types ought to have caught.
172
+ *
173
+ * Saying it in the type needs a walk over the tuple, and the walk is blocked by
174
+ * ubugeeei-prod/uf#300: a spread in a conditional type's tuple pattern binds
175
+ * `infer` as `unknown` and the rest as the whole array widened. The obvious
176
+ * recursion is not merely rejected, it quietly answers wrongly —
177
+ *
178
+ * type NoForm<T> = T extends [] ? true
179
+ * : T extends [infer H, ...infer R]
180
+ * ? (H extends FormData ? false : NoForm<R>) : true;
181
+ *
182
+ * — gives `true` for `[string, FormData]`, because the spread pattern never
183
+ * matches and every tuple falls through to the last branch. A constraint built
184
+ * on that would be worse than none: it would report every signature as fine.
185
+ *
186
+ * `tests/type-tests/server-actions.js` pins the gap, so that whoever fixes
187
+ * #300 is told this is waiting on it. The run-time guard and its test are in
188
+ * `internal/action-wire.js` and `tests/library/server-actions.test.js`.
98
189
  */
99
- export type ActionArguments<TArgs extends $ReadOnlyArray<ActionValue>> = TArgs;
190
+ export type ActionArguments<TArgs extends $ReadOnlyArray<ActionArgument>> = TArgs;
100
191
 
101
192
  /**
102
193
  * An action's result, held against the same grammar.
@@ -144,9 +235,15 @@ export class ServerActionError extends Error {
144
235
  * The returned function is `async` and refuses before it sends: an argument
145
236
  * outside the wire grammar throws an `ActionValueError` naming the argument's
146
237
  * position, at the call site, rather than becoming a `400` with nothing in it.
238
+ *
239
+ * It carries no `$$FORM_ACTION`, which is deliberate and is the header's last
240
+ * section: React uses that property to make a form submit *natively* before
241
+ * hydration, and a native submit is a content type this endpoint refuses.
147
242
  */
148
243
  export function createServerReference(id: string, name: string): ServerActionFunction {
149
- return async function callServerAction(...args: Array<ActionValue>): Promise<ActionValue | void> {
244
+ return async function callServerAction(
245
+ ...args: Array<ActionArgument>
246
+ ): Promise<ActionValue | void> {
150
247
  const body = encodeActionArguments(args);
151
248
  // Built rather than written as a literal, because the header's name is a
152
249
  // constant and a computed key in an object literal is a shape Flow
package/client.js CHANGED
@@ -18,6 +18,37 @@
18
18
  // Development only, and dynamically imported so a production bundle has no path
19
19
  // to it. See ubugeeei-prod/uf#508.
20
20
  //
21
+ // # And whether React DevTools can see the page at all
22
+ //
23
+ // The other question only this module is in a position to ask.
24
+ // `@uniflowed/vite` installs the hook DevTools attaches through, above every
25
+ // module in the document; whether that worked *on this page* is a fact about a
26
+ // running browser, and the line after hydration is where it can be read.
27
+ // `internal/devtools.js` has the two findings and sends them to the same
28
+ // terminal the hydration report goes to. See ubugeeei-prod/uf#503.
29
+ //
30
+ // # Strict Mode, in development, by default
31
+ //
32
+ // `uf dev` generates `strictMode: true` into `virtual:uf/client` and `uf build`
33
+ // does not, so a development render is doubled and a visitor's is not. That is
34
+ // React's own check for the thing it cannot check any other way: a component
35
+ // whose render is not pure, and an effect whose cleanup does not undo its
36
+ // setup, both behave correctly until the one production render that interleaves
37
+ // with something — and Strict Mode makes them behave incorrectly at once, on
38
+ // the machine of the person writing them.
39
+ //
40
+ // The wrapper is the argument to `hydrateRoot` rather than something inside
41
+ // `<App>`, and that is load-bearing rather than tidy. React decides whether to
42
+ // double-invoke a mount's effects at the *topmost fiber it is placing*: if that
43
+ // fiber is not itself in Strict Mode, React stops there and never looks inside
44
+ // it. A `<StrictMode>` further down still doubles the renders under it — that
45
+ // comes from the fiber's own mode — and doubles no effect at all, so it would
46
+ // have bought the half of the check that is easy to notice and silently lost
47
+ // the half that finds the bug. It renders no element, so the hydrated tree is
48
+ // unchanged and the markup comparison above is unaffected.
49
+ // `app.react.strictMode: false` in `uf.config.js` turns it off. See
50
+ // ubugeeei-prod/uf#516.
51
+ //
21
52
  // # A route can decline to be hydrated
22
53
  //
23
54
  // uf's server-component analysis decides which routes have a `"use client"`
@@ -29,7 +60,7 @@
29
60
  // See ubugeeei-prod/uf#350.
30
61
 
31
62
  import * as React from "react";
32
- import { startTransition } from "react";
63
+ import { StrictMode, startTransition } from "react";
33
64
  import { hydrateRoot } from "react-dom/client";
34
65
 
35
66
  import {
@@ -54,6 +85,7 @@ export async function hydrate(options: {|
54
85
  readonly routes: RouteTable["routes"],
55
86
  readonly notFound: RouteTable["notFound"],
56
87
  readonly errors: RouteTable["errors"],
88
+ readonly strictMode?: boolean,
57
89
  |}): Promise<void> {
58
90
  const table: RouteTable = {
59
91
  routes: options.routes,
@@ -95,11 +127,28 @@ export async function hydrate(options: {|
95
127
  recovery = hydrationErrorHandler(container, captureServerMarkup(container), document);
96
128
  }
97
129
 
130
+ // `<StrictMode>` renders no element of its own, so the tree React hydrates
131
+ // against the server's markup is the same tree either way and the flag can
132
+ // be a development-only difference without being a hydration difference.
133
+ const tree = <App url={url} initial={resolved} />;
134
+
98
135
  startTransition(() => {
99
136
  hydrateRoot(
100
137
  container,
101
- <App url={url} initial={resolved} />,
138
+ options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
102
139
  recovery == null ? undefined : { onRecoverableError: recovery },
103
140
  );
104
141
  });
142
+
143
+ // And, in development only, whether the panel a developer is about to open
144
+ // can see any of that. `react-dom` announced itself while it was being
145
+ // imported — long before this line — so the answer is already settled and
146
+ // this only reads it. Behind the same `import.meta.hot` gate as the
147
+ // hydration reporter, dynamically imported for the same reason: a production
148
+ // bundle has no path to the module rather than merely no reason to run it.
149
+ // See `./internal/devtools.js` and ubugeeei-prod/uf#503.
150
+ if (import.meta.hot != null) {
151
+ const { reportDevtools } = await import("./internal/devtools.js");
152
+ reportDevtools(window);
153
+ }
105
154
  }
package/index.js CHANGED
@@ -56,6 +56,7 @@ export {
56
56
  RouteView,
57
57
  RouterProvider,
58
58
  UnauthorizedError,
59
+ buildRoute,
59
60
  forbidden,
60
61
  hasClientPage,
61
62
  matchRoute,
@@ -54,6 +54,18 @@
54
54
  // prototype keys, no constructor named by the payload. See that module's
55
55
  // header for what is excluded and why.
56
56
  //
57
+ // A submitted form is inside that boundary and does not widen it: `<form
58
+ // action={fn}>` reaches here as the same `application/json` body, with the
59
+ // form's entries beside the values under a `form` key, bounded in count and in
60
+ // field-name length and holding strings only. Nothing about it is multipart
61
+ // and nothing about it is a content type a cross-origin `<form>` could
62
+ // produce, so rule 4 is exactly as true of a form call as of any other. The
63
+ // cost of keeping it that way is written down where it is paid — a form that
64
+ // submits before its page has hydrated throws in the page rather than posting
65
+ // anywhere, because React writes `action="javascript:throw …"` for a form
66
+ // whose action carries no `$$FORM_ACTION`, and giving it one would mean
67
+ // accepting a native form post here.
68
+ //
57
69
  // **6. Nothing about a failure goes back.** An action that throws is a `500`
58
70
  // with a fixed body; the exception goes to the host's error reporting. A
59
71
  // message, a name or a stack in that response is an application's internals
@@ -81,7 +93,7 @@ import { asResponder } from "@uniflowed/server/host";
81
93
  import {
82
94
  ACTION_CONTENT_TYPE,
83
95
  ACTION_HEADER,
84
- type ActionValue,
96
+ type ActionArgument,
85
97
  ActionValueError,
86
98
  MAX_ACTION_BODY_BYTES,
87
99
  decodeActionArguments,
@@ -163,7 +175,7 @@ export function createActionDispatcher(options: {|
163
175
  return refusal(413);
164
176
  }
165
177
 
166
- let args: Array<ActionValue>;
178
+ let args: Array<ActionArgument>;
167
179
  try {
168
180
  args = decodeActionArguments(body);
169
181
  } catch (error) {
@@ -213,7 +225,7 @@ export function createActionDispatcher(options: {|
213
225
  return asResponder("a server action", async () => {
214
226
  let result: mixed;
215
227
  try {
216
- // The build-time contract says this is `async (...ActionValue) => …`
228
+ // The build-time contract says this is `async (...ActionArgument) => …`
217
229
  // (`ServerActionBoundary` in `../action.js`), and Flow cannot read that
218
230
  // through a module loaded by a thunk. The arguments are the ones
219
231
  // `decodeActionArguments` produced, so what is unchecked here is the
@@ -10,7 +10,7 @@
10
10
  //
11
11
  // # The boundary
12
12
  //
13
- // **A server action's arguments are plain JSON data and nothing else.**
13
+ // **A server action's arguments are plain JSON data, plus at most one form.**
14
14
  //
15
15
  // One JSON object, `{"args": [...]}`, at most `MAX_ACTION_BODY_BYTES` of valid
16
16
  // UTF-8, holding at most `MAX_ACTION_ARGUMENTS` values, nested at most
@@ -18,7 +18,8 @@
18
18
  // Each of those values is `null`, a boolean, a finite number, a string, an
19
19
  // array of them, or a plain object whose keys are ordinary strings. The result
20
20
  // travels back under exactly the same grammar, plus `undefined` for an action
21
- // that returns nothing.
21
+ // that returns nothing — and with no form, because a form is something a
22
+ // browser submits and not something a server answers with.
22
23
  //
23
24
  // Nothing in a payload can name a function, a module, a class, a prototype, a
24
25
  // React element, an id or a reference, and nothing in it is revived into an
@@ -27,6 +28,35 @@
27
28
  // `JSON.parse` would have let through — which is the whole of what a decoder
28
29
  // has to get right.
29
30
  //
31
+ // # The one thing that is not a value: `<form action={fn}>`
32
+ //
33
+ // React 19 hands a form action a `FormData`, and `useActionState` hands it the
34
+ // previous state and then a `FormData`. So one argument of a call may be a
35
+ // form, and the envelope grows a second key to say which:
36
+ //
37
+ // {"args": [null, null], "form": {"at": 1, "entries": [["note", "hi"]]}}
38
+ //
39
+ // It is written *beside* the values rather than inside one, and that placement
40
+ // is the whole design. A tag in the value tree — `{"$formData": …}` — would be
41
+ // a payload saying which constructor to call, which is the shape of every
42
+ // deserialisation CVE and the thing the list below refuses on principle.
43
+ // Outside the tree there is no tag: `checkActionValue` is unchanged and still
44
+ // knows nothing but JSON data, `at` names a position and not a type, and the
45
+ // slot it names must hold `null` so a sender cannot say two things about one
46
+ // argument. At most one form crosses per call, it is never nested inside a
47
+ // value, its entries are `[name, value]` pairs of strings, and the decoder's
48
+ // only constructor is `FormData` — fixed here, never named by the payload.
49
+ //
50
+ // What that buys is `<form action={fn}>`, `useActionState` and `useFormStatus`
51
+ // against a real endpoint. What it does not buy is a form that works before
52
+ // hydration: React's progressive enhancement needs `$$FORM_ACTION` on the
53
+ // reference, which makes the submit a *native* form post, and a native form
54
+ // post is `multipart/form-data` — a content type this endpoint refuses on
55
+ // purpose (see `./action-endpoint.js`, rule 4). Without it React writes the
56
+ // form it writes for any client action, whose `action` is a `javascript:` URL
57
+ // that throws, so such a submit does nothing rather than posting somewhere it
58
+ // should not. That is still open; see ubugeeei-prod/uf#252.
59
+ //
30
60
  // # What is deliberately absent, and why
31
61
  //
32
62
  // * **A reference format.** React's Flight payload can carry a reference to a
@@ -39,11 +69,11 @@
39
69
  // tag naming a constructor is the oracle every deserialisation CVE is made
40
70
  // of. An action that wants a date takes an ISO string and parses it, where
41
71
  // the parse is the application's and is checked.
42
- // * **`FormData` and `<form action={fn}>`.** Multipart parsing is its own
43
- // attack surface with its own bounds, and a form post is a *simple* CORS
44
- // request — it reaches a server with the visitor's cookies and no preflight.
45
- // Both are worth having and neither is worth having by accident; see the
46
- // security note in `./action-endpoint.js`.
72
+ // * **A file in a form.** A `File` entry is refused at the call site rather
73
+ // than encoded: bytes in this envelope would be base64 in a JSON string with
74
+ // no ceiling of their own, and an upload wants a content type, a streaming
75
+ // read and a size limit that are not this module's. Multipart parsing is its
76
+ // own attack surface and is still deliberately absent.
47
77
  // * **Cycles and shared references.** A value that refers to itself is
48
78
  // rejected rather than encoded, because the alternative is a marker in the
49
79
  // payload that says "this is the object you saw earlier", which is a
@@ -87,6 +117,26 @@ export const MAX_ACTION_DEPTH: number = 24;
87
117
  /** Most values, of any kind, one payload may hold. */
88
118
  export const MAX_ACTION_VALUES: number = 10000;
89
119
 
120
+ /**
121
+ * Most fields one submitted form may carry.
122
+ *
123
+ * A ceiling on the *count* rather than on the bytes, because the bytes already
124
+ * have one: the whole body is bounded by `MAX_ACTION_BODY_BYTES` before a
125
+ * character of it is parsed. What this bounds is the number of `append` calls
126
+ * a sender can make the decoder do, and the size of the multimap they build.
127
+ * A form with more than 256 controls is a form that wants a different shape.
128
+ */
129
+ export const MAX_FORM_ENTRIES: number = 256;
130
+
131
+ /**
132
+ * Longest field name one form entry may have.
133
+ *
134
+ * A name is an HTML `name` attribute — `email`, `items[3][quantity]` — so this
135
+ * is generous by two orders of magnitude for anything a document declares, and
136
+ * it stops a body's whole byte budget being spent on one key.
137
+ */
138
+ export const MAX_FORM_NAME_LENGTH: number = 128;
139
+
90
140
  /**
91
141
  * Everything that may cross the wire.
92
142
  *
@@ -103,6 +153,18 @@ export type ActionValue =
103
153
  | $ReadOnlyArray<ActionValue>
104
154
  | { readonly [string]: ActionValue };
105
155
 
156
+ /**
157
+ * Everything an *argument* may be: a value, or the one form.
158
+ *
159
+ * Wider than [`ActionValue`] in exactly one place and deliberately not
160
+ * recursive: a `FormData` is something a call passes, never something inside
161
+ * something a call passes. `ActionArguments` in `../action.js` holds every
162
+ * action's parameter list against this, and `ActionResult` still holds every
163
+ * return type against `ActionValue` — an action receives a form and does not
164
+ * answer with one.
165
+ */
166
+ export type ActionArgument = ActionValue | FormData;
167
+
106
168
  /**
107
169
  * A value that is outside the grammar, and where in the payload it was.
108
170
  *
@@ -158,6 +220,28 @@ function isPlainObject(value: interface {}): boolean {
158
220
  */
159
221
  const PLAIN_PROTOTYPE: mixed = Object.getPrototypeOf({});
160
222
 
223
+ /**
224
+ * The value as a submitted form, or `null`.
225
+ *
226
+ * `typeof` first, because this module is imported by the browser's half and by
227
+ * the server's, and `FormData` is a global that a runtime is allowed not to
228
+ * have. `instanceof` and not a duck-type, for the reason `isPlainObject` gives
229
+ * about prototypes: an object with `entries` and `get` is not a form.
230
+ *
231
+ * It answers with the form rather than with a `boolean` so that the caller
232
+ * that goes on to read the entries has the type from the check rather than
233
+ * from a cast. A Flow type guard would be the direct spelling and is not
234
+ * available here: a guard has to refine the type away on the false branch too,
235
+ * and "this runtime has no `FormData` at all" is a false branch that says
236
+ * nothing about the value.
237
+ */
238
+ function asFormData(value: mixed): FormData | null {
239
+ if (typeof FormData === "undefined" || !(value instanceof FormData)) {
240
+ return null;
241
+ }
242
+ return value;
243
+ }
244
+
161
245
  /** One entry of the explicit walk stack. */
162
246
  type Pending = {| readonly value: mixed, readonly path: string, readonly depth: number |};
163
247
 
@@ -229,6 +313,18 @@ export function checkActionValue(root: mixed, label: string): void {
229
313
  }
230
314
 
231
315
  if (!isPlainObject(object)) {
316
+ // A form gets its own sentence. It is the one prototype this grammar
317
+ // does carry, just not here — a `FormData` is an argument of a call, and
318
+ // this walk only ever sees the inside of one, or a result — and "is a
319
+ // class instance" would send the reader looking for a class they did not
320
+ // write.
321
+ if (asFormData(object) != null) {
322
+ throw new ActionValueError(
323
+ path,
324
+ "is a FormData, and a form may only be an argument of a call: never part of a value, " +
325
+ "and never a result",
326
+ );
327
+ }
232
328
  throw new ActionValueError(
233
329
  path,
234
330
  "is a class instance, a Map, a Set, a Date, a React element or another object with a " +
@@ -252,12 +348,52 @@ export function checkActionValue(root: mixed, label: string): void {
252
348
  }
253
349
  }
254
350
 
351
+ /**
352
+ * One form's entries, as pairs of strings, or a named failure.
353
+ *
354
+ * The count is checked as the entries are read rather than afterwards, so an
355
+ * enormous form is refused before the whole of it has been copied. A `File`
356
+ * entry is refused by name: an upload is not in this grammar and the field
357
+ * that carried it is the useful half of saying so.
358
+ */
359
+ function formEntries(form: FormData, label: string): Array<Array<string>> {
360
+ const entries: Array<Array<string>> = [];
361
+ for (const [name, value] of form.entries()) {
362
+ if (entries.length >= MAX_FORM_ENTRIES) {
363
+ throw new ActionValueError(
364
+ label,
365
+ `carries more than ${String(MAX_FORM_ENTRIES)} form fields`,
366
+ );
367
+ }
368
+ if (name.length > MAX_FORM_NAME_LENGTH) {
369
+ throw new ActionValueError(
370
+ label,
371
+ `has a form field name longer than ${String(MAX_FORM_NAME_LENGTH)} characters`,
372
+ );
373
+ }
374
+ if (typeof value !== "string") {
375
+ throw new ActionValueError(
376
+ `${label} field \`${name}\``,
377
+ "is a file, and a file cannot cross to a server action",
378
+ );
379
+ }
380
+ entries.push([name, value]);
381
+ }
382
+ return entries;
383
+ }
384
+
255
385
  /**
256
386
  * The request body for a call, or a named failure.
257
387
  *
258
388
  * The browser's half. Refusing here is what turns "the server answered 400"
259
389
  * into "argument 2.createdAt is a class instance", at the call site, with a
260
390
  * stack that reaches the component.
391
+ *
392
+ * A `FormData` argument becomes the envelope's `form` key and leaves `null` in
393
+ * its own slot, so `args` stays a list of values of exactly the length the
394
+ * call had. Two forms is a refusal rather than a choice: React passes one, and
395
+ * an envelope that could carry several would need to say which is which in a
396
+ * place that is not `at`.
261
397
  */
262
398
  export function encodeActionArguments(args: $ReadOnlyArray<mixed>): string {
263
399
  if (args.length > MAX_ACTION_ARGUMENTS) {
@@ -267,10 +403,24 @@ export function encodeActionArguments(args: $ReadOnlyArray<mixed>): string {
267
403
  String(MAX_ACTION_ARGUMENTS),
268
404
  );
269
405
  }
406
+ const values: Array<mixed> = [];
407
+ let form: {| readonly at: number, readonly entries: Array<Array<string>> |} | null = null;
270
408
  for (let index = 0; index < args.length; index += 1) {
271
- checkActionValue(args[index], `argument ${String(index + 1)}`);
409
+ const argument = args[index];
410
+ const label = `argument ${String(index + 1)}`;
411
+ const submitted = asFormData(argument);
412
+ if (submitted != null) {
413
+ if (form != null) {
414
+ throw new ActionValueError(label, "is a second form, and a call carries at most one");
415
+ }
416
+ form = { at: index, entries: formEntries(submitted, label) };
417
+ values.push(null);
418
+ continue;
419
+ }
420
+ checkActionValue(argument, label);
421
+ values.push(argument);
272
422
  }
273
- return JSON.stringify({ args });
423
+ return form == null ? JSON.stringify({ args: values }) : JSON.stringify({ args: values, form });
274
424
  }
275
425
 
276
426
  /**
@@ -282,7 +432,7 @@ export function encodeActionArguments(args: $ReadOnlyArray<mixed>): string {
282
432
  * only way to keep this closed as it grows is to refuse anything that is not
283
433
  * this today.
284
434
  */
285
- export function decodeActionArguments(text: string): Array<ActionValue> {
435
+ export function decodeActionArguments(text: string): Array<ActionArgument> {
286
436
  let parsed: mixed;
287
437
  try {
288
438
  parsed = JSON.parse(text);
@@ -293,8 +443,9 @@ export function decodeActionArguments(text: string): Array<ActionValue> {
293
443
  throw new ActionValueError("the body", "is not a JSON object");
294
444
  }
295
445
  const keys: $ReadOnlyArray<string> = Object.getOwnPropertyNames(parsed);
296
- if (keys.length !== 1 || keys[0] !== "args") {
297
- throw new ActionValueError("the body", 'has keys other than "args"');
446
+ const named = keys.length === 1 ? keys[0] === "args" : keys.length === 2 && keys.includes("form");
447
+ if (!named || !keys.includes("args")) {
448
+ throw new ActionValueError("the body", 'has keys other than "args" and "form"');
298
449
  }
299
450
  const args: mixed = parsed.args;
300
451
  if (!Array.isArray(args)) {
@@ -309,7 +460,79 @@ export function decodeActionArguments(text: string): Array<ActionValue> {
309
460
  for (let index = 0; index < args.length; index += 1) {
310
461
  checkActionValue(args[index], `argument ${String(index + 1)}`);
311
462
  }
312
- return args as $FlowFixMe;
463
+ const decoded: Array<ActionArgument> = args as $FlowFixMe;
464
+ if (keys.length === 2) {
465
+ const at = decodeForm(parsed.form, decoded);
466
+ decoded[at.index] = at.form;
467
+ }
468
+ return decoded;
469
+ }
470
+
471
+ /**
472
+ * The envelope's `form`, as a `FormData` and the position it belongs at.
473
+ *
474
+ * Every field of the envelope is checked before anything is built, and the
475
+ * slot it names must already hold `null`: `args` and `form` are two statements
476
+ * about one call, and a payload that makes both about the same argument is a
477
+ * payload whose sender believed something that is not true. The only thing
478
+ * constructed is a `FormData`, from strings, and which constructor that is was
479
+ * decided here rather than by the bytes.
480
+ */
481
+ function decodeForm(
482
+ candidate: mixed,
483
+ args: $ReadOnlyArray<ActionArgument>,
484
+ ): {| readonly index: number, readonly form: FormData |} {
485
+ if (typeof FormData === "undefined") {
486
+ throw new ActionValueError("the body", "carries a form, and this runtime has no FormData");
487
+ }
488
+ if (candidate == null || typeof candidate !== "object" || Array.isArray(candidate)) {
489
+ throw new ActionValueError("the form", "is not a JSON object");
490
+ }
491
+ const keys: $ReadOnlyArray<string> = Object.getOwnPropertyNames(candidate);
492
+ if (keys.length !== 2 || !keys.includes("at") || !keys.includes("entries")) {
493
+ throw new ActionValueError("the form", 'has keys other than "at" and "entries"');
494
+ }
495
+
496
+ const at: mixed = candidate.at;
497
+ if (typeof at !== "number" || !Number.isInteger(at) || at < 0 || at >= args.length) {
498
+ throw new ActionValueError("the form", "names no argument of this call");
499
+ }
500
+ if (args[at] !== null) {
501
+ throw new ActionValueError(
502
+ "the form",
503
+ `names argument ${String(at + 1)}, which the payload also gives a value`,
504
+ );
505
+ }
506
+
507
+ const entries: mixed = candidate.entries;
508
+ if (!Array.isArray(entries)) {
509
+ throw new ActionValueError("the form", 'has an "entries" that is not an array');
510
+ }
511
+ if (entries.length > MAX_FORM_ENTRIES) {
512
+ throw new ActionValueError(
513
+ "the form",
514
+ `carries more than ${String(MAX_FORM_ENTRIES)} form fields`,
515
+ );
516
+ }
517
+
518
+ const form: FormData = new FormData();
519
+ for (const entry of entries) {
520
+ if (!Array.isArray(entry) || entry.length !== 2) {
521
+ throw new ActionValueError("the form", "has an entry that is not a name and a value");
522
+ }
523
+ const [name, value] = entry;
524
+ if (typeof name !== "string" || typeof value !== "string") {
525
+ throw new ActionValueError("the form", "has an entry whose name or value is not a string");
526
+ }
527
+ if (name.length > MAX_FORM_NAME_LENGTH) {
528
+ throw new ActionValueError(
529
+ "the form",
530
+ `has a form field name longer than ${String(MAX_FORM_NAME_LENGTH)} characters`,
531
+ );
532
+ }
533
+ form.append(name, value);
534
+ }
535
+ return { index: at, form };
313
536
  }
314
537
 
315
538
  /**
@@ -0,0 +1,131 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: whether React DevTools can actually attach
4
+ // to the page this browser just hydrated.
5
+ //
6
+ // `@uniflowed/vite` installs the hook DevTools attaches through, as a classic
7
+ // script above every module — see `packages/vite/internal/devtools.js`, which
8
+ // has the argument. That is uf saying what it intends. This is the half that
9
+ // checks it happened, and the two are not the same claim: the preamble is
10
+ // injected by a `transformIndexHtml` hook, into a document a project's own Vite
11
+ // plugins also write to, and the thing that has to be true is an *ordering* —
12
+ // the hook exists before `react-dom` is evaluated — which no amount of reading
13
+ // the injector can establish about a particular page.
14
+ //
15
+ // It runs once, after hydration, in development only, and says nothing at all
16
+ // when there is nothing wrong. See ubugeeei-prod/uf#503.
17
+ //
18
+ // # Why it reports rather than throws
19
+ //
20
+ // Nothing here is a reason to stop a page. DevTools not attaching costs a
21
+ // developer a panel, and a framework that refused to render over it would have
22
+ // turned a missing convenience into an outage. So the two findings go to the
23
+ // terminal on the same channel as every other browser-side diagnostic — see
24
+ // `./diagnostics.js` — and the page carries on.
25
+ //
26
+ // # The two findings, and why they are the two
27
+ //
28
+ // A third condition — React's *development* build, which is what gives the
29
+ // panel props, hooks and source positions — is not checked here because a
30
+ // browser cannot tell the difference from the outside, and because uf owns it
31
+ // end to end: `mode` is `development` and `uf transform` is called with
32
+ // `development: true`, both asserted in `tests/library/devtools.test.js`
33
+ // against the plugin rather than against a page. What is left is what only a
34
+ // running page knows.
35
+ //
36
+ // 1. **There is no hook.** Something ran before `react-dom` and there was
37
+ // nothing for it to register with, or the preamble did not reach this
38
+ // document. Either way no renderer was announced and the panel will say
39
+ // the page is not using React.
40
+ // 2. **There is more than one renderer.** Two copies of `react-dom` each
41
+ // injected, and DevTools shows the tree of whichever it heard from — which
42
+ // is the shape of the "multiple renderers concurrently rendering the same
43
+ // context provider" report, and a component tree that is missing half the
44
+ // page for a reason nothing on screen explains.
45
+
46
+ import { reportDiagnostic } from "./diagnostics.js";
47
+
48
+ /**
49
+ * The global React registers itself with.
50
+ *
51
+ * The same string as `DEVTOOLS_HOOK` in `@uniflowed/vite`'s
52
+ * `internal/devtools.js`, written out again rather than imported for the reason
53
+ * that file's neighbour `internal/diagnostics.js` gives about the endpoint
54
+ * paths: `@uniflowed/vite` is loaded by Vite before any Flow transform exists
55
+ * and this module is Flow, so the import cannot go either way.
56
+ * `tests/library/devtools.test.js` asserts the two spellings agree, which is
57
+ * what makes a duplicated constant honest.
58
+ */
59
+ export const DEVTOOLS_HOOK: string = "__REACT_DEVTOOLS_GLOBAL_HOOK__";
60
+
61
+ /** The part of a page this module reads. */
62
+ type HookWindow = {
63
+ [key: string]: mixed,
64
+ ...
65
+ };
66
+
67
+ /**
68
+ * What is wrong with this page's DevTools hook, as a diagnostic, or `null`.
69
+ *
70
+ * Separated from the reporting so that a test can ask the question without a
71
+ * channel to answer on, and because the wording is the part worth pinning: a
72
+ * reader who sees this in a terminal has to be able to act on it without
73
+ * reading this file.
74
+ */
75
+ export function devtoolsProblem(win: HookWindow): {|
76
+ readonly message: string,
77
+ readonly detail: $ReadOnlyArray<string>,
78
+ |} | null {
79
+ const hook = win[DEVTOOLS_HOOK];
80
+ if (hook == null || typeof hook !== "object") {
81
+ return {
82
+ message: `React DevTools cannot attach: nothing installed \`${DEVTOOLS_HOOK}\` before react-dom ran`,
83
+ detail: [
84
+ "React registers itself with that global while `react-dom` is evaluated, once and never again.",
85
+ "`uf dev` injects the hook as a classic script at the top of the head; a plugin that replaces",
86
+ "`transformIndexHtml`'s output, or a document that does not go through it, takes it away.",
87
+ ],
88
+ };
89
+ }
90
+
91
+ // `renderers` is a Map React puts its renderer in, keyed by the id `inject`
92
+ // handed back. Anything else there is a hook uf did not install and DevTools
93
+ // did not either, and guessing at its shape would report a problem that is
94
+ // really this module not recognising one.
95
+ const renderers = (hook: $FlowFixMe).renderers;
96
+ const count = renderers instanceof Map ? renderers.size : null;
97
+ if (count != null && count > 1) {
98
+ return {
99
+ message: `React DevTools has ${count} renderers on this page and will show one of them`,
100
+ detail: [
101
+ "Two copies of `react-dom` are loaded, so half the component tree is in a tree the panel cannot see.",
102
+ '`resolve.dedupe: ["react", "react-dom"]` is what usually prevents it; a linked package with its',
103
+ "own `react-dom` in `node_modules` is what usually causes it.",
104
+ ],
105
+ };
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Report the problem, if there is one, to the terminal running `uf dev`.
112
+ *
113
+ * Throws nothing and returns nothing, and the guard is around the whole body
114
+ * rather than around the reading: this is called from the line after a
115
+ * successful hydration, so every failure available to it — a hook object whose
116
+ * property getter throws, a host with no `fetch` to report through — is a
117
+ * development convenience failing, and a development convenience that can take
118
+ * a working page down is worse than no convenience at all.
119
+ */
120
+ export function reportDevtools(win: HookWindow): void {
121
+ try {
122
+ const problem = devtoolsProblem(win);
123
+ if (problem == null) {
124
+ return;
125
+ }
126
+ reportDiagnostic({ severity: "warn", message: problem.message, detail: problem.detail });
127
+ } catch {
128
+ // Deliberately silent. There is no second channel to complain on, and the
129
+ // thing being reported was never worth interrupting anybody for.
130
+ }
131
+ }
@@ -39,9 +39,9 @@
39
39
  // `uf dev` serves this path and nothing else does: a built application has no
40
40
  // `/__uf/` anything, so a call in production posts to a path that answers 404
41
41
  // and the rejected promise is swallowed here. That is a fallback rather than a
42
- // design — the only caller is behind `import.meta.hot` in `../client.js`, so a
43
- // production bundle has no path to this module rather than merely no answer
44
- // from it.
42
+ // design — both callers are behind `import.meta.hot` in `../client.js`, the
43
+ // hydration report and the DevTools check, so a production bundle has no path
44
+ // to this module rather than merely no answer from it.
45
45
  //
46
46
  // Nothing here opens a connection until it is called, importing it does nothing
47
47
  // at all, and what it sends goes to the page's own origin as a path rather than
@@ -732,6 +732,68 @@ function matchSegments(
732
732
  return index === parts.length ? params : null;
733
733
  }
734
734
 
735
+ /**
736
+ * The URL for a route pattern and the parameters it takes.
737
+ *
738
+ * The inverse of [`matchSegments`], and deliberately built out of the same
739
+ * [`compile`]: a builder that parsed patterns its own way would drift from the
740
+ * matcher, and the drift would show up as a link that 404s rather than as a
741
+ * failure anybody could see.
742
+ *
743
+ * The generated `router.js` is what a project calls — `route("/posts/:slug",
744
+ * { slug })` — and it is typed there, so the parameters are checked before this
745
+ * runs. This still refuses a bad call rather than building a wrong URL,
746
+ * because the types are only in front of the callers that have them: a value
747
+ * that arrived from JSON, or from a module that opted out of Flow, reaches
748
+ * here unchecked. A link to `/posts/undefined` is the failure this exists to
749
+ * turn into an error with a name on it.
750
+ *
751
+ * Each segment is `encodeURIComponent`d, which is what [`decodeSegment`]
752
+ * undoes on the way back — so a slug with a slash in it round-trips as one
753
+ * segment rather than becoming two.
754
+ */
755
+ export function buildRoute(routePath: string, params?: RouteParams): string {
756
+ const values: RouteParams = params ?? {};
757
+ const parts: Array<string> = [];
758
+ for (const segment of compile(routePath)) {
759
+ match (segment) {
760
+ {kind: "static", value: const value} => {
761
+ parts.push(value);
762
+ }
763
+ {kind: "param", name: const name} => {
764
+ const value = values[name];
765
+ if (typeof value !== "string") {
766
+ throw new Error(
767
+ `route ${routePath} takes a string for :${name}, and got ${describeParam(value)}`,
768
+ );
769
+ }
770
+ parts.push(encodeURIComponent(value));
771
+ }
772
+ {kind: "catchAll", name: const name} => {
773
+ const value = values[name];
774
+ if (value == null || typeof value === "string") {
775
+ throw new Error(
776
+ `route ${routePath} takes an array of segments for :${name}*, and got ` +
777
+ describeParam(value),
778
+ );
779
+ }
780
+ for (const part of value) {
781
+ parts.push(encodeURIComponent(part));
782
+ }
783
+ }
784
+ }
785
+ }
786
+ return parts.length === 0 ? "/" : `/${parts.join("/")}`;
787
+ }
788
+
789
+ /** What a parameter was, for the message that says it was the wrong thing. */
790
+ function describeParam(value: string | $ReadOnlyArray<string> | void): string {
791
+ if (value === undefined) {
792
+ return "nothing";
793
+ }
794
+ return typeof value === "string" ? `the string ${JSON.stringify(value)}` : "an array";
795
+ }
796
+
735
797
  function decodeSegment(segment: string): string {
736
798
  try {
737
799
  return decodeURIComponent(segment);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.14",
3
+ "version": "0.0.0-alpha.16",
4
4
  "description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "react-dom": ">=19"
34
34
  },
35
35
  "dependencies": {
36
- "@uniflowed/hooks": "0.0.0-alpha.14",
37
- "@uniflowed/server": "0.0.0-alpha.14"
36
+ "@uniflowed/hooks": "0.0.0-alpha.16",
37
+ "@uniflowed/server": "0.0.0-alpha.16"
38
38
  }
39
39
  }