@uniflowed/router 0.1.0 → 0.3.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.
- package/action.js +101 -24
- package/client.js +30 -9
- package/internal/action-endpoint.js +322 -14
- package/internal/action-wire.js +70 -8
- package/internal/compose.js +14 -4
- package/internal/deployment.js +3 -2
- package/internal/devtools.js +1 -1
- package/internal/error-view.js +1 -1
- package/internal/flight-browser.js +86 -10
- package/internal/flight-chunks.js +7 -7
- package/internal/flight-rows.js +7 -2
- package/internal/flight-ssr.js +10 -3
- package/internal/flight.js +32 -0
- package/internal/form-action.js +243 -0
- package/internal/hydrate-options.js +38 -0
- package/internal/hydration.js +11 -6
- package/internal/navigation-cache.js +1 -1
- package/internal/payload-rows.js +5 -4
- package/internal/payload.js +18 -12
- package/internal/prepare-document.js +6 -0
- package/internal/resolve.js +17 -11
- package/internal/routing.js +77 -12
- package/internal/runtime.js +114 -6
- package/internal/shell.js +9 -2
- package/internal/stream.js +41 -14
- package/middleware.js +140 -11
- package/package.json +6 -4
- package/rsc-client.js +3 -1
- package/rsc-ssr.js +19 -6
- package/rsc.js +23 -7
- package/server.js +13 -4
- package/testing.js +980 -0
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
import { requireServerComponentsReact } from "./react-version.js";
|
|
40
40
|
|
|
41
41
|
/** A module namespace, as far as the loader looks into one. */
|
|
42
|
-
type ModuleNamespace = {
|
|
42
|
+
type ModuleNamespace = { readonly [string]: mixed };
|
|
43
43
|
|
|
44
44
|
/** The entry a refusal names: the one an application reaches this module through. */
|
|
45
45
|
const ENTRY = "@uniflowed/router/rsc/client";
|
|
@@ -86,16 +86,24 @@ export function installBrowserModules(): void {
|
|
|
86
86
|
throw new Error("@uniflowed/router: a payload asked for an import map, which uf never writes");
|
|
87
87
|
};
|
|
88
88
|
parcelRequire.meta = { publicUrl: "", devServer: null };
|
|
89
|
-
|
|
89
|
+
// `Reflect`, because Flow reads a property name given to
|
|
90
|
+
// `Object.defineProperty` as one the target must already declare, and
|
|
91
|
+
// `parcelRequire` is exactly the global nothing declares. `Object`'s throws
|
|
92
|
+
// where this answers `false`, so the throw is kept.
|
|
93
|
+
const defined = Reflect.defineProperty(globalThis, "parcelRequire", {
|
|
90
94
|
value: parcelRequire,
|
|
91
95
|
writable: true,
|
|
92
96
|
configurable: true,
|
|
93
97
|
});
|
|
98
|
+
if (!defined) {
|
|
99
|
+
throw new TypeError("@uniflowed/router: could not install parcelRequire on the global object");
|
|
100
|
+
}
|
|
94
101
|
}
|
|
95
102
|
|
|
96
103
|
/** The parts of a `Document` the reader uses. */
|
|
97
104
|
type DocumentLike = interface {
|
|
98
|
-
|
|
105
|
+
// A method, as a `Document`'s is: a method cannot be read off as a property.
|
|
106
|
+
querySelectorAll(selector: string): Iterable<ElementLike>,
|
|
99
107
|
readonly documentElement: mixed,
|
|
100
108
|
};
|
|
101
109
|
|
|
@@ -222,12 +230,16 @@ export async function fetchFlight(
|
|
|
222
230
|
const landed = new URL(response.url, window.location.href);
|
|
223
231
|
const document = documentPathOf(landed.pathname);
|
|
224
232
|
const type = response.headers.get("content-type") ?? "";
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
233
|
+
// A payload file a static host could not type (#1495) is taken for what it
|
|
234
|
+
// is only after its first bytes say so; see [`untypedPayload`].
|
|
235
|
+
const payload =
|
|
236
|
+
landed.origin === window.location.origin && document != null
|
|
237
|
+
? type.startsWith(FLIGHT_CONTENT_TYPE)
|
|
238
|
+
? response
|
|
239
|
+
: await untypedPayload(response, type)
|
|
240
|
+
: null;
|
|
241
|
+
if (payload == null || document == null) {
|
|
242
|
+
void response.body?.cancel().catch(() => {});
|
|
231
243
|
// The URL the reader asked for when the redirect did not end on a payload,
|
|
232
244
|
// rather than the one it ended on. A middleware that answered with its
|
|
233
245
|
// sign-in page wrote a `next=` naming the payload URL; loading the document
|
|
@@ -237,6 +249,70 @@ export async function fetchFlight(
|
|
|
237
249
|
return {
|
|
238
250
|
kind: "flight",
|
|
239
251
|
url: `${document}${landed.search}`,
|
|
240
|
-
root: createFromFetch(Promise.resolve(
|
|
252
|
+
root: createFromFetch(Promise.resolve(payload)),
|
|
241
253
|
};
|
|
242
254
|
}
|
|
255
|
+
|
|
256
|
+
/** The types a host sends for a file it does not know, or no type at all. */
|
|
257
|
+
const UNTYPED: $ReadOnlyArray<string> = Object.freeze([
|
|
258
|
+
"",
|
|
259
|
+
"application/octet-stream",
|
|
260
|
+
"binary/octet-stream",
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
/** How far into a body to look for the first row before giving up. */
|
|
264
|
+
const SNIFF_LIMIT = 256;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* `response` as a payload, when a host served one without saying so.
|
|
268
|
+
*
|
|
269
|
+
* A prerendered page's payload is a file, `<route>/__uf.flight`, and a static
|
|
270
|
+
* host that does not know the extension sends it with no `Content-Type` (the
|
|
271
|
+
* Workers asset server, Pages) or as `application/octet-stream`. Refusing it
|
|
272
|
+
* made every client navigation on such a host a full page load. The type
|
|
273
|
+
* check is not dropped for it, it is replaced by one that cannot be fooled the
|
|
274
|
+
* same way: the answer has to be a `200` for the payload URL itself (no
|
|
275
|
+
* redirect — a sign-in page reached by one is what the type check is for),
|
|
276
|
+
* untyped rather than typed as something else (`text/html` is a document,
|
|
277
|
+
* whatever its bytes), and its first line has to be a Flight row, `<hex id>:`.
|
|
278
|
+
* Anything else answers `null` and the router loads the document, as before.
|
|
279
|
+
*
|
|
280
|
+
* The bytes read to decide are put back in front of the rest, so React reads
|
|
281
|
+
* the whole payload.
|
|
282
|
+
*/
|
|
283
|
+
async function untypedPayload(response: Response, type: string): Promise<Response | null> {
|
|
284
|
+
const media = type.split(";")[0].trim().toLowerCase();
|
|
285
|
+
if (!UNTYPED.includes(media) || response.status !== 200 || response.redirected) return null;
|
|
286
|
+
const body = response.body;
|
|
287
|
+
if (body == null) return null;
|
|
288
|
+
const reader = body.getReader();
|
|
289
|
+
const read: Array<Uint8Array> = [];
|
|
290
|
+
let seen = "";
|
|
291
|
+
const decoder = new TextDecoder();
|
|
292
|
+
while (!seen.includes("\n") && seen.length < SNIFF_LIMIT) {
|
|
293
|
+
const step = await reader.read();
|
|
294
|
+
if (step.done === true) break;
|
|
295
|
+
read.push(step.value);
|
|
296
|
+
seen += decoder.decode(step.value, { stream: true });
|
|
297
|
+
}
|
|
298
|
+
if (!/^[0-9a-f]+:/i.test(seen)) {
|
|
299
|
+
void reader.cancel().catch(() => {});
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
const replayed = new ReadableStream({
|
|
303
|
+
start(controller) {
|
|
304
|
+
for (const chunk of read) controller.enqueue(chunk);
|
|
305
|
+
},
|
|
306
|
+
async pull(controller) {
|
|
307
|
+
const step = await reader.read();
|
|
308
|
+
if (step.done === true) controller.close();
|
|
309
|
+
else controller.enqueue(step.value);
|
|
310
|
+
},
|
|
311
|
+
cancel(reason) {
|
|
312
|
+
return reader.cancel(reason);
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
const headers = new Headers(response.headers);
|
|
316
|
+
headers.set("content-type", FLIGHT_CONTENT_TYPE);
|
|
317
|
+
return new Response(replayed, { status: 200, headers });
|
|
318
|
+
}
|
|
@@ -142,13 +142,13 @@ export function flightChunkBytes(text: string): Uint8Array | null {
|
|
|
142
142
|
if (typeof value === "string") {
|
|
143
143
|
return new TextEncoder().encode(value);
|
|
144
144
|
}
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
Object.keys(value).length === 1
|
|
150
|
-
|
|
151
|
-
|
|
145
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
146
|
+
// Read once into a local: a refinement of `value.bytes` does not survive
|
|
147
|
+
// to the call, a `const` does.
|
|
148
|
+
const bytes = value.bytes;
|
|
149
|
+
if (typeof bytes === "string" && Object.keys(value).length === 1) {
|
|
150
|
+
return fromBase64(bytes);
|
|
151
|
+
}
|
|
152
152
|
}
|
|
153
153
|
throw new Error(`@uniflowed/router: a ${FLIGHT_CHUNK_ATTRIBUTE} element holds no payload chunk`);
|
|
154
154
|
}
|
package/internal/flight-rows.js
CHANGED
|
@@ -39,7 +39,12 @@ const NEWLINE = 0x0a;
|
|
|
39
39
|
const ERROR_TAG = "E".charCodeAt(0);
|
|
40
40
|
|
|
41
41
|
/** One row's place in the payload. */
|
|
42
|
-
type Row = {|
|
|
42
|
+
type Row = {|
|
|
43
|
+
readonly start: number,
|
|
44
|
+
readonly end: number,
|
|
45
|
+
readonly tag: number,
|
|
46
|
+
readonly body: number,
|
|
47
|
+
|};
|
|
43
48
|
|
|
44
49
|
/**
|
|
45
50
|
* Every row of a finished payload, in order.
|
|
@@ -92,7 +97,7 @@ function rowsOf(bytes: Uint8Array): Array<Row> {
|
|
|
92
97
|
export function withoutErrorRows(
|
|
93
98
|
payload: Uint8Array,
|
|
94
99
|
left: (digest: string) => boolean,
|
|
95
|
-
): {|
|
|
100
|
+
): {| readonly payload: Uint8Array, readonly removed: number |} {
|
|
96
101
|
const decoder = new TextDecoder();
|
|
97
102
|
const kept: Array<Uint8Array> = [];
|
|
98
103
|
let removed = 0;
|
package/internal/flight-ssr.js
CHANGED
|
@@ -28,7 +28,7 @@ import type { FlightRoot } from "./flight.js";
|
|
|
28
28
|
import { requireServerComponentsReact } from "./react-version.js";
|
|
29
29
|
|
|
30
30
|
/** A module namespace, as far as the hook looks into one. */
|
|
31
|
-
type ModuleNamespace = {
|
|
31
|
+
type ModuleNamespace = { readonly [string]: mixed };
|
|
32
32
|
|
|
33
33
|
/** The server copy of the client module at a browser chunk URL. */
|
|
34
34
|
export type ClientModuleLoader = (url: string) => Promise<ModuleNamespace>;
|
|
@@ -64,11 +64,18 @@ export function installServerModules(load: ClientModuleLoader): void {
|
|
|
64
64
|
throw new Error("@uniflowed/router: a payload asked for an import map, which uf never writes");
|
|
65
65
|
};
|
|
66
66
|
parcelRequire.meta = { publicUrl: "", devServer: null };
|
|
67
|
-
|
|
67
|
+
// `Reflect`, because Flow reads a property name given to
|
|
68
|
+
// `Object.defineProperty` as one the target must already declare, and
|
|
69
|
+
// `parcelRequire` is exactly the global nothing declares. `Object`'s throws
|
|
70
|
+
// where this answers `false`, so the throw is kept.
|
|
71
|
+
const defined = Reflect.defineProperty(globalThis, "parcelRequire", {
|
|
68
72
|
value: parcelRequire,
|
|
69
73
|
writable: true,
|
|
70
74
|
configurable: true,
|
|
71
75
|
});
|
|
76
|
+
if (!defined) {
|
|
77
|
+
throw new TypeError("@uniflowed/router: could not install parcelRequire on the global object");
|
|
78
|
+
}
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
/**
|
|
@@ -82,7 +89,7 @@ export function installServerModules(load: ClientModuleLoader): void {
|
|
|
82
89
|
*/
|
|
83
90
|
export function readPayload(
|
|
84
91
|
stream: ReadableStream<Uint8Array>,
|
|
85
|
-
options?: {|
|
|
92
|
+
options?: {| readonly partial?: boolean |},
|
|
86
93
|
): Promise<FlightRoot> {
|
|
87
94
|
requireServerComponentsReact(ENTRY);
|
|
88
95
|
return options?.partial === true
|
package/internal/flight.js
CHANGED
|
@@ -108,6 +108,38 @@ export type FetchedFlight =
|
|
|
108
108
|
readonly url: string,
|
|
109
109
|
|};
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* `error` as it may cross into a Flight payload: a thrown value that is not an
|
|
113
|
+
* `Error` becomes one.
|
|
114
|
+
*
|
|
115
|
+
* The route's error travels to the browser as a prop of the error page and as
|
|
116
|
+
* part of the route state, and React serialises it the way it serialises any
|
|
117
|
+
* prop. For an `Error` that is safe by React's own rule: a production payload
|
|
118
|
+
* carries the digest and none of the message or the stack. A thrown value
|
|
119
|
+
* that is *not* an `Error` gets no such rule. A string, or a plain object
|
|
120
|
+
* such as an API client's `{ message, details, hint }`, would be serialised
|
|
121
|
+
* as the data it is, and a query, a table name or a token in it would be
|
|
122
|
+
* published to whoever asked for the page.
|
|
123
|
+
*
|
|
124
|
+
* So it is wrapped. The message is the value's `String()`, which `uf dev`
|
|
125
|
+
* shows and which a production payload drops along with every other `Error`
|
|
126
|
+
* message. The original value is still what the server reported: this is
|
|
127
|
+
* applied only to the copy that crosses. `unauthorized` and `forbidden` carry
|
|
128
|
+
* nothing and pass through as they are.
|
|
129
|
+
*/
|
|
130
|
+
export function crossableRouteError(error: ?RouteError): ?RouteError {
|
|
131
|
+
if (error == null || error.kind !== "thrown" || error.error instanceof Error) {
|
|
132
|
+
return error;
|
|
133
|
+
}
|
|
134
|
+
let text = "a value that is not an Error was thrown";
|
|
135
|
+
try {
|
|
136
|
+
text = String(error.error);
|
|
137
|
+
} catch {
|
|
138
|
+
// A value whose `toString` throws: the sentence above is all it gets.
|
|
139
|
+
}
|
|
140
|
+
return { kind: "thrown", error: new Error(text) };
|
|
141
|
+
}
|
|
142
|
+
|
|
111
143
|
/** The part of a resolved route that crosses to the browser. */
|
|
112
144
|
export function routeState(resolved: ResolvedRoute): RouteState {
|
|
113
145
|
const interception = resolved.interception;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: a server action as a form the browser can
|
|
4
|
+
// submit before any JavaScript has run.
|
|
5
|
+
//
|
|
6
|
+
// React 19's contract for `<form action={serverFunction}>` is progressive
|
|
7
|
+
// enhancement. While rendering to HTML, React asks the function for
|
|
8
|
+
// `$$FORM_ACTION(prefix)` and writes what it answers into the markup — a
|
|
9
|
+
// `method`, an `encType`, a hidden field named `name`, and one hidden field per
|
|
10
|
+
// entry of `data` — so the form is a real form that posts to the page it is on.
|
|
11
|
+
// A function without the property gets `action="javascript:throw …"` instead,
|
|
12
|
+
// which is what every uf form got before ubugeeei-prod/uf#1358.
|
|
13
|
+
//
|
|
14
|
+
// Two functions carry the property: the reference the browser holds
|
|
15
|
+
// (`createServerReference` in `../action.js`) and the real function the server
|
|
16
|
+
// renders with (`registerServerAction`, which `@uniflowed/vite` calls on every
|
|
17
|
+
// callable export of a `"use server"` module in the server graph). Both answer
|
|
18
|
+
// with the same fields, from the code below, so the markup the server writes and
|
|
19
|
+
// what a hydrated page would have written agree.
|
|
20
|
+
//
|
|
21
|
+
// # The fields
|
|
22
|
+
//
|
|
23
|
+
// React hands `$$FORM_ACTION` a prefix unique to the form in its render, and
|
|
24
|
+
// the prefix is in every field name so that two forms — or a `<form>` and a
|
|
25
|
+
// `<button formAction>` inside it — never read each other's fields:
|
|
26
|
+
//
|
|
27
|
+
// | Field | Value |
|
|
28
|
+
// | --- | --- |
|
|
29
|
+
// | `$uf_ref_<prefix>` | empty. The field that says *which* action this submit is for; on a `<button formAction>` it is the button's own name, so only the button that was pressed sends it |
|
|
30
|
+
// | `$uf_id_<prefix>` | the action's id, the same 64 hexadecimal characters the JSON call carries in `uf-action` |
|
|
31
|
+
// | `$uf_bound_<prefix>` | the bound arguments, as the JSON envelope `encodeActionArguments` writes, when there are any |
|
|
32
|
+
//
|
|
33
|
+
// `useActionState` adds React's own `$ACTION_KEY`, which names the hook that
|
|
34
|
+
// submitted, so the answer can put the action's result back into the same hook.
|
|
35
|
+
//
|
|
36
|
+
// Every field is a string and every bound argument goes through the same
|
|
37
|
+
// grammar as a JSON call: nothing new is allowed to cross by arriving as a form.
|
|
38
|
+
// `./action-endpoint.js` is the other half, and its header says what a native
|
|
39
|
+
// post is refused for.
|
|
40
|
+
|
|
41
|
+
import { encodeActionArguments } from "./action-wire.js";
|
|
42
|
+
|
|
43
|
+
/** The name of the field that says which action a submit is for. */
|
|
44
|
+
export const FORM_REF_FIELD = "$uf_ref_";
|
|
45
|
+
/** The action id, beside it. */
|
|
46
|
+
export const FORM_ID_FIELD = "$uf_id_";
|
|
47
|
+
/** The bound arguments, beside it. */
|
|
48
|
+
export const FORM_BOUND_FIELD = "$uf_bound_";
|
|
49
|
+
/** React's own: which `useActionState` submitted. */
|
|
50
|
+
export const FORM_STATE_KEY_FIELD = "$ACTION_KEY";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The only content type a native action post may have.
|
|
54
|
+
*
|
|
55
|
+
* `application/x-www-form-urlencoded` rather than `multipart/form-data`,
|
|
56
|
+
* because a server action refuses a file anyway and multipart parsing is a
|
|
57
|
+
* surface uf does not open for one (`docs/security.md`). `$$FORM_ACTION` says
|
|
58
|
+
* it explicitly, so React writes it on the form and on a submit button.
|
|
59
|
+
*/
|
|
60
|
+
export const FORM_ACTION_CONTENT_TYPE = "application/x-www-form-urlencoded";
|
|
61
|
+
|
|
62
|
+
/** What `$$FORM_ACTION` answers, in the shape React reads. */
|
|
63
|
+
export type FormActionFields = {|
|
|
64
|
+
readonly name: string,
|
|
65
|
+
readonly method: "POST",
|
|
66
|
+
readonly encType: string,
|
|
67
|
+
readonly data: FormData,
|
|
68
|
+
|};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What a `useActionState` postback leaves for the page it renders.
|
|
72
|
+
*
|
|
73
|
+
* React's `ReactFormState`: the action's result, the hook's key, the action's
|
|
74
|
+
* id, and how many bound arguments it had *besides* the state
|
|
75
|
+
* `useActionState` bound itself.
|
|
76
|
+
*/
|
|
77
|
+
export type FormState = [mixed, string, string, number];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Make `fn` a server action a form can post to without JavaScript.
|
|
81
|
+
*
|
|
82
|
+
* Defines three non-enumerable properties on `fn` and returns it:
|
|
83
|
+
*
|
|
84
|
+
* - `$$FORM_ACTION(prefix)`, the fields above;
|
|
85
|
+
* - `$$IS_SIGNATURE_EQUAL(id, bound)`, which React asks while rendering a
|
|
86
|
+
* postback's answer to decide whether a `useActionState` is the one that
|
|
87
|
+
* submitted — the same action, bound the same number of times;
|
|
88
|
+
* - `bind`, which keeps both of those on the function it returns, with the
|
|
89
|
+
* bound arguments recorded so they travel in the form.
|
|
90
|
+
*
|
|
91
|
+
* `call` is what the bound function runs: `fn` itself on the server, the
|
|
92
|
+
* network call in the browser.
|
|
93
|
+
*/
|
|
94
|
+
export function withFormAction<T extends (...args: $ReadOnlyArray<empty>) => mixed>(
|
|
95
|
+
fn: T,
|
|
96
|
+
id: string,
|
|
97
|
+
bound: $ReadOnlyArray<mixed>,
|
|
98
|
+
): T {
|
|
99
|
+
const define = (name: string, value: mixed) => {
|
|
100
|
+
Object.defineProperty(fn, name, { value, configurable: true, writable: true });
|
|
101
|
+
};
|
|
102
|
+
define("$$FORM_ACTION", (prefix: string): FormActionFields => formFields(id, bound, prefix));
|
|
103
|
+
define(
|
|
104
|
+
"$$IS_SIGNATURE_EQUAL",
|
|
105
|
+
(referenceId: string, boundCount: number): boolean =>
|
|
106
|
+
referenceId === id && boundCount === bound.length,
|
|
107
|
+
);
|
|
108
|
+
// `Function.prototype.bind` itself, reached through a cast: Flow types
|
|
109
|
+
// `bind` per call site and has no type for the method taken off the
|
|
110
|
+
// prototype and applied to an arbitrary function.
|
|
111
|
+
const nativeBind: $FlowFixMe = Function.prototype.bind;
|
|
112
|
+
define("bind", function bindAction(thisArg: mixed, ...more: Array<mixed>) {
|
|
113
|
+
const next: T = nativeBind.call(fn, thisArg, ...more);
|
|
114
|
+
return withFormAction(next, id, [...bound, ...more]);
|
|
115
|
+
});
|
|
116
|
+
return fn;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The fields a form bound to this action is written with.
|
|
121
|
+
*
|
|
122
|
+
* Throws `ActionValueError` when a bound argument cannot cross, which React
|
|
123
|
+
* catches and reports as "Failed to serialize an action for progressive
|
|
124
|
+
* enhancement" before writing the form it would have written without this —
|
|
125
|
+
* so an unencodable state costs the pre-hydration submit and nothing else.
|
|
126
|
+
*/
|
|
127
|
+
export function formFields(
|
|
128
|
+
id: string,
|
|
129
|
+
bound: $ReadOnlyArray<mixed>,
|
|
130
|
+
prefix: string,
|
|
131
|
+
): FormActionFields {
|
|
132
|
+
const data = new FormData();
|
|
133
|
+
data.append(`${FORM_ID_FIELD}${prefix}`, id);
|
|
134
|
+
if (bound.length > 0) {
|
|
135
|
+
data.append(`${FORM_BOUND_FIELD}${prefix}`, encodeActionArguments(bound));
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
name: `${FORM_REF_FIELD}${prefix}`,
|
|
139
|
+
method: "POST",
|
|
140
|
+
encType: FORM_ACTION_CONTENT_TYPE,
|
|
141
|
+
data,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** What a native post names, read off its fields. */
|
|
146
|
+
export type FormPost = {|
|
|
147
|
+
/** The action id, as sent; checked by the caller. */
|
|
148
|
+
readonly id: string | null,
|
|
149
|
+
/** The bound-argument envelope, as sent, or `null` when there is none. */
|
|
150
|
+
readonly bound: string | null,
|
|
151
|
+
/** `$ACTION_KEY`, or `null` when no `useActionState` submitted. */
|
|
152
|
+
readonly stateKey: string | null,
|
|
153
|
+
/** Every other field, in order: what the action is handed as its form. */
|
|
154
|
+
readonly entries: Array<[string, string]>,
|
|
155
|
+
|};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Read a native post's fields, or `null` when it names no action at all.
|
|
159
|
+
*
|
|
160
|
+
* `null` is the caller's signal to decline, exactly as a JSON request with no
|
|
161
|
+
* `uf-action` header is: an ordinary form posted to a route handler must reach
|
|
162
|
+
* that handler. The last `$uf_ref_` field wins, because the browser writes a
|
|
163
|
+
* pressed button's name after the fields of the form around it, and a button's
|
|
164
|
+
* `formAction` is meant to override the form's `action`.
|
|
165
|
+
*
|
|
166
|
+
* Every field whose name uf or React owns is kept out of `entries`, including
|
|
167
|
+
* another prefix's, so the action receives the form the person filled in and
|
|
168
|
+
* nothing that says how it was wired.
|
|
169
|
+
*/
|
|
170
|
+
export function readFormPost(fields: URLSearchParams): FormPost | null {
|
|
171
|
+
let prefix: string | null = null;
|
|
172
|
+
for (const [name] of fields) {
|
|
173
|
+
if (name.startsWith(FORM_REF_FIELD)) {
|
|
174
|
+
prefix = name.slice(FORM_REF_FIELD.length);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (prefix == null) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const entries: Array<[string, string]> = [];
|
|
181
|
+
for (const [name, value] of fields) {
|
|
182
|
+
if (!isWiring(name)) {
|
|
183
|
+
entries.push([name, value]);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
id: fields.get(`${FORM_ID_FIELD}${prefix}`),
|
|
188
|
+
bound: fields.get(`${FORM_BOUND_FIELD}${prefix}`),
|
|
189
|
+
stateKey: fields.get(FORM_STATE_KEY_FIELD),
|
|
190
|
+
entries,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Whether a field is uf's or React's rather than the form's own. */
|
|
195
|
+
function isWiring(name: string): boolean {
|
|
196
|
+
return name.startsWith("$uf_") || name.startsWith("$ACTION_");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The element a postback's form state is written into the document as. */
|
|
200
|
+
export const FORM_STATE_ELEMENT_ID = "uf:form-state";
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The `<script>` that carries a postback's form state to `hydrateRoot`.
|
|
204
|
+
*
|
|
205
|
+
* `application/json`, so it is data and never executed — no nonce, and nothing
|
|
206
|
+
* a Content Security Policy has to admit. The result was already held to the
|
|
207
|
+
* wire grammar before it got here, so it is plain JSON; `<` is escaped so
|
|
208
|
+
* that no string in it can close the element.
|
|
209
|
+
*/
|
|
210
|
+
export function formStateScript(state: FormState): string {
|
|
211
|
+
const json = JSON.stringify(state).replace(/</g, "\\u003c");
|
|
212
|
+
return `<script type="application/json" id="${FORM_STATE_ELEMENT_ID}">${json}</script>`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The form state a document carries, or `undefined`.
|
|
217
|
+
*
|
|
218
|
+
* Read by both hydrating entries before `hydrateRoot`. Anything malformed is
|
|
219
|
+
* `undefined` rather than an exception: the page still hydrates, and the
|
|
220
|
+
* `useActionState` that submitted starts from its initial state, which is what
|
|
221
|
+
* it would have done with JavaScript from the start.
|
|
222
|
+
*/
|
|
223
|
+
export function readFormState(document: Document): FormState | void {
|
|
224
|
+
const element = document.getElementById(FORM_STATE_ELEMENT_ID);
|
|
225
|
+
if (element == null) {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const parsed: mixed = JSON.parse(element.textContent);
|
|
230
|
+
if (
|
|
231
|
+
Array.isArray(parsed) &&
|
|
232
|
+
parsed.length === 4 &&
|
|
233
|
+
typeof parsed[1] === "string" &&
|
|
234
|
+
typeof parsed[2] === "string" &&
|
|
235
|
+
typeof parsed[3] === "number"
|
|
236
|
+
) {
|
|
237
|
+
return [parsed[0], parsed[1], parsed[2], parsed[3]];
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// Fall through: see above.
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: what both hydrating entries hand
|
|
4
|
+
// `hydrateRoot` besides the tree.
|
|
5
|
+
|
|
6
|
+
import type { FormState } from "./form-action.js";
|
|
7
|
+
|
|
8
|
+
/** React's `onRecoverableError`, as the development report builds one. */
|
|
9
|
+
type Recovery = (error: mixed, info: { componentStack?: ?string, ... }) => void;
|
|
10
|
+
|
|
11
|
+
/** What this hands `hydrateRoot`. */
|
|
12
|
+
type HydrationOptions = {| onRecoverableError?: Recovery, formState?: FormState |};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `hydrateRoot`'s options: the development recovery handler, and a postback's
|
|
16
|
+
* form state.
|
|
17
|
+
*
|
|
18
|
+
* `formState` is React's own option. A page rendered in answer to a form posted
|
|
19
|
+
* before hydration was rendered with it, React marked the `useActionState` that
|
|
20
|
+
* submitted, and passing the same value here is how the browser's copy of that
|
|
21
|
+
* hook starts from the action's result instead of its initial state.
|
|
22
|
+
*/
|
|
23
|
+
export function hydrationOptions(
|
|
24
|
+
recovery: ?Recovery,
|
|
25
|
+
formState: FormState | void,
|
|
26
|
+
): HydrationOptions | void {
|
|
27
|
+
if (recovery == null && formState == null) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const options: HydrationOptions = {};
|
|
31
|
+
if (recovery != null) {
|
|
32
|
+
options.onRecoverableError = recovery;
|
|
33
|
+
}
|
|
34
|
+
if (formState != null) {
|
|
35
|
+
options.formState = formState;
|
|
36
|
+
}
|
|
37
|
+
return options;
|
|
38
|
+
}
|
package/internal/hydration.js
CHANGED
|
@@ -195,21 +195,26 @@ type DetachedHeadStyle = {|
|
|
|
195
195
|
* puts them back in the same order.
|
|
196
196
|
*/
|
|
197
197
|
export function prepareDevHeadForHydration(document: Document): () => void {
|
|
198
|
-
|
|
198
|
+
// A document without a head has nothing of uf's in it to hide.
|
|
199
|
+
const head = document.head;
|
|
200
|
+
if (head == null) {
|
|
201
|
+
return () => {};
|
|
202
|
+
}
|
|
203
|
+
for (const script of head.querySelectorAll("script")) {
|
|
199
204
|
if (isDevHeadScript(script)) {
|
|
200
205
|
script.remove();
|
|
201
206
|
}
|
|
202
207
|
}
|
|
203
|
-
for (const child of Array.from(
|
|
208
|
+
for (const child of Array.from(head.childNodes)) {
|
|
204
209
|
if (isIgnorableHeadWhitespace(child)) {
|
|
205
|
-
|
|
210
|
+
head.removeChild(child);
|
|
206
211
|
}
|
|
207
212
|
}
|
|
208
213
|
const detached = [];
|
|
209
|
-
for (const style of
|
|
214
|
+
for (const style of head.querySelectorAll("style")) {
|
|
210
215
|
if (isViteDevStyle(style)) {
|
|
211
216
|
const anchor = document.createComment("uf dev style");
|
|
212
|
-
|
|
217
|
+
head.insertBefore(anchor, style);
|
|
213
218
|
style.remove();
|
|
214
219
|
detached.push({ anchor, style });
|
|
215
220
|
}
|
|
@@ -243,7 +248,7 @@ function restoreDevHeadStyles(
|
|
|
243
248
|
parent.insertBefore(style, anchor);
|
|
244
249
|
parent.removeChild(anchor);
|
|
245
250
|
} else {
|
|
246
|
-
document.head
|
|
251
|
+
document.head?.appendChild(style);
|
|
247
252
|
}
|
|
248
253
|
}
|
|
249
254
|
}
|
|
@@ -62,7 +62,7 @@ let staleTimeMs: number = 0;
|
|
|
62
62
|
export function installStaleTime(seconds: number): void {
|
|
63
63
|
staleTimeMs = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0;
|
|
64
64
|
if (import.meta.hot != null) {
|
|
65
|
-
Object.defineProperty(
|
|
65
|
+
Object.defineProperty(globalThis as $FlowFixMe, "__UF_NAVIGATION_CACHE__", {
|
|
66
66
|
configurable: true,
|
|
67
67
|
value: inspectNavigationCache,
|
|
68
68
|
});
|
package/internal/payload-rows.js
CHANGED
|
@@ -81,13 +81,15 @@ export type PayloadReader = {|
|
|
|
81
81
|
|
|
82
82
|
/** The parts of a `Document` this module uses, so it needs no DOM lib. */
|
|
83
83
|
type DocumentLike = interface {
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
// Methods rather than function-valued properties: a `Document`'s are
|
|
85
|
+
// methods, and a method cannot be read off its object as a property.
|
|
86
|
+
querySelectorAll(selector: string): Iterable<ElementLike>,
|
|
87
|
+
readonly documentElement: ?Node,
|
|
86
88
|
};
|
|
87
89
|
|
|
88
90
|
/** The parts of an `Element` this module uses. */
|
|
89
91
|
type ElementLike = interface {
|
|
90
|
-
|
|
92
|
+
getAttribute(name: string): ?string,
|
|
91
93
|
readonly textContent: string | null,
|
|
92
94
|
};
|
|
93
95
|
|
|
@@ -261,7 +263,6 @@ export function domObserver(
|
|
|
261
263
|
const observer = new MutationObserver(() => {
|
|
262
264
|
callback();
|
|
263
265
|
});
|
|
264
|
-
// $FlowFixMe[incompatible-call] `documentElement` is a `Node`; the interface above says only what is read.
|
|
265
266
|
observer.observe(root, { childList: true, subtree: true });
|
|
266
267
|
return () => {
|
|
267
268
|
observer.disconnect();
|