@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 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
- // **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.
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 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.
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(currentUrl(), {
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<void> {
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 == null ? undefined : { onRecoverableError: 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<void> {
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).render(
323
- options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
324
- );
343
+ const root = createRoot(container);
344
+ root.render(options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree);
345
+ return root;
325
346
  }