@uniflowed/router 0.0.0-alpha.7 → 0.0.0-alpha.9

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/client.js CHANGED
@@ -6,16 +6,37 @@
6
6
  // The current route's chunks are loaded and its embedded loader data read
7
7
  // *before* `hydrateRoot`, so the first client render is synchronous and
8
8
  // matches the server's markup exactly.
9
+ //
10
+ // # A route can decline to be hydrated
11
+ //
12
+ // uf's server-component analysis decides which routes have a `"use client"`
13
+ // boundary anywhere in them, and `@uniflowed/vite` leaves the page out of the
14
+ // client route table for the ones that have none. Such a route has nothing in
15
+ // the browser to attach: the document the server wrote is the whole of it. So
16
+ // this returns without calling `hydrateRoot`, and the `<a>` elements a `Link`
17
+ // rendered stay what the server made them — real links the browser follows.
18
+ // See ubugeeei-prod/uf#350.
9
19
 
10
20
  import * as React from "react";
11
21
  import { startTransition } from "react";
12
22
  import { hydrateRoot } from "react-dom/client";
13
23
 
14
- import { type AppProps, type RouteTable, installRoutes, resolveMatch } from "./internal/runtime.js";
24
+ import {
25
+ type AppProps,
26
+ type RouteTable,
27
+ hasClientPage,
28
+ installRoutes,
29
+ matchRoute,
30
+ resolveMatch,
31
+ } from "./internal/runtime.js";
15
32
  import { DATA_ID, ROOT_ID } from "./internal/document.js";
16
33
 
17
34
  /**
18
35
  * Hydrate the current document.
36
+ *
37
+ * Resolves without mounting anything when the current route ships no client
38
+ * page — see the header. The promise settling is not a claim that React is on
39
+ * the document.
19
40
  */
