@uniflowed/router 0.0.0-alpha.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/action.js +344 -0
  2. package/client.js +263 -7
  3. package/handler.js +113 -99
  4. package/http-client.js +104 -0
  5. package/index.js +49 -6
  6. package/instrumentation.js +92 -0
  7. package/internal/action-endpoint.js +646 -0
  8. package/internal/action-wire.js +608 -0
  9. package/internal/base-path.js +175 -0
  10. package/internal/boundaries.js +481 -0
  11. package/internal/boundary-data.js +88 -0
  12. package/internal/compose.js +490 -0
  13. package/internal/deployment.js +160 -0
  14. package/internal/devtools.js +131 -0
  15. package/internal/diagnostics.js +169 -0
  16. package/internal/error-view.js +193 -0
  17. package/internal/flight-browser.js +242 -0
  18. package/internal/flight-chunks.js +205 -0
  19. package/internal/flight-rows.js +135 -0
  20. package/internal/flight-ssr.js +91 -0
  21. package/internal/flight.js +192 -0
  22. package/internal/form-action.js +243 -0
  23. package/internal/head.js +219 -0
  24. package/internal/hydrate-options.js +38 -0
  25. package/internal/hydration.js +1085 -0
  26. package/internal/inspector.js +626 -0
  27. package/internal/native-links.js +67 -0
  28. package/internal/native-tree.js +89 -0
  29. package/internal/navigation-cache.js +181 -0
  30. package/internal/payload-rows.js +270 -0
  31. package/internal/payload.js +685 -0
  32. package/internal/prepare-document.js +54 -0
  33. package/internal/react-version.js +77 -0
  34. package/internal/resolve.js +1617 -0
  35. package/internal/resolved-summary.js +199 -0
  36. package/internal/routing.js +478 -0
  37. package/internal/runtime.js +1593 -1341
  38. package/internal/server-instrumentation.js +12 -0
  39. package/internal/server-route.js +58 -0
  40. package/internal/shell.js +132 -0
  41. package/internal/stream.js +766 -21
  42. package/middleware.js +274 -22
  43. package/native-navigation.js +217 -0
  44. package/native.js +416 -0
  45. package/package.json +48 -7
  46. package/routing.js +51 -0
  47. package/rsc-client.js +122 -0
  48. package/rsc-ssr.js +641 -0
  49. package/rsc.js +402 -0
  50. package/server-components.js +159 -0
  51. package/server.js +263 -106
