@uniflowed/router 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/action.js +101 -24
- package/client.js +30 -9
- package/internal/action-endpoint.js +322 -14
- package/internal/action-wire.js +70 -8
- package/internal/compose.js +14 -4
- package/internal/deployment.js +3 -2
- package/internal/devtools.js +1 -1
- package/internal/error-view.js +1 -1
- package/internal/flight-browser.js +86 -10
- package/internal/flight-chunks.js +7 -7
- package/internal/flight-rows.js +7 -2
- package/internal/flight-ssr.js +10 -3
- package/internal/flight.js +32 -0
- package/internal/form-action.js +243 -0
- package/internal/hydrate-options.js +38 -0
- package/internal/hydration.js +11 -6
- package/internal/navigation-cache.js +1 -1
- package/internal/payload-rows.js +5 -4
- package/internal/payload.js +18 -12
- package/internal/prepare-document.js +6 -0
- package/internal/resolve.js +17 -11
- package/internal/routing.js +77 -12
- package/internal/runtime.js +114 -6
- package/internal/shell.js +9 -2
- package/internal/stream.js +41 -14
- package/middleware.js +140 -11
- package/package.json +6 -4
- package/rsc-client.js +3 -1
- package/rsc-ssr.js +19 -6
- package/rsc.js +23 -7
- package/server.js +13 -4
- package/testing.js +980 -0
package/action.js
CHANGED
|
@@ -66,26 +66,19 @@
|
|
|
66
66
|
// still reads `submitNote`'s own declaration, so a form action whose first
|
|
67
67
|
// parameter is not the state it was given `null` for is a `uf check` error.
|
|
68
68
|
//
|
|
69
|
-
// **
|
|
70
|
-
// the form that submits before its JavaScript has arrived —
|
|
71
|
-
// `$$FORM_ACTION`
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
// the
|
|
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.
|
|
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.
|
|
89
82
|
//
|
|
90
83
|
// # After a deploy
|
|
91
84
|
//
|
|
@@ -116,13 +109,16 @@
|
|
|
116
109
|
import {
|
|
117
110
|
ACTION_CONTENT_TYPE,
|
|
118
111
|
ACTION_HEADER,
|
|
112
|
+
ACTION_OUTCOME_HEADER,
|
|
119
113
|
type ActionArgument,
|
|
120
114
|
type ActionValue,
|
|
121
115
|
decodeActionResult,
|
|
122
116
|
encodeActionArguments,
|
|
123
117
|
} from "./internal/action-wire.js";
|
|
124
118
|
import { loadDocument, refusedAsAnotherDeployment, withDeployment } from "./internal/deployment.js";
|
|
119
|
+
import { withFormAction } from "./internal/form-action.js";
|
|
125
120
|
import { clearNavigationCache } from "./internal/navigation-cache.js";
|
|
121
|
+
import { ForbiddenError, NotFoundError, UnauthorizedError } from "./internal/routing.js";
|
|
126
122
|
|
|
127
123
|
export type { ActionArgument, ActionValue } from "./internal/action-wire.js";
|
|
128
124
|
export {
|
|
@@ -248,15 +244,44 @@ export class ServerActionError extends Error {
|
|
|
248
244
|
* outside the wire grammar throws an `ActionValueError` naming the argument's
|
|
249
245
|
* position, at the call site, rather than becoming a `400` with nothing in it.
|
|
250
246
|
*
|
|
251
|
-
* It carries
|
|
252
|
-
*
|
|
253
|
-
* hydration, and a native submit is a content type this endpoint refuses.
|
|
247
|
+
* It carries `$$FORM_ACTION`, so a form bound to it is a real form before the
|
|
248
|
+
* page hydrates; see the header and `./internal/form-action.js`.
|
|
254
249
|
*/
|
|
255
250
|
export function createServerReference(id: string, name: string): ServerActionFunction {
|
|
251
|
+
return withFormAction(callServerActionFor(id, name), id, []);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Give a server action, on the server, what its reference has in the browser.
|
|
256
|
+
*
|
|
257
|
+
* `@uniflowed/vite` calls this on every callable export of a `"use server"`
|
|
258
|
+
* module in the server graphs, with the id the build derived for it, so that a
|
|
259
|
+
* client component rendered to HTML writes a form that posts without
|
|
260
|
+
* JavaScript. It changes nothing about calling the function: the properties it
|
|
261
|
+
* defines are ones only React reads, and `bind` still binds. Anything that is
|
|
262
|
+
* not a function is returned untouched, because the RSC graph has already
|
|
263
|
+
* refused a `"use server"` export that is not one and this is not the place to
|
|
264
|
+
* say it twice.
|
|
265
|
+
*/
|
|
266
|
+
export function registerServerAction<T>(fn: T, id: string): T {
|
|
267
|
+
if (typeof fn !== "function") {
|
|
268
|
+
return fn;
|
|
269
|
+
}
|
|
270
|
+
// `typeof` refines `T` to a function whose parameters Flow cannot name, and
|
|
271
|
+
// `withFormAction` changes nothing about calling it; the cast says that.
|
|
272
|
+
withFormAction(fn as $FlowFixMe, id, []);
|
|
273
|
+
return fn;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The network call one reference makes. */
|
|
277
|
+
function callServerActionFor(id: string, name: string): ServerActionFunction {
|
|
256
278
|
return async function callServerAction(
|
|
257
279
|
...args: Array<ActionArgument>
|
|
258
280
|
): Promise<ActionValue | void> {
|
|
259
281
|
const body = encodeActionArguments(args);
|
|
282
|
+
// The page the call is made from, kept for the answer: a relative redirect
|
|
283
|
+
// means relative to this page, whatever the visitor has done since.
|
|
284
|
+
const from = currentUrl();
|
|
260
285
|
// Built rather than written as a literal, because the header's name is a
|
|
261
286
|
// constant and a computed key in an object literal is a shape Flow
|
|
262
287
|
// declines to track.
|
|
@@ -265,7 +290,7 @@ export function createServerReference(id: string, name: string): ServerActionFun
|
|
|
265
290
|
// Which build this page is, so a server on another build refuses the call
|
|
266
291
|
// rather than looking this id up in a table it was never in.
|
|
267
292
|
withDeployment(headers);
|
|
268
|
-
const response = await fetch(
|
|
293
|
+
const response = await fetch(from, {
|
|
269
294
|
method: "POST",
|
|
270
295
|
// Stated rather than left to the default, because the default is what a
|
|
271
296
|
// reader has to look up and because this one is load-bearing: the
|
|
@@ -293,6 +318,14 @@ export function createServerReference(id: string, name: string): ServerActionFun
|
|
|
293
318
|
loadDocument(currentUrl());
|
|
294
319
|
return new Promise<ActionValue | void>(() => {});
|
|
295
320
|
}
|
|
321
|
+
// The action called `redirect()`, `notFound()`, `unauthorized()` or
|
|
322
|
+
// `forbidden()`. Checked before the status, because three of the four are
|
|
323
|
+
// not `ok` and none of them is a failure.
|
|
324
|
+
const outcome = response.headers.get(ACTION_OUTCOME_HEADER);
|
|
325
|
+
if (outcome != null) {
|
|
326
|
+
void response.body?.cancel();
|
|
327
|
+
return followOutcome(name, outcome, response, from);
|
|
328
|
+
}
|
|
296
329
|
if (!response.ok) {
|
|
297
330
|
throw new ServerActionError(name, response.status);
|
|
298
331
|
}
|
|
@@ -300,6 +333,50 @@ export function createServerReference(id: string, name: string): ServerActionFun
|
|
|
300
333
|
};
|
|
301
334
|
}
|
|
302
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Do in the browser what the action's routing call asked for.
|
|
338
|
+
*
|
|
339
|
+
* - `redirect`: go to the answer's `Location` the way `router.push` would. The
|
|
340
|
+
* call then resolves with `undefined`, because the action returned nothing;
|
|
341
|
+
* it threw. It resolves only once the navigation has settled, so a
|
|
342
|
+
* `useActionState` on a page that is still on screen afterwards (a redirect
|
|
343
|
+
* to the same page) is not left pending.
|
|
344
|
+
* - `not-found`, `unauthorized`, `forbidden`: throw the same error the server
|
|
345
|
+
* caught. React hands an error thrown by an action to the nearest error
|
|
346
|
+
* boundary, and uf's route boundary already tells `unauthorized()` and
|
|
347
|
+
* `forbidden()` apart. `notFound()` arrives as a `NotFoundError`, which an
|
|
348
|
+
* `$error.js` can test for.
|
|
349
|
+
*
|
|
350
|
+
* Any other value is a server this reference does not understand, and it is
|
|
351
|
+
* the same `ServerActionError` as any other failure.
|
|
352
|
+
*
|
|
353
|
+
* The router is imported when a redirect happens, not at module scope. This
|
|
354
|
+
* module is also imported by the server graphs (`registerServerAction`), and
|
|
355
|
+
* the router's runtime is client-rendering code that has no place there.
|
|
356
|
+
*/
|
|
357
|
+
async function followOutcome(
|
|
358
|
+
name: string,
|
|
359
|
+
outcome: string,
|
|
360
|
+
response: Response,
|
|
361
|
+
from: string,
|
|
362
|
+
): Promise<ActionValue | void> {
|
|
363
|
+
switch (outcome) {
|
|
364
|
+
case "redirect": {
|
|
365
|
+
const { followActionRedirect } = await import("./internal/runtime.js");
|
|
366
|
+
await followActionRedirect(response.headers.get("location") ?? from, from);
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
case "not-found":
|
|
370
|
+
throw new NotFoundError();
|
|
371
|
+
case "unauthorized":
|
|
372
|
+
throw new UnauthorizedError();
|
|
373
|
+
case "forbidden":
|
|
374
|
+
throw new ForbiddenError();
|
|
375
|
+
default:
|
|
376
|
+
throw new ServerActionError(name, response.status);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
303
380
|
/**
|
|
304
381
|
* The URL an action call is sent to: the page the browser is on.
|
|
305
382
|
*
|
package/client.js
CHANGED
|
@@ -119,6 +119,14 @@ import { decodePayload } from "./internal/payload.js";
|
|
|
119
119
|
import { createPayloadReader, domObserver } from "./internal/payload-rows.js";
|
|
120
120
|
import { prepareDocumentForHydration } from "./internal/prepare-document.js";
|
|
121
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";
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The React root `hydrate` and `render` mounted, as far as a caller needs it:
|
|
127
|
+
* a way to take the application down again. See `hydrate` for who needs that.
|
|
128
|
+
*/
|
|
129
|
+
export type ApplicationRoot = { readonly unmount: () => void, ... };
|
|
122
130
|
|
|
123
131
|
/**
|
|
124
132
|
* Hydrate the current document.
|
|
@@ -126,6 +134,14 @@ import { type TrailingSlash, addressOf, applicationPathOf } from "./internal/bas
|
|
|
126
134
|
* Resolves without mounting anything when the current route ships no client
|
|
127
135
|
* page — see the header. The promise settling is not a claim that React is on
|
|
128
136
|
* the document.
|
|
137
|
+
*
|
|
138
|
+
* Resolves with the React root it created, or `null` when it mounted nothing.
|
|
139
|
+
* A page never needs it: its root lives as long as the document. It is for
|
|
140
|
+
* whoever has to take the application down again — a test that hydrates one
|
|
141
|
+
* page and then another in the same process, where an application left
|
|
142
|
+
* mounted keeps its router, its `popstate` listener and its effects running
|
|
143
|
+
* under the next one. `root.unmount()` runs every cleanup, including the one
|
|
144
|
+
* that stops a server action from navigating a router that is gone.
|
|
129
145
|
*/
|
|
130
146
|
export async function hydrate(options: {|
|
|
131
147
|
readonly App: React.ComponentType<AppProps>,
|
|
@@ -137,7 +153,7 @@ export async function hydrate(options: {|
|
|
|
137
153
|
readonly basePath?: string,
|
|
138
154
|
readonly trailingSlash?: TrailingSlash,
|
|
139
155
|
readonly staleTime?: number,
|
|
140
|
-
|}): Promise<
|
|
156
|
+
|}): Promise<ApplicationRoot | null> {
|
|
141
157
|
const table: RouteTable = {
|
|
142
158
|
routes: options.routes,
|
|
143
159
|
notFound: options.notFound,
|
|
@@ -158,7 +174,7 @@ export async function hydrate(options: {|
|
|
|
158
174
|
// would go looking for a page module that is not in this bundle.
|
|
159
175
|
const matched = matchRoute(table.routes, applicationPath);
|
|
160
176
|
if (matched != null && !hasClientPage(matched.route)) {
|
|
161
|
-
return;
|
|
177
|
+
return null;
|
|
162
178
|
}
|
|
163
179
|
|
|
164
180
|
const url = applicationPath + window.location.search;
|
|
@@ -211,11 +227,12 @@ export async function hydrate(options: {|
|
|
|
211
227
|
// be a development-only difference without being a hydration difference.
|
|
212
228
|
const tree = <App url={url} initial={resolved} />;
|
|
213
229
|
|
|
230
|
+
let root: ApplicationRoot | null = null;
|
|
214
231
|
startTransition(() => {
|
|
215
|
-
hydrateRoot(
|
|
232
|
+
root = hydrateRoot(
|
|
216
233
|
container,
|
|
217
234
|
options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
|
|
218
|
-
recovery
|
|
235
|
+
hydrationOptions(recovery, readFormState(document)),
|
|
219
236
|
);
|
|
220
237
|
if (restoreDevHead != null) {
|
|
221
238
|
setTimeout(restoreDevHead, 250);
|
|
@@ -233,6 +250,7 @@ export async function hydrate(options: {|
|
|
|
233
250
|
const { reportDevtools } = await import("./internal/devtools.js");
|
|
234
251
|
reportDevtools(window);
|
|
235
252
|
}
|
|
253
|
+
return root;
|
|
236
254
|
}
|
|
237
255
|
|
|
238
256
|
/**
|
|
@@ -263,6 +281,9 @@ export async function hydrate(options: {|
|
|
|
263
281
|
* * **A redirect is the browser's.** `redirect()` from a loader throws before
|
|
264
282
|
* anything is rendered; on a server that becomes a 307 and here it becomes
|
|
265
283
|
* `location.replace`, which is the same instruction to the same browser.
|
|
284
|
+
*
|
|
285
|
+
* Resolves with the React root, or `null` when a loader redirected and nothing
|
|
286
|
+
* was mounted — for the reason `hydrate` gives.
|
|
266
287
|
*/
|
|
267
288
|
export async function render(options: {|
|
|
268
289
|
readonly App: React.ComponentType<AppProps>,
|
|
@@ -274,7 +295,7 @@ export async function render(options: {|
|
|
|
274
295
|
readonly basePath?: string,
|
|
275
296
|
readonly trailingSlash?: TrailingSlash,
|
|
276
297
|
readonly staleTime?: number,
|
|
277
|
-
|}): Promise<
|
|
298
|
+
|}): Promise<ApplicationRoot | null> {
|
|
278
299
|
const table: RouteTable = {
|
|
279
300
|
routes: options.routes,
|
|
280
301
|
notFound: options.notFound,
|
|
@@ -294,7 +315,7 @@ export async function render(options: {|
|
|
|
294
315
|
} catch (error) {
|
|
295
316
|
if (error instanceof RedirectError) {
|
|
296
317
|
window.location.replace(addressOf(error.to));
|
|
297
|
-
return;
|
|
318
|
+
return null;
|
|
298
319
|
}
|
|
299
320
|
// The error boundary, chosen the same way the server chooses it. A throw
|
|
300
321
|
// from a loader is a page that cannot render, and rendering the boundary is
|
|
@@ -319,7 +340,7 @@ export async function render(options: {|
|
|
|
319
340
|
);
|
|
320
341
|
}
|
|
321
342
|
const tree = <App url={url} initial={resolved} />;
|
|
322
|
-
createRoot(container)
|
|
323
|
-
|
|
324
|
-
|
|
343
|
+
const root = createRoot(container);
|
|
344
|
+
root.render(options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree);
|
|
345
|
+
return root;
|
|
325
346
|
}
|