20
41
  export async function hydrate(options: {|
21
42
  readonly App: React.ComponentType<AppProps>,
@@ -30,6 +51,13 @@ export async function hydrate(options: {|
30
51
  };
31
52
  installRoutes(table);
32
53
 
54
+ // Before the loader data is read and before `resolveMatch` is called: both
55
+ // would go looking for a page module that is not in this bundle.
56
+ const matched = matchRoute(table.routes, window.location.pathname);
57
+ if (matched != null && !hasClientPage(matched.route)) {
58
+ return;
59
+ }
60
+
33
61
  const url = window.location.pathname + window.location.search;
34
62
  const embedded = document.getElementById(DATA_ID);
35
63
  const data = embedded != null ? JSON.parse(embedded.textContent ?? "null") : undefined;
package/handler.js CHANGED
@@ -25,8 +25,16 @@
25
25
  // It does answer `405` itself when the path matches and the method does not,
26
26
  // with the `Allow` header the specification requires — that is not the
27
27
  // handler's business, and every handler would otherwise write it.
28
-
29
- import { contextFor, drainDeferred, runWithContext } from "@uniflowed/server/host";
28
+ //
29
+ // It also does not establish the request a handler is inside. The host does,
30
+ // around the whole of it, so a handler and the guard above it share one
31
+ // context; see the same section in `./middleware.js`. This module used to
32
+ // build its own and drain it the moment the handler returned, which the
33
+ // comment there called "the response is in hand" — true, and not what
34
+ // `after()` promises. A handler that streams its body has not sent a byte at
35
+ // that point. See ubugeeei-prod/uf#389.
36
+
37
+ import { requireRequest } from "./internal/request.js";
30
38
  import type { RouteParams } from "./internal/runtime.js";
31
39
 
32
40
  /** What a handler is given besides the request. */
@@ -76,6 +84,9 @@ export function createDispatcher(options: {|
76
84
  const table = [...options.handlers].sort((a, b) => specificity(b.path) - specificity(a.path));
77
85
 
78
86
  return async function dispatch(request: Request): Promise<Response | null> {
87
+ // The host's half of the contract, checked rather than assumed; see
88
+ // `./internal/request.js`.
89
+ requireRequest("dispatch");
79
90
  const url = new URL(request.url);
80
91
  for (const record of table) {
81
92
  const params = matchPath(record.path, url.pathname);
@@ -90,17 +101,13 @@ export function createDispatcher(options: {|
90
101
  return methodNotAllowed(module);
91
102
  }
92
103
 
93
- // Inside the request, so a handler that calls `headers()`, `cookies()`
94
- // or `after()` has something to answer about. `drainDeferred` runs after
95
- // the response is in hand, which is what `after()` means.
96
- const context = contextFor(request);
97
- const response = await runWithContext(context, () =>
98
- handler(request, {
99
- params,
100
- searchParams: url.searchParams,
101
- }),
102
- );
103
- await drainDeferred(context);
104
+ // In the host's request, so a handler that calls `headers()`,
105
+ // `cookies()` or `after()` answers about the same one its guard did, and
106
+ // what it defers is drained once, by the host, after the bytes are out.
107
+ const response = await handler(request, {
108
+ params,
109
+ searchParams: url.searchParams,
110
+ });
104
111
 
105
112
  // A `HEAD` answered by `GET` must not carry the body. The test is
106
113
  // against the module's own `HEAD`, not `pick`'s — `pick` falls back to
package/index.js CHANGED
@@ -11,6 +11,8 @@
11
11
  // path that matched nothing, and what renders in place of a subtree that threw.
12
12
  // Both are segment files, resolved by the nearest one above the path.
13
13
 
14
+ import * as React from "react";
15
+
14
16
  import type { RouteError } from "./internal/runtime.js";
15
17
 
16
18
  export type {
@@ -46,6 +48,7 @@ export {
46
48
  RouterProvider,
47
49
  UnauthorizedError,
48
50
  forbidden,
51
+ hasClientPage,
49
52
  matchRoute,
50
53
  notFound,
51
54
  parseSearch,
@@ -84,5 +87,5 @@ export type LayoutProps<
84
87
  TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
85
88
  > = {|
86
89
  readonly params: TParams,
87
- readonly children: React$Node,
90
+ readonly children: React.Node,
88
91
  |};
@@ -0,0 +1,43 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: the request the server half runs inside.
4
+ //
5
+ // One function, and it exists because two modules need the same refusal.
6
+ // `createMiddlewareRunner` and `createDispatcher` both used to build a request
7
+ // context of their own — `contextFor`, then `runWithContext`, then
8
+ // `drainDeferred` — which gave one request up to two contexts and ran what
9
+ // `after()` deferred before the response was written, and in the middleware's
10
+ // case before there was a response at all. See ubugeeei-prod/uf#389.
11
+ //
12
+ // Both now run inside whatever request the host established, which means both
13
+ // depend on the host having established one. That dependency is checked rather
14
+ // than assumed, for the reason `createApplicationHandler` gives about
15
+ // `entry.runMiddleware` itself: a missing half of the contract should be a
16
+ // failure on the first request, not an application that answers strangely.
17
+ //
18
+ // Server-only, like the two modules that import it. Nothing the browser loads
19
+ // reaches this.
20
+
21
+ import { insideRequest } from "@uniflowed/server/host";
22
+
23
+ /**
24
+ * Refuse to run outside a request, naming what has to establish one.
25
+ *
26
+ * The message is addressed to whoever wired the host, because that is who can
27
+ * fix it. Left to fail on its own, a mis-wired host would surface as the first
28
+ * `cookies()` in somebody's guard throwing "called outside a request … a
29
+ * static prerender, a module's top level, or a client component" — three
30
+ * places to look, none of them this one — and an application that calls no
31
+ * server function at all would sail past that and lose every `after()` instead.
32
+ */
33
+ export function requireRequest(entry: string): void {
34
+ if (insideRequest()) {
35
+ return;
36
+ }
37
+ throw new Error(
38
+ `@uniflowed/router: ${entry}() was called outside a request. A host owns the request: ` +
39
+ "begin it with `beginRequest` from `@uniflowed/server/host` (a bundled application " +
40
+ "re-exports it from `virtual:uf/server`), run the whole request inside `run`, and call " +
41
+ "`settle` once the response has been sent. See ubugeeei-prod/uf#389.",
42
+ );
43
+ }
@@ -11,6 +11,7 @@
11
11
 
12
12
  import * as React from "react";
13
13
  import {
14
+ Suspense,
14
15
  createContext,
15
16
  startTransition,
16
17
  useCallback,
@@ -115,6 +116,21 @@ export type ErrorModule = {
115
116
  ...
116
117
  };
117
118
 
119
+ /**
120
+ * What a loading module may export. The component is `default` or `Loading`.
121
+ *
122
+ * No `metadata`, and that is the type saying something true rather than an
123
+ * omission. A fallback renders while the route is still resolving, and the
124
+ * route's metadata was decided before the first byte — a title on a file that
125
+ * renders after the head has gone could never be used. `packages/web/head.js`
126
+ * documents the same constraint from the other side.
127
+ */
128
+ export type LoadingModule = {
129
+ readonly default?: RouteComponent,
130
+ readonly Loading?: RouteComponent,
131
+ ...
132
+ };
133
+
118
134
  /** Document metadata a page or layout declares. */
119
135
  export type Metadata = {
120
136
  readonly title?: string,
@@ -146,8 +162,46 @@ export type RouteRecord = {|
146
162
  readonly params: $ReadOnlyArray<RouteParamSpec>,
147
163
  readonly mdx: boolean,
148
164
  readonly file: string,
149
- readonly page: () => Promise<PageModule>,
165
+ /**
166
+ * The page module — absent when this table cannot render the route.
167
+ *
168
+ * The server's table always has one: the server renders every route. The
169
+ * browser's may not. `@uniflowed/vite` leaves the page out of the client
170
+ * route table when uf's server-component analysis finds no `"use client"`
171
+ * boundary reachable from the page, its layouts or its fallbacks, and with
172
+ * the `import()` gone so is the whole subtree it reached — which is the
173
+ * point of leaving it out.
174
+ *
175
+ * The route stays in the table because the router still has to *match* the
176
+ * URL. Matching is what tells a `Link` that the destination is a document
177
+ * the browser must fetch rather than a page this bundle can render; a route
178
+ * missing from the table entirely would be a 404 instead. See
179
+ * [`hasClientPage`], which is the question every caller asks.
180
+ */
181
+ readonly page?: () => Promise<PageModule>,
150
182
  readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
183
+ /**
184
+ * The `<Suspense>` boundaries this route renders inside, root first.
185
+ *
186
+ * Optional because a table written before `_uf.loading.js` existed — a
187
+ * hand-written one in a test, a server bundle built by an older `uf` —
188
+ * is still a table this router can render, and a route with no boundary is
189
+ * exactly what it had before.
190
+ */
191
+ readonly loading?: $ReadOnlyArray<LoadingRecord>,
192
+ |};
193
+
194
+ /**
195
+ * One `_uf.loading.js`, as the route table carries it.
196
+ *
197
+ * `above` is how many of the route's `layouts` are outside the boundary, which
198
+ * is the same number `ResolvedRoute["errorBoundary"].above` means and is
199
+ * spelled the same way on purpose: both answer "where in the stack of layouts
200
+ * does this thing sit", and there is no second vocabulary for it.
201
+ */
202
+ export type LoadingRecord = {|
203
+ readonly above: number,
204
+ readonly module: () => Promise<LoadingModule>,
151
205
  |};
152
206
 
153
207
  /**
@@ -264,6 +318,16 @@ export type ResolvedRoute = {|
264
318
  readonly module: ?ErrorModule,
265
319
  readonly above: number,
266
320
  |},
321
+ /**
322
+ * The loading boundaries around this route, root first, already imported.
323
+ *
324
+ * Imported rather than lazy: React decides to render a fallback
325
+ * synchronously, during the render that suspended, so a module that is still
326
+ * being fetched is a module that is not there at the only moment it is
327
+ * wanted. Empty for a route with no `_uf.loading.js` above it, which is the
328
+ * ordinary case and renders exactly the tree it did before.
329
+ */
330
+ readonly loading: $ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>,
267
331
  |};
268
332
 
269
333
  /** Thrown by `notFound()`; the renderer answers with the not-found page. */
@@ -381,6 +445,18 @@ function decodeSegment(segment: string): string {
381
445
  }
382
446
  }
383
447
 
448
+ /**
449
+ * Whether this table can render the route in the browser.
450
+ *
451
+ * False only in the client bundle, and only for a route uf decided ships no
452
+ * JavaScript. Every caller that would load a page asks this first, and the two
453
+ * answers are different actions rather than a success and a failure: render
454
+ * it, or let the browser fetch the document.
455
+ */
456
+ export function hasClientPage(route: RouteRecord): boolean {
457
+ return route.page != null;
458
+ }
459
+
384
460
  /**
385
461
  * Match a pathname against the table, preferring the most specific route.
386
462
  */
@@ -552,17 +628,44 @@ async function resolveRoute(
552
628
  return resolveNotFound(table, pathname, search, searchParams);
553
629
  }
554
630
 
631
+ const load = matched.route.page;
632
+ if (load == null) {
633
+ // Reachable only by asking this table to render a route it was built
634
+ // without. `hydrate` and every navigation check `hasClientPage` first and
635
+ // hand the URL to the browser instead, so arriving here means a caller
636
+ // went around them — and the honest answer is to say so rather than to
637
+ // render an empty page.
638
+ throw new Error(
639
+ `@uniflowed/router: ${matched.route.path} has no page in this route table; it ships no ` +
640
+ "client JavaScript, so the browser navigates to it rather than rendering it",
641
+ );
642
+ }
555
643
  const [page, ...layouts] = await Promise.all([
556
- loadOnce(matched.route.page),
644
+ loadOnce(load),
557
645
  ...matched.route.layouts.map((layout) => loadOnce(layout)),
558
646
  ]);
559
647
  // Started here and awaited at the end: the boundary's module does not depend
560
648
  // on the loader, so importing it alongside costs a navigation nothing. It
561
649
  // never rejects, so an early throw below leaves no unhandled rejection.
562
650
  const boundary = resolveErrorBoundary(table, pathname, matched.route.layouts.length);
651
+ // Started alongside for the same reason, and awaited at the end: a fallback
652
+ // depends on nothing the loader produces.
653
+ const loading = resolveLoading(matched.route, matched.route.layouts.length);
563
654
 
564
655
  let data: mixed = options?.data;
565
656
  if (options?.skipLoader !== true && typeof page.loader === "function") {
657
+ // Awaited here, so a route's time to first byte is still its slowest
658
+ // loader. A page that suspends while *rendering* streams — that is what the
659
+ // `<Suspense>` boundaries below are for — but a page waiting on its loader
660
+ // has already waited by the time React sees the tree, so its fallback shows
661
+ // for no time at all.
662
+ //
663
+ // Deferring it means handing the page a promise and unwrapping it inside
664
+ // the boundary, and the obstacle is not the awaiting: it is that
665
+ // `generateMetadata` reads `data` and metadata goes in the head, and that
666
+ // the loader data is embedded in the head too, for hydration. Both are
667
+ // decisions about the document rather than about the route.
668
+ // ubugeeei-prod/uf#373 has the design.
566
669
  data = await page.loader({ params: matched.params, searchParams, pathname });
567
670
  }
568
671
 
@@ -584,9 +687,46 @@ async function resolveRoute(
584
687
  status: 200,
585
688
  error: null,
586
689
  errorBoundary: await boundary,
690
+ loading: await loading,
587
691
  };
588
692
  }
589
693
 
694
+ /**
695
+ * The route's loading boundaries, imported.
696
+ *
697
+ * A boundary whose module will not load is dropped rather than thrown for, and
698
+ * this is the same judgement `resolveErrorBoundary` makes one function above: a
699
+ * fallback is what the router shows while it does not yet have the page, so a
700
+ * broken fallback must not become a broken page. The route renders without that
701
+ * boundary — the next one out, or the shell, waits for it instead — and the
702
+ * import error surfaces where it belongs, when the module is next asked for.
703
+ */
704
+ async function resolveLoading(
705
+ route: RouteRecord,
706
+ layoutCount: number,
707
+ ): Promise<$ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>> {
708
+ const records = route.loading ?? [];
709
+ if (records.length === 0) {
710
+ return [];
711
+ }
712
+ const loaded = await Promise.all(
713
+ records.map(async (record) => {
714
+ try {
715
+ return {
716
+ // Clamped exactly as the error boundary's is, and for the same
717
+ // reason: a `(group)` directory can leave a route with fewer layouts
718
+ // than the boundary that covers it.
719
+ above: Math.min(record.above, layoutCount),
720
+ module: await loadOnce(record.module),
721
+ };
722
+ } catch {
723
+ return null;
724
+ }
725
+ }),
726
+ );
727
+ return loaded.filter(Boolean);
728
+ }
729
+
590
730
  /**
591
731
  * The route to render after something threw.
592
732
  *
@@ -722,6 +862,10 @@ async function resolveError(
722
862
  // All of the boundary's layouts are above it, and no inner boundary is
723
863
  // inserted around a page that already is one; see `RouteView`.
724
864
  errorBoundary: { module, above: layouts.length },
865
+ // An error page has nothing left to wait for: it renders the value it was
866
+ // resolved with. A fallback around it would be a boundary that can never
867
+ // show, which is worse than none.
868
+ loading: [],
725
869
  };
726
870
  }
727
871
 
@@ -758,6 +902,7 @@ async function resolveNotFound(
758
902
  status: 404,
759
903
  error: null,
760
904
  errorBoundary: await resolveErrorBoundary(table, pathname, 0),
905
+ loading: [],
761
906
  };
762
907
  }
763
908
  const [page, ...layouts] = await Promise.all([
@@ -783,6 +928,10 @@ async function resolveNotFound(
783
928
  error: null,
784
929
  // A not-found page is a page: one that throws is contained like any other.
785
930
  errorBoundary: await resolveErrorBoundary(table, pathname, layouts.length),
931
+ // A not-found boundary is matched, not nested: `nearestBoundary` picked one
932
+ // record and the loading files are a property of the route that was walked
933
+ // to, which this URL never reached. Nothing to wait for, so no boundary.
934
+ loading: [],
786
935
  };
787
936
  }
788
937
 
@@ -1034,7 +1183,23 @@ export type AppProps = {|
1034
1183
  readonly initial: ResolvedRoute,
1035
1184
  |};
1036
1185
 
1037
- const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
1186
+ /**
1187
+ * Whether there is a document to navigate.
1188
+ *
1189
+ * Asked every time rather than answered once at module scope, and the
1190
+ * difference is not a style preference. The answer is a constant inside a
1191
+ * browser bundle and inside a server process; it is *not* a constant inside a
1192
+ * test runner, where a DOM is installed on the first render and one worker
1193
+ * serves many files out of one module registry. Latched, the first file in a
1194
+ * worker to import this module decided for every file after it whether a
1195
+ * `Link` navigates or silently does nothing — and a server-rendering test
1196
+ * imports it before any document exists. See ubugeeei-prod/uf#445.
1197
+ *
1198
+ * The cost is a `typeof` per navigation, which is a navigation.
1199
+ */
1200
+ function isBrowser(): boolean {
1201
+ return typeof window !== "undefined" && typeof document !== "undefined";
1202
+ }
1038
1203
 
1039
1204
  /**
1040
1205
  * Provides the current route to the tree and performs navigation.
@@ -1049,11 +1214,23 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1049
1214
  const [pending, setPending] = useState<boolean>(false);
1050
1215
 
1051
1216
  const navigate = useCallback(async (to: string, options?: NavigateOptions): Promise<void> => {
1052
- if (!isBrowser) {
1217
+ if (!isBrowser()) {
1053
1218
  return;
1054
1219
  }
1055
1220
  const target = new URL(to, window.location.href);
1056
1221
  const next = target.pathname + target.search;
1222
+ // The half of the split that is not about bytes. A route whose page is not
1223
+ // in this bundle is not a route this router can render, and pretending
1224
+ // otherwise is the silent break: the navigation would resolve to nothing
1225
+ // and the visitor would be left on the page they clicked from. The browser
1226
+ // has the document, so the browser does the navigation — which is what a
1227
+ // link does when there is no JavaScript at all, and what the anchor
1228
+ // `Link` renders would have done on its own.
1229
+ const matched = matchRoute(routeTable().routes, target.pathname);
1230
+ if (matched != null && !hasClientPage(matched.route)) {
1231
+ window.location.assign(target.href);
1232
+ return;
1233
+ }
1057
1234
  setPending(true);
1058
1235
  try {
1059
1236
  const nextResolved = await resolveMatch(routeTable(), next);
@@ -1083,11 +1260,19 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1083
1260
  }, []);
1084
1261
 
1085
1262
  useEffect(() => {
1086
- if (!isBrowser) {
1263
+ if (!isBrowser()) {
1087
1264
  return undefined;
1088
1265
  }
1089
1266
  const onPopState = () => {
1090
1267
  const next = window.location.pathname + window.location.search;
1268
+ // Back into a route this bundle has no page for. The history entry is
1269
+ // already the browser's — it moved before this listener ran — so the
1270
+ // document that belongs to it is what has to be fetched.
1271
+ const matched = matchRoute(routeTable().routes, window.location.pathname);
1272
+ if (matched != null && !hasClientPage(matched.route)) {
1273
+ window.location.reload();
1274
+ return;
1275
+ }
1091
1276
  resolveMatch(routeTable(), next).then((nextResolved) => {
1092
1277
  startTransition(() => {
1093
1278
  setResolved(nextResolved);
@@ -1105,21 +1290,22 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1105
1290
  push: (to, options) => navigate(to, options),
1106
1291
  replace: (to) => navigate(to, { replace: true }),
1107
1292
  prefetch: async (to) => {
1108
- if (!isBrowser) {
1293
+ if (!isBrowser()) {
1109
1294
  return;
1110
1295
  }
1111
1296
  const target = new URL(to, window.location.href);
1112
1297
  const matched = matchRoute(routeTable().routes, target.pathname);
1113
- if (matched == null) {
1298
+ const load = matched?.route.page;
1299
+ if (matched == null || load == null) {
1114
1300
  return;
1115
1301
  }
1116
1302
  await Promise.all([
1117
- loadOnce(matched.route.page),
1303
+ loadOnce(load),
1118
1304
  ...matched.route.layouts.map((layout) => loadOnce(layout)),
1119
1305
  ]);
1120
1306
  },
1121
1307
  refresh: async () => {
1122
- if (!isBrowser) {
1308
+ if (!isBrowser()) {
1123
1309
  return;
1124
1310
  }
1125
1311
  const nextResolved = await resolveMatch(
@@ -1131,12 +1317,12 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1131
1317
  });
1132
1318
  },
1133
1319
  back: () => {
1134
- if (isBrowser) {
1320
+ if (isBrowser()) {
1135
1321
  window.history.back();
1136
1322
  }
1137
1323
  },
1138
1324
  forward: () => {
1139
- if (isBrowser) {
1325
+ if (isBrowser()) {
1140
1326
  window.history.forward();
1141
1327
  }
1142
1328
  },
@@ -1207,6 +1393,15 @@ export hook useLoaderData(): mixed {
1207
1393
  * Renders the matched page inside its layouts, innermost last, with the
1208
1394
  * document metadata as hoistable head elements.
1209
1395
  *
1396
+ * # One walk down the layouts, not three
1397
+ *
1398
+ * The layouts, the error boundary and the `<Suspense>` boundaries all have to
1399
+ * be threaded into the same stack at the depth each was declared at, so this
1400
+ * is one descending loop over that depth rather than a pass per kind. `depth`
1401
+ * counts the layouts still *outside* the element built so far, which is what
1402
+ * `above` means on both a route's `errorBoundary` and each of its `loading`
1403
+ * entries — one number, one meaning, one place it is compared.
1404
+ *
1210
1405
  * # Where the error boundaries go
1211
1406
  *
1212
1407
  * Two, and they are not the same thing twice. The inner one is the project's
@@ -1217,6 +1412,22 @@ export hook useLoaderData(): mixed {
1217
1412
  * the error component itself, and an unmounted document. A single boundary
1218
1413
  * cannot be both: put it outside and a page's throw takes the navigation down
1219
1414
  * with it; put it inside and nothing catches the layout above.
1415
+ *
1416
+ * # Where the loading boundaries go
1417
+ *
1418
+ * Inside the layout of the segment that declared the file and outside
1419
+ * everything under it, which is what makes the shell arrive first: a renderer
1420
+ * streaming this tree can send every layout down to the boundary, and the
1421
+ * fallback, before whatever the page is waiting for has resolved. A segment
1422
+ * with no `_uf.loading.js` contributes no boundary at all — it is not wrapped
1423
+ * in a `<Suspense fallback={null}>` on the way past — so a project that
1424
+ * declares none renders the tree it rendered before this existed, and a page
1425
+ * that suspends without a boundary above it still fails the way React says it
1426
+ * should rather than silently rendering nothing.
1427
+ *
1428
+ * The error boundary goes *outside* the fallback at the same depth. A throw
1429
+ * while the page is resolving has to reach a boundary that is still mounted,
1430
+ * and the `<Suspense>` is part of what the throw came out of.
1220
1431
  */
1221
1432
  export component RouteView() {
1222
1433
  const { resolved } = useRouterState();
@@ -1225,23 +1436,34 @@ export component RouteView() {
1225
1436
  let element: React.Node = (
1226
1437
  <Page params={resolved.params} searchParams={resolved.searchParams} data={resolved.data} />
1227
1438
  );
1228
- for (let index = resolved.layouts.length - 1; index >= above; index -= 1) {
1229
- const Layout = layoutComponent(resolved.layouts[index]);
1230
- element = <Layout params={resolved.params}>{element}</Layout>;
1231
- }
1232
- // Not around a route that already resolved to its error page: that page is
1233
- // the boundary's own component, and wrapping it in the same boundary would
1234
- // answer a throw inside it with itself.
1235
- if (module != null && resolved.error == null) {
1236
- element = (
1237
- <RouteErrorBoundary module={module} resetKey={resolved.pathname}>
1238
- {element}
1239
- </RouteErrorBoundary>
1240
- );
1241
- }
1242
- for (let index = above - 1; index >= 0; index -= 1) {
1243
- const Layout = layoutComponent(resolved.layouts[index]);
1244
- element = <Layout params={resolved.params}>{element}</Layout>;
1439
+
1440
+ for (let depth = resolved.layouts.length; depth >= 0; depth -= 1) {
1441
+ // Backwards over a root-first list, so the deepest segment's fallback ends
1442
+ // up closest to the page. Two segments land on the same depth whenever the
1443
+ // inner one declares no layout of its own, and then this order is the only
1444
+ // thing that keeps them nested the way the directories are.
1445
+ for (let index = resolved.loading.length - 1; index >= 0; index -= 1) {
1446
+ const boundary = resolved.loading[index];
1447
+ if (boundary.above !== depth) {
1448
+ continue;
1449
+ }
1450
+ const Fallback = loadingComponent(boundary.module);
1451
+ element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
1452
+ }
1453
+ // Not around a route that already resolved to its error page: that page is
1454
+ // the boundary's own component, and wrapping it in the same boundary would
1455
+ // answer a throw inside it with itself.
1456
+ if (depth === above && module != null && resolved.error == null) {
1457
+ element = (
1458
+ <RouteErrorBoundary module={module} resetKey={resolved.pathname}>
1459
+ {element}
1460
+ </RouteErrorBoundary>
1461
+ );
1462
+ }
1463
+ if (depth > 0) {
1464
+ const Layout = layoutComponent(resolved.layouts[depth - 1]);
1465
+ element = <Layout params={resolved.params}>{element}</Layout>;
1466
+ }
1245
1467
  }
1246
1468
  return (
1247
1469
  <>
@@ -1267,6 +1489,24 @@ function pageComponent(module: PageModule): React.ComponentType<PageRenderProps>
1267
1489
  return renderable(component);
1268
1490
  }
1269
1491
 
1492
+ /**
1493
+ * The component a loading module renders: `default`, or the named `Loading`.
1494
+ *
1495
+ * No props, unlike a page or a layout. A fallback is what the router shows
1496
+ * when it does not have the route's answer yet, so there is nothing it could
1497
+ * be handed that would be true — not `data`, which is the thing being waited
1498
+ * for, and not `children`, because it renders instead of them.
1499
+ */
1500
+ function loadingComponent(module: LoadingModule): React.ComponentType<{||}> {
1501
+ const component = module.default ?? module.Loading;
1502
+ if (component == null) {
1503
+ throw new Error(
1504
+ "@uniflowed/router: a loading module must export a component as `default` or `Loading`",
1505
+ );
1506
+ }
1507
+ return renderable(component);
1508
+ }
1509
+
1270
1510
  /** The component a layout module renders: `default`, or the named `Layout`. */
1271
1511
  function layoutComponent(module: LayoutModule): React.ComponentType<LayoutRenderProps> {
1272
1512
  const component = module.default ?? module.Layout;
@@ -1292,11 +1532,14 @@ function layoutComponent(module: LayoutModule): React.ComponentType<LayoutRender
1292
1532
  * unsoundness spread over six declarations, where it also stopped anyone from
1293
1533
  * checking that `RouteView` passes the props a page is documented to receive.
1294
1534
  * Here it is one line, and everything on either side of it is checked: what a
1295
- * module may export, and what a page is handed.
1535
+ * module may export, and what a page is handed. Suppressed by name so that
1536
+ * `check:lib` can gate CI without this file being the thing that stops it; the
1537
+ * directive names the rule, and this is the argument for escaping it.
1296
1538
  */
1297
1539
  function renderable<TProps extends { ... }>(
1298
1540
  component: RouteComponent,
1299
1541
  ): React.ComponentType<TProps> {
1542
+ // uf-lint-disable-next-line flow/unclear-type
1300
1543
  return component as any;
1301
1544
  }
1302
1545