@uniflowed/router 0.2.0 → 0.4.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
@@ -109,6 +109,7 @@
109
109
  import {
110
110
  ACTION_CONTENT_TYPE,
111
111
  ACTION_HEADER,
112
+ ACTION_OUTCOME_HEADER,
112
113
  type ActionArgument,
113
114
  type ActionValue,
114
115
  decodeActionResult,
@@ -117,6 +118,7 @@ import {
117
118
  import { loadDocument, refusedAsAnotherDeployment, withDeployment } from "./internal/deployment.js";
118
119
  import { withFormAction } from "./internal/form-action.js";
119
120
  import { clearNavigationCache } from "./internal/navigation-cache.js";
121
+ import { ForbiddenError, NotFoundError, UnauthorizedError } from "./internal/routing.js";
120
122
 
121
123
  export type { ActionArgument, ActionValue } from "./internal/action-wire.js";
122
124
  export {
@@ -277,6 +279,9 @@ function callServerActionFor(id: string, name: string): ServerActionFunction {
277
279
  ...args: Array<ActionArgument>
278
280
  ): Promise<ActionValue | void> {
279
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();
280
285
  // Built rather than written as a literal, because the header's name is a
281
286
  // constant and a computed key in an object literal is a shape Flow
282
287
  // declines to track.
@@ -285,7 +290,7 @@ function callServerActionFor(id: string, name: string): ServerActionFunction {
285
290
  // Which build this page is, so a server on another build refuses the call
286
291
  // rather than looking this id up in a table it was never in.
287
292
  withDeployment(headers);
288
- const response = await fetch(currentUrl(), {
293
+ const response = await fetch(from, {
289
294
  method: "POST",
290
295
  // Stated rather than left to the default, because the default is what a
291
296
  // reader has to look up and because this one is load-bearing: the
@@ -313,6 +318,14 @@ function callServerActionFor(id: string, name: string): ServerActionFunction {
313
318
  loadDocument(currentUrl());
314
319
  return new Promise<ActionValue | void>(() => {});
315
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
+ }
316
329
  if (!response.ok) {
317
330
  throw new ServerActionError(name, response.status);
318
331
  }
@@ -320,6 +333,50 @@ function callServerActionFor(id: string, name: string): ServerActionFunction {
320
333
  };
321
334
  }
322
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
+
323
380
  /**
324
381
  * The URL an action call is sent to: the page the browser is on.
325
382
  *
package/client.js CHANGED
@@ -122,12 +122,26 @@ import { type TrailingSlash, addressOf, applicationPathOf } from "./internal/bas
122
122
  import { hydrationOptions } from "./internal/hydrate-options.js";
123
123
  import { readFormState } from "./internal/form-action.js";
124
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, ... };
130
+
125
131
  /**
126
132
  * Hydrate the current document.
127
133
  *
128
134
  * Resolves without mounting anything when the current route ships no client
129
135
  * page — see the header. The promise settling is not a claim that React is on
130
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.
131
145
  */
132
146
  export async function hydrate(options: {|
133
147
  readonly App: React.ComponentType<AppProps>,
@@ -139,7 +153,7 @@ export async function hydrate(options: {|
139
153
  readonly basePath?: string,
140
154
  readonly trailingSlash?: TrailingSlash,
141
155
  readonly staleTime?: number,
142
- |}): Promise<void> {
156
+ |}): Promise<ApplicationRoot | null> {
143
157
  const table: RouteTable = {
144
158
  routes: options.routes,
145
159
  notFound: options.notFound,
@@ -160,7 +174,7 @@ export async function hydrate(options: {|
160
174
  // would go looking for a page module that is not in this bundle.
161
175
  const matched = matchRoute(table.routes, applicationPath);
162
176
  if (matched != null && !hasClientPage(matched.route)) {
163
- return;
177
+ return null;
164
178
  }
165
179
 
166
180
  const url = applicationPath + window.location.search;
@@ -213,8 +227,9 @@ export async function hydrate(options: {|
213
227
  // be a development-only difference without being a hydration difference.
214
228
  const tree = <App url={url} initial={resolved} />;
215
229
 
230
+ let root: ApplicationRoot | null = null;
216
231
  startTransition(() => {
217
- hydrateRoot(
232
+ root = hydrateRoot(
218
233
  container,
219
234
  options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
220
235
  hydrationOptions(recovery, readFormState(document)),
@@ -235,6 +250,7 @@ export async function hydrate(options: {|
235
250
  const { reportDevtools } = await import("./internal/devtools.js");
236
251
  reportDevtools(window);
237
252
  }
253
+ return root;
238
254
  }
239
255
 
240
256
  /**
@@ -265,6 +281,9 @@ export async function hydrate(options: {|
265
281
  * * **A redirect is the browser's.** `redirect()` from a loader throws before
266
282
  * anything is rendered; on a server that becomes a 307 and here it becomes
267
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.
268
287
  */
269
288
  export async function render(options: {|
270
289
  readonly App: React.ComponentType<AppProps>,
@@ -276,7 +295,7 @@ export async function render(options: {|
276
295
  readonly basePath?: string,
277
296
  readonly trailingSlash?: TrailingSlash,
278
297
  readonly staleTime?: number,
279
- |}): Promise<void> {
298
+ |}): Promise<ApplicationRoot | null> {
280
299
  const table: RouteTable = {
281
300
  routes: options.routes,
282
301
  notFound: options.notFound,
@@ -296,7 +315,7 @@ export async function render(options: {|
296
315
  } catch (error) {
297
316
  if (error instanceof RedirectError) {
298
317
  window.location.replace(addressOf(error.to));
299
- return;
318
+ return null;
300
319
  }
301
320
  // The error boundary, chosen the same way the server chooses it. A throw
302
321
  // from a loader is a page that cannot render, and rendering the boundary is
@@ -321,7 +340,7 @@ export async function render(options: {|
321
340
  );
322
341
  }
323
342
  const tree = <App url={url} initial={resolved} />;
324
- createRoot(container).render(
325
- options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
326
- );
343
+ const root = createRoot(container);
344
+ root.render(options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree);
345
+ return root;
327
346
  }
@@ -125,6 +125,7 @@ import { asResponder, nativeActionAllowed } from "@uniflowed/server/host";
125
125
  import {
126
126
  ACTION_CONTENT_TYPE,
127
127
  ACTION_HEADER,
128
+ ACTION_OUTCOME_HEADER,
128
129
  type ActionArgument,
129
130
  ActionValueError,
130
131
  MAX_ACTION_BODY_BYTES,
@@ -140,7 +141,7 @@ import {
140
141
  readFormPost,
141
142
  } from "./form-action.js";
142
143
  import { requireRequest } from "./request.js";
143
- import { RedirectError } from "./routing.js";
144
+ import { ForbiddenError, NotFoundError, RedirectError, UnauthorizedError } from "./routing.js";
144
145
 
145
146
  export type { FormState } from "./form-action.js";
146
147
 
@@ -294,6 +295,13 @@ export function createActionDispatcher(options: {|
294
295
  const call = action as $FlowFixMe;
295
296
  result = await traceRequestPhase("action", () => call(...args));
296
297
  } catch (error) {
298
+ // `redirect()` and its three siblings are where the visitor goes next,
299
+ // not something that went wrong: answered as themselves, and never
300
+ // reported. See `ACTION_OUTCOME_HEADER`.
301
+ const outcome = routingOutcome(error);
302
+ if (outcome != null) {
303
+ return outcome;
304
+ }
297
305
  report(record, error);
298
306
  return refusal(500);
299
307
  }
@@ -390,13 +398,23 @@ async function nativeFormPost(
390
398
  if (error instanceof RedirectError) {
391
399
  return seeOther(addressOf(error.to));
392
400
  }
401
+ // A person is looking at this answer and there is no page to render the
402
+ // boundary into, so it is the status with a fixed line of text — the same
403
+ // three statuses the JSON door answers, and nothing of the exception.
404
+ const status = routingStatus(error);
405
+ if (status != null) {
406
+ return new Response(`${status.text}\n`, {
407
+ status: status.code,
408
+ headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
409
+ });
410
+ }
393
411
  report(record, error);
394
412
  return refusal(500);
395
413
  }
396
414
  if (stateKey == null || postback == null) {
397
415
  // Post/redirect/get: the page again, by a `GET`, so a reload does not
398
416
  // submit the form a second time.
399
- return seeOther(addressOf(url.pathname + url.search));
417
+ return seeOther(addressOf(withOneLeadingSlash(url.pathname) + url.search));
400
418
  }
401
419
  try {
402
420
  // Held to the grammar for the reason the JSON door holds a result to it:
@@ -451,6 +469,88 @@ function sameSiteFetch(request: Request): boolean {
451
469
  return site == null || site === "same-origin";
452
470
  }
453
471
 
472
+ /** The kind, status and text of a routing error other than a redirect. */
473
+ type RoutingStatus = {|
474
+ readonly kind: "not-found" | "unauthorized" | "forbidden",
475
+ readonly code: 404 | 401 | 403,
476
+ readonly text: string,
477
+ |};
478
+
479
+ /**
480
+ * What `notFound()`, `unauthorized()` or `forbidden()` answers with, or `null`
481
+ * for anything else.
482
+ *
483
+ * The statuses are the ones a page gets for the same call (`routeErrorStatus`
484
+ * and the not-found page), so a client that reads only the status reads the
485
+ * same thing a document request would have told it.
486
+ */
487
+ function routingStatus(error: mixed): RoutingStatus | null {
488
+ if (error instanceof NotFoundError) {
489
+ return { kind: "not-found", code: 404, text: "not found" };
490
+ }
491
+ if (error instanceof UnauthorizedError) {
492
+ return { kind: "unauthorized", code: 401, text: "unauthorized" };
493
+ }
494
+ if (error instanceof ForbiddenError) {
495
+ return { kind: "forbidden", code: 403, text: "forbidden" };
496
+ }
497
+ return null;
498
+ }
499
+
500
+ /**
501
+ * The JSON door's answer for a routing error, or `null` for any other throw.
502
+ *
503
+ * A redirect is a `204` carrying `Location`, the address `addressOf` makes of
504
+ * it (under the base path for a path on this application). It is not a `3xx`,
505
+ * because `fetch` would follow that itself and fetch the page's HTML for
506
+ * nothing, and the reference could not read where it pointed. The other three
507
+ * are their page statuses. Each one carries `ACTION_OUTCOME_HEADER`, so a `404`
508
+ * from `notFound()` is not mistaken for the `404` of an unknown id. The body
509
+ * names the kind and nothing else.
510
+ */
511
+ function routingOutcome(error: mixed): Response | null {
512
+ if (error instanceof RedirectError) {
513
+ const headers: { [string]: string } = {
514
+ "cache-control": "no-store",
515
+ location: addressOf(error.to),
516
+ };
517
+ headers[ACTION_OUTCOME_HEADER] = "redirect";
518
+ return new Response(null, { status: 204, headers });
519
+ }
520
+ const status = routingStatus(error);
521
+ if (status == null) {
522
+ return null;
523
+ }
524
+ const headers: { [string]: string } = { ...ANSWER_HEADERS };
525
+ headers[ACTION_OUTCOME_HEADER] = status.kind;
526
+ return new Response(JSON.stringify({ outcome: status.kind }), {
527
+ status: status.code,
528
+ headers,
529
+ });
530
+ }
531
+
532
+ /**
533
+ * `path` with a leading run of `/` counted down to one.
534
+ *
535
+ * The post/redirect/get above answers with the path the form was posted to,
536
+ * and that path is the request's: `https://app.example//evil.example/notes` is
537
+ * a link anybody can write, its page posts its forms back to the same address,
538
+ * and a URL parser reads `/\evil.example` as the same two slashes. Written
539
+ * into `Location` as it stands, `//evil.example/notes` is a network-path
540
+ * reference and the browser follows it to another host — after the person
541
+ * submitted a form on this one. `@uniflowed/server` makes the same repair to
542
+ * the trailing-slash redirect (`withOneLeadingSlash` in its `routing.js`); the
543
+ * router cannot import it, so it is spelled here as a counted loop, never a
544
+ * pattern (`docs/security.md`, rule 5).
545
+ */
546
+ function withOneLeadingSlash(path: string): string {
547
+ let start = 0;
548
+ while (start + 1 < path.length && path.charCodeAt(start + 1) === 47) {
549
+ start += 1;
550
+ }
551
+ return path.charCodeAt(0) === 47 ? path.slice(start) : path;
552
+ }
553
+
454
554
  /** A `303 See Other`, which a browser follows with a `GET`. */
455
555
  function seeOther(location: string): Response {
456
556
  return new Response(null, {
@@ -93,6 +93,24 @@
93
93
  // Pure: no imports, no platform APIs beyond `JSON`, so the browser half of
94
94
  // `@uniflowed/router` can reach it without reaching anything server-only.
95
95
 
96
+ /**
97
+ * The response header that says a server action ended in a routing outcome
98
+ * rather than a result: `redirect`, `not-found`, `unauthorized` or
99
+ * `forbidden`.
100
+ *
101
+ * `redirect()`, `notFound()`, `unauthorized()` and `forbidden()` are how a
102
+ * page's code says where the visitor goes next, and an action is page code:
103
+ * `redirect("/notes")` at the end of a mutation is the common way to finish
104
+ * one. They are control flow, not failures, so the endpoint does not report
105
+ * them or answer `500`. It answers with this header, the matching status and,
106
+ * for a redirect, `Location`. The reference in the browser reads the header
107
+ * and navigates or throws the same error for the route's boundary. Only the
108
+ * kind crosses, plus the address for a redirect. The exception's message and
109
+ * stack never do, which is the fixed-`500` rule applied to the one failure
110
+ * that is not a failure.
111
+ */
112
+ export const ACTION_OUTCOME_HEADER: string = "uf-action-outcome";
113
+
96
114
  /** The request header carrying the action id. */
97
115
  export const ACTION_HEADER: string = "uf-action";
98
116
 
@@ -248,6 +266,21 @@ function asFormData(value: mixed): FormData | null {
248
266
  /** One entry of the explicit walk stack. */
249
267
  type Pending = {| readonly value: mixed, readonly path: string, readonly depth: number |};
250
268
 
269
+ /**
270
+ * How many more values a walk may visit before `MAX_ACTION_VALUES` is spent.
271
+ *
272
+ * An object rather than a number so that one budget can be handed to the walk
273
+ * of every argument of a call: the ceiling `docs/security.md` states is on the
274
+ * *call* — "at most 10,000 values in total" — and a budget per argument would
275
+ * be `MAX_ACTION_ARGUMENTS` times that.
276
+ */
277
+ export type ValueBudget = { remaining: number };
278
+
279
+ /** A fresh budget of `MAX_ACTION_VALUES`, for one call or one result. */
280
+ export function valueBudget(): ValueBudget {
281
+ return { remaining: MAX_ACTION_VALUES };
282
+ }
283
+
251
284
  /**
252
285
  * Refuse a value that cannot cross the wire, naming where it was.
253
286
  *
@@ -257,11 +290,27 @@ type Pending = {| readonly value: mixed, readonly path: string, readonly depth:
257
290
  * hand on the depth. Every bound is checked as the walk runs rather than
258
291
  * afterwards, so a payload that busts one is refused before the rest of it is
259
292
  * visited.
293
+ *
294
+ * That includes the count, which is checked *before* an array's or an
295
+ * object's members are queued rather than as each is visited. One array of
296
+ * half a million zeros fits in a 1 MiB body, and queueing every element — each
297
+ * with a path string of its own — before the counter reached any of them
298
+ * spent tens of megabytes of heap on a payload that was always going to be
299
+ * refused.
300
+ *
301
+ * `budget` is spent by this walk and may be shared: `decodeActionArguments`
302
+ * and `encodeActionArguments` hand every argument the same one, so the ceiling
303
+ * is on the call. Without one, the value has a budget of its own.
260
304
  */
261
- export function checkActionValue(root: mixed, label: string): void {
305
+ export function checkActionValue(
306
+ root: mixed,
307
+ label: string,
308
+ budget?: ValueBudget = valueBudget(),
309
+ ): void {
262
310
  const stack: Array<Pending> = [{ value: root, path: label, depth: 0 }];
263
311
  const seen = new Set<mixed>();
264
- let remaining = MAX_ACTION_VALUES;
312
+ const overBudget = () =>
313
+ new ActionValueError(label, `holds more than ${String(MAX_ACTION_VALUES)} values`);
265
314
 
266
315
  while (stack.length > 0) {
267
316
  const pending = stack.pop();
@@ -270,9 +319,9 @@ export function checkActionValue(root: mixed, label: string): void {
270
319
  }
271
320
  const { value, path, depth } = pending;
272
321
 
273
- remaining -= 1;
274
- if (remaining < 0) {
275
- throw new ActionValueError(label, `holds more than ${String(MAX_ACTION_VALUES)} values`);
322
+ budget.remaining -= 1;
323
+ if (budget.remaining < 0) {
324
+ throw overBudget();
276
325
  }
277
326
  if (depth > MAX_ACTION_DEPTH) {
278
327
  throw new ActionValueError(path, `is nested deeper than ${String(MAX_ACTION_DEPTH)}`);
@@ -309,6 +358,11 @@ export function checkActionValue(root: mixed, label: string): void {
309
358
 
310
359
  if (Array.isArray(object)) {
311
360
  const items: $ReadOnlyArray<mixed> = object as $FlowFixMe;
361
+ // Every queued value will be visited and spent, so a queue longer than
362
+ // what is left is a refusal now rather than after the push.
363
+ if (stack.length + items.length > budget.remaining) {
364
+ throw overBudget();
365
+ }
312
366
  for (let index = 0; index < items.length; index += 1) {
313
367
  stack.push({ value: items[index], path: `${path}[${String(index)}]`, depth: depth + 1 });
314
368
  }
@@ -342,7 +396,11 @@ export function checkActionValue(root: mixed, label: string): void {
342
396
  }
343
397
 
344
398
  const record: { readonly [string]: mixed } = object as $FlowFixMe;
345
- for (const key of Object.getOwnPropertyNames(object)) {
399
+ const keys = Object.getOwnPropertyNames(object);
400
+ if (stack.length + keys.length > budget.remaining) {
401
+ throw overBudget();
402
+ }
403
+ for (const key of keys) {
346
404
  if (FORBIDDEN_KEYS.includes(key)) {
347
405
  throw new ActionValueError(`${path}.${key}`, "is a key this grammar never carries");
348
406
  }
@@ -407,6 +465,8 @@ export function encodeActionArguments(args: $ReadOnlyArray<mixed>): string {
407
465
  );
408
466
  }
409
467
  const values: Array<mixed> = [];
468
+ // One budget for the whole call; see `ValueBudget`.
469
+ const budget = valueBudget();
410
470
  let form: {| readonly at: number, readonly entries: Array<Array<string>> |} | null = null;
411
471
  for (let index = 0; index < args.length; index += 1) {
412
472
  const argument = args[index];
@@ -420,7 +480,7 @@ export function encodeActionArguments(args: $ReadOnlyArray<mixed>): string {
420
480
  values.push(null);
421
481
  continue;
422
482
  }
423
- checkActionValue(argument, label);
483
+ checkActionValue(argument, label, budget);
424
484
  values.push(argument);
425
485
  }
426
486
  return form == null ? JSON.stringify({ args: values }) : JSON.stringify({ args: values, form });
@@ -460,8 +520,10 @@ export function decodeActionArguments(text: string): Array<ActionArgument> {
460
520
  `passes more than ${String(MAX_ACTION_ARGUMENTS)} arguments`,
461
521
  );
462
522
  }
523
+ // One budget for the whole call; see `ValueBudget`.
524
+ const budget = valueBudget();
463
525
  for (let index = 0; index < args.length; index += 1) {
464
- checkActionValue(args[index], `argument ${String(index + 1)}`);
526
+ checkActionValue(args[index], `argument ${String(index + 1)}`, budget);
465
527
  }
466
528
  const decoded: Array<ActionArgument> = args as $FlowFixMe;
467
529
  if (keys.length === 2) {
@@ -58,7 +58,7 @@ export type ComposeOptions = {|
58
58
  |};
59
59
 
60
60
  /** The route a slot renders inside, as much of it as a slot reads. */
61
- type SlotRoute = { +pathname: string, +searchParams: SearchParams, ... };
61
+ type SlotRoute = { readonly pathname: string, readonly searchParams: SearchParams, ... };
62
62
 
63
63
  /**
64
64
  * Whether this bundle marks the boundaries it renders.
@@ -356,11 +356,11 @@ export function slotsAt(
356
356
  slots: $ReadOnlyArray<ResolvedSlot>,
357
357
  depth: number,
358
358
  route: SlotRoute,
359
- ): { readonly [string]: React.Node } {
359
+ ): SlotProps {
360
360
  if (slots.length === 0) {
361
361
  return EMPTY_SLOTS;
362
362
  }
363
- const props: { [string]: React.Node } = {};
363
+ const props: { key?: empty, [string]: React.Node } = {};
364
364
  for (const slot of slots) {
365
365
  if (slot.above === depth) {
366
366
  // `null` rather than an element that renders nothing, and the difference
@@ -376,8 +376,18 @@ export function slotsAt(
376
376
  return props;
377
377
  }
378
378
 
379
+ /**
380
+ * A layout's slots, as the props they are spread into.
381
+ *
382
+ * `key` is named out of the indexer, the way `@uniflowed/ui`'s `Rest` names it:
383
+ * the object is spread onto an element, and a `React.Node` is not a key. No
384
+ * slot is called `key` — a slot is a prop the layout declares, and React
385
+ * never hands a component its key.
386
+ */
387
+ export type SlotProps = { readonly key?: empty, readonly [string]: React.Node };
388
+
379
389
  /** One object for every layout on a project that declares no slot. */
380
- const EMPTY_SLOTS: { readonly [string]: React.Node } = Object.freeze({});
390
+ const EMPTY_SLOTS: SlotProps = Object.freeze({});
381
391
 
382
392
  /**
383
393
  * One slot's tree: its page, inside the layouts declared under the slot, with
@@ -52,8 +52,9 @@ let remembered: string | null | void;
52
52
 
53
53
  /** The parts of a `Document` read here. */
54
54
  type HeadLike = interface {
55
- readonly querySelector: (selector: string) => ?interface {
56
- readonly getAttribute: (name: string) => ?string,
55
+ // Methods, as a `Document`'s are: a method cannot be read off as a property.
56
+ querySelector(selector: string): ?interface {
57
+ getAttribute(name: string): ?string,
57
58
  },
58
59
  };
59
60
 
@@ -92,7 +92,7 @@ export function devtoolsProblem(win: HookWindow): {|
92
92
  // handed back. Anything else there is a hook uf did not install and DevTools
93
93
  // did not either, and guessing at its shape would report a problem that is
94
94
  // really this module not recognising one.
95
- const renderers = (hook: $FlowFixMe).renderers;
95
+ const renderers = (hook as $FlowFixMe).renderers;
96
96
  const count = renderers instanceof Map ? renderers.size : null;
97
97
  if (count != null && count > 1) {
98
98
  return {
@@ -34,7 +34,7 @@ component DefaultRouteError(error: RouteError, reset: () => void) {
34
34
  const detail = match (error) {
35
35
  {kind: "unauthorized"} => "This page needs you to be signed in.",
36
36
  {kind: "forbidden"} => "You do not have access to this page.",
37
- {kind: "thrown"} => "This page could not be rendered.",
37
+ {kind: "thrown", ...} => "This page could not be rendered.",
38
38
  };
39
39
  return (
40
40
  <main>