@uniflowed/router 0.0.0-alpha.9 → 0.1.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 (49) hide show
  1. package/action.js +324 -0
  2. package/client.js +261 -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 +438 -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/head.js +219 -0
  23. package/internal/hydration.js +1085 -0
  24. package/internal/inspector.js +626 -0
  25. package/internal/native-links.js +67 -0
  26. package/internal/native-tree.js +89 -0
  27. package/internal/navigation-cache.js +181 -0
  28. package/internal/payload-rows.js +270 -0
  29. package/internal/payload.js +685 -0
  30. package/internal/prepare-document.js +54 -0
  31. package/internal/react-version.js +77 -0
  32. package/internal/resolve.js +1617 -0
  33. package/internal/resolved-summary.js +199 -0
  34. package/internal/routing.js +478 -0
  35. package/internal/runtime.js +1593 -1341
  36. package/internal/server-instrumentation.js +12 -0
  37. package/internal/server-route.js +58 -0
  38. package/internal/shell.js +125 -0
  39. package/internal/stream.js +754 -21
  40. package/middleware.js +161 -22
  41. package/native-navigation.js +217 -0
  42. package/native.js +416 -0
  43. package/package.json +48 -7
  44. package/routing.js +51 -0
  45. package/rsc-client.js +120 -0
  46. package/rsc-ssr.js +637 -0
  47. package/rsc.js +402 -0
  48. package/server-components.js +159 -0
  49. package/server.js +254 -106
