@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 +29 -1
- package/handler.js +20 -13
- package/index.js +4 -1
- package/internal/request.js +43 -0
- package/internal/runtime.js +272 -29
- package/internal/stream.js +627 -0
- package/middleware.js +211 -0
- package/package.json +5 -3
- package/server.js +267 -37
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
94
|
-
// or `after()`
|
|
95
|
-
//
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
+
}
|
package/internal/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1298
|
+
const load = matched?.route.page;
|
|
1299
|
+
if (matched == null || load == null) {
|
|
1114
1300
|
return;
|
|
1115
1301
|
}
|
|
1116
1302
|
await Promise.all([
|
|
1117
|
-
loadOnce(
|
|
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
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
|