@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.
@@ -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 = { +[string]: mixed };
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
- Object.defineProperty(globalThis, "parcelRequire", {
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
- readonly querySelectorAll: (selector: string) => Iterable<ElementLike>,
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
- if (
226
- landed.origin !== window.location.origin ||
227
- document == null ||
228
- !type.startsWith(FLIGHT_CONTENT_TYPE)
229
- ) {
230
- void response.body?.cancel();
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(response)),
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
- typeof value === "object" &&
147
- !Array.isArray(value) &&
148
- typeof value.bytes === "string" &&
149
- Object.keys(value).length === 1
150
- ) {
151
- return fromBase64(value.bytes);
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
  }
@@ -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 = {| +start: number, +end: number, +tag: number, +body: number |};
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
- ): {| +payload: Uint8Array, +removed: number |} {
100
+ ): {| readonly payload: Uint8Array, readonly removed: number |} {
96
101
  const decoder = new TextDecoder();
97
102
  const kept: Array<Uint8Array> = [];
98
103
  let removed = 0;
@@ -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 = { +[string]: mixed };
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
- Object.defineProperty(globalThis, "parcelRequire", {
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?: {| +partial?: boolean |},
92
+ options?: {| readonly partial?: boolean |},
86
93
  ): Promise<FlightRoot> {
87
94
  requireServerComponentsReact(ENTRY);
88
95
  return options?.partial === true
@@ -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
+ }
@@ -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
- for (const script of document.head.querySelectorAll("script")) {
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(document.head.childNodes)) {
208
+ for (const child of Array.from(head.childNodes)) {
204
209
  if (isIgnorableHeadWhitespace(child)) {
205
- child.remove();
210
+ head.removeChild(child);
206
211
  }
207
212
  }
208
213
  const detached = [];
209
- for (const style of document.head.querySelectorAll("style")) {
214
+ for (const style of head.querySelectorAll("style")) {
210
215
  if (isViteDevStyle(style)) {
211
216
  const anchor = document.createComment("uf dev style");
212
- document.head.insertBefore(anchor, style);
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.appendChild(style);
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((globalThis: $FlowFixMe), "__UF_NAVIGATION_CACHE__", {
65
+ Object.defineProperty(globalThis as $FlowFixMe, "__UF_NAVIGATION_CACHE__", {
66
66
  configurable: true,
67
67
  value: inspectNavigationCache,
68
68
  });
@@ -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
- readonly querySelectorAll: (selector: string) => Iterable<ElementLike>,
85
- readonly documentElement: mixed,
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
- readonly getAttribute: (name: string) => string | null,
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();