package/action.js ADDED
@@ -0,0 +1,324 @@
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
+ // **This needs the page to be hydrated.** React's progressive enhancement —
70
+ // the form that submits before its JavaScript has arrived — works through a
71
+ // `$$FORM_ACTION` property that turns the submit into a *native* form post,
72
+ // and a native form post is `multipart/form-data`: the content type this
73
+ // endpoint refuses, deliberately, as one of the three things standing between
74
+ // it and a cross-site call. Supporting the pre-hydration submit would mean
75
+ // accepting that content type, so a reference carries no `$$FORM_ACTION`, and
76
+ // React writes the form it writes for any client action —
77
+ // `action="javascript:throw new Error('React form unexpectedly submitted.')"`
78
+ // — so a submit before hydration throws in the page rather than posting
79
+ // anywhere. Nothing reaches a server that was not meant to; what is missing is
80
+ // the submit working at all. See ubugeeei-prod/uf#252.
81
+ //
82
+ // The refusal is why the property is withheld, not what would stop it: no
83
+ // request is made, so nothing is refused. Nor would a native form post aimed
84
+ // at a page by hand be refused as multipart — `createActionDispatcher` reads
85
+ // the `uf-action` header before it looks at the method or the content type and
86
+ // returns `null` when it is absent, so the request is not an action call at
87
+ // all and falls through to the route handlers. The `415` answers a request
88
+ // that claims to be an action, which is the only kind that reaches it.
89
+ //
90
+ // # After a deploy
91
+ //
92
+ // A reference carries its build's id, and a new build mints new ids, so a tab
93
+ // left open across a deploy holds ids the live server has never heard of. The
94
+ // call names the page's build in `uf-deployment`; a server on another build
95
+ // answers `409` without running anything; and the reference then loads the
96
+ // page's document again — a hard navigation onto the live build — instead of
97
+ // throwing. The promise it returned never settles, because the page it was
98
+ // returned to is being replaced. See `./internal/deployment.js`.
99
+ //
100
+ // # What Flow checks, and where
101
+ //
102
+ // Flow reads `app/_actions/clicks.js`, not the reference the bundler
103
+ // substitutes for it, so `recordClick("nine")` against
104
+ // `recordClick(count: number)` is a `uf check` error rather than a `400`. That
105
+ // is the half that needs nothing from this module.
106
+ //
107
+ // What a declaration alone does not say is whether those arguments and that
108
+ // result can cross a wire at all, and `ActionArguments` and `ActionResult` are
109
+ // that half: `uf prepare` writes one instantiation of each into
110
+ // `server-actions.js`, over every action in the project at once, so an action
111
+ // taking a callback or returning a `Map` is a `uf check` error naming the
112
+ // offending type. Without them the same mistake is a request that arrives with
113
+ // `{}` where an object was passed — the kind of bug found in production by a
114
+ // column that stopped being written.
115
+
116
+ import {
117
+ ACTION_CONTENT_TYPE,
118
+ ACTION_HEADER,
119
+ type ActionArgument,
120
+ type ActionValue,
121
+ decodeActionResult,
122
+ encodeActionArguments,
123
+ } from "./internal/action-wire.js";
124
+ import { loadDocument, refusedAsAnotherDeployment, withDeployment } from "./internal/deployment.js";
125
+ import { clearNavigationCache } from "./internal/navigation-cache.js";
126
+
127
+ export type { ActionArgument, ActionValue } from "./internal/action-wire.js";
128
+ export {
129
+ ACTION_CONTENT_TYPE,
130
+ ACTION_HEADER,
131
+ ActionValueError,
132
+ MAX_ACTION_ARGUMENTS,
133
+ MAX_ACTION_BODY_BYTES,
134
+ MAX_ACTION_DEPTH,
135
+ MAX_ACTION_VALUES,
136
+ MAX_FORM_ENTRIES,
137
+ MAX_FORM_NAME_LENGTH,
138
+ } from "./internal/action-wire.js";
139
+
140
+ /**
141
+ * A function that can be a server action.
142
+ *
143
+ * Both halves of the signature are the wire grammar, and they are not the same
144
+ * half: an action is called with values that can cross *or* the one form a
145
+ * submit produces, and answers with a value that can cross. `void` is a result
146
+ * and not an argument, because JSON has no `undefined` and an action declared
147
+ * to take one would be taking something the caller cannot send; a `FormData`
148
+ * is an argument and not a result, because a form is something a browser
149
+ * submits and not something a server answers with.
150
+ */
151
+ export type ServerActionFunction = (...args: Array<ActionArgument>) => Promise<ActionValue | void>;
152
+
153
+ /**
154
+ * An action's arguments, held against what a wire can carry.
155
+ *
156
+ * A bound on a tuple rather than a bound on the function, and the difference
157
+ * is the whole reason this is two types instead of one. A function type puts
158
+ * its parameters in a contravariant position: `F extends (…args:
159
+ * Array<ActionValue>) => …` asks whether `F` accepts *every* `ActionValue`,
160
+ * which `createUser(name: string)` does not and should not. `Parameters<F>` is
161
+ * the same list read covariantly, where the question is the one worth asking —
162
+ * is each argument something that can cross?
163
+ *
164
+ * `uf prepare` writes one instantiation over the whole project:
165
+ *
166
+ * export type ServerActionArgsFitTheWire =
167
+ * ActionArguments<ServerActionArgs<ServerActionName>>;
168
+ *
169
+ * `ServerActionName` is every action's name, so `ServerActionArgs` of it is
170
+ * every action's argument list, and one line checks all of them.
171
+ *
172
+ * The bound is [`ActionArgument`] and not [`ActionValue`], which is the whole
173
+ * of what makes `<form action={fn}>` type-check: a form action's parameter is
174
+ * a `FormData`, and a `FormData` crosses as an argument and only as one.
175
+ *
176
+ * # What this does not catch, and why
177
+ *
178
+ * The bound is element-wise, so it holds each argument against the grammar and
179
+ * says nothing about the list as a whole. The wire has one rule that is about
180
+ * the list: a call carries at most one form, because the envelope names the
181
+ * form's position once. So `(a: FormData, b: FormData)` type-checks here and
182
+ * throws `ActionValueError` at `encodeActionArguments` — a rule enforced at
183
+ * run time that the types ought to have caught.
184
+ *
185
+ * Saying it in the type needs a walk over the tuple, and the walk is blocked by
186
+ * ubugeeei-prod/uf#300: a spread in a conditional type's tuple pattern binds
187
+ * `infer` as `unknown` and the rest as the whole array widened. The obvious
188
+ * recursion is not merely rejected, it quietly answers wrongly —
189
+ *
190
+ * type NoForm<T> = T extends [] ? true
191
+ * : T extends [infer H, ...infer R]
192
+ * ? (H extends FormData ? false : NoForm<R>) : true;
193
+ *
194
+ * — gives `true` for `[string, FormData]`, because the spread pattern never
195
+ * matches and every tuple falls through to the last branch. A constraint built
196
+ * on that would be worse than none: it would report every signature as fine.
197
+ *
198
+ * `tests/type-tests/server-actions.js` pins the gap, so that whoever fixes
199
+ * #300 is told this is waiting on it. The run-time guard and its test are in
200
+ * `internal/action-wire.js` and `tests/library/server-actions.test.js`.
201
+ */
202
+ export type ActionArguments<TArgs extends $ReadOnlyArray<ActionArgument>> = TArgs;
203
+
204
+ /**
205
+ * An action's result, held against the same grammar.
206
+ *
207
+ * A promise, because a server action is always async — the RSC graph rejects
208
+ * one that is not — and `void` is allowed because an action that returns
209
+ * nothing is ordinary. `Promise` is covariant in Flow, so the bound reaches
210
+ * the resolved type without any of the contortion the arguments needed.
211
+ */
212
+ export type ActionResult<TResult extends Promise<ActionValue | void>> = TResult;
213
+
214
+ /**
215
+ * A server action that did not answer.
216
+ *
217
+ * Carries the status and the action's build-time name and nothing else,
218
+ * because nothing else came back: the endpoint answers every refusal with a
219
+ * fixed body, so there is no message from the server to relay. What went wrong
220
+ * is in the server's log, which is where an application's internals belong.
221
+ */
222
+ export class ServerActionError extends Error {
223
+ /** The HTTP status the endpoint answered with. */
224
+ status: number;
225
+ /** `module#export` of the action that was called. */
226
+ action: string;
227
+
228
+ constructor(action: string, status: number) {
229
+ super(
230
+ `@uniflowed/router: the server action \`${action}\` answered ${String(status)}. ` +
231
+ "The endpoint reports every failure the same way; the reason is in the server's log.",
232
+ );
233
+ this.name = "ServerActionError";
234
+ this.status = status;
235
+ this.action = action;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * The reference the client bundle holds in place of one server action.
241
+ *
242
+ * Generated, never written by hand: `@uniflowed/vite` emits one call per
243
+ * callable export of a `"use server"` module, with the id
244
+ * `crates/uf_rsc/src/action.rs` derived for it and the `module#export` name
245
+ * that only ever appears in an error.
246
+ *
247
+ * The returned function is `async` and refuses before it sends: an argument
248
+ * outside the wire grammar throws an `ActionValueError` naming the argument's
249
+ * position, at the call site, rather than becoming a `400` with nothing in it.
250
+ *
251
+ * It carries no `$$FORM_ACTION`, which is deliberate and is the header's last
252
+ * section: React uses that property to make a form submit *natively* before
253
+ * hydration, and a native submit is a content type this endpoint refuses.
254
+ */
255
+ export function createServerReference(id: string, name: string): ServerActionFunction {
256
+ return async function callServerAction(
257
+ ...args: Array<ActionArgument>
258
+ ): Promise<ActionValue | void> {
259
+ const body = encodeActionArguments(args);
260
+ // Built rather than written as a literal, because the header's name is a
261
+ // constant and a computed key in an object literal is a shape Flow
262
+ // declines to track.
263
+ const headers: { [string]: string } = { "content-type": ACTION_CONTENT_TYPE };
264
+ headers[ACTION_HEADER] = id;
265
+ // Which build this page is, so a server on another build refuses the call
266
+ // rather than looking this id up in a table it was never in.
267
+ withDeployment(headers);
268
+ const response = await fetch(currentUrl(), {
269
+ method: "POST",
270
+ // Stated rather than left to the default, because the default is what a
271
+ // reader has to look up and because this one is load-bearing: the
272
+ // endpoint's `Origin` check is only meaningful for a request that
273
+ // carries the visitor's cookies in the first place.
274
+ credentials: "same-origin",
275
+ // Never a cached answer, and never one written to a cache: an action is
276
+ // a side effect, and `POST` responses are outside HTTP caching by
277
+ // default only until something decides otherwise.
278
+ cache: "no-store",
279
+ headers,
280
+ body,
281
+ });
282
+ // A route a navigation kept may no longer show what this action wrote, and
283
+ // which routes is the server's to know, so every kept route is asked for
284
+ // again. Whatever the status: an action that failed part-way may have
285
+ // written before it failed. See `./internal/navigation-cache.js`.
286
+ clearNavigationCache();
287
+ // The server is on another build: nothing ran, and nothing on this page
288
+ // can be called correctly any more. Load the page again, from the build
289
+ // that is live, and never settle — a rejection here would reach an error
290
+ // boundary for the moment before the document is replaced, and a result
291
+ // would be a lie. See `./internal/deployment.js`.
292
+ if (refusedAsAnotherDeployment(response)) {
293
+ loadDocument(currentUrl());
294
+ return new Promise<ActionValue | void>(() => {});
295
+ }
296
+ if (!response.ok) {
297
+ throw new ServerActionError(name, response.status);
298
+ }
299
+ return decodeActionResult(await response.text());
300
+ };
301
+ }
302
+
303
+ /**
304
+ * The URL an action call is sent to: the page the browser is on.
305
+ *
306
+ * Path and query, not the whole URL, so the request is same-origin by
307
+ * construction rather than by a comparison somebody could get wrong. The hash
308
+ * is left off because it never reaches a server.
309
+ *
310
+ * A reference only exists in the client bundle — the server imports the real
311
+ * module — so there is no `location` here only if something has imported the
312
+ * browser's half into a server, and saying so is better than posting to a
313
+ * relative path that means nothing there.
314
+ */
315
+ function currentUrl(): string {
316
+ const location = globalThis.location;
317
+ if (location == null) {
318
+ throw new Error(
319
+ "@uniflowed/router: a server action reference was called where there is no `location`. " +
320
+ "A reference is the browser's half of an action; the server imports the module itself.",
321
+ );
322
+ }
323
+ return `${location.pathname}${location.search}`;
324
+ }
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,28 @@
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";
33
122
 
34
123
  /**
35
124
  * Hydrate the current document.
@@ -43,6 +132,11 @@ export async function hydrate(options: {|
43
132
  readonly routes: RouteTable["routes"],
44
133
  readonly notFound: RouteTable["notFound"],
45
134
  readonly errors: RouteTable["errors"],
135
+ readonly strictMode?: boolean,
136
+ readonly navigation?: Navigation,
137
+ readonly basePath?: string,
138
+ readonly trailingSlash?: TrailingSlash,
139
+ readonly staleTime?: number,
46
140
  |}): Promise<void> {
47
141
  const table: RouteTable = {
48
142
  routes: options.routes,
@@ -50,22 +144,182 @@ export async function hydrate(options: {|
50
144
  errors: options.errors,
51
145
  };
52
146
  installRoutes(table);
147
+ // Beside the table, and before anything renders. `"client"` when the entry
148
+ // says nothing, which is what `virtual:uf/client` generated before
149
+ // `app.rendering.navigation` existed and what a hand-written entry still
150
+ // means: the default is the behaviour, not the absence of one.
151
+ installNavigation(options.navigation ?? "client");
152
+ installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
153
+ installStaleTime(options.staleTime ?? 0);
154
+ // The route table has no base path in it, and the address bar does.
155
+ const applicationPath = applicationPathOf(window.location.pathname) ?? window.location.pathname;
53
156
 
54
157
  // Before the loader data is read and before `resolveMatch` is called: both
55
158
  // would go looking for a page module that is not in this bundle.
56
- const matched = matchRoute(table.routes, window.location.pathname);
159
+ const matched = matchRoute(table.routes, applicationPath);
57
160
  if (matched != null && !hasClientPage(matched.route)) {
58
161
  return;
59
162
  }
60
163
 
61
- const url = window.location.pathname + window.location.search;
164
+ const url = applicationPath + window.location.search;
165
+ // Row 0 of the payload, and the reader that will fill in the rows it refers
166
+ // to. Both before `hydrateRoot`, and in this order: `decodePayload` is what
167
+ // tells the reader which rows the page is waiting for, and `watch` is what
168
+ // makes it notice the ones the server has not written yet. A document with
169
+ // nothing deferred has no references, so the reader is handed no ids, and
170
+ // `watch` returns without installing anything — see `internal/payload.js`.
62
171
  const embedded = document.getElementById(DATA_ID);
63
- const data = embedded != null ? JSON.parse(embedded.textContent ?? "null") : undefined;
172
+ const reader = createPayloadReader(document, domObserver(document));
173
+ const data =
174
+ embedded != null
175
+ ? decodePayload(
176
+ JSON.parse(embedded.textContent ?? "null"),
177
+ reader.resolve,
178
+ "the route's loader data",
179
+ )
180
+ : undefined;
181
+ reader.watch();
64
182
  const resolved = await resolveMatch(table, url, { data, skipLoader: embedded != null });
65
183
 
66
184
  const { App } = options;
67
185
  const container = document.getElementById(ROOT_ID) ?? document;
186
+ prepareDocumentForHydration(document);
187
+
188
+ // The server's markup, and the reporter that will read it, in development
189
+ // only. Both have to be in place *before* `hydrateRoot`: React repairs a
190
+ // mismatched subtree by rendering over it, so the bytes the server sent exist
191
+ // for exactly the moment between the parser finishing and this line.
192
+ //
193
+ // `import.meta.hot` is the gate because it is the one signal that is right in
194
+ // all three places this module is evaluated. Vite defines it while serving
195
+ // and replaces it with `undefined` in a build, so the branch is statically
196
+ // dead there; Node leaves it undefined, so `packages/vite/rsc-split.test.js`
197
+ // imports this file without a bundler and gets the production path. The
198
+ // import is dynamic so that the overlay is not merely shaken out of a
199
+ // production bundle but never reachable from one.
200
+ let recovery = null;
201
+ let restoreDevHead = null;
202
+ if (import.meta.hot != null) {
203
+ const { captureServerMarkup, hydrationErrorHandler, prepareDevHeadForHydration } =
204
+ await import("./internal/hydration.js");
205
+ restoreDevHead = prepareDevHeadForHydration(document);
206
+ recovery = hydrationErrorHandler(container, captureServerMarkup(container), document);
207
+ }
208
+
209
+ // `<StrictMode>` renders no element of its own, so the tree React hydrates
210
+ // against the server's markup is the same tree either way and the flag can
211
+ // be a development-only difference without being a hydration difference.
212
+ const tree = <App url={url} initial={resolved} />;
213
+
68
214
  startTransition(() => {
69
- hydrateRoot(container, <App url={url} initial={resolved} />);
215
+ hydrateRoot(
216
+ container,
217
+ options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
218
+ recovery == null ? undefined : { onRecoverableError: recovery },
219
+ );
220
+ if (restoreDevHead != null) {
221
+ setTimeout(restoreDevHead, 250);
222
+ }
70
223
  });
224
+
225
+ // And, in development only, whether the panel a developer is about to open
226
+ // can see any of that. `react-dom` announced itself while it was being
227
+ // imported — long before this line — so the answer is already settled and
228
+ // this only reads it. Behind the same `import.meta.hot` gate as the
229
+ // hydration reporter, dynamically imported for the same reason: a production
230
+ // bundle has no path to the module rather than merely no reason to run it.
231
+ // See `./internal/devtools.js` and ubugeeei-prod/uf#503.
232
+ if (import.meta.hot != null) {
233
+ const { reportDevtools } = await import("./internal/devtools.js");
234
+ reportDevtools(window);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Render the current route into an empty shell.
240
+ *
241
+ * The single-page entry point: `app.rendering.modes: ["csr"]` writes one
242
+ * document with an empty root and no markup in it, and this is what fills it.
243
+ *
244
+ * # Why it is not `hydrate` with a flag
245
+ *
246
+ * Because hydration is React comparing what it renders against what a server
247
+ * sent, and here no server sent anything. `hydrateRoot` against an empty
248
+ * container is a mismatch on the first node of every page — React would report
249
+ * it, throw the shell away and render from scratch, which is this function
250
+ * with a warning in front of it. `createRoot` says what is actually happening:
251
+ * the browser is the only renderer this application has.
252
+ *
253
+ * Three more things follow from there, and each of them is a line below rather
254
+ * than an omission:
255
+ *
256
+ * * **The loader runs here.** `resolveMatch` fetches the route's modules and
257
+ * runs its loader in the browser, because there was no server render to run
258
+ * it in and no `<script id="__uf_data">` for it to have left an answer in.
259
+ * * **A URL that matches nothing is the not-found boundary**, resolved the
260
+ * way a server resolves it. The host served this shell for a URL it had no
261
+ * file for, so "nothing matched" is a perfectly ordinary arrival here
262
+ * rather than the exception it is during hydration.
263
+ * * **A redirect is the browser's.** `redirect()` from a loader throws before
264
+ * anything is rendered; on a server that becomes a 307 and here it becomes
265
+ * `location.replace`, which is the same instruction to the same browser.
266
+ */
267
+ export async function render(options: {|
268
+ readonly App: React.ComponentType<AppProps>,
269
+ readonly routes: RouteTable["routes"],
270
+ readonly notFound: RouteTable["notFound"],
271
+ readonly errors: RouteTable["errors"],
272
+ readonly strictMode?: boolean,
273
+ readonly navigation?: Navigation,
274
+ readonly basePath?: string,
275
+ readonly trailingSlash?: TrailingSlash,
276
+ readonly staleTime?: number,
277
+ |}): Promise<void> {
278
+ const table: RouteTable = {
279
+ routes: options.routes,
280
+ notFound: options.notFound,
281
+ errors: options.errors,
282
+ };
283
+ installRoutes(table);
284
+ installNavigation(options.navigation ?? "client");
285
+ installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
286
+ installStaleTime(options.staleTime ?? 0);
287
+
288
+ const url =
289
+ (applicationPathOf(window.location.pathname) ?? window.location.pathname) +
290
+ window.location.search;
291
+ let resolved;
292
+ try {
293
+ resolved = await resolveMatch(table, url);
294
+ } catch (error) {
295
+ if (error instanceof RedirectError) {
296
+ window.location.replace(addressOf(error.to));
297
+ return;
298
+ }
299
+ // The error boundary, chosen the same way the server chooses it. A throw
300
+ // from a loader is a page that cannot render, and rendering the boundary is
301
+ // what this application has instead of a 500.
302
+ resolved = await resolveFailure(table, url, error);
303
+ }
304
+
305
+ const { App } = options;
306
+ // An element, and never `document` — which is the other difference from
307
+ // `hydrate` above. `hydrateRoot` takes a document, because an app whose root
308
+ // layout renders `<html>` owns the whole of one and the server wrote it;
309
+ // `createRoot` does not, because creating a root *is* replacing the
310
+ // container's children and the container here would be the document. The
311
+ // shell always writes this element, so its absence means the document being
312
+ // rendered into is not one this build produced.
313
+ const container = document.getElementById(ROOT_ID);
314
+ if (container == null) {
315
+ throw new Error(
316
+ `@uniflowed/router: no #${ROOT_ID} in this document, so there is nothing to render into. ` +
317
+ "A single-page build writes the shell that carries it; this document came from " +
318
+ "somewhere else.",
319
+ );
320
+ }
321
+ const tree = <App url={url} initial={resolved} />;
322
+ createRoot(container).render(
323
+ options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
324
+ );
71
325
  }