@uniflowed/router 0.0.0-alpha.4 → 0.0.0-alpha.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/action.js +301 -0
- package/client.js +295 -8
- package/handler.js +101 -17
- package/index.js +71 -4
- package/internal/action-endpoint.js +434 -0
- package/internal/action-wire.js +608 -0
- package/internal/base-path.js +175 -0
- package/internal/boundaries.js +481 -0
- package/internal/boundary-data.js +88 -0
- package/internal/compose.js +490 -0
- package/internal/devtools.js +131 -0
- package/internal/diagnostics.js +169 -0
- package/internal/error-view.js +193 -0
- package/internal/flight-browser.js +228 -0
- package/internal/flight-chunks.js +205 -0
- package/internal/flight-ssr.js +78 -0
- package/internal/flight.js +158 -0
- package/internal/head.js +219 -0
- package/internal/hydration.js +1085 -0
- package/internal/inspector.js +626 -0
- package/internal/navigation-cache.js +144 -0
- package/internal/payload-rows.js +270 -0
- package/internal/payload.js +685 -0
- package/internal/prepare-document.js +49 -0
- package/internal/react-version.js +77 -0
- package/internal/request.js +43 -0
- package/internal/resolve.js +1615 -0
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +474 -0
- package/internal/runtime.js +1511 -543
- package/internal/server-route.js +58 -0
- package/internal/shell.js +115 -0
- package/internal/stream.js +1084 -0
- package/middleware.js +350 -0
- package/native.js +408 -0
- package/package.json +36 -7
- package/routing.js +51 -0
- package/rsc-client.js +120 -0
- package/rsc-ssr.js +440 -0
- package/rsc.js +334 -0
- package/server-components.js +159 -0
- package/server.js +448 -75
package/action.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
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
|
+
// # What Flow checks, and where
|
|
91
|
+
//
|
|
92
|
+
// Flow reads `app/_actions/clicks.js`, not the reference the bundler
|
|
93
|
+
// substitutes for it, so `recordClick("nine")` against
|
|
94
|
+
// `recordClick(count: number)` is a `uf check` error rather than a `400`. That
|
|
95
|
+
// is the half that needs nothing from this module.
|
|
96
|
+
//
|
|
97
|
+
// What a declaration alone does not say is whether those arguments and that
|
|
98
|
+
// result can cross a wire at all, and `ActionArguments` and `ActionResult` are
|
|
99
|
+
// that half: `uf prepare` writes one instantiation of each into
|
|
100
|
+
// `server-actions.js`, over every action in the project at once, so an action
|
|
101
|
+
// taking a callback or returning a `Map` is a `uf check` error naming the
|
|
102
|
+
// offending type. Without them the same mistake is a request that arrives with
|
|
103
|
+
// `{}` where an object was passed — the kind of bug found in production by a
|
|
104
|
+
// column that stopped being written.
|
|
105
|
+
|
|
106
|
+
import {
|
|
107
|
+
ACTION_CONTENT_TYPE,
|
|
108
|
+
ACTION_HEADER,
|
|
109
|
+
type ActionArgument,
|
|
110
|
+
type ActionValue,
|
|
111
|
+
decodeActionResult,
|
|
112
|
+
encodeActionArguments,
|
|
113
|
+
} from "./internal/action-wire.js";
|
|
114
|
+
import { clearNavigationCache } from "./internal/navigation-cache.js";
|
|
115
|
+
|
|
116
|
+
export type { ActionArgument, ActionValue } from "./internal/action-wire.js";
|
|
117
|
+
export {
|
|
118
|
+
ACTION_CONTENT_TYPE,
|
|
119
|
+
ACTION_HEADER,
|
|
120
|
+
ActionValueError,
|
|
121
|
+
MAX_ACTION_ARGUMENTS,
|
|
122
|
+
MAX_ACTION_BODY_BYTES,
|
|
123
|
+
MAX_ACTION_DEPTH,
|
|
124
|
+
MAX_ACTION_VALUES,
|
|
125
|
+
MAX_FORM_ENTRIES,
|
|
126
|
+
MAX_FORM_NAME_LENGTH,
|
|
127
|
+
} from "./internal/action-wire.js";
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A function that can be a server action.
|
|
131
|
+
*
|
|
132
|
+
* Both halves of the signature are the wire grammar, and they are not the same
|
|
133
|
+
* half: an action is called with values that can cross *or* the one form a
|
|
134
|
+
* submit produces, and answers with a value that can cross. `void` is a result
|
|
135
|
+
* and not an argument, because JSON has no `undefined` and an action declared
|
|
136
|
+
* to take one would be taking something the caller cannot send; a `FormData`
|
|
137
|
+
* is an argument and not a result, because a form is something a browser
|
|
138
|
+
* submits and not something a server answers with.
|
|
139
|
+
*/
|
|
140
|
+
export type ServerActionFunction = (...args: Array<ActionArgument>) => Promise<ActionValue | void>;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* An action's arguments, held against what a wire can carry.
|
|
144
|
+
*
|
|
145
|
+
* A bound on a tuple rather than a bound on the function, and the difference
|
|
146
|
+
* is the whole reason this is two types instead of one. A function type puts
|
|
147
|
+
* its parameters in a contravariant position: `F extends (…args:
|
|
148
|
+
* Array<ActionValue>) => …` asks whether `F` accepts *every* `ActionValue`,
|
|
149
|
+
* which `createUser(name: string)` does not and should not. `Parameters<F>` is
|
|
150
|
+
* the same list read covariantly, where the question is the one worth asking —
|
|
151
|
+
* is each argument something that can cross?
|
|
152
|
+
*
|
|
153
|
+
* `uf prepare` writes one instantiation over the whole project:
|
|
154
|
+
*
|
|
155
|
+
* export type ServerActionArgsFitTheWire =
|
|
156
|
+
* ActionArguments<ServerActionArgs<ServerActionName>>;
|
|
157
|
+
*
|
|
158
|
+
* `ServerActionName` is every action's name, so `ServerActionArgs` of it is
|
|
159
|
+
* every action's argument list, and one line checks all of them.
|
|
160
|
+
*
|
|
161
|
+
* The bound is [`ActionArgument`] and not [`ActionValue`], which is the whole
|
|
162
|
+
* of what makes `<form action={fn}>` type-check: a form action's parameter is
|
|
163
|
+
* a `FormData`, and a `FormData` crosses as an argument and only as one.
|
|
164
|
+
*
|
|
165
|
+
* # What this does not catch, and why
|
|
166
|
+
*
|
|
167
|
+
* The bound is element-wise, so it holds each argument against the grammar and
|
|
168
|
+
* says nothing about the list as a whole. The wire has one rule that is about
|
|
169
|
+
* the list: a call carries at most one form, because the envelope names the
|
|
170
|
+
* form's position once. So `(a: FormData, b: FormData)` type-checks here and
|
|
171
|
+
* throws `ActionValueError` at `encodeActionArguments` — a rule enforced at
|
|
172
|
+
* run time that the types ought to have caught.
|
|
173
|
+
*
|
|
174
|
+
* Saying it in the type needs a walk over the tuple, and the walk is blocked by
|
|
175
|
+
* ubugeeei-prod/uf#300: a spread in a conditional type's tuple pattern binds
|
|
176
|
+
* `infer` as `unknown` and the rest as the whole array widened. The obvious
|
|
177
|
+
* recursion is not merely rejected, it quietly answers wrongly —
|
|
178
|
+
*
|
|
179
|
+
* type NoForm<T> = T extends [] ? true
|
|
180
|
+
* : T extends [infer H, ...infer R]
|
|
181
|
+
* ? (H extends FormData ? false : NoForm<R>) : true;
|
|
182
|
+
*
|
|
183
|
+
* — gives `true` for `[string, FormData]`, because the spread pattern never
|
|
184
|
+
* matches and every tuple falls through to the last branch. A constraint built
|
|
185
|
+
* on that would be worse than none: it would report every signature as fine.
|
|
186
|
+
*
|
|
187
|
+
* `tests/type-tests/server-actions.js` pins the gap, so that whoever fixes
|
|
188
|
+
* #300 is told this is waiting on it. The run-time guard and its test are in
|
|
189
|
+
* `internal/action-wire.js` and `tests/library/server-actions.test.js`.
|
|
190
|
+
*/
|
|
191
|
+
export type ActionArguments<TArgs extends $ReadOnlyArray<ActionArgument>> = TArgs;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* An action's result, held against the same grammar.
|
|
195
|
+
*
|
|
196
|
+
* A promise, because a server action is always async — the RSC graph rejects
|
|
197
|
+
* one that is not — and `void` is allowed because an action that returns
|
|
198
|
+
* nothing is ordinary. `Promise` is covariant in Flow, so the bound reaches
|
|
199
|
+
* the resolved type without any of the contortion the arguments needed.
|
|
200
|
+
*/
|
|
201
|
+
export type ActionResult<TResult extends Promise<ActionValue | void>> = TResult;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A server action that did not answer.
|
|
205
|
+
*
|
|
206
|
+
* Carries the status and the action's build-time name and nothing else,
|
|
207
|
+
* because nothing else came back: the endpoint answers every refusal with a
|
|
208
|
+
* fixed body, so there is no message from the server to relay. What went wrong
|
|
209
|
+
* is in the server's log, which is where an application's internals belong.
|
|
210
|
+
*/
|
|
211
|
+
export class ServerActionError extends Error {
|
|
212
|
+
/** The HTTP status the endpoint answered with. */
|
|
213
|
+
status: number;
|
|
214
|
+
/** `module#export` of the action that was called. */
|
|
215
|
+
action: string;
|
|
216
|
+
|
|
217
|
+
constructor(action: string, status: number) {
|
|
218
|
+
super(
|
|
219
|
+
`@uniflowed/router: the server action \`${action}\` answered ${String(status)}. ` +
|
|
220
|
+
"The endpoint reports every failure the same way; the reason is in the server's log.",
|
|
221
|
+
);
|
|
222
|
+
this.name = "ServerActionError";
|
|
223
|
+
this.status = status;
|
|
224
|
+
this.action = action;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The reference the client bundle holds in place of one server action.
|
|
230
|
+
*
|
|
231
|
+
* Generated, never written by hand: `@uniflowed/vite` emits one call per
|
|
232
|
+
* callable export of a `"use server"` module, with the id
|
|
233
|
+
* `crates/uf_rsc/src/action.rs` derived for it and the `module#export` name
|
|
234
|
+
* that only ever appears in an error.
|
|
235
|
+
*
|
|
236
|
+
* The returned function is `async` and refuses before it sends: an argument
|
|
237
|
+
* outside the wire grammar throws an `ActionValueError` naming the argument's
|
|
238
|
+
* position, at the call site, rather than becoming a `400` with nothing in it.
|
|
239
|
+
*
|
|
240
|
+
* It carries no `$$FORM_ACTION`, which is deliberate and is the header's last
|
|
241
|
+
* section: React uses that property to make a form submit *natively* before
|
|
242
|
+
* hydration, and a native submit is a content type this endpoint refuses.
|
|
243
|
+
*/
|
|
244
|
+
export function createServerReference(id: string, name: string): ServerActionFunction {
|
|
245
|
+
return async function callServerAction(
|
|
246
|
+
...args: Array<ActionArgument>
|
|
247
|
+
): Promise<ActionValue | void> {
|
|
248
|
+
const body = encodeActionArguments(args);
|
|
249
|
+
// Built rather than written as a literal, because the header's name is a
|
|
250
|
+
// constant and a computed key in an object literal is a shape Flow
|
|
251
|
+
// declines to track.
|
|
252
|
+
const headers: { [string]: string } = { "content-type": ACTION_CONTENT_TYPE };
|
|
253
|
+
headers[ACTION_HEADER] = id;
|
|
254
|
+
const response = await fetch(currentUrl(), {
|
|
255
|
+
method: "POST",
|
|
256
|
+
// Stated rather than left to the default, because the default is what a
|
|
257
|
+
// reader has to look up and because this one is load-bearing: the
|
|
258
|
+
// endpoint's `Origin` check is only meaningful for a request that
|
|
259
|
+
// carries the visitor's cookies in the first place.
|
|
260
|
+
credentials: "same-origin",
|
|
261
|
+
// Never a cached answer, and never one written to a cache: an action is
|
|
262
|
+
// a side effect, and `POST` responses are outside HTTP caching by
|
|
263
|
+
// default only until something decides otherwise.
|
|
264
|
+
cache: "no-store",
|
|
265
|
+
headers,
|
|
266
|
+
body,
|
|
267
|
+
});
|
|
268
|
+
// A route a navigation kept may no longer show what this action wrote, and
|
|
269
|
+
// which routes is the server's to know, so every kept route is asked for
|
|
270
|
+
// again. Whatever the status: an action that failed part-way may have
|
|
271
|
+
// written before it failed. See `./internal/navigation-cache.js`.
|
|
272
|
+
clearNavigationCache();
|
|
273
|
+
if (!response.ok) {
|
|
274
|
+
throw new ServerActionError(name, response.status);
|
|
275
|
+
}
|
|
276
|
+
return decodeActionResult(await response.text());
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The URL an action call is sent to: the page the browser is on.
|
|
282
|
+
*
|
|
283
|
+
* Path and query, not the whole URL, so the request is same-origin by
|
|
284
|
+
* construction rather than by a comparison somebody could get wrong. The hash
|
|
285
|
+
* is left off because it never reaches a server.
|
|
286
|
+
*
|
|
287
|
+
* A reference only exists in the client bundle — the server imports the real
|
|
288
|
+
* module — so there is no `location` here only if something has imported the
|
|
289
|
+
* browser's half into a server, and saying so is better than posting to a
|
|
290
|
+
* relative path that means nothing there.
|
|
291
|
+
*/
|
|
292
|
+
function currentUrl(): string {
|
|
293
|
+
const location = globalThis.location;
|
|
294
|
+
if (location == null) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
"@uniflowed/router: a server action reference was called where there is no `location`. " +
|
|
297
|
+
"A reference is the browser's half of an action; the server imports the module itself.",
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return `${location.pathname}${location.search}`;
|
|
301
|
+
}
|
package/client.js
CHANGED
|
@@ -1,38 +1,325 @@
|
|
|
1
1
|
// @flow
|
|
2
2
|
//
|
|
3
|
-
//
|
|
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.
|
|
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
|
+
//
|
|
89
|
+
// # A route can decline to be hydrated
|
|
90
|
+
//
|
|
91
|
+
// uf's server-component analysis decides which routes have a `"use client"`
|
|
92
|
+
// boundary anywhere in them, and `@uniflowed/vite` leaves the page out of the
|
|
93
|
+
// client route table for the ones that have none. Such a route has nothing in
|
|
94
|
+
// the browser to attach: the document the server wrote is the whole of it. So
|
|
95
|
+
// this returns without calling `hydrateRoot`, and the `<a>` elements a `Link`
|
|
96
|
+
// rendered stay what the server made them — real links the browser follows.
|
|
97
|
+
// See ubugeeei-prod/uf#350.
|
|
9
98
|
|
|
10
99
|
import * as React from "react";
|
|
11
|
-
import { startTransition } from "react";
|
|
12
|
-
import { hydrateRoot } from "react-dom/client";
|
|
100
|
+
import { StrictMode, startTransition } from "react";
|
|
101
|
+
import { createRoot, hydrateRoot } from "react-dom/client";
|
|
13
102
|
|
|
14
|
-
import {
|
|
103
|
+
import {
|
|
104
|
+
type AppProps,
|
|
105
|
+
type Navigation,
|
|
106
|
+
type RouteTable,
|
|
107
|
+
RedirectError,
|
|
108
|
+
hasClientPage,
|
|
109
|
+
installNavigation,
|
|
110
|
+
installRoutes,
|
|
111
|
+
installRouting,
|
|
112
|
+
installStaleTime,
|
|
113
|
+
matchRoute,
|
|
114
|
+
resolveFailure,
|
|
115
|
+
resolveMatch,
|
|
116
|
+
} from "./internal/runtime.js";
|
|
15
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";
|
|
16
122
|
|
|
17
123
|
/**
|
|
18
124
|
* Hydrate the current document.
|
|
125
|
+
*
|
|
126
|
+
* Resolves without mounting anything when the current route ships no client
|
|
127
|
+
* page — see the header. The promise settling is not a claim that React is on
|
|
128
|
+
* the document.
|
|
19
129
|
*/
|
|
20
130
|
export async function hydrate(options: {|
|
|
21
131
|
readonly App: React.ComponentType<AppProps>,
|
|
22
132
|
readonly routes: RouteTable["routes"],
|
|
23
133
|
readonly notFound: RouteTable["notFound"],
|
|
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,
|
|
24
140
|
|}): Promise<void> {
|
|
25
|
-
const table: RouteTable = {
|
|
141
|
+
const table: RouteTable = {
|
|
142
|
+
routes: options.routes,
|
|
143
|
+
notFound: options.notFound,
|
|
144
|
+
errors: options.errors,
|
|
145
|
+
};
|
|
26
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;
|
|
156
|
+
|
|
157
|
+
// Before the loader data is read and before `resolveMatch` is called: both
|
|
158
|
+
// would go looking for a page module that is not in this bundle.
|
|
159
|
+
const matched = matchRoute(table.routes, applicationPath);
|
|
160
|
+
if (matched != null && !hasClientPage(matched.route)) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
27
163
|
|
|
28
|
-
const url =
|
|
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`.
|
|
29
171
|
const embedded = document.getElementById(DATA_ID);
|
|
30
|
-
const
|
|
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();
|
|
31
182
|
const resolved = await resolveMatch(table, url, { data, skipLoader: embedded != null });
|
|
32
183
|
|
|
33
184
|
const { App } = options;
|
|
34
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
|
+
|
|
35
214
|
startTransition(() => {
|
|
36
|
-
hydrateRoot(
|
|
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
|
+
}
|
|
37
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
|
+
);
|
|
38
325
|
}
|