package/action.js ADDED
@@ -0,0 +1,344 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/router/action`: a server action, as the browser holds it.
4
+ //
5
+ // A `"use client"` module writes an ordinary import and an ordinary call:
6
+ //
7
+ // // app/counter/_components/Counter.js
8
+ // "use client";
9
+ // import { recordClick } from "../_actions/clicks.js";
10
+ // …
11
+ // <button onClick={() => { void recordClick(count); }}>
12
+ //
13
+ // On the server that import is the function. In the browser it is a reference
14
+ // built here: `@uniflowed/vite` replaces the `"use server"` module, in the
15
+ // client graph only, with one `createServerReference` per callable export, so
16
+ // what crosses the import is an id and a `fetch` — and the module's body, its
17
+ // imports and every secret they reached stay where they were written.
18
+ //
19
+ // # Why this module is where it is
20
+ //
21
+ // It is the browser's half, so it may not live in `@uniflowed/server`: that
22
+ // package is in `SERVER_ONLY_PACKAGES` and importing it from a client
23
+ // component is the error the RSC graph exists to produce. It holds no
24
+ // platform API beyond `fetch` and `location`, and it imports one internal
25
+ // module, which is the grammar the server applies to the same bytes.
26
+ //
27
+ // # The call
28
+ //
29
+ // `POST` to the page's own URL, `uf-action: <id>`, `application/json`, and
30
+ // `{"args":[…]}`. The URL is the page rather than a path uf reserves so that
31
+ // the middleware guarding that path runs above the call exactly as it does
32
+ // above the page — and it is not a substitute for the action authorizing
33
+ // itself; `internal/action-endpoint.js` says why in the paragraph that matters.
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
+ // **It works before the page has hydrated, too.** React's progressive
70
+ // enhancement — the form that submits before its JavaScript has arrived — asks
71
+ // the function for `$$FORM_ACTION` while rendering to HTML, and a reference
72
+ // has one: `method="POST"`, `application/x-www-form-urlencoded`, and hidden
73
+ // fields naming the action and carrying its bound arguments. The server
74
+ // renders with the real function, which `@uniflowed/vite` gives the same
75
+ // property through `registerServerAction` below, so the markup is the same
76
+ // whichever side wrote it. A native post is not the JSON call: it is a
77
+ // separate door in `./internal/action-endpoint.js`, which accepts only that
78
+ // content type, only from the page's own origin, and only with the fields
79
+ // `./internal/form-action.js` writes. `useActionState` works there as well
80
+ // — the answer is the page rendered with the action's result as the hook's
81
+ // state. See ubugeeei-prod/uf#1358.
82
+ //
83
+ // # After a deploy
84
+ //
85
+ // A reference carries its build's id, and a new build mints new ids, so a tab
86
+ // left open across a deploy holds ids the live server has never heard of. The
87
+ // call names the page's build in `uf-deployment`; a server on another build
88
+ // answers `409` without running anything; and the reference then loads the
89
+ // page's document again — a hard navigation onto the live build — instead of
90
+ // throwing. The promise it returned never settles, because the page it was
91
+ // returned to is being replaced. See `./internal/deployment.js`.
92
+ //
93
+ // # What Flow checks, and where
94
+ //
95
+ // Flow reads `app/_actions/clicks.js`, not the reference the bundler
96
+ // substitutes for it, so `recordClick("nine")` against
97
+ // `recordClick(count: number)` is a `uf check` error rather than a `400`. That
98
+ // is the half that needs nothing from this module.
99
+ //
100
+ // What a declaration alone does not say is whether those arguments and that
101
+ // result can cross a wire at all, and `ActionArguments` and `ActionResult` are
102
+ // that half: `uf prepare` writes one instantiation of each into
103
+ // `server-actions.js`, over every action in the project at once, so an action
104
+ // taking a callback or returning a `Map` is a `uf check` error naming the
105
+ // offending type. Without them the same mistake is a request that arrives with
106
+ // `{}` where an object was passed — the kind of bug found in production by a
107
+ // column that stopped being written.
108
+
109
+ import {
110
+ ACTION_CONTENT_TYPE,
111
+ ACTION_HEADER,
112
+ type ActionArgument,
113
+ type ActionValue,
114
+ decodeActionResult,
115
+ encodeActionArguments,
116
+ } from "./internal/action-wire.js";
117
+ import { loadDocument, refusedAsAnotherDeployment, withDeployment } from "./internal/deployment.js";
118
+ import { withFormAction } from "./internal/form-action.js";
119
+ import { clearNavigationCache } from "./internal/navigation-cache.js";
120
+
121
+ export type { ActionArgument, ActionValue } from "./internal/action-wire.js";
122
+ export {
123
+ ACTION_CONTENT_TYPE,
124
+ ACTION_HEADER,
125
+ ActionValueError,
126
+ MAX_ACTION_ARGUMENTS,
127
+ MAX_ACTION_BODY_BYTES,
128
+ MAX_ACTION_DEPTH,
129
+ MAX_ACTION_VALUES,
130
+ MAX_FORM_ENTRIES,
131
+ MAX_FORM_NAME_LENGTH,
132
+ } from "./internal/action-wire.js";
133
+
134
+ /**
135
+ * A function that can be a server action.
136
+ *
137
+ * Both halves of the signature are the wire grammar, and they are not the same
138
+ * half: an action is called with values that can cross *or* the one form a
139
+ * submit produces, and answers with a value that can cross. `void` is a result
140
+ * and not an argument, because JSON has no `undefined` and an action declared
141
+ * to take one would be taking something the caller cannot send; a `FormData`
142
+ * is an argument and not a result, because a form is something a browser
143
+ * submits and not something a server answers with.
144
+ */
145
+ export type ServerActionFunction = (...args: Array<ActionArgument>) => Promise<ActionValue | void>;
146
+
147
+ /**
148
+ * An action's arguments, held against what a wire can carry.
149
+ *
150
+ * A bound on a tuple rather than a bound on the function, and the difference
151
+ * is the whole reason this is two types instead of one. A function type puts
152
+ * its parameters in a contravariant position: `F extends (…args:
153
+ * Array<ActionValue>) => …` asks whether `F` accepts *every* `ActionValue`,
154
+ * which `createUser(name: string)` does not and should not. `Parameters<F>` is
155
+ * the same list read covariantly, where the question is the one worth asking —
156
+ * is each argument something that can cross?
157
+ *
158
+ * `uf prepare` writes one instantiation over the whole project:
159
+ *
160
+ * export type ServerActionArgsFitTheWire =
161
+ * ActionArguments<ServerActionArgs<ServerActionName>>;
162
+ *
163
+ * `ServerActionName` is every action's name, so `ServerActionArgs` of it is
164
+ * every action's argument list, and one line checks all of them.
165
+ *
166
+ * The bound is [`ActionArgument`] and not [`ActionValue`], which is the whole
167
+ * of what makes `<form action={fn}>` type-check: a form action's parameter is
168
+ * a `FormData`, and a `FormData` crosses as an argument and only as one.
169
+ *
170
+ * # What this does not catch, and why
171
+ *
172
+ * The bound is element-wise, so it holds each argument against the grammar and
173
+ * says nothing about the list as a whole. The wire has one rule that is about
174
+ * the list: a call carries at most one form, because the envelope names the
175
+ * form's position once. So `(a: FormData, b: FormData)` type-checks here and
176
+ * throws `ActionValueError` at `encodeActionArguments` — a rule enforced at
177
+ * run time that the types ought to have caught.
178
+ *
179
+ * Saying it in the type needs a walk over the tuple, and the walk is blocked by
180
+ * ubugeeei-prod/uf#300: a spread in a conditional type's tuple pattern binds
181
+ * `infer` as `unknown` and the rest as the whole array widened. The obvious
182
+ * recursion is not merely rejected, it quietly answers wrongly —
183
+ *
184
+ * type NoForm<T> = T extends [] ? true
185
+ * : T extends [infer H, ...infer R]
186
+ * ? (H extends FormData ? false : NoForm<R>) : true;
187
+ *
188
+ * — gives `true` for `[string, FormData]`, because the spread pattern never
189
+ * matches and every tuple falls through to the last branch. A constraint built
190
+ * on that would be worse than none: it would report every signature as fine.
191
+ *
192
+ * `tests/type-tests/server-actions.js` pins the gap, so that whoever fixes
193
+ * #300 is told this is waiting on it. The run-time guard and its test are in
194
+ * `internal/action-wire.js` and `tests/library/server-actions.test.js`.
195
+ */
196
+ export type ActionArguments<TArgs extends $ReadOnlyArray<ActionArgument>> = TArgs;
197
+
198
+ /**
199
+ * An action's result, held against the same grammar.
200
+ *
201
+ * A promise, because a server action is always async — the RSC graph rejects
202
+ * one that is not — and `void` is allowed because an action that returns
203
+ * nothing is ordinary. `Promise` is covariant in Flow, so the bound reaches
204
+ * the resolved type without any of the contortion the arguments needed.
205
+ */
206
+ export type ActionResult<TResult extends Promise<ActionValue | void>> = TResult;
207
+
208
+ /**
209
+ * A server action that did not answer.
210
+ *
211
+ * Carries the status and the action's build-time name and nothing else,
212
+ * because nothing else came back: the endpoint answers every refusal with a
213
+ * fixed body, so there is no message from the server to relay. What went wrong
214
+ * is in the server's log, which is where an application's internals belong.
215
+ */
216
+ export class ServerActionError extends Error {
217
+ /** The HTTP status the endpoint answered with. */
218
+ status: number;
219
+ /** `module#export` of the action that was called. */
220
+ action: string;
221
+
222
+ constructor(action: string, status: number) {
223
+ super(
224
+ `@uniflowed/router: the server action \`${action}\` answered ${String(status)}. ` +
225
+ "The endpoint reports every failure the same way; the reason is in the server's log.",
226
+ );
227
+ this.name = "ServerActionError";
228
+ this.status = status;
229
+ this.action = action;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * The reference the client bundle holds in place of one server action.
235
+ *
236
+ * Generated, never written by hand: `@uniflowed/vite` emits one call per
237
+ * callable export of a `"use server"` module, with the id
238
+ * `crates/uf_rsc/src/action.rs` derived for it and the `module#export` name
239
+ * that only ever appears in an error.
240
+ *
241
+ * The returned function is `async` and refuses before it sends: an argument
242
+ * outside the wire grammar throws an `ActionValueError` naming the argument's
243
+ * position, at the call site, rather than becoming a `400` with nothing in it.
244
+ *
245
+ * It carries `$$FORM_ACTION`, so a form bound to it is a real form before the
246
+ * page hydrates; see the header and `./internal/form-action.js`.
247
+ */
248
+ export function createServerReference(id: string, name: string): ServerActionFunction {
249
+ return withFormAction(callServerActionFor(id, name), id, []);
250
+ }
251
+
252
+ /**
253
+ * Give a server action, on the server, what its reference has in the browser.
254
+ *
255
+ * `@uniflowed/vite` calls this on every callable export of a `"use server"`
256
+ * module in the server graphs, with the id the build derived for it, so that a
257
+ * client component rendered to HTML writes a form that posts without
258
+ * JavaScript. It changes nothing about calling the function: the properties it
259
+ * defines are ones only React reads, and `bind` still binds. Anything that is
260
+ * not a function is returned untouched, because the RSC graph has already
261
+ * refused a `"use server"` export that is not one and this is not the place to
262
+ * say it twice.
263
+ */
264
+ export function registerServerAction<T>(fn: T, id: string): T {
265
+ if (typeof fn !== "function") {
266
+ return fn;
267
+ }
268
+ // `typeof` refines `T` to a function whose parameters Flow cannot name, and
269
+ // `withFormAction` changes nothing about calling it; the cast says that.
270
+ withFormAction(fn as $FlowFixMe, id, []);
271
+ return fn;
272
+ }
273
+
274
+ /** The network call one reference makes. */
275
+ function callServerActionFor(id: string, name: string): ServerActionFunction {
276
+ return async function callServerAction(
277
+ ...args: Array<ActionArgument>
278
+ ): Promise<ActionValue | void> {
279
+ const body = encodeActionArguments(args);
280
+ // Built rather than written as a literal, because the header's name is a
281
+ // constant and a computed key in an object literal is a shape Flow
282
+ // declines to track.
283
+ const headers: { [string]: string } = { "content-type": ACTION_CONTENT_TYPE };
284
+ headers[ACTION_HEADER] = id;
285
+ // Which build this page is, so a server on another build refuses the call
286
+ // rather than looking this id up in a table it was never in.
287
+ withDeployment(headers);
288
+ const response = await fetch(currentUrl(), {
289
+ method: "POST",
290
+ // Stated rather than left to the default, because the default is what a
291
+ // reader has to look up and because this one is load-bearing: the
292
+ // endpoint's `Origin` check is only meaningful for a request that
293
+ // carries the visitor's cookies in the first place.
294
+ credentials: "same-origin",
295
+ // Never a cached answer, and never one written to a cache: an action is
296
+ // a side effect, and `POST` responses are outside HTTP caching by
297
+ // default only until something decides otherwise.
298
+ cache: "no-store",
299
+ headers,
300
+ body,
301
+ });
302
+ // A route a navigation kept may no longer show what this action wrote, and
303
+ // which routes is the server's to know, so every kept route is asked for
304
+ // again. Whatever the status: an action that failed part-way may have
305
+ // written before it failed. See `./internal/navigation-cache.js`.
306
+ clearNavigationCache();
307
+ // The server is on another build: nothing ran, and nothing on this page
308
+ // can be called correctly any more. Load the page again, from the build
309
+ // that is live, and never settle — a rejection here would reach an error
310
+ // boundary for the moment before the document is replaced, and a result
311
+ // would be a lie. See `./internal/deployment.js`.
312
+ if (refusedAsAnotherDeployment(response)) {
313
+ loadDocument(currentUrl());
314
+ return new Promise<ActionValue | void>(() => {});
315
+ }
316
+ if (!response.ok) {
317
+ throw new ServerActionError(name, response.status);
318
+ }
319
+ return decodeActionResult(await response.text());
320
+ };
321
+ }
322
+
323
+ /**
324
+ * The URL an action call is sent to: the page the browser is on.
325
+ *
326
+ * Path and query, not the whole URL, so the request is same-origin by
327
+ * construction rather than by a comparison somebody could get wrong. The hash
328
+ * is left off because it never reaches a server.
329
+ *
330
+ * A reference only exists in the client bundle — the server imports the real
331
+ * module — so there is no `location` here only if something has imported the
332
+ * browser's half into a server, and saying so is better than posting to a
333
+ * relative path that means nothing there.
334
+ */
335
+ function currentUrl(): string {
336
+ const location = globalThis.location;
337
+ if (location == null) {
338
+ throw new Error(
339
+ "@uniflowed/router: a server action reference was called where there is no `location`. " +
340
+ "A reference is the browser's half of an action; the server imports the module itself.",
341
+ );
342
+ }
343
+ return `${location.pathname}${location.search}`;
344
+ }
package/client.js CHANGED
@@ -1,12 +1,91 @@
1
1
  // @flow
2
2
  //
3
- // Hydrating the document in the browser.
3
+ // Starting the application in the browser.
4
+ //
5
+ // Two entry points, and which one `virtual:uf/client` calls is decided by
6
+ // `app.rendering.modes`. `hydrate` is the one every uf build has used: a server
7
+ // or a prerender wrote the markup, and React attaches to it. `render` is for
8
+ // `["csr"]`, where the build wrote one shell with an empty root and nothing has
9
+ // been rendered anywhere yet; see its own comment for why that is not `hydrate`
10
+ // with a flag.
4
11
  //
5
12
  // `virtual:uf/client` calls `hydrate` with the app root and the route table.
6
13
  // The current route's chunks are loaded and its embedded loader data read
7
14
  // *before* `hydrateRoot`, so the first client render is synchronous and
8
15
  // matches the server's markup exactly.
9
16
  //
17
+ // An application whose routes render as React Server Components, the default,
18
+ // starts from neither: `hydrateFlight` in `./rsc-client.js` hydrates the Flight
19
+ // payload its document carries. That is a separate entry so that this one,
20
+ // which every other web application imports, never names React's Flight
21
+ // client. The client is an optional peer that needs React 19.3, and a project
22
+ // on React 19.2 does not install it (ubugeeei-prod/uf#992).
23
+ //
24
+ // # What "its loader data" means once the loader can defer
25
+ //
26
+ // It is a payload rather than a value: `internal/payload.js` writes the model
27
+ // into `<script id="__uf_data">` with a `"$P<n>"` reference wherever the loader
28
+ // left a promise, and each of those arrives later in a `<script data-uf-row>`
29
+ // of its own. So two things happen before `hydrateRoot` rather than one — the
30
+ // model is decoded, and `internal/payload-rows.js` starts watching for the
31
+ // rows it referred to. Both have to be first: the decoded model is what the
32
+ // first render is handed, and a row that landed while nothing was watching
33
+ // would be a boundary that never resolves. A document with nothing deferred
34
+ // has no references, so the reader is handed no ids and installs nothing.
35
+ //
36
+ // # A hydration that fails says what differed
37
+ //
38
+ // React reports a mismatch with one sentence and a list of the six things that
39
+ // usually cause it, and leaves the reader to find which node of the two
40
+ // thousand on the page was the one. This module is the only place that can do
41
+ // better, because it is the only place that runs between the parser finishing
42
+ // and React starting: `internal/hydration.js` takes a copy of the server's
43
+ // markup here, and compares it against the repaired tree when React reports.
44
+ // Development only, and dynamically imported so a production bundle has no path
45
+ // to it. See ubugeeei-prod/uf#508.
46
+ //
47
+ // # And whether React DevTools can see the page at all
48
+ //
49
+ // The other question only this module is in a position to ask.
50
+ // `@uniflowed/vite` installs the hook DevTools attaches through, above every
51
+ // module in the document; whether that worked *on this page* is a fact about a
52
+ // running browser, and the line after hydration is where it can be read.
53
+ // `internal/devtools.js` has the two findings and sends them to the same
54
+ // terminal the hydration report goes to. See ubugeeei-prod/uf#503.
55
+ //
56
+ // # Strict Mode, in development, by default
57
+ //
58
+ // `uf dev` generates `strictMode: true` into `virtual:uf/client` and `uf build`
59
+ // does not, so a development render is doubled and a visitor's is not. That is
60
+ // React's own check for the thing it cannot check any other way: a component
61
+ // whose render is not pure, and an effect whose cleanup does not undo its
62
+ // setup, both behave correctly until the one production render that interleaves
63
+ // with something — and Strict Mode makes them behave incorrectly at once, on
64
+ // the machine of the person writing them.
65
+ //
66
+ // The wrapper is the argument to `hydrateRoot` rather than something inside
67
+ // `<App>`, and that is load-bearing rather than tidy. React decides whether to
68
+ // double-invoke a mount's effects at the *topmost fiber it is placing*: if that
69
+ // fiber is not itself in Strict Mode, React stops there and never looks inside
70
+ // it. A `<StrictMode>` further down still doubles the renders under it — that
71
+ // comes from the fiber's own mode — and doubles no effect at all, so it would
72
+ // have bought the half of the check that is easy to notice and silently lost
73
+ // the half that finds the bug. It renders no element, so the hydrated tree is
74
+ // unchanged and the markup comparison above is unaffected.
75
+ // `app.react.strictMode: false` in `uf.config.js` turns it off. See
76
+ // ubugeeei-prod/uf#516.
77
+ //
78
+ // # And an application can decline to be navigated
79
+ //
80
+ // `app.rendering.navigation: "document"` is the whole application saying what
81
+ // the paragraph below says about one route: the document the server wrote is
82
+ // what a link produces, and the browser fetches the next one. It is *not* the
83
+ // same as declining to hydrate — the page still hydrates, so a `"use client"`
84
+ // component is still interactive — and what it removes is the takeover. The
85
+ // flag reaches the runtime through `installNavigation` before the first render;
86
+ // see `internal/runtime.js` for what `RouterProvider` and `Link` then do, and
87
+ // `docs/app/guide/rendering` for when a project wants it.
88
+ //
10
89
  // # A route can decline to be hydrated
11
90
  //
12
91
  // uf's server-component analysis decides which routes have a `"use client"`
@@ -18,18 +97,30 @@
18
97
  // See ubugeeei-prod/uf#350.
19
98
 
20
99
  import * as React from "react";
21
- import { startTransition } from "react";
22
- import { hydrateRoot } from "react-dom/client";
100
+ import { StrictMode, startTransition } from "react";
101
+ import { createRoot, hydrateRoot } from "react-dom/client";
23
102
 
24
103
  import {
25
104
  type AppProps,
105
+ type Navigation,
26
106
  type RouteTable,
107
+ RedirectError,
27
108
  hasClientPage,
109
+ installNavigation,
28
110
  installRoutes,
111
+ installRouting,
112
+ installStaleTime,
29
113
  matchRoute,
114
+ resolveFailure,
30
115
  resolveMatch,
31
116
  } from "./internal/runtime.js";
32
117
  import { DATA_ID, ROOT_ID } from "./internal/document.js";
118
+ import { decodePayload } from "./internal/payload.js";
119
+ import { createPayloadReader, domObserver } from "./internal/payload-rows.js";
120
+ import { prepareDocumentForHydration } from "./internal/prepare-document.js";
121
+ import { type TrailingSlash, addressOf, applicationPathOf } from "./internal/base-path.js";
122
+ import { hydrationOptions } from "./internal/hydrate-options.js";
123
+ import { readFormState } from "./internal/form-action.js";
33
124
 
34
125
  /**
35
126
  * Hydrate the current document.
@@ -43,6 +134,11 @@ export async function hydrate(options: {|
43
134
  readonly routes: RouteTable["routes"],
44
135
  readonly notFound: RouteTable["notFound"],
45
136
  readonly errors: RouteTable["errors"],
137
+ readonly strictMode?: boolean,
138
+ readonly navigation?: Navigation,
139
+ readonly basePath?: string,
140
+ readonly trailingSlash?: TrailingSlash,
141
+ readonly staleTime?: number,
46
142
  |}): Promise<void> {
47
143
  const table: RouteTable = {
48
144
  routes: options.routes,
@@ -50,22 +146,182 @@ export async function hydrate(options: {|
50
146
  errors: options.errors,
51
147
  };
52
148
  installRoutes(table);
149
+ // Beside the table, and before anything renders. `"client"` when the entry
150
+ // says nothing, which is what `virtual:uf/client` generated before
151
+ // `app.rendering.navigation` existed and what a hand-written entry still
152
+ // means: the default is the behaviour, not the absence of one.
153
+ installNavigation(options.navigation ?? "client");
154
+ installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
155
+ installStaleTime(options.staleTime ?? 0);
156
+ // The route table has no base path in it, and the address bar does.
157
+ const applicationPath = applicationPathOf(window.location.pathname) ?? window.location.pathname;
53
158
 
54
159
  // Before the loader data is read and before `resolveMatch` is called: both
55
160
  // would go looking for a page module that is not in this bundle.
56
- const matched = matchRoute(table.routes, window.location.pathname);
161
+ const matched = matchRoute(table.routes, applicationPath);
57
162
  if (matched != null && !hasClientPage(matched.route)) {
58
163
  return;
59
164
  }
60
165
 
61
- const url = window.location.pathname + window.location.search;
166
+ const url = applicationPath + window.location.search;
167
+ // Row 0 of the payload, and the reader that will fill in the rows it refers
168
+ // to. Both before `hydrateRoot`, and in this order: `decodePayload` is what
169
+ // tells the reader which rows the page is waiting for, and `watch` is what
170
+ // makes it notice the ones the server has not written yet. A document with
171
+ // nothing deferred has no references, so the reader is handed no ids, and
172
+ // `watch` returns without installing anything — see `internal/payload.js`.
62
173
  const embedded = document.getElementById(DATA_ID);
63
- const data = embedded != null ? JSON.parse(embedded.textContent ?? "null") : undefined;
174
+ const reader = createPayloadReader(document, domObserver(document));
175
+ const data =
176
+ embedded != null
177
+ ? decodePayload(
178
+ JSON.parse(embedded.textContent ?? "null"),
179
+ reader.resolve,
180
+ "the route's loader data",
181
+ )
182
+ : undefined;
183
+ reader.watch();
64
184
  const resolved = await resolveMatch(table, url, { data, skipLoader: embedded != null });
65
185
 
66
186
  const { App } = options;
67
187
  const container = document.getElementById(ROOT_ID) ?? document;
188
+ prepareDocumentForHydration(document);
189
+
190
+ // The server's markup, and the reporter that will read it, in development
191
+ // only. Both have to be in place *before* `hydrateRoot`: React repairs a
192
+ // mismatched subtree by rendering over it, so the bytes the server sent exist
193
+ // for exactly the moment between the parser finishing and this line.
194
+ //
195
+ // `import.meta.hot` is the gate because it is the one signal that is right in
196
+ // all three places this module is evaluated. Vite defines it while serving
197
+ // and replaces it with `undefined` in a build, so the branch is statically
198
+ // dead there; Node leaves it undefined, so `packages/vite/rsc-split.test.js`
199
+ // imports this file without a bundler and gets the production path. The
200
+ // import is dynamic so that the overlay is not merely shaken out of a
201
+ // production bundle but never reachable from one.
202
+ let recovery = null;
203
+ let restoreDevHead = null;
204
+ if (import.meta.hot != null) {
205
+ const { captureServerMarkup, hydrationErrorHandler, prepareDevHeadForHydration } =
206
+ await import("./internal/hydration.js");
207
+ restoreDevHead = prepareDevHeadForHydration(document);
208
+ recovery = hydrationErrorHandler(container, captureServerMarkup(container), document);
209
+ }
210
+
211
+ // `<StrictMode>` renders no element of its own, so the tree React hydrates
212
+ // against the server's markup is the same tree either way and the flag can
213
+ // be a development-only difference without being a hydration difference.
214
+ const tree = <App url={url} initial={resolved} />;
215
+
68
216
  startTransition(() => {
69
- hydrateRoot(container, <App url={url} initial={resolved} />);
217
+ hydrateRoot(
218
+ container,
219
+ options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
220
+ hydrationOptions(recovery, readFormState(document)),
221
+ );
222
+ if (restoreDevHead != null) {
223
+ setTimeout(restoreDevHead, 250);
224
+ }
70
225
  });
226
+
227
+ // And, in development only, whether the panel a developer is about to open
228
+ // can see any of that. `react-dom` announced itself while it was being
229
+ // imported — long before this line — so the answer is already settled and
230
+ // this only reads it. Behind the same `import.meta.hot` gate as the
231
+ // hydration reporter, dynamically imported for the same reason: a production
232
+ // bundle has no path to the module rather than merely no reason to run it.
233
+ // See `./internal/devtools.js` and ubugeeei-prod/uf#503.
234
+ if (import.meta.hot != null) {
235
+ const { reportDevtools } = await import("./internal/devtools.js");
236
+ reportDevtools(window);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Render the current route into an empty shell.
242
+ *
243
+ * The single-page entry point: `app.rendering.modes: ["csr"]` writes one
244
+ * document with an empty root and no markup in it, and this is what fills it.
245
+ *
246
+ * # Why it is not `hydrate` with a flag
247
+ *
248
+ * Because hydration is React comparing what it renders against what a server
249
+ * sent, and here no server sent anything. `hydrateRoot` against an empty
250
+ * container is a mismatch on the first node of every page — React would report
251
+ * it, throw the shell away and render from scratch, which is this function
252
+ * with a warning in front of it. `createRoot` says what is actually happening:
253
+ * the browser is the only renderer this application has.
254
+ *
255
+ * Three more things follow from there, and each of them is a line below rather
256
+ * than an omission:
257
+ *
258
+ * * **The loader runs here.** `resolveMatch` fetches the route's modules and
259
+ * runs its loader in the browser, because there was no server render to run
260
+ * it in and no `<script id="__uf_data">` for it to have left an answer in.
261
+ * * **A URL that matches nothing is the not-found boundary**, resolved the
262
+ * way a server resolves it. The host served this shell for a URL it had no
263
+ * file for, so "nothing matched" is a perfectly ordinary arrival here
264
+ * rather than the exception it is during hydration.
265
+ * * **A redirect is the browser's.** `redirect()` from a loader throws before
266
+ * anything is rendered; on a server that becomes a 307 and here it becomes
267
+ * `location.replace`, which is the same instruction to the same browser.
268
+ */
269
+ export async function render(options: {|
270
+ readonly App: React.ComponentType<AppProps>,
271
+ readonly routes: RouteTable["routes"],
272
+ readonly notFound: RouteTable["notFound"],
273
+ readonly errors: RouteTable["errors"],
274
+ readonly strictMode?: boolean,
275
+ readonly navigation?: Navigation,
276
+ readonly basePath?: string,
277
+ readonly trailingSlash?: TrailingSlash,
278
+ readonly staleTime?: number,
279
+ |}): Promise<void> {
280
+ const table: RouteTable = {
281
+ routes: options.routes,
282
+ notFound: options.notFound,
283
+ errors: options.errors,
284
+ };
285
+ installRoutes(table);
286
+ installNavigation(options.navigation ?? "client");
287
+ installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
288
+ installStaleTime(options.staleTime ?? 0);
289
+
290
+ const url =
291
+ (applicationPathOf(window.location.pathname) ?? window.location.pathname) +
292
+ window.location.search;
293
+ let resolved;
294
+ try {
295
+ resolved = await resolveMatch(table, url);
296
+ } catch (error) {
297
+ if (error instanceof RedirectError) {
298
+ window.location.replace(addressOf(error.to));
299
+ return;
300
+ }
301
+ // The error boundary, chosen the same way the server chooses it. A throw
302
+ // from a loader is a page that cannot render, and rendering the boundary is
303
+ // what this application has instead of a 500.
304
+ resolved = await resolveFailure(table, url, error);
305
+ }
306
+
307
+ const { App } = options;
308
+ // An element, and never `document` — which is the other difference from
309
+ // `hydrate` above. `hydrateRoot` takes a document, because an app whose root
310
+ // layout renders `<html>` owns the whole of one and the server wrote it;
311
+ // `createRoot` does not, because creating a root *is* replacing the
312
+ // container's children and the container here would be the document. The
313
+ // shell always writes this element, so its absence means the document being
314
+ // rendered into is not one this build produced.
315
+ const container = document.getElementById(ROOT_ID);
316
+ if (container == null) {
317
+ throw new Error(
318
+ `@uniflowed/router: no #${ROOT_ID} in this document, so there is nothing to render into. ` +
319
+ "A single-page build writes the shell that carries it; this document came from " +
320
+ "somewhere else.",
321
+ );
322
+ }
323
+ const tree = <App url={url} initial={resolved} />;
324
+ createRoot(container).render(
325
+ options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
326
+ );
71
327
  }