@uniflowed/router 0.0.0-alpha.9 → 0.1.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 +324 -0
- package/client.js +261 -7
- package/handler.js +113 -99
- package/http-client.js +104 -0
- package/index.js +49 -6
- package/instrumentation.js +92 -0
- package/internal/action-endpoint.js +438 -0
- package/internal/action-wire.js +608 -0
- package/internal/base-path.js +175 -0
- package/internal/boundaries.js +481 -0
- package/internal/boundary-data.js +88 -0
- package/internal/compose.js +490 -0
- package/internal/deployment.js +160 -0
- package/internal/devtools.js +131 -0
- package/internal/diagnostics.js +169 -0
- package/internal/error-view.js +193 -0
- package/internal/flight-browser.js +242 -0
- package/internal/flight-chunks.js +205 -0
- package/internal/flight-rows.js +135 -0
- package/internal/flight-ssr.js +91 -0
- package/internal/flight.js +192 -0
- package/internal/head.js +219 -0
- package/internal/hydration.js +1085 -0
- package/internal/inspector.js +626 -0
- package/internal/native-links.js +67 -0
- package/internal/native-tree.js +89 -0
- package/internal/navigation-cache.js +181 -0
- package/internal/payload-rows.js +270 -0
- package/internal/payload.js +685 -0
- package/internal/prepare-document.js +54 -0
- package/internal/react-version.js +77 -0
- package/internal/resolve.js +1617 -0
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +478 -0
- package/internal/runtime.js +1593 -1341
- package/internal/server-instrumentation.js +12 -0
- package/internal/server-route.js +58 -0
- package/internal/shell.js +125 -0
- package/internal/stream.js +754 -21
- package/middleware.js +161 -22
- package/native-navigation.js +217 -0
- package/native.js +416 -0
- package/package.json +48 -7
- package/routing.js +51 -0
- package/rsc-client.js +120 -0
- package/rsc-ssr.js +637 -0
- package/rsc.js +402 -0
- package/server-components.js +159 -0
- package/server.js +254 -106
package/internal/runtime.js
CHANGED
|
@@ -1,1249 +1,1092 @@
|
|
|
1
1
|
// @flow
|
|
2
2
|
//
|
|
3
|
-
// The router runtime:
|
|
3
|
+
// The router runtime: the browser's binding.
|
|
4
|
+
//
|
|
5
|
+
// A client module, and the directive is load-bearing rather than descriptive:
|
|
6
|
+
// in the module graph React Server Components render in, every export of this
|
|
7
|
+
// file is a client reference — `Link` renders as markup on the server and runs
|
|
8
|
+
// in the browser — and `../server-components.js` is what that graph gets for
|
|
9
|
+
// the hooks instead. Everywhere else the directive changes nothing.
|
|
4
10
|
//
|
|
5
11
|
// A route table is data — the virtual module `virtual:uf/routes` that
|
|
6
12
|
// `@uniflowed/vite` generates from the `app/` directory — and this module is
|
|
7
|
-
//
|
|
8
|
-
// the
|
|
9
|
-
//
|
|
10
|
-
//
|
|
13
|
+
// what turns it into a running application in a page: the provider that holds
|
|
14
|
+
// the current route, the hooks that read it, navigation, view transitions and
|
|
15
|
+
// `Link`. What a URL resolves to is `./resolve.js`, the tree a resolved route
|
|
16
|
+
// renders is `./compose.js`, and the metadata elements are `./head.js`; the
|
|
17
|
+
// three are split out because none of them may reach a hook, a context or a
|
|
18
|
+
// class component, which is what lets a server graph resolved under React's
|
|
19
|
+
// `react-server` condition import them (ubugeeei-prod/uf#519).
|
|
20
|
+
|
|
21
|
+
"use client";
|
|
11
22
|
|
|
12
|
-
import
|
|
23
|
+
import { observeNavigation } from "../instrumentation.js";
|
|
24
|
+
|
|
25
|
+
import type * as React from "react";
|
|
13
26
|
import {
|
|
14
27
|
Suspense,
|
|
15
28
|
createContext,
|
|
16
29
|
startTransition,
|
|
17
|
-
|
|
30
|
+
use,
|
|
18
31
|
useContext,
|
|
19
32
|
useEffect,
|
|
20
|
-
|
|
33
|
+
useRef,
|
|
21
34
|
useState,
|
|
22
35
|
useSyncExternalStore,
|
|
36
|
+
useTransition,
|
|
23
37
|
} from "react";
|
|
38
|
+
// The one thing in this module that only a browser can do, and the reason it
|
|
39
|
+
// is imported here rather than from `../client.js`: a view transition needs
|
|
40
|
+
// the DOM updated inside the callback it was handed, and `startTransition`
|
|
41
|
+
// schedules. "View transitions", below, is the argument. Importing `react-dom`
|
|
42
|
+
// costs the server bundle nothing it did not already have — `internal/stream.js`
|
|
43
|
+
// imports `react-dom/server` — and this entry touches no document while it is
|
|
44
|
+
// being evaluated.
|
|
45
|
+
import { flushSync } from "react-dom";
|
|
46
|
+
|
|
47
|
+
// The two things a render has to fix — its instant and its random seed — and
|
|
48
|
+
// the provider that fixes them. Imported here rather than left to the
|
|
49
|
+
// application, because a hydration guarantee nobody wires is not a guarantee:
|
|
50
|
+
// see [`routerView`] and ubugeeei-prod/uf#559.
|
|
51
|
+
import { RenderProvider } from "@uniflowed/hooks/render";
|
|
52
|
+
|
|
53
|
+
// The id of the script the loader data is embedded in. It moved out of the
|
|
54
|
+
// head and into the tree with ubugeeei-prod/uf#373 — see [`payloadElements`]
|
|
55
|
+
// — so the module that renders it is this one rather than `../server.js`.
|
|
56
|
+
import { DATA_ID } from "./document.js";
|
|
57
|
+
|
|
58
|
+
// The payload the loader's answer is written as, and the rows it defers. Row 0
|
|
59
|
+
// is the element `DATA_ID` names and is byte-identical to what this file wrote
|
|
60
|
+
// inline before the payload existed whenever nothing is deferred; a promise
|
|
61
|
+
// anywhere in the data turns into a reference and a row of its own. See
|
|
62
|
+
// `./payload.js` for the format and ubugeeei-prod/uf#519 for the half of it
|
|
63
|
+
// that is still an element payload rather than a data one.
|
|
64
|
+
import {
|
|
65
|
+
type PayloadRowMessage,
|
|
66
|
+
PayloadRowError,
|
|
67
|
+
encodePayload,
|
|
68
|
+
encodeRowValue,
|
|
69
|
+
payloadJson,
|
|
70
|
+
} from "./payload.js";
|
|
71
|
+
|
|
72
|
+
// The development-only half of [`RouteView`]: the marks that say which DOM
|
|
73
|
+
// subtree each boundary owns, and the report that reads them. Every reference
|
|
74
|
+
// to it is inside a `BOUNDARY_MARKS` branch, which is why a static import is
|
|
75
|
+
// safe here where `../client.js` needs a dynamic one — a component cannot be
|
|
76
|
+
// awaited in the middle of a render, and `false` folds the references away
|
|
77
|
+
// before the bundler is asked to keep the module. See [`BOUNDARY_MARKS`].
|
|
78
|
+
import { BoundaryReporter } from "./boundaries.js";
|
|
79
|
+
import { routeBoundaries } from "./boundary-data.js";
|
|
80
|
+
import { composeRoute, pageComponent } from "./compose.js";
|
|
81
|
+
import {
|
|
82
|
+
type FetchedFlight,
|
|
83
|
+
type FlightFetchOptions,
|
|
84
|
+
type FlightRoot,
|
|
85
|
+
type RouteState,
|
|
86
|
+
routeState,
|
|
87
|
+
} from "./flight.js";
|
|
88
|
+
import { Head } from "./head.js";
|
|
89
|
+
import {
|
|
90
|
+
failedOnAMissingChunk,
|
|
91
|
+
fromAnotherDeployment,
|
|
92
|
+
isChunkLoadFailure,
|
|
93
|
+
loadDocument,
|
|
94
|
+
} from "./deployment.js";
|
|
95
|
+
import { addressOf, applicationPathOf, canonicalAddress } from "./base-path.js";
|
|
96
|
+
import {
|
|
97
|
+
clearNavigationCache,
|
|
98
|
+
flightNavigations,
|
|
99
|
+
keepsNavigations,
|
|
100
|
+
navigationKey,
|
|
101
|
+
routeNavigations,
|
|
102
|
+
} from "./navigation-cache.js";
|
|
103
|
+
import { hasClientPage, matchRoute, nearestBoundary } from "./routing.js";
|
|
104
|
+
import type { RouteParams, SearchParams } from "./routing.js";
|
|
105
|
+
import {
|
|
106
|
+
beneath,
|
|
107
|
+
interceptingRoutes,
|
|
108
|
+
loadOnce,
|
|
109
|
+
resolveInterception,
|
|
110
|
+
resolveMatch,
|
|
111
|
+
} from "./resolve.js";
|
|
112
|
+
import type { Metadata, ResolvedRoute, RouteTable } from "./resolve.js";
|
|
113
|
+
|
|
114
|
+
export type { RouteError, RouteParamSpec, RouteParams, SearchParams } from "./routing.js";
|
|
115
|
+
|
|
116
|
+
export {
|
|
117
|
+
ForbiddenError,
|
|
118
|
+
NotFoundError,
|
|
119
|
+
RedirectError,
|
|
120
|
+
UnauthorizedError,
|
|
121
|
+
buildRoute,
|
|
122
|
+
forbidden,
|
|
123
|
+
hasClientPage,
|
|
124
|
+
matchRoute,
|
|
125
|
+
notFound,
|
|
126
|
+
parseSearch,
|
|
127
|
+
permanentRedirect,
|
|
128
|
+
redirect,
|
|
129
|
+
routeErrorStatus,
|
|
130
|
+
splitUrl,
|
|
131
|
+
unauthorized,
|
|
132
|
+
} from "./routing.js";
|
|
133
|
+
|
|
134
|
+
export type {
|
|
135
|
+
ErrorBoundary,
|
|
136
|
+
ErrorModule,
|
|
137
|
+
Interception,
|
|
138
|
+
JsonLd,
|
|
139
|
+
LayoutModule,
|
|
140
|
+
LoaderArgs,
|
|
141
|
+
LoadingModule,
|
|
142
|
+
LoadingRecord,
|
|
143
|
+
Metadata,
|
|
144
|
+
MetadataArgs,
|
|
145
|
+
NotFoundBoundary,
|
|
146
|
+
PageModule,
|
|
147
|
+
ResolveOptions,
|
|
148
|
+
ResolvedRoute,
|
|
149
|
+
ResolvedSlot,
|
|
150
|
+
Robots,
|
|
151
|
+
RouteMatch,
|
|
152
|
+
RouteRecord,
|
|
153
|
+
RouteTable,
|
|
154
|
+
SlotRecord,
|
|
155
|
+
SlotRouteRecord,
|
|
156
|
+
TemplateModule,
|
|
157
|
+
TemplateRecord,
|
|
158
|
+
TwitterCard,
|
|
159
|
+
} from "./resolve.js";
|
|
160
|
+
|
|
161
|
+
export { resolveFailure, resolveMatch } from "./resolve.js";
|
|
162
|
+
|
|
163
|
+
// `app.router.basePath` and `trailingSlash`, installed by the entry that starts
|
|
164
|
+
// the application; see `./base-path.js`.
|
|
165
|
+
export type { RoutingSettings, TrailingSlash } from "./base-path.js";
|
|
166
|
+
export { basePath, installRouting } from "./base-path.js";
|
|
167
|
+
|
|
168
|
+
// `app.rendering.staleTime`, installed by the same entry; see
|
|
169
|
+
// `./navigation-cache.js`.
|
|
170
|
+
export { installStaleTime } from "./navigation-cache.js";
|
|
24
171
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// View transitions
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
//
|
|
176
|
+
// A client navigation replaces the tree and the browser paints the new one,
|
|
177
|
+
// which is a cut. `document.startViewTransition` is the platform's answer, and
|
|
178
|
+
// it is opt-in per navigation rather than per site — so somebody has to call
|
|
179
|
+
// it, and the somebody is whatever replaced the tree. That is this module.
|
|
180
|
+
// Leaving it to the application would mean every application reimplementing
|
|
181
|
+
// the same four decisions below, and getting the last one wrong.
|
|
182
|
+
//
|
|
183
|
+
// # Why `flushSync` rather than `startTransition`
|
|
184
|
+
//
|
|
185
|
+
// The browser captures the old frame, calls the callback, and waits on the
|
|
186
|
+
// promise the callback returns before capturing the new one. So the callback
|
|
187
|
+
// has to leave the DOM updated, and `startTransition` deliberately does not:
|
|
188
|
+
// it schedules, and returns having changed nothing.
|
|
189
|
+
//
|
|
190
|
+
// The alternative was to hand the browser a promise resolved from a layout
|
|
191
|
+
// effect after the commit, which keeps the render concurrent and can hang: a
|
|
192
|
+
// running view transition blocks input until its callback settles, so a commit
|
|
193
|
+
// React decides not to make — an interrupted transition, an unmounted provider
|
|
194
|
+
// — is a frozen page with no way back. `flushSync` cannot hang.
|
|
195
|
+
//
|
|
196
|
+
// The cost is real and worth stating rather than discovering. Inside a
|
|
197
|
+
// transition the commit is synchronous, so a route that suspends *while
|
|
198
|
+
// rendering* shows its `$loading.js` fallback instead of leaving the
|
|
199
|
+
// previous page up until it resolves. Its modules and its loader are already
|
|
200
|
+
// finished by this point — `resolveMatch` awaited both — so what is left is a
|
|
201
|
+
// component suspending on something else, and it degrades to the fallback the
|
|
202
|
+
// project wrote for exactly that.
|
|
203
|
+
//
|
|
204
|
+
// # Why not React's `<ViewTransition>`
|
|
205
|
+
//
|
|
206
|
+
// It is not in a stable React. This package's peer range is `react >= 19`, and
|
|
207
|
+
// reaching for a component that exists only in an experimental build would
|
|
208
|
+
// turn an animation into a reason a project cannot use the router at all.
|
|
209
|
+
// `startViewTransition` is the same feature one layer down, and it is in the
|
|
210
|
+
// browser rather than in a dependency.
|
|
211
|
+
//
|
|
212
|
+
// # What must not change
|
|
213
|
+
//
|
|
214
|
+
// A browser without `startViewTransition` navigates exactly as it did before
|
|
215
|
+
// any of this. A reader who asked for less motion gets the cut they asked for,
|
|
216
|
+
// without the application having to remember to ask on their behalf. And the
|
|
217
|
+
// server renders nothing about it: a transition is a client-only concern, and
|
|
218
|
+
// the moment one reaches the markup it is a hydration difference instead.
|
|
33
219
|
|
|
34
220
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* the *top* of the component types: every component is one, and nothing may be
|
|
41
|
-
* passed to one until a caller has said which props it is passing. That is
|
|
42
|
-
* exactly what is known here. The router finds these by dynamic import, and
|
|
43
|
-
* nobody has told it what a page's props are.
|
|
44
|
-
*
|
|
45
|
-
* It cannot be `React.ComponentType<PageRenderProps>`, the props the router
|
|
46
|
-
* actually passes, because Flow's `component` syntax gives a component *exact*
|
|
47
|
-
* props and a page is free to want none of them. This repository's own pages
|
|
48
|
-
* and layouts are `component NotFound()` and
|
|
49
|
-
* `component Layout(children: React.Node)`, and against the props the router
|
|
50
|
-
* hands them that reads:
|
|
51
|
-
*
|
|
52
|
-
* error[incompatible-type]: property `data`, property `params`, and
|
|
53
|
-
* property `searchParams` are extra in `PageRenderProps` but missing in
|
|
54
|
-
* `props of component NotFound`. Exact objects do not accept extra props.
|
|
55
|
-
*
|
|
56
|
-
* React passing a component a prop it did not declare is allowed and always
|
|
57
|
-
* has been. `renderable` is the one line that says so.
|
|
221
|
+
* The attribute a running transition's name reaches CSS through.
|
|
222
|
+
*
|
|
223
|
+
* On the document element, because that is where the `::view-transition`
|
|
224
|
+
* pseudo-elements hang and therefore the only element a selector can reach
|
|
225
|
+
* them from.
|
|
58
226
|
*/
|
|
59
|
-
|
|
227
|
+
const VIEW_TRANSITION_ATTRIBUTE = "data-uf-view-transition";
|
|
60
228
|
|
|
61
229
|
/**
|
|
62
|
-
* The
|
|
230
|
+
* The part of a running transition this module reads.
|
|
63
231
|
*
|
|
64
|
-
*
|
|
65
|
-
* the
|
|
66
|
-
*
|
|
67
|
-
*
|
|
232
|
+
* One property, because one is what a navigation needs: `finished` settles
|
|
233
|
+
* when the animation is over, which is when the document may stop saying which
|
|
234
|
+
* transition is running. `ready` and `updateCallbackDone` are for an
|
|
235
|
+
* application animating something itself, and a router holding them would be
|
|
236
|
+
* claiming to know what they were for.
|
|
68
237
|
*/
|
|
69
|
-
type
|
|
70
|
-
readonly params: RouteParams,
|
|
71
|
-
readonly searchParams: SearchParams,
|
|
72
|
-
readonly data: mixed,
|
|
73
|
-
|};
|
|
74
|
-
|
|
75
|
-
/** The props `RouteView` gives each layout, outermost first. */
|
|
76
|
-
type LayoutRenderProps = {|
|
|
77
|
-
readonly params: RouteParams,
|
|
78
|
-
readonly children: React.Node,
|
|
79
|
-
|};
|
|
238
|
+
type ViewTransition = { readonly finished: Promise<mixed>, ... };
|
|
80
239
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
readonly
|
|
98
|
-
|
|
99
|
-
readonly metadata?: Metadata,
|
|
100
|
-
...
|
|
101
|
-
};
|
|
240
|
+
/**
|
|
241
|
+
* The document, under the one description this module has of it.
|
|
242
|
+
*
|
|
243
|
+
* Flow's library definitions have no `startViewTransition` — the API is newer
|
|
244
|
+
* than they are — and reading it off `any` would leave the one call that
|
|
245
|
+
* performs a navigation unchecked, where a wrong type is a broken navigation
|
|
246
|
+
* rather than a broken animation. Optional, because "this browser may not have
|
|
247
|
+
* it" is the entire point.
|
|
248
|
+
*
|
|
249
|
+
* An `interface` rather than an object type, because a `Document` is a class
|
|
250
|
+
* instance and class instances are not subtypes of object types. `documentElement`
|
|
251
|
+
* is nullable for the same reason it is in Flow's own libdef: a document parsed
|
|
252
|
+
* from nothing has no root element.
|
|
253
|
+
*/
|
|
254
|
+
interface ViewTransitionDocument {
|
|
255
|
+
readonly startViewTransition?: (update: () => mixed) => ViewTransition;
|
|
256
|
+
readonly documentElement: HTMLElement | null;
|
|
257
|
+
}
|
|
102
258
|
|
|
103
259
|
/**
|
|
104
|
-
*
|
|
260
|
+
* Whether the reader has asked for less motion.
|
|
105
261
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
262
|
+
* Asked at the moment of the navigation rather than subscribed to, because it
|
|
263
|
+
* is not a rendered value: nothing re-renders when the preference changes, and
|
|
264
|
+
* the only question is what to do with the click that just happened.
|
|
265
|
+
* `usePrefersReducedMotion` in `@uniflowed/hooks` is the rendered form of the
|
|
266
|
+
* same query and answers a different question.
|
|
267
|
+
*
|
|
268
|
+
* `matchMedia` is optional here because a document installed by a test runner
|
|
269
|
+
* may not have one, and a media query that cannot be asked is not a reason to
|
|
270
|
+
* fail a navigation.
|
|
111
271
|
*/
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
...
|
|
117
|
-
};
|
|
272
|
+
function prefersReducedMotion(): boolean {
|
|
273
|
+
const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
|
274
|
+
return query != null && query.matches === true;
|
|
275
|
+
}
|
|
118
276
|
|
|
119
277
|
/**
|
|
120
|
-
*
|
|
278
|
+
* Apply `update`, inside a view transition where there is one to be had.
|
|
121
279
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
280
|
+
* Two ways out and they are one decision: with no `startViewTransition`, or
|
|
281
|
+
* with a reader who asked for less motion, this is the `startTransition` the
|
|
282
|
+
* router did before any of this existed — same commit, same concurrency, no
|
|
283
|
+
* animation.
|
|
284
|
+
*
|
|
285
|
+
* `name` is the route's, and it reaches CSS as an attribute for as long as the
|
|
286
|
+
* transition runs. The other spelling is the `types` option, which is the
|
|
287
|
+
* platform's own vocabulary for the same idea and is *newer than
|
|
288
|
+
* `startViewTransition` itself* — so passing the options object to a browser
|
|
289
|
+
* that has only the callback form is a `TypeError` thrown out of the call that
|
|
290
|
+
* performs the navigation. Naming a transition would then need a second and
|
|
291
|
+
* finer feature detection than the one for having transitions at all, and the
|
|
292
|
+
* cost of getting that one wrong is the navigation rather than the animation.
|
|
293
|
+
* One attribute needs no detection and is removed again when the transition
|
|
294
|
+
* ends.
|
|
127
295
|
*/
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
export type Metadata = {
|
|
136
|
-
readonly title?: string,
|
|
137
|
-
readonly description?: string,
|
|
138
|
-
readonly openGraph?: {
|
|
139
|
-
readonly title?: string,
|
|
140
|
-
readonly description?: string,
|
|
141
|
-
readonly images?: $ReadOnlyArray<string>,
|
|
142
|
-
},
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
/** Arguments a loader receives. */
|
|
146
|
-
export type LoaderArgs = {|
|
|
147
|
-
readonly params: RouteParams,
|
|
148
|
-
readonly searchParams: SearchParams,
|
|
149
|
-
readonly pathname: string,
|
|
150
|
-
|};
|
|
296
|
+
function withViewTransition(name: ?string, update: () => void): void {
|
|
297
|
+
const owner: ViewTransitionDocument = document;
|
|
298
|
+
const start = owner.startViewTransition?.bind(owner);
|
|
299
|
+
if (start == null || prefersReducedMotion()) {
|
|
300
|
+
startTransition(update);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
151
303
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
304
|
+
const root = owner.documentElement;
|
|
305
|
+
if (name != null && root != null) {
|
|
306
|
+
root.setAttribute(VIEW_TRANSITION_ATTRIBUTE, name);
|
|
307
|
+
}
|
|
308
|
+
const ended = () => {
|
|
309
|
+
if (name != null && root != null) {
|
|
310
|
+
root.removeAttribute(VIEW_TRANSITION_ATTRIBUTE);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
// Both settlements do the same thing, and the rejection is not a failure:
|
|
314
|
+
// `finished` rejects when the transition is skipped — a second navigation
|
|
315
|
+
// before this one finished, a tab that went to the background — and a
|
|
316
|
+
// skipped transition has still ended. Handling it is also what keeps a
|
|
317
|
+
// routine interruption from being reported as an unhandled rejection.
|
|
318
|
+
start(() => {
|
|
319
|
+
flushSync(update);
|
|
320
|
+
}).finished.then(ended, ended);
|
|
321
|
+
}
|
|
158
322
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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>,
|
|
182
|
-
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// The React binding
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
/** How a navigation is performed. */
|
|
328
|
+
export type NavigateOptions = {|
|
|
329
|
+
readonly replace?: boolean,
|
|
330
|
+
readonly scroll?: boolean,
|
|
183
331
|
/**
|
|
184
|
-
*
|
|
332
|
+
* Whether this navigation may animate. Defaults to `true`, which is what
|
|
333
|
+
* every navigation does.
|
|
185
334
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
335
|
+
* `false` is how a caller says this one is a change of state rather than a
|
|
336
|
+
* change of place — a tab within a page, a filter written into the query
|
|
337
|
+
* string — and should be a cut. `true` does not *force* one: a browser
|
|
338
|
+
* without `startViewTransition` and a reader who asked for less motion still
|
|
339
|
+
* get the cut, because an application able to override the second would
|
|
340
|
+
* eventually override it.
|
|
190
341
|
*/
|
|
191
|
-
readonly
|
|
342
|
+
readonly transition?: boolean,
|
|
192
343
|
|};
|
|
193
344
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
export type LoadingRecord = {|
|
|
203
|
-
readonly above: number,
|
|
204
|
-
readonly module: () => Promise<LoadingModule>,
|
|
345
|
+
/** What `useRouter()` returns. */
|
|
346
|
+
export type Router = {|
|
|
347
|
+
readonly push: (to: string, options?: NavigateOptions) => Promise<void>,
|
|
348
|
+
readonly replace: (to: string) => Promise<void>,
|
|
349
|
+
readonly prefetch: (to: string) => Promise<void>,
|
|
350
|
+
readonly refresh: () => Promise<void>,
|
|
351
|
+
readonly back: () => void,
|
|
352
|
+
readonly forward: () => void,
|
|
205
353
|
|};
|
|
206
354
|
|
|
207
|
-
/**
|
|
208
|
-
|
|
209
|
-
* nothing.
|
|
210
|
-
*
|
|
211
|
-
* `_uf.not-found.js` is a segment file, so `path` is the route path of the
|
|
212
|
-
* directory that declares it and `layouts` are the layouts in scope *there* —
|
|
213
|
-
* which is what the boundary renders inside. A project with one at the router
|
|
214
|
-
* root has one of these; a project whose manual answers its own 404 has two.
|
|
215
|
-
*/
|
|
216
|
-
export type NotFoundBoundary = {|
|
|
355
|
+
/** What `useRoute()` returns. */
|
|
356
|
+
export type RouteInfo = {|
|
|
217
357
|
readonly path: string,
|
|
218
|
-
readonly
|
|
219
|
-
readonly
|
|
220
|
-
readonly
|
|
221
|
-
readonly
|
|
358
|
+
readonly pathname: string,
|
|
359
|
+
readonly params: RouteParams,
|
|
360
|
+
readonly searchParams: SearchParams,
|
|
361
|
+
readonly data: mixed,
|
|
362
|
+
readonly pending: boolean,
|
|
222
363
|
|};
|
|
223
364
|
|
|
224
365
|
/**
|
|
225
|
-
*
|
|
226
|
-
* something in it throws.
|
|
366
|
+
* What this application does when a visitor follows a link.
|
|
227
367
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
* around the error and are why the rest of the document is still there.
|
|
368
|
+
* `app.rendering.navigation` in `uf.config.js`, and the same two words: the
|
|
369
|
+
* client router takes the link over, or the browser does.
|
|
231
370
|
*/
|
|
232
|
-
export type
|
|
233
|
-
readonly path: string,
|
|
234
|
-
readonly file: string,
|
|
235
|
-
readonly module: () => Promise<ErrorModule>,
|
|
236
|
-
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
237
|
-
|};
|
|
371
|
+
export type Navigation = "client" | "document";
|
|
238
372
|
|
|
239
373
|
/**
|
|
240
|
-
*
|
|
374
|
+
* What the router holds, and what every hook and `RouteView` read.
|
|
241
375
|
*
|
|
242
|
-
* `
|
|
243
|
-
*
|
|
376
|
+
* Two halves, because a route arrives two ways. `route` is what a hook reads —
|
|
377
|
+
* the path, the parameters, the loader's answer — and it is the same shape
|
|
378
|
+
* whichever way the route was rendered. `view` is what `RouteView` renders:
|
|
379
|
+
* the tree a server composed for React Server Components, or a route resolved
|
|
380
|
+
* from its modules, which the browser composes itself.
|
|
244
381
|
*/
|
|
245
|
-
|
|
246
|
-
readonly
|
|
247
|
-
readonly
|
|
248
|
-
readonly
|
|
382
|
+
type RouterState = {|
|
|
383
|
+
readonly route: RouteState,
|
|
384
|
+
readonly view: RouteViewState,
|
|
385
|
+
readonly router: Router,
|
|
386
|
+
readonly pending: boolean,
|
|
387
|
+
readonly navigation: Navigation,
|
|
249
388
|
|};
|
|
250
389
|
|
|
251
|
-
/**
|
|
252
|
-
|
|
253
|
-
readonly
|
|
254
|
-
readonly
|
|
255
|
-
|
|
390
|
+
/** What `RouteView` renders: a server's tree, or a route to compose. */
|
|
391
|
+
type RouteViewState =
|
|
392
|
+
| {| readonly kind: "flight", readonly tree: React.Node |}
|
|
393
|
+
| {| readonly kind: "modules", readonly resolved: ResolvedRoute |};
|
|
394
|
+
|
|
395
|
+
const RouterContext: React.Context<?RouterState> = createContext(null);
|
|
396
|
+
|
|
397
|
+
/** The route table the application was started with. */
|
|
398
|
+
let installedTable: ?RouteTable = null;
|
|
256
399
|
|
|
257
400
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
* the
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
* boundary: a server exception's message is written for the person who
|
|
271
|
-
* deployed the application, not for whoever asks for the page.
|
|
401
|
+
* How the application navigates, installed by the entry that started it.
|
|
402
|
+
*
|
|
403
|
+
* Module state beside `installedTable`, and for the same reason: the entry is
|
|
404
|
+
* the only thing that knows, and every component that needs the answer is
|
|
405
|
+
* somewhere under a `RouterProvider` it did not construct. `routerView` builds
|
|
406
|
+
* that provider from two props the server handed it, and threading a third one
|
|
407
|
+
* from the entry through the application root would have made every
|
|
408
|
+
* hand-written `<App>` in a test a place the default lives.
|
|
409
|
+
*
|
|
410
|
+
* `"client"` until something says otherwise, which is what every uf
|
|
411
|
+
* application did before `app.rendering.navigation` existed and what a test
|
|
412
|
+
* that renders `routerView` directly still gets.
|
|
272
413
|
*/
|
|
273
|
-
|
|
274
|
-
| {| readonly kind: "thrown", readonly error: mixed |}
|
|
275
|
-
| {| readonly kind: "unauthorized" |}
|
|
276
|
-
| {| readonly kind: "forbidden" |};
|
|
277
|
-
|
|
278
|
-
/** The status a `RouteError` answers with. */
|
|
279
|
-
export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
|
|
280
|
-
return match (error) {
|
|
281
|
-
{kind: "unauthorized"} => 401,
|
|
282
|
-
{kind: "forbidden"} => 403,
|
|
283
|
-
{kind: "thrown"} => 500,
|
|
284
|
-
};
|
|
285
|
-
}
|
|
414
|
+
let installedNavigation: Navigation = "client";
|
|
286
415
|
|
|
287
416
|
/**
|
|
288
|
-
*
|
|
289
|
-
*
|
|
417
|
+
* Say how this application navigates. Called once, by the client entry.
|
|
418
|
+
*
|
|
419
|
+
* `@uniflowed/vite` generates the call into `virtual:uf/client` from
|
|
420
|
+
* `app.rendering.navigation`; nothing else should call it, and calling it after
|
|
421
|
+
* the first render is a change no rendered `Link` will notice.
|
|
290
422
|
*/
|
|
291
|
-
export
|
|
292
|
-
|
|
293
|
-
readonly search: string,
|
|
294
|
-
readonly path: string,
|
|
295
|
-
readonly params: RouteParams,
|
|
296
|
-
readonly searchParams: SearchParams,
|
|
297
|
-
readonly page: PageModule,
|
|
298
|
-
readonly layouts: $ReadOnlyArray<LayoutModule>,
|
|
299
|
-
readonly data: mixed,
|
|
300
|
-
readonly metadata: Metadata,
|
|
301
|
-
readonly status: 200 | 401 | 403 | 404 | 500,
|
|
302
|
-
/**
|
|
303
|
-
* Set when this resolution *is* the error page: the loader threw, or the
|
|
304
|
-
* server render did and the renderer resolved again. `null` on the ordinary
|
|
305
|
-
* path.
|
|
306
|
-
*/
|
|
307
|
-
readonly error: ?RouteError,
|
|
308
|
-
/**
|
|
309
|
-
* The boundary that would catch a throw while rendering this route.
|
|
310
|
-
*
|
|
311
|
-
* Always present, because every route has an answer for a throw: `module`
|
|
312
|
-
* is `null` when the project declares no `_uf.error.js` above the path, and
|
|
313
|
-
* the framework's own error page renders instead. `above` is how many of
|
|
314
|
-
* `layouts` are outside the boundary — the ones that stay mounted, which is
|
|
315
|
-
* what "the rest of the document is still interactive" means.
|
|
316
|
-
*/
|
|
317
|
-
readonly errorBoundary: {|
|
|
318
|
-
readonly module: ?ErrorModule,
|
|
319
|
-
readonly above: number,
|
|
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 |}>,
|
|
331
|
-
|};
|
|
332
|
-
|
|
333
|
-
/** Thrown by `notFound()`; the renderer answers with the not-found page. */
|
|
334
|
-
export class NotFoundError extends Error {
|
|
335
|
-
constructor() {
|
|
336
|
-
super("not found");
|
|
337
|
-
this.name = "NotFoundError";
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** Thrown by `unauthorized()`; the renderer answers with the error boundary. */
|
|
342
|
-
export class UnauthorizedError extends Error {
|
|
343
|
-
constructor() {
|
|
344
|
-
super("unauthorized");
|
|
345
|
-
this.name = "UnauthorizedError";
|
|
346
|
-
}
|
|
423
|
+
export function installNavigation(navigation: Navigation): void {
|
|
424
|
+
installedNavigation = navigation;
|
|
347
425
|
}
|
|
348
426
|
|
|
349
|
-
/**
|
|
350
|
-
export
|
|
351
|
-
|
|
352
|
-
super("forbidden");
|
|
353
|
-
this.name = "ForbiddenError";
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/** Thrown by `redirect()`; the renderer answers with a redirect. */
|
|
358
|
-
export class RedirectError extends Error {
|
|
359
|
-
to: string;
|
|
360
|
-
permanent: boolean;
|
|
361
|
-
|
|
362
|
-
constructor(to: string, permanent: boolean) {
|
|
363
|
-
super(`redirect to ${to}`);
|
|
364
|
-
this.name = "RedirectError";
|
|
365
|
-
this.to = to;
|
|
366
|
-
this.permanent = permanent;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// ---------------------------------------------------------------------------
|
|
371
|
-
// Matching
|
|
372
|
-
// ---------------------------------------------------------------------------
|
|
373
|
-
|
|
374
|
-
type Segment =
|
|
375
|
-
| {| readonly kind: "static", readonly value: string |}
|
|
376
|
-
| {| readonly kind: "param", readonly name: string |}
|
|
377
|
-
| {| readonly kind: "catchAll", readonly name: string |};
|
|
378
|
-
|
|
379
|
-
function compile(routePath: string): $ReadOnlyArray<Segment> {
|
|
380
|
-
return routePath
|
|
381
|
-
.split("/")
|
|
382
|
-
.filter((segment) => segment !== "")
|
|
383
|
-
.map((segment): Segment => {
|
|
384
|
-
if (segment.startsWith(":") && segment.endsWith("*")) {
|
|
385
|
-
return { kind: "catchAll", name: segment.slice(1, -1) };
|
|
386
|
-
}
|
|
387
|
-
if (segment.startsWith(":")) {
|
|
388
|
-
return { kind: "param", name: segment.slice(1) };
|
|
389
|
-
}
|
|
390
|
-
return { kind: "static", value: segment };
|
|
391
|
-
});
|
|
427
|
+
/** How this application navigates. */
|
|
428
|
+
export function navigationMode(): Navigation {
|
|
429
|
+
return installedNavigation;
|
|
392
430
|
}
|
|
393
431
|
|
|
394
432
|
/**
|
|
395
|
-
* How
|
|
396
|
-
*
|
|
433
|
+
* How a page that React Server Components rendered fetches the next route's
|
|
434
|
+
* payload. `hydrateFlight` in `../rsc-client.js` installs it.
|
|
435
|
+
*
|
|
436
|
+
* Handed in rather than imported, because this module is in every
|
|
437
|
+
* application's bundle: one rendered from its modules, a single-page one, and
|
|
438
|
+
* the server's. The fetch reads its answer with React's Flight client,
|
|
439
|
+
* `react-server-dom-parcel`, which only an application that renders Server
|
|
440
|
+
* Components installs, and which needs React 19.3 while the rest of the router
|
|
441
|
+
* runs on 19.2.3 (ubugeeei-prod/uf#992). A bundler resolves every import it is
|
|
442
|
+
* shown, whether or not anything calls it, so an import here would put that
|
|
443
|
+
* package in every one of those bundles, or fail the build where it is absent.
|
|
397
444
|
*/
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
return score;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
function matchSegments(
|
|
411
|
-
segments: $ReadOnlyArray<Segment>,
|
|
412
|
-
parts: $ReadOnlyArray<string>,
|
|
413
|
-
): ?RouteParams {
|
|
414
|
-
const params: { [string]: string | $ReadOnlyArray<string> } = {};
|
|
415
|
-
let index = 0;
|
|
416
|
-
for (const segment of segments) {
|
|
417
|
-
match (segment) {
|
|
418
|
-
{kind: "static", value: const value} => {
|
|
419
|
-
if (parts[index] !== value) {
|
|
420
|
-
return null;
|
|
421
|
-
}
|
|
422
|
-
index += 1;
|
|
423
|
-
}
|
|
424
|
-
{kind: "param", name: const name} => {
|
|
425
|
-
if (index >= parts.length) {
|
|
426
|
-
return null;
|
|
427
|
-
}
|
|
428
|
-
params[name] = decodeSegment(parts[index]);
|
|
429
|
-
index += 1;
|
|
430
|
-
}
|
|
431
|
-
{kind: "catchAll", name: const name} => {
|
|
432
|
-
params[name] = parts.slice(index).map(decodeSegment);
|
|
433
|
-
index = parts.length;
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
return index === parts.length ? params : null;
|
|
445
|
+
let installedFlightFetch:
|
|
446
|
+
| ((url: string, options?: FlightFetchOptions) => Promise<FetchedFlight>)
|
|
447
|
+
| null = null;
|
|
448
|
+
|
|
449
|
+
/** Hand the router the payload fetch. Called once, by `hydrateFlight`, before the first render. */
|
|
450
|
+
export function installFlightFetch(
|
|
451
|
+
fetcher: (url: string, options?: FlightFetchOptions) => Promise<FetchedFlight>,
|
|
452
|
+
): void {
|
|
453
|
+
installedFlightFetch = fetcher;
|
|
438
454
|
}
|
|
439
455
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
456
|
+
/** The next route's payload, through the fetch `hydrateFlight` installed. */
|
|
457
|
+
function fetchFlight(url: string, options?: FlightFetchOptions): Promise<FetchedFlight> {
|
|
458
|
+
if (installedFlightFetch == null) {
|
|
459
|
+
return Promise.reject(
|
|
460
|
+
new Error(
|
|
461
|
+
"@uniflowed/router: a page rendered from a Flight payload navigated before anything " +
|
|
462
|
+
"installed the payload fetch. `hydrateFlight` from `@uniflowed/router/rsc/client` " +
|
|
463
|
+
"installs it before it hydrates, so an entry that hydrates a payload has to call that.",
|
|
464
|
+
),
|
|
465
|
+
);
|
|
445
466
|
}
|
|
467
|
+
return installedFlightFetch(url, options);
|
|
446
468
|
}
|
|
447
469
|
|
|
448
|
-
/**
|
|
449
|
-
|
|
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;
|
|
470
|
+
/** Register the generated route table. Called once by the client and server entries. */
|
|
471
|
+
export function installRoutes(table: RouteTable): void {
|
|
472
|
+
installedTable = table;
|
|
458
473
|
}
|
|
459
474
|
|
|
460
|
-
/**
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
let bestScore = -1;
|
|
467
|
-
for (const route of routes) {
|
|
468
|
-
const segments = compile(route.path);
|
|
469
|
-
const params = matchSegments(segments, parts);
|
|
470
|
-
if (params == null) {
|
|
471
|
-
continue;
|
|
472
|
-
}
|
|
473
|
-
const score = specificity(segments);
|
|
474
|
-
if (score > bestScore) {
|
|
475
|
-
best = { route, params };
|
|
476
|
-
bestScore = score;
|
|
477
|
-
}
|
|
475
|
+
/** The registered table, or a clear error when the entry forgot to install it. */
|
|
476
|
+
export function routeTable(): RouteTable {
|
|
477
|
+
if (installedTable == null) {
|
|
478
|
+
throw new Error(
|
|
479
|
+
"@uniflowed/router: no route table is installed; start the app through `uf dev` or `uf build`",
|
|
480
|
+
);
|
|
478
481
|
}
|
|
479
|
-
return
|
|
482
|
+
return installedTable;
|
|
480
483
|
}
|
|
481
484
|
|
|
482
485
|
/**
|
|
483
|
-
*
|
|
486
|
+
* Props the app root receives from the client and server entries.
|
|
484
487
|
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
+
* One of `flight` and `initial`. A document React Server Components rendered
|
|
489
|
+
* hands the root its payload, on the server and again in the browser, so both
|
|
490
|
+
* sides render the same tree from the same bytes. A single-page application —
|
|
491
|
+
* and a project that turned `app.rsc` off — hands it a route resolved from its
|
|
492
|
+
* modules instead. See ubugeeei-prod/uf#519.
|
|
488
493
|
*/
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
{kind: "param"} => index < parts.length ? index + 1 : -1,
|
|
495
|
-
{kind: "catchAll"} => parts.length,
|
|
496
|
-
};
|
|
497
|
-
if (next === -1) {
|
|
498
|
-
return false;
|
|
499
|
-
}
|
|
500
|
-
index = next;
|
|
501
|
-
}
|
|
502
|
-
return true;
|
|
503
|
-
}
|
|
494
|
+
export type AppProps = {|
|
|
495
|
+
readonly url: string,
|
|
496
|
+
readonly initial?: ResolvedRoute,
|
|
497
|
+
readonly flight?: Promise<FlightRoot>,
|
|
498
|
+
|};
|
|
504
499
|
|
|
505
500
|
/**
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
501
|
+
* Whether there is a document to navigate.
|
|
502
|
+
*
|
|
503
|
+
* Asked every time rather than answered once at module scope, and the
|
|
504
|
+
* difference is not a style preference. The answer is a constant inside a
|
|
505
|
+
* browser bundle and inside a server process; it is *not* a constant inside a
|
|
506
|
+
* test runner, where a DOM is installed on the first render and one worker
|
|
507
|
+
* serves many files out of one module registry. Latched, the first file in a
|
|
508
|
+
* worker to import this module decided for every file after it whether a
|
|
509
|
+
* `Link` navigates or silently does nothing — and a server-rendering test
|
|
510
|
+
* imports it before any document exists. See ubugeeei-prod/uf#445.
|
|
511
|
+
*
|
|
512
|
+
* The cost is a `typeof` per navigation, which is a navigation.
|
|
515
513
|
*/
|
|
516
|
-
function
|
|
517
|
-
|
|
518
|
-
pathname: string,
|
|
519
|
-
): ?TBoundary {
|
|
520
|
-
const parts = pathname.split("/").filter((part) => part !== "");
|
|
521
|
-
let best: ?TBoundary = null;
|
|
522
|
-
let bestDepth = -1;
|
|
523
|
-
for (const boundary of boundaries) {
|
|
524
|
-
const segments = compile(boundary.path);
|
|
525
|
-
if (!covers(segments, parts)) {
|
|
526
|
-
continue;
|
|
527
|
-
}
|
|
528
|
-
if (segments.length > bestDepth) {
|
|
529
|
-
best = boundary;
|
|
530
|
-
bestDepth = segments.length;
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
return best;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/** Split a URL into its pathname and search string. */
|
|
537
|
-
export function splitUrl(url: string): {| readonly pathname: string, readonly search: string |} {
|
|
538
|
-
const hash = url.indexOf("#");
|
|
539
|
-
const withoutHash = hash === -1 ? url : url.slice(0, hash);
|
|
540
|
-
const question = withoutHash.indexOf("?");
|
|
541
|
-
if (question === -1) {
|
|
542
|
-
return { pathname: normalizePathname(withoutHash), search: "" };
|
|
543
|
-
}
|
|
544
|
-
return {
|
|
545
|
-
pathname: normalizePathname(withoutHash.slice(0, question)),
|
|
546
|
-
search: withoutHash.slice(question),
|
|
547
|
-
};
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
function normalizePathname(pathname: string): string {
|
|
551
|
-
if (pathname === "" || pathname === "/") {
|
|
552
|
-
return "/";
|
|
553
|
-
}
|
|
554
|
-
const trimmed = pathname.replace(/\/+$/, "");
|
|
555
|
-
return trimmed === "" ? "/" : trimmed;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/** Parse a search string into a flat map; a repeated key keeps its last value. */
|
|
559
|
-
export function parseSearch(search: string): SearchParams {
|
|
560
|
-
const params: { [string]: string } = {};
|
|
561
|
-
for (const [key, value] of new URLSearchParams(search)) {
|
|
562
|
-
params[key] = value;
|
|
563
|
-
}
|
|
564
|
-
return params;
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// ---------------------------------------------------------------------------
|
|
568
|
-
// Loading
|
|
569
|
-
// ---------------------------------------------------------------------------
|
|
570
|
-
|
|
571
|
-
const moduleCache: Map<() => Promise<mixed>, Promise<mixed>> = new Map();
|
|
572
|
-
|
|
573
|
-
function loadOnce<T>(load: () => Promise<T>): Promise<T> {
|
|
574
|
-
let pending = moduleCache.get(load);
|
|
575
|
-
if (pending == null) {
|
|
576
|
-
pending = load();
|
|
577
|
-
moduleCache.set(load, pending);
|
|
578
|
-
}
|
|
579
|
-
// $FlowFixMe[incompatible-return] the cache is keyed by the loader, whose result type it stores.
|
|
580
|
-
return pending;
|
|
514
|
+
function isBrowser(): boolean {
|
|
515
|
+
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
581
516
|
}
|
|
582
517
|
|
|
583
518
|
/**
|
|
584
|
-
*
|
|
519
|
+
* Provides the current route to the tree and performs navigation.
|
|
585
520
|
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
521
|
+
* On the server the route is fixed for the request. In the browser the
|
|
522
|
+
* provider listens to history and to `Link` clicks; a navigation fetches the
|
|
523
|
+
* next route's payload — or, for a route resolved from its modules, loads its
|
|
524
|
+
* chunks and runs its loader — *before* committing, inside a transition, so the
|
|
525
|
+
* previous page stays interactive meanwhile.
|
|
526
|
+
*
|
|
527
|
+
* Which of the two it does is decided by what it was started with: a Flight
|
|
528
|
+
* payload is [`FlightRouter`], and a resolved route is [`ModuleRouter`].
|
|
589
529
|
*
|
|
590
|
-
* #
|
|
530
|
+
* # Unless the application asked the browser to do it
|
|
591
531
|
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
* `
|
|
595
|
-
*
|
|
596
|
-
*
|
|
532
|
+
* Under `app.rendering.navigation: "document"` every one of those sentences
|
|
533
|
+
* stops being true, and the provider is still here: the tree below it still
|
|
534
|
+
* reads `useRoute`, still renders `<RouteView>`, and still hydrates whatever
|
|
535
|
+
* `"use client"` boundary made the document interactive. What it does not do is
|
|
536
|
+
* take the link over. `navigate` hands the URL to the browser, no `popstate`
|
|
537
|
+
* listener is installed, and `prefetch` — which exists to load the chunks of a
|
|
538
|
+
* route this page will render — has no page to load them for.
|
|
597
539
|
*
|
|
598
|
-
* That
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
* with
|
|
540
|
+
* That is one branch rather than a second provider because the two differ in
|
|
541
|
+
* what happens on a click and in nothing else. A second implementation would
|
|
542
|
+
* have had to keep `resolved`, `pending`, the context and every hook that
|
|
543
|
+
* reads it in step with this one, which is four things to keep in step for one
|
|
544
|
+
* that actually differs.
|
|
602
545
|
*/
|
|
603
|
-
export
|
|
604
|
-
table: RouteTable,
|
|
546
|
+
export component RouterProvider(
|
|
605
547
|
url: string,
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
throw error;
|
|
613
|
-
}
|
|
614
|
-
return resolveFailure(table, url, error);
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
async function resolveRoute(
|
|
619
|
-
table: RouteTable,
|
|
620
|
-
url: string,
|
|
621
|
-
options?: {| readonly data?: mixed, readonly skipLoader?: boolean |},
|
|
622
|
-
): Promise<ResolvedRoute> {
|
|
623
|
-
const { pathname, search } = splitUrl(url);
|
|
624
|
-
const searchParams = parseSearch(search);
|
|
625
|
-
const matched = matchRoute(table.routes, pathname);
|
|
626
|
-
|
|
627
|
-
if (matched == null) {
|
|
628
|
-
return resolveNotFound(table, pathname, search, searchParams);
|
|
548
|
+
initial?: ResolvedRoute,
|
|
549
|
+
flight?: Promise<FlightRoot>,
|
|
550
|
+
children: React.Node,
|
|
551
|
+
) {
|
|
552
|
+
if (flight != null) {
|
|
553
|
+
return <FlightRouter flight={flight}>{children}</FlightRouter>;
|
|
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.
|
|
555
|
+
if (initial == null) {
|
|
638
556
|
throw new Error(
|
|
639
|
-
|
|
640
|
-
"
|
|
557
|
+
"@uniflowed/router: RouterProvider was given neither a Flight payload nor a resolved route " +
|
|
558
|
+
"to start from. `virtual:uf/client` and `virtual:uf/server` hand it one of the two.",
|
|
641
559
|
);
|
|
642
560
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
// on the loader, so importing it alongside costs a navigation nothing. It
|
|
649
|
-
// never rejects, so an early throw below leaves no unhandled rejection.
|
|
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);
|
|
654
|
-
|
|
655
|
-
let data: mixed = options?.data;
|
|
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.
|
|
669
|
-
data = await page.loader({ params: matched.params, searchParams, pathname });
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
const metadata = await resolveMetadata(page, layouts, {
|
|
673
|
-
params: matched.params,
|
|
674
|
-
searchParams,
|
|
675
|
-
data,
|
|
676
|
-
});
|
|
677
|
-
return {
|
|
678
|
-
pathname,
|
|
679
|
-
search,
|
|
680
|
-
path: matched.route.path,
|
|
681
|
-
params: matched.params,
|
|
682
|
-
searchParams,
|
|
683
|
-
page,
|
|
684
|
-
layouts,
|
|
685
|
-
data,
|
|
686
|
-
metadata,
|
|
687
|
-
status: 200,
|
|
688
|
-
error: null,
|
|
689
|
-
errorBoundary: await boundary,
|
|
690
|
-
loading: await loading,
|
|
691
|
-
};
|
|
561
|
+
return (
|
|
562
|
+
<ModuleRouter url={url} initial={initial}>
|
|
563
|
+
{children}
|
|
564
|
+
</ModuleRouter>
|
|
565
|
+
);
|
|
692
566
|
}
|
|
693
567
|
|
|
694
568
|
/**
|
|
695
|
-
* The
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
* import error surfaces where it belongs, when the module is next asked for.
|
|
569
|
+
* The key an intercepted navigation writes into its history entry.
|
|
570
|
+
*
|
|
571
|
+
* One string in `history.state` rather than the resolved route, because the
|
|
572
|
+
* browser structured-clones the state and keeps it across a reload: it can hold
|
|
573
|
+
* a URL and nothing with a module in it. A URL is also all the entry needs —
|
|
574
|
+
* where the navigation came from, resolved again when that page is not the one
|
|
575
|
+
* on screen, and the entry's own URL for what intercepted it.
|
|
703
576
|
*/
|
|
704
|
-
|
|
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
|
-
}
|
|
577
|
+
const INTERCEPTED_FROM = "uf:intercepted-from";
|
|
729
578
|
|
|
730
579
|
/**
|
|
731
|
-
* The
|
|
580
|
+
* The state a history entry for `resolved` is written with.
|
|
732
581
|
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
* boundaries do not run in `renderToString`, so the server has to catch it
|
|
736
|
-
* itself and resolve again.
|
|
582
|
+
* `null` for a navigation nothing intercepted, which is what every entry this
|
|
583
|
+
* router wrote was before interception existed.
|
|
737
584
|
*/
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
): Promise<ResolvedRoute> {
|
|
743
|
-
const { pathname, search } = splitUrl(url);
|
|
744
|
-
const searchParams = parseSearch(search);
|
|
745
|
-
if (error instanceof NotFoundError) {
|
|
746
|
-
try {
|
|
747
|
-
return await resolveNotFound(table, pathname, search, searchParams);
|
|
748
|
-
} catch (failure) {
|
|
749
|
-
// The not-found page itself would not load. Falling through to the error
|
|
750
|
-
// boundary rather than rethrowing is what keeps the promise above: the
|
|
751
|
-
// page a project wrote to explain a 404 is not more load-bearing than
|
|
752
|
-
// the document staying on screen.
|
|
753
|
-
return resolveError(table, pathname, search, searchParams, routeErrorFor(failure));
|
|
754
|
-
}
|
|
585
|
+
function historyStateFor(resolved: ResolvedRoute): mixed {
|
|
586
|
+
const interception = resolved.interception;
|
|
587
|
+
if (interception == null) {
|
|
588
|
+
return null;
|
|
755
589
|
}
|
|
756
|
-
return
|
|
590
|
+
return { [INTERCEPTED_FROM]: interception.base.pathname + interception.base.search };
|
|
757
591
|
}
|
|
758
592
|
|
|
759
|
-
/**
|
|
760
|
-
function
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
}
|
|
764
|
-
if (error instanceof ForbiddenError) {
|
|
765
|
-
return { kind: "forbidden" };
|
|
766
|
-
}
|
|
767
|
-
return { kind: "thrown", error };
|
|
593
|
+
/** The state a history entry for a Flight route is written with. */
|
|
594
|
+
function historyStateForRoute(route: RouteState): mixed {
|
|
595
|
+
const from = route.interception?.from;
|
|
596
|
+
return from == null ? null : { [INTERCEPTED_FROM]: from };
|
|
768
597
|
}
|
|
769
598
|
|
|
770
|
-
/**
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
* React decides to show a boundary's fallback synchronously, during the render
|
|
775
|
-
* that threw. A module that still has to be imported is a module that is not
|
|
776
|
-
* there at the only moment it can be used, so this is one more dynamic import
|
|
777
|
-
* per navigation and not a lazy one.
|
|
778
|
-
*
|
|
779
|
-
* `above` is the boundary's own layout count, clamped to the route's. The
|
|
780
|
-
* first attempt compared the two layout arrays for a shared prefix, which is
|
|
781
|
-
* more precise when a `(group)` directory puts a boundary beside a route
|
|
782
|
-
* rather than above it — and it worked by *reference identity* of the loader
|
|
783
|
-
* functions, which holds only because `routesModuleSource` deduplicates them
|
|
784
|
-
* by file. A rule that depends on an invisible property of the generated
|
|
785
|
-
* module is a rule that reads as zero the moment a table is built any other
|
|
786
|
-
* way, and it did: it put the boundary outside the layouts it was written
|
|
787
|
-
* inside. Nesting a boundary per group needs parallel-route trees (#267);
|
|
788
|
-
* until then this is the honest approximation, and it is stated rather than
|
|
789
|
-
* inferred.
|
|
790
|
-
*/
|
|
791
|
-
async function resolveErrorBoundary(
|
|
792
|
-
table: RouteTable,
|
|
793
|
-
pathname: string,
|
|
794
|
-
layoutCount: number,
|
|
795
|
-
): Promise<{| readonly module: ?ErrorModule, readonly above: number |}> {
|
|
796
|
-
const boundary = nearestBoundary(table.errors, pathname);
|
|
797
|
-
if (boundary == null) {
|
|
798
|
-
return { module: null, above: 0 };
|
|
799
|
-
}
|
|
800
|
-
// Clamped, because a route group can leave a route with fewer layouts than
|
|
801
|
-
// the boundary covering it, and an `above` past the end would compose the
|
|
802
|
-
// layouts out of nothing.
|
|
803
|
-
const above = Math.min(boundary.layouts.length, layoutCount);
|
|
804
|
-
try {
|
|
805
|
-
return { module: await loadOnce(boundary.module), above };
|
|
806
|
-
} catch {
|
|
807
|
-
// A boundary whose module will not load cannot be the answer to a throw,
|
|
808
|
-
// and this is why the field is nullable: containment must not itself
|
|
809
|
-
// depend on an import working.
|
|
810
|
-
return { module: null, above: 0 };
|
|
599
|
+
/** Where the history entry holding `state` was intercepted from, if it was. */
|
|
600
|
+
function interceptedFrom(state: mixed): ?string {
|
|
601
|
+
if (state == null || typeof state !== "object" || Array.isArray(state)) {
|
|
602
|
+
return null;
|
|
811
603
|
}
|
|
604
|
+
const from = state[INTERCEPTED_FROM];
|
|
605
|
+
return typeof from === "string" ? from : null;
|
|
812
606
|
}
|
|
813
607
|
|
|
814
608
|
/**
|
|
815
|
-
*
|
|
816
|
-
* answers it.
|
|
609
|
+
* `state` without the interception in it.
|
|
817
610
|
*
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*/
|
|
822
|
-
async function resolveError(
|
|
823
|
-
table: RouteTable,
|
|
824
|
-
pathname: string,
|
|
825
|
-
search: string,
|
|
826
|
-
searchParams: SearchParams,
|
|
827
|
-
routeError: RouteError,
|
|
828
|
-
): Promise<ResolvedRoute> {
|
|
829
|
-
const boundary = nearestBoundary(table.errors, pathname);
|
|
830
|
-
let module: ?ErrorModule = null;
|
|
831
|
-
let layouts: $ReadOnlyArray<LayoutModule> = [];
|
|
832
|
-
if (boundary != null) {
|
|
833
|
-
try {
|
|
834
|
-
[module, layouts] = await Promise.all([
|
|
835
|
-
loadOnce(boundary.module),
|
|
836
|
-
Promise.all(boundary.layouts.map((layout) => loadOnce(layout))),
|
|
837
|
-
]);
|
|
838
|
-
} catch {
|
|
839
|
-
// See `resolveErrorBoundary`: the framework's own page answers instead.
|
|
840
|
-
module = null;
|
|
841
|
-
layouts = [];
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
const declared = await resolveMetadata(
|
|
846
|
-
module?.metadata != null ? { metadata: module.metadata } : {},
|
|
847
|
-
layouts,
|
|
848
|
-
{ params: {}, searchParams, data: undefined },
|
|
849
|
-
);
|
|
850
|
-
return {
|
|
851
|
-
pathname,
|
|
852
|
-
search,
|
|
853
|
-
path: "*",
|
|
854
|
-
params: {},
|
|
855
|
-
searchParams,
|
|
856
|
-
page: { default: ResolvedErrorPage },
|
|
857
|
-
layouts,
|
|
858
|
-
data: undefined,
|
|
859
|
-
metadata: declared.title != null ? declared : { ...declared, title: errorTitle(routeError) },
|
|
860
|
-
status: routeErrorStatus(routeError),
|
|
861
|
-
error: routeError,
|
|
862
|
-
// All of the boundary's layouts are above it, and no inner boundary is
|
|
863
|
-
// inserted around a page that already is one; see `RouteView`.
|
|
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: [],
|
|
869
|
-
};
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
/**
|
|
873
|
-
* The not-found page for `pathname`, inside the layouts above the boundary
|
|
874
|
-
* that answers it.
|
|
875
|
-
*
|
|
876
|
-
* The layouts are the *boundary's*, not the ones the URL had already matched.
|
|
877
|
-
* Taking the matched route's layouts was the other candidate and it is wrong
|
|
878
|
-
* in both directions: for an unmatched URL there is no matched route to take
|
|
879
|
-
* them from, and for `notFound()` thrown from a page they would keep the
|
|
880
|
-
* layouts *below* the boundary — so `app/guide/[slug]/_uf.layout.js` would
|
|
881
|
-
* wrap a 404 that `app/guide/_uf.not-found.js` answered, which is the layout
|
|
882
|
-
* of the page that just said it does not exist.
|
|
611
|
+
* What is left is handed back rather than cleared, because an entry's state is
|
|
612
|
+
* not only this router's to write: another library may have put something
|
|
613
|
+
* beside it.
|
|
883
614
|
*/
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
search: string,
|
|
888
|
-
searchParams: SearchParams,
|
|
889
|
-
): Promise<ResolvedRoute> {
|
|
890
|
-
const record = nearestBoundary(table.notFound, pathname);
|
|
891
|
-
if (record == null) {
|
|
892
|
-
return {
|
|
893
|
-
pathname,
|
|
894
|
-
search,
|
|
895
|
-
path: "*",
|
|
896
|
-
params: {},
|
|
897
|
-
searchParams,
|
|
898
|
-
page: { default: DefaultNotFound },
|
|
899
|
-
layouts: [],
|
|
900
|
-
data: undefined,
|
|
901
|
-
metadata: { title: "Not found" },
|
|
902
|
-
status: 404,
|
|
903
|
-
error: null,
|
|
904
|
-
errorBoundary: await resolveErrorBoundary(table, pathname, 0),
|
|
905
|
-
loading: [],
|
|
906
|
-
};
|
|
615
|
+
function withoutInterception(state: mixed): mixed {
|
|
616
|
+
if (state == null || typeof state !== "object" || Array.isArray(state)) {
|
|
617
|
+
return state;
|
|
907
618
|
}
|
|
908
|
-
const [
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
const metadata = await resolveMetadata(page, layouts, {
|
|
913
|
-
params: {},
|
|
914
|
-
searchParams,
|
|
915
|
-
data: undefined,
|
|
916
|
-
});
|
|
917
|
-
return {
|
|
918
|
-
pathname,
|
|
919
|
-
search,
|
|
920
|
-
path: "*",
|
|
921
|
-
params: {},
|
|
922
|
-
searchParams,
|
|
923
|
-
page,
|
|
924
|
-
layouts,
|
|
925
|
-
data: undefined,
|
|
926
|
-
metadata,
|
|
927
|
-
status: 404,
|
|
928
|
-
error: null,
|
|
929
|
-
// A not-found page is a page: one that throws is contained like any other.
|
|
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: [],
|
|
935
|
-
};
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
async function resolveMetadata(
|
|
939
|
-
page: PageModule,
|
|
940
|
-
layouts: $ReadOnlyArray<LayoutModule>,
|
|
941
|
-
args: MetadataArgs,
|
|
942
|
-
): Promise<Metadata> {
|
|
943
|
-
let merged: Metadata = {};
|
|
944
|
-
for (const layout of layouts) {
|
|
945
|
-
if (layout.metadata != null) {
|
|
946
|
-
merged = { ...merged, ...layout.metadata };
|
|
619
|
+
const rest: { [string]: mixed } = {};
|
|
620
|
+
for (const key of Object.keys(state)) {
|
|
621
|
+
if (key !== INTERCEPTED_FROM) {
|
|
622
|
+
rest[key] = state[key];
|
|
947
623
|
}
|
|
948
624
|
}
|
|
949
|
-
|
|
950
|
-
const { title, description } = page.frontmatter;
|
|
951
|
-
merged = {
|
|
952
|
-
...merged,
|
|
953
|
-
...(title != null ? { title } : {}),
|
|
954
|
-
...(description != null ? { description } : {}),
|
|
955
|
-
};
|
|
956
|
-
}
|
|
957
|
-
if (page.metadata != null) {
|
|
958
|
-
merged = { ...merged, ...page.metadata };
|
|
959
|
-
}
|
|
960
|
-
if (typeof page.generateMetadata === "function") {
|
|
961
|
-
merged = { ...merged, ...(await page.generateMetadata(args)) };
|
|
962
|
-
}
|
|
963
|
-
return merged;
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
component DefaultNotFound() {
|
|
967
|
-
return (
|
|
968
|
-
<main>
|
|
969
|
-
<title>Not found</title>
|
|
970
|
-
<h1>404</h1>
|
|
971
|
-
<p>This page does not exist.</p>
|
|
972
|
-
</main>
|
|
973
|
-
);
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
/** The document title an error page gets when nothing declared one. */
|
|
977
|
-
function errorTitle(error: RouteError): string {
|
|
978
|
-
return match (error) {
|
|
979
|
-
{kind: "unauthorized"} => "Sign in required",
|
|
980
|
-
{kind: "forbidden"} => "Not allowed",
|
|
981
|
-
{kind: "thrown"} => "Something went wrong",
|
|
982
|
-
};
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
/**
|
|
986
|
-
* The framework's error page, for a project that declares no `_uf.error.js`.
|
|
987
|
-
*
|
|
988
|
-
* It says which of the three happened and offers the reset, and it does *not*
|
|
989
|
-
* print the thrown error: on the server that message is written for whoever
|
|
990
|
-
* deployed the application — a query, a path, a token in a stack — and this
|
|
991
|
-
* markup is sent to whoever asked for the page. `uf dev` reports the throw in
|
|
992
|
-
* the terminal and `uf build` fails the route, which are the places the person
|
|
993
|
-
* who can act on it is looking.
|
|
994
|
-
*/
|
|
995
|
-
component DefaultRouteError(error: RouteError, reset: () => void) {
|
|
996
|
-
const title = errorTitle(error);
|
|
997
|
-
const detail = match (error) {
|
|
998
|
-
{kind: "unauthorized"} => "This page needs you to be signed in.",
|
|
999
|
-
{kind: "forbidden"} => "You do not have access to this page.",
|
|
1000
|
-
{kind: "thrown"} => "This page could not be rendered.",
|
|
1001
|
-
};
|
|
1002
|
-
return (
|
|
1003
|
-
<main>
|
|
1004
|
-
<title>{title}</title>
|
|
1005
|
-
<h1>{title}</h1>
|
|
1006
|
-
<p>{detail}</p>
|
|
1007
|
-
<button type="button" onClick={reset}>
|
|
1008
|
-
Try again
|
|
1009
|
-
</button>
|
|
1010
|
-
</main>
|
|
1011
|
-
);
|
|
625
|
+
return Object.keys(rest).length === 0 ? null : rest;
|
|
1012
626
|
}
|
|
1013
627
|
|
|
1014
|
-
/** The
|
|
1015
|
-
function
|
|
1016
|
-
|
|
1017
|
-
if (component == null) {
|
|
1018
|
-
throw new Error(
|
|
1019
|
-
"@uniflowed/router: an error module must export a component as `default` or `Error`",
|
|
1020
|
-
);
|
|
1021
|
-
}
|
|
1022
|
-
return renderable(component);
|
|
628
|
+
/** The page a Flight navigation is leaving from, as an application path and query. */
|
|
629
|
+
function flightNavigationOrigin(root: FlightRoot): string {
|
|
630
|
+
return root.route.interception?.from ?? root.route.pathname + root.route.search;
|
|
1023
631
|
}
|
|
1024
632
|
|
|
1025
|
-
/** The
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
readonly reset: () => void,
|
|
1029
|
-
|};
|
|
1030
|
-
|
|
1031
|
-
/**
|
|
1032
|
-
* The error UI, from whichever module is in scope.
|
|
1033
|
-
*
|
|
1034
|
-
* One component for both ways in — the class boundary below, which catches a
|
|
1035
|
-
* throw while the browser renders, and `ResolvedErrorPage`, which is what the
|
|
1036
|
-
* server renders because React's boundaries do not run in `renderToString`.
|
|
1037
|
-
* Two paths to the same screen is exactly the pair that drifts.
|
|
1038
|
-
*/
|
|
1039
|
-
component RouteErrorView(module: ?ErrorModule, error: RouteError, reset: () => void) {
|
|
1040
|
-
if (module == null) {
|
|
1041
|
-
return <DefaultRouteError error={error} reset={reset} />;
|
|
1042
|
-
}
|
|
1043
|
-
const Boundary = errorComponent(module);
|
|
1044
|
-
return <Boundary error={error} reset={reset} />;
|
|
633
|
+
/** The optional fetch settings for an intercepted payload request. */
|
|
634
|
+
function flightFetchOptions(from: ?string): FlightFetchOptions | void {
|
|
635
|
+
return from == null ? undefined : { interceptedFrom: from };
|
|
1045
636
|
}
|
|
1046
637
|
|
|
1047
|
-
/**
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
* so this is a static component rather than a closure the resolver builds:
|
|
1052
|
-
* `RouteView` composes it in its layouts exactly like a page, which is what
|
|
1053
|
-
* makes "inside the layouts above the boundary" one code path and not two.
|
|
1054
|
-
*
|
|
1055
|
-
* `reset()` here is `router.refresh()` — this route resolved to an error
|
|
1056
|
-
* because a loader or an import threw, so re-running the resolution is what
|
|
1057
|
-
* trying again means. On the server `refresh` does nothing, which is correct:
|
|
1058
|
-
* a static render has nothing to re-run.
|
|
1059
|
-
*/
|
|
1060
|
-
component ResolvedErrorPage() {
|
|
1061
|
-
const { resolved, router } = useRouterState();
|
|
1062
|
-
const reset = useCallback(() => {
|
|
1063
|
-
router.refresh().catch(() => {});
|
|
1064
|
-
}, [router]);
|
|
1065
|
-
|
|
1066
|
-
if (resolved.error == null) {
|
|
1067
|
-
// Unreachable: this module is only ever the page of a resolved error route.
|
|
1068
|
-
return null;
|
|
1069
|
-
}
|
|
1070
|
-
return (
|
|
1071
|
-
<RouteErrorView module={resolved.errorBoundary.module} error={resolved.error} reset={reset} />
|
|
1072
|
-
);
|
|
638
|
+
/** The cache key for a Flight payload, separated by the page it renders over. */
|
|
639
|
+
function flightNavigationKey(pathname: string, search: string, from: ?string): string {
|
|
640
|
+
const key = navigationKey(pathname, search);
|
|
641
|
+
return from == null ? key : `${key}\0${from}`;
|
|
1073
642
|
}
|
|
1074
643
|
|
|
1075
|
-
type RouteErrorBoundaryProps = {|
|
|
1076
|
-
readonly module: ?ErrorModule,
|
|
1077
|
-
readonly resetKey: string,
|
|
1078
|
-
readonly children: React.Node,
|
|
1079
|
-
|};
|
|
1080
|
-
|
|
1081
|
-
type RouteErrorBoundaryState = {| readonly error: ?RouteError |};
|
|
1082
|
-
|
|
1083
644
|
/**
|
|
1084
|
-
* The
|
|
1085
|
-
*
|
|
1086
|
-
* A class, because `getDerivedStateFromError` is React's contract for this and
|
|
1087
|
-
* there is no hook that does it — this is the one place in the router where
|
|
1088
|
-
* following React's public contract means not using a function component.
|
|
645
|
+
* The provider for a route resolved from its modules: a single-page
|
|
646
|
+
* application, and a project that turned `app.rsc` off.
|
|
1089
647
|
*
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
* their scroll position and their open sections intact.
|
|
648
|
+
* It is also the provider that intercepts. Whether a navigation is intercepted
|
|
649
|
+
* is a question about the slots on screen, and only a router holding a route
|
|
650
|
+
* resolved from its modules has them to ask; a payload holds a rendered tree.
|
|
651
|
+
* See [`resolveInterception`].
|
|
1095
652
|
*/
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
653
|
+
component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node) {
|
|
654
|
+
const [resolved, setResolved] = useState<ResolvedRoute>(initial);
|
|
655
|
+
const [pending, setPending] = useState<boolean>(false);
|
|
656
|
+
// Read once per render rather than per navigation: it is installed by the
|
|
657
|
+
// entry before the first render and never changes after it, and a `Link`
|
|
658
|
+
// that asked at click time would be asking a question whose answer decided
|
|
659
|
+
// what it rendered.
|
|
660
|
+
const navigation = navigationMode();
|
|
661
|
+
// The route on screen, for the code that runs after a render has finished.
|
|
662
|
+
//
|
|
663
|
+
// State is what renders, and a closure only sees the state of the render that
|
|
664
|
+
// made it: the `popstate` listener below is installed once and would go on
|
|
665
|
+
// reading the first route forever, and a navigation awaits between reading
|
|
666
|
+
// what is on screen and replacing it. Interception is what needs the answer —
|
|
667
|
+
// whether a navigation is intercepted is a question about the page it starts
|
|
668
|
+
// on — and `show` writes both in the same breath, so the two cannot disagree
|
|
669
|
+
// about what was last committed.
|
|
670
|
+
const shown = useRef<ResolvedRoute>(initial);
|
|
671
|
+
const show = (next: ResolvedRoute) => {
|
|
672
|
+
shown.current = next;
|
|
673
|
+
setResolved(next);
|
|
674
|
+
};
|
|
1105
675
|
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
676
|
+
const navigate = (to: string, options?: NavigateOptions): Promise<void> =>
|
|
677
|
+
observeNavigation(to, () => navigateTo(to, options));
|
|
678
|
+
const navigateTo = async (to: string, options?: NavigateOptions): Promise<void> => {
|
|
679
|
+
if (!isBrowser()) {
|
|
680
|
+
return;
|
|
1109
681
|
}
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
const
|
|
1114
|
-
|
|
1115
|
-
|
|
682
|
+
const target = new URL(addressOf(to), window.location.href);
|
|
683
|
+
// The application path the route table is asked about, and the address the
|
|
684
|
+
// history entry keeps: one URL, with and without `app.router.basePath`.
|
|
685
|
+
const applicationPath = applicationPathOf(target.pathname);
|
|
686
|
+
const next = (applicationPath ?? target.pathname) + target.search;
|
|
687
|
+
const address = target.pathname + target.search;
|
|
688
|
+
// The browser's job in this application. `assign` and `replace` rather
|
|
689
|
+
// than the history API, because the point is a document request: the
|
|
690
|
+
// history entry, the scroll position, the `Referer` and the unload
|
|
691
|
+
// handlers are then the browser's, done the way they are done for a link
|
|
692
|
+
// in a page with no JavaScript on it at all.
|
|
693
|
+
if (navigation === "document") {
|
|
694
|
+
if (options?.replace === true) {
|
|
695
|
+
window.location.replace(target.href);
|
|
696
|
+
} else {
|
|
697
|
+
window.location.assign(target.href);
|
|
698
|
+
}
|
|
699
|
+
return;
|
|
1116
700
|
}
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
)
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
//
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
701
|
+
// Interception first, because it is a question about the page this
|
|
702
|
+
// navigation starts on rather than about the one it reaches. A slot on
|
|
703
|
+
// screen that intercepts the URL renders a page of its own, so whether the
|
|
704
|
+
// URL's ordinary page is in this bundle — the paragraph below — is not a
|
|
705
|
+
// question this navigation has to ask.
|
|
706
|
+
// An address outside the base path is not this application's to render.
|
|
707
|
+
if (applicationPath == null) {
|
|
708
|
+
window.location.assign(target.href);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const origin = beneath(shown.current);
|
|
712
|
+
const intercepting = interceptingRoutes(origin.slots, applicationPath).length > 0;
|
|
713
|
+
// The half of the split that is not about bytes. A route whose page is not
|
|
714
|
+
// in this bundle is not a route this router can render, and pretending
|
|
715
|
+
// otherwise is the silent break: the navigation would resolve to nothing
|
|
716
|
+
// and the visitor would be left on the page they clicked from. The browser
|
|
717
|
+
// has the document, so the browser does the navigation — which is what a
|
|
718
|
+
// link does when there is no JavaScript at all, and what the anchor
|
|
719
|
+
// `Link` renders would have done on its own.
|
|
720
|
+
if (!intercepting) {
|
|
721
|
+
const matched = matchRoute(routeTable().routes, applicationPath);
|
|
722
|
+
if (matched != null && !hasClientPage(matched.route)) {
|
|
723
|
+
window.location.assign(target.href);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
setPending(true);
|
|
728
|
+
try {
|
|
729
|
+
// An interception depends on the page it starts from, so it is resolved
|
|
730
|
+
// every time; any other navigation reads what this page kept while it is
|
|
731
|
+
// fresh. See `./navigation-cache.js`.
|
|
732
|
+
const key = navigationKey(target.pathname, target.search);
|
|
733
|
+
const nextResolved =
|
|
734
|
+
(intercepting ? await resolveInterception(routeTable(), origin, next) : null) ??
|
|
735
|
+
(await (routeNavigations.read(key) ?? keepRoute(key, resolveMatch(routeTable(), next))));
|
|
736
|
+
// A module that could not be fetched, rather than one that threw: the
|
|
737
|
+
// chunk is gone, which after a deploy means this page is a build the
|
|
738
|
+
// server no longer carries. The document is the live build's page for
|
|
739
|
+
// this URL, so that is what is loaded — an error boundary would be a page
|
|
740
|
+
// that works after a reload, reported as though it were broken. See
|
|
741
|
+
// `./deployment.js`.
|
|
742
|
+
if (failedOnAMissingChunk(nextResolved.error)) {
|
|
743
|
+
setPending(false);
|
|
744
|
+
loadDocument(target.href);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
// An intercepted entry remembers where it was intercepted from, so back
|
|
748
|
+
// and forward can put the page underneath under it again. Every other
|
|
749
|
+
// entry is written the way it always was.
|
|
750
|
+
const state = historyStateFor(nextResolved);
|
|
751
|
+
if (options?.replace === true) {
|
|
752
|
+
window.history.replaceState(state, "", address + target.hash);
|
|
753
|
+
} else {
|
|
754
|
+
window.history.pushState(state, "", address + target.hash);
|
|
755
|
+
}
|
|
756
|
+
const commit = () => {
|
|
757
|
+
show(nextResolved);
|
|
758
|
+
setPending(false);
|
|
759
|
+
};
|
|
760
|
+
if (options?.transition === false) {
|
|
761
|
+
startTransition(commit);
|
|
762
|
+
} else {
|
|
763
|
+
withViewTransition(nextResolved.viewTransition, commit);
|
|
764
|
+
}
|
|
765
|
+
// An intercepted navigation leaves the page underneath where the reader
|
|
766
|
+
// left it — the modal opens over the post they clicked, not over the top
|
|
767
|
+
// of the feed — so it moves the window only for a caller who asks with
|
|
768
|
+
// `scroll: true`. Every other navigation scrolls unless asked not to.
|
|
769
|
+
const scroll =
|
|
770
|
+
nextResolved.interception == null ? options?.scroll !== false : options?.scroll === true;
|
|
771
|
+
if (scroll) {
|
|
772
|
+
if (target.hash !== "") {
|
|
773
|
+
const element = document.getElementById(target.hash.slice(1));
|
|
774
|
+
if (element != null) {
|
|
775
|
+
element.scrollIntoView();
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
window.scrollTo(0, 0);
|
|
780
|
+
}
|
|
781
|
+
} catch (error) {
|
|
782
|
+
setPending(false);
|
|
783
|
+
throw error;
|
|
784
|
+
}
|
|
785
|
+
};
|
|
1161
786
|
|
|
1162
|
-
|
|
1163
|
-
|
|
787
|
+
useEffect(() => {
|
|
788
|
+
if (!isBrowser()) {
|
|
789
|
+
return undefined;
|
|
790
|
+
}
|
|
791
|
+
// An entry that says it was intercepted, under a provider that has only
|
|
792
|
+
// just mounted, is an entry the browser reloaded or restored — and the
|
|
793
|
+
// document on screen is what a request for its URL returned, which is the
|
|
794
|
+
// ordinary page. Clearing the mark makes the entry say what the reader is
|
|
795
|
+
// looking at, so coming back to it later renders this page again rather
|
|
796
|
+
// than a modal over a page they never saw one on.
|
|
797
|
+
const restored = window.history.state;
|
|
798
|
+
if (interceptedFrom(restored) != null) {
|
|
799
|
+
window.history.replaceState(withoutInterception(restored), "", window.location.href);
|
|
800
|
+
}
|
|
801
|
+
// Nothing pushed a history entry, so there is nothing to pop back into: a
|
|
802
|
+
// document-navigating application left this page when the link was
|
|
803
|
+
// followed, and the back button asks the browser for the previous document
|
|
804
|
+
// rather than asking this listener to rebuild it. Installing one anyway
|
|
805
|
+
// would put a `resolveMatch` on the back button of a page that is about to
|
|
806
|
+
// be replaced by the one the browser already has.
|
|
807
|
+
if (navigation === "document") {
|
|
808
|
+
return undefined;
|
|
809
|
+
}
|
|
810
|
+
const arrive = (nextResolved: ResolvedRoute) => {
|
|
811
|
+
// A chunk this page's build had and the server no longer does; the
|
|
812
|
+
// history entry has already moved, so reloading loads its document. See
|
|
813
|
+
// `navigateTo` above.
|
|
814
|
+
if (failedOnAMissingChunk(nextResolved.error)) {
|
|
815
|
+
window.location.reload();
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
// The back button is a navigation, and a navigation that animates in
|
|
819
|
+
// one direction and cuts in the other would read as a bug in the
|
|
820
|
+
// animation rather than as a decision.
|
|
821
|
+
withViewTransition(nextResolved.viewTransition, () => {
|
|
822
|
+
show(nextResolved);
|
|
823
|
+
});
|
|
824
|
+
};
|
|
825
|
+
const onPopState = () => {
|
|
826
|
+
const next =
|
|
827
|
+
(applicationPathOf(window.location.pathname) ?? window.location.pathname) +
|
|
828
|
+
window.location.search;
|
|
829
|
+
// Back or forward into an entry an interception wrote: the page it was
|
|
830
|
+
// intercepted from, with the interception over it again. That page is
|
|
831
|
+
// resolved afresh only when it is not already the one underneath, so
|
|
832
|
+
// back from the second photo to the first leaves the feed exactly where
|
|
833
|
+
// it is.
|
|
834
|
+
const from = interceptedFrom(window.history.state);
|
|
835
|
+
if (from != null) {
|
|
836
|
+
const underneath = beneath(shown.current);
|
|
837
|
+
const origin =
|
|
838
|
+
underneath.pathname + underneath.search === from
|
|
839
|
+
? Promise.resolve(underneath)
|
|
840
|
+
: resolveMatch(routeTable(), from);
|
|
841
|
+
origin
|
|
842
|
+
.then((page) => resolveInterception(routeTable(), page, next))
|
|
843
|
+
// Nothing on that page intercepts the entry's URL any more — a
|
|
844
|
+
// module that will not load, a table a development server rebuilt —
|
|
845
|
+
// so the entry is what its URL names.
|
|
846
|
+
.then((intercepted) => intercepted ?? resolveMatch(routeTable(), next))
|
|
847
|
+
.then(arrive);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
// Back into a route this bundle has no page for. The history entry is
|
|
851
|
+
// already the browser's — it moved before this listener ran — so the
|
|
852
|
+
// document that belongs to it is what has to be fetched.
|
|
853
|
+
const matched = matchRoute(
|
|
854
|
+
routeTable().routes,
|
|
855
|
+
applicationPathOf(window.location.pathname) ?? window.location.pathname,
|
|
856
|
+
);
|
|
857
|
+
if (matched != null && !hasClientPage(matched.route)) {
|
|
858
|
+
window.location.reload();
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const key = navigationKey(window.location.pathname, window.location.search);
|
|
862
|
+
(routeNavigations.read(key) ?? keepRoute(key, resolveMatch(routeTable(), next))).then(arrive);
|
|
863
|
+
};
|
|
864
|
+
window.addEventListener("popstate", onPopState);
|
|
865
|
+
return () => {
|
|
866
|
+
window.removeEventListener("popstate", onPopState);
|
|
867
|
+
};
|
|
868
|
+
}, []);
|
|
1164
869
|
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
870
|
+
const router: Router = {
|
|
871
|
+
push: (to, options) => navigate(to, options),
|
|
872
|
+
replace: (to) => navigate(to, { replace: true }),
|
|
873
|
+
prefetch: async (to) => {
|
|
874
|
+
// A prefetch loads the modules the *next render* will need, and under
|
|
875
|
+
// document navigation there is no next render in this page: the browser
|
|
876
|
+
// fetches a document and throws this one away. Loading the chunks would
|
|
877
|
+
// be bytes spent on a page that is leaving, so this declines rather than
|
|
878
|
+
// warming a cache nothing reads.
|
|
879
|
+
if (!isBrowser() || navigation === "document") {
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const target = new URL(addressOf(to), window.location.href);
|
|
883
|
+
const applicationPath = applicationPathOf(target.pathname);
|
|
884
|
+
if (applicationPath == null) {
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
// What the next render will need is decided the way the navigation will
|
|
888
|
+
// decide it: a URL a slot on screen intercepts renders that slot's page,
|
|
889
|
+
// so that is the module worth having, and the page the URL names is not.
|
|
890
|
+
const intercepting = interceptingRoutes(beneath(shown.current).slots, applicationPath);
|
|
891
|
+
if (intercepting.length > 0) {
|
|
892
|
+
await Promise.all(
|
|
893
|
+
intercepting.flatMap((route) => [
|
|
894
|
+
loadOnce(route.page),
|
|
895
|
+
...route.layouts.map((layout) => loadOnce(layout)),
|
|
896
|
+
]),
|
|
897
|
+
);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
const matched = matchRoute(routeTable().routes, applicationPath);
|
|
901
|
+
const load = matched?.route.page;
|
|
902
|
+
if (matched == null || load == null) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
// With `app.rendering.staleTime` set, the whole route: its loader runs
|
|
906
|
+
// now, and the click, a later visit and the back button read what it
|
|
907
|
+
// answered while it is fresh. Otherwise only the modules it will need.
|
|
908
|
+
if (keepsNavigations()) {
|
|
909
|
+
const key = navigationKey(target.pathname, target.search);
|
|
910
|
+
await (
|
|
911
|
+
routeNavigations.read(key) ??
|
|
912
|
+
keepRoute(key, resolveMatch(routeTable(), applicationPath + target.search))
|
|
913
|
+
);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
await Promise.all([
|
|
917
|
+
loadOnce(load),
|
|
918
|
+
...matched.route.layouts.map((layout) => loadOnce(layout)),
|
|
919
|
+
]);
|
|
920
|
+
},
|
|
921
|
+
refresh: async () => {
|
|
922
|
+
if (!isBrowser()) {
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
// The same URL, rendered again — which under document navigation is what
|
|
926
|
+
// the browser calls a reload. Resolving it in the page instead would
|
|
927
|
+
// re-run the loader and commit a tree whose links this application has
|
|
928
|
+
// already said it does not drive.
|
|
929
|
+
if (navigation === "document") {
|
|
930
|
+
window.location.reload();
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
// Everything a navigation kept is older than what this asks for, so none
|
|
934
|
+
// of it is shown again; see `./navigation-cache.js`.
|
|
935
|
+
clearNavigationCache();
|
|
936
|
+
// A refresh of an intercepted page refreshes both of its halves: the
|
|
937
|
+
// page underneath, resolved again for its own URL, and the interception
|
|
938
|
+
// resolved again over it. Resolving only the address bar's URL would
|
|
939
|
+
// close the modal, which is a navigation nobody asked for.
|
|
940
|
+
const interception = shown.current.interception;
|
|
941
|
+
const nextResolved =
|
|
942
|
+
interception == null
|
|
943
|
+
? await resolveMatch(
|
|
944
|
+
routeTable(),
|
|
945
|
+
(applicationPathOf(window.location.pathname) ?? window.location.pathname) +
|
|
946
|
+
window.location.search,
|
|
947
|
+
)
|
|
948
|
+
: ((await resolveInterception(
|
|
949
|
+
routeTable(),
|
|
950
|
+
await resolveMatch(
|
|
951
|
+
routeTable(),
|
|
952
|
+
interception.base.pathname + interception.base.search,
|
|
953
|
+
),
|
|
954
|
+
interception.pathname + interception.search,
|
|
955
|
+
)) ?? (await resolveMatch(routeTable(), interception.pathname + interception.search)));
|
|
956
|
+
// No view transition, and it is the one place that is right: a refresh
|
|
957
|
+
// is the same URL resolved again, so a transition would animate a page
|
|
958
|
+
// into itself — a cross-fade between two frames of the same thing,
|
|
959
|
+
// which is a flicker with a name.
|
|
960
|
+
startTransition(() => {
|
|
961
|
+
show(nextResolved);
|
|
962
|
+
});
|
|
963
|
+
},
|
|
964
|
+
back: () => {
|
|
965
|
+
if (isBrowser()) {
|
|
966
|
+
window.history.back();
|
|
967
|
+
}
|
|
968
|
+
},
|
|
969
|
+
forward: () => {
|
|
970
|
+
if (isBrowser()) {
|
|
971
|
+
window.history.forward();
|
|
972
|
+
}
|
|
973
|
+
},
|
|
974
|
+
};
|
|
1169
975
|
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
}
|
|
1177
|
-
return
|
|
976
|
+
const value: RouterState = {
|
|
977
|
+
route: routeState(resolved),
|
|
978
|
+
view: { kind: "modules", resolved },
|
|
979
|
+
router,
|
|
980
|
+
pending,
|
|
981
|
+
navigation,
|
|
982
|
+
};
|
|
983
|
+
return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
|
|
1178
984
|
}
|
|
1179
985
|
|
|
1180
|
-
/** Props the app root receives from the client and server entries. */
|
|
1181
|
-
export type AppProps = {|
|
|
1182
|
-
readonly url: string,
|
|
1183
|
-
readonly initial: ResolvedRoute,
|
|
1184
|
-
|};
|
|
1185
|
-
|
|
1186
986
|
/**
|
|
1187
|
-
*
|
|
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.
|
|
987
|
+
* The provider for a route React Server Components rendered.
|
|
1197
988
|
*
|
|
1198
|
-
*
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
/**
|
|
1205
|
-
* Provides the current route to the tree and performs navigation.
|
|
989
|
+
* What it holds is the payload rather than a resolved route: `use` reads its
|
|
990
|
+
* root — the route a hook reads and the tree `RouteView` renders — and a
|
|
991
|
+
* navigation fetches the next route's payload and swaps the promise. The
|
|
992
|
+
* browser resolves nothing and imports no page, layout or loader; the server
|
|
993
|
+
* did all three, and a component that needs the browser arrived as a client
|
|
994
|
+
* reference inside the tree.
|
|
1206
995
|
*
|
|
1207
|
-
*
|
|
1208
|
-
*
|
|
1209
|
-
*
|
|
1210
|
-
* inside a transition, so
|
|
996
|
+
* A navigation reads the next payload's root before it commits, for the reason
|
|
997
|
+
* [`ModuleRouter`] resolves the next route before it commits: the page on
|
|
998
|
+
* screen stays interactive while the next one is on its way, and a commit
|
|
999
|
+
* inside a view transition is synchronous, so a root that had not arrived would
|
|
1000
|
+
* show nothing rather than the page being left. What may still suspend after
|
|
1001
|
+
* the commit is a `$loading.js` boundary inside the new tree, which is what that
|
|
1002
|
+
* file is for.
|
|
1211
1003
|
*/
|
|
1212
|
-
|
|
1213
|
-
const [
|
|
1004
|
+
component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
1005
|
+
const [current, setCurrent] = useState<Promise<FlightRoot>>(flight);
|
|
1214
1006
|
const [pending, setPending] = useState<boolean>(false);
|
|
1007
|
+
const root = use(current);
|
|
1008
|
+
const shown = useRef<FlightRoot>(root);
|
|
1009
|
+
const show = (payload: Promise<FlightRoot>, nextRoot: FlightRoot) => {
|
|
1010
|
+
shown.current = nextRoot;
|
|
1011
|
+
setCurrent(payload);
|
|
1012
|
+
};
|
|
1013
|
+
// Read once per render, for the reason `ModuleRouter` reads it once.
|
|
1014
|
+
const navigation = navigationMode();
|
|
1215
1015
|
|
|
1216
|
-
const navigate =
|
|
1016
|
+
const navigate = (to: string, options?: NavigateOptions): Promise<void> =>
|
|
1017
|
+
observeNavigation(to, () => navigateTo(to, options));
|
|
1018
|
+
const navigateTo = async (to: string, options?: NavigateOptions): Promise<void> => {
|
|
1217
1019
|
if (!isBrowser()) {
|
|
1218
1020
|
return;
|
|
1219
1021
|
}
|
|
1220
|
-
|
|
1022
|
+
// A payload URL is an address, so it keeps the base path; the server takes
|
|
1023
|
+
// it off.
|
|
1024
|
+
const target = new URL(addressOf(to), window.location.href);
|
|
1221
1025
|
const next = target.pathname + target.search;
|
|
1222
|
-
// The
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
const matched = matchRoute(routeTable().routes, target.pathname);
|
|
1230
|
-
if (matched != null && !hasClientPage(matched.route)) {
|
|
1231
|
-
window.location.assign(target.href);
|
|
1026
|
+
// The browser's job in this application; `ModuleRouter` has the argument.
|
|
1027
|
+
if (navigation === "document") {
|
|
1028
|
+
if (options?.replace === true) {
|
|
1029
|
+
window.location.replace(target.href);
|
|
1030
|
+
} else {
|
|
1031
|
+
window.location.assign(target.href);
|
|
1032
|
+
}
|
|
1232
1033
|
return;
|
|
1233
1034
|
}
|
|
1234
1035
|
setPending(true);
|
|
1235
1036
|
try {
|
|
1236
|
-
|
|
1037
|
+
// The route this page already has while it is fresh, then a prefetch
|
|
1038
|
+
// still in hand, then the network. See `./navigation-cache.js`.
|
|
1039
|
+
const from = flightNavigationOrigin(shown.current);
|
|
1040
|
+
const ordinaryKey = navigationKey(target.pathname, target.search);
|
|
1041
|
+
const key = flightNavigationKey(target.pathname, target.search, from);
|
|
1042
|
+
const fetched = await (
|
|
1043
|
+
flightNavigations.read(key) ??
|
|
1044
|
+
takePrefetched(next, from) ??
|
|
1045
|
+
keepFlight(key, fetchFlight(next, flightFetchOptions(from)), ordinaryKey)
|
|
1046
|
+
);
|
|
1047
|
+
// Not a payload: a redirect off this origin, or a host that has no payload
|
|
1048
|
+
// for this URL. The browser loads it as a document, which is what the
|
|
1049
|
+
// anchor would have done.
|
|
1050
|
+
if (fetched.kind === "document") {
|
|
1051
|
+
window.location.assign(fetched.url);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const payload = fetched.root;
|
|
1055
|
+
const nextRoot = await payload;
|
|
1056
|
+
// A payload another build rendered: a prerendered one, answered as a
|
|
1057
|
+
// file by a host that runs nothing and so refused nothing. Its tree names
|
|
1058
|
+
// that build's chunks, so it is not rendered here; the document is. See
|
|
1059
|
+
// `./deployment.js`.
|
|
1060
|
+
if (fromAnotherDeployment(nextRoot.deployment)) {
|
|
1061
|
+
setPending(false);
|
|
1062
|
+
loadDocument(target.href);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
// The URL the payload came from, which is a redirect's target when the
|
|
1066
|
+
// route redirected: the history entry is where the visitor ended up.
|
|
1067
|
+
// In the trailing-slash policy's spelling: a payload URL names its
|
|
1068
|
+
// document without the slash, and the history entry should be the
|
|
1069
|
+
// address the server answers without a redirect.
|
|
1070
|
+
const arrived = new URL(fetched.url, window.location.href);
|
|
1071
|
+
const landed = canonicalAddress(arrived.pathname) + arrived.search + target.hash;
|
|
1072
|
+
const state = historyStateForRoute(nextRoot.route);
|
|
1237
1073
|
if (options?.replace === true) {
|
|
1238
|
-
window.history.replaceState(
|
|
1074
|
+
window.history.replaceState(state, "", landed);
|
|
1239
1075
|
} else {
|
|
1240
|
-
window.history.pushState(
|
|
1076
|
+
window.history.pushState(state, "", landed);
|
|
1241
1077
|
}
|
|
1242
|
-
|
|
1243
|
-
|
|
1078
|
+
const commit = () => {
|
|
1079
|
+
show(payload, nextRoot);
|
|
1244
1080
|
setPending(false);
|
|
1245
|
-
}
|
|
1246
|
-
if (options?.
|
|
1081
|
+
};
|
|
1082
|
+
if (options?.transition === false) {
|
|
1083
|
+
startTransition(commit);
|
|
1084
|
+
} else {
|
|
1085
|
+
withViewTransition(nextRoot.route.viewTransition, commit);
|
|
1086
|
+
}
|
|
1087
|
+
const scroll =
|
|
1088
|
+
nextRoot.route.interception == null ? options?.scroll !== false : options?.scroll === true;
|
|
1089
|
+
if (scroll) {
|
|
1247
1090
|
if (target.hash !== "") {
|
|
1248
1091
|
const element = document.getElementById(target.hash.slice(1));
|
|
1249
1092
|
if (element != null) {
|
|
@@ -1255,29 +1098,70 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
1255
1098
|
}
|
|
1256
1099
|
} catch (error) {
|
|
1257
1100
|
setPending(false);
|
|
1101
|
+
// A client module the payload named could not be fetched: after a
|
|
1102
|
+
// deploy, a chunk the server no longer has. The document for the URL is
|
|
1103
|
+
// the live build's, so it is what the reader gets.
|
|
1104
|
+
if (isChunkLoadFailure(error)) {
|
|
1105
|
+
loadDocument(target.href);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1258
1108
|
throw error;
|
|
1259
1109
|
}
|
|
1260
|
-
}
|
|
1110
|
+
};
|
|
1261
1111
|
|
|
1262
1112
|
useEffect(() => {
|
|
1263
1113
|
if (!isBrowser()) {
|
|
1264
1114
|
return undefined;
|
|
1265
1115
|
}
|
|
1116
|
+
// A document reload rendered the URL's ordinary page. If the browser kept
|
|
1117
|
+
// an intercepted history marker for it, clear that marker before a later
|
|
1118
|
+
// back/forward asks for a modal over a page this document did not show.
|
|
1119
|
+
const restored = window.history.state;
|
|
1120
|
+
if (interceptedFrom(restored) != null) {
|
|
1121
|
+
window.history.replaceState(withoutInterception(restored), "", window.location.href);
|
|
1122
|
+
}
|
|
1123
|
+
// No history entry was pushed, so there is nothing to pop back into; see
|
|
1124
|
+
// `ModuleRouter`.
|
|
1125
|
+
if (navigation === "document") {
|
|
1126
|
+
return undefined;
|
|
1127
|
+
}
|
|
1266
1128
|
const onPopState = () => {
|
|
1267
1129
|
const next = window.location.pathname + window.location.search;
|
|
1268
|
-
//
|
|
1269
|
-
//
|
|
1270
|
-
//
|
|
1271
|
-
const
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1130
|
+
// The history entry already moved; a payload that cannot be had for it is
|
|
1131
|
+
// a document to load, and a reload is the browser's way to load it. While
|
|
1132
|
+
// this page keeps the route fresh, what it kept is what comes back.
|
|
1133
|
+
const from = interceptedFrom(window.history.state);
|
|
1134
|
+
const ordinaryKey = navigationKey(window.location.pathname, window.location.search);
|
|
1135
|
+
const key = flightNavigationKey(window.location.pathname, window.location.search, from);
|
|
1136
|
+
(
|
|
1137
|
+
flightNavigations.read(key) ??
|
|
1138
|
+
keepFlight(key, fetchFlight(next, flightFetchOptions(from)), ordinaryKey)
|
|
1139
|
+
).then(
|
|
1140
|
+
(fetched) => {
|
|
1141
|
+
if (fetched.kind === "document") {
|
|
1142
|
+
window.location.reload();
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
const payload = fetched.root;
|
|
1146
|
+
payload.then(
|
|
1147
|
+
(nextRoot) => {
|
|
1148
|
+
if (fromAnotherDeployment(nextRoot.deployment)) {
|
|
1149
|
+
window.location.reload();
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
withViewTransition(nextRoot.route.viewTransition, () => {
|
|
1153
|
+
show(payload, nextRoot);
|
|
1154
|
+
});
|
|
1155
|
+
},
|
|
1156
|
+
() => {
|
|
1157
|
+
window.location.reload();
|
|
1158
|
+
},
|
|
1159
|
+
);
|
|
1160
|
+
},
|
|
1161
|
+
() => {
|
|
1162
|
+
window.location.reload();
|
|
1163
|
+
},
|
|
1164
|
+
);
|
|
1281
1165
|
};
|
|
1282
1166
|
window.addEventListener("popstate", onPopState);
|
|
1283
1167
|
return () => {
|
|
@@ -1285,59 +1169,203 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
1285
1169
|
};
|
|
1286
1170
|
}, []);
|
|
1287
1171
|
|
|
1288
|
-
const router =
|
|
1289
|
-
() => (
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
const nextResolved = await resolveMatch(
|
|
1312
|
-
routeTable(),
|
|
1313
|
-
window.location.pathname + window.location.search,
|
|
1172
|
+
const router: Router = {
|
|
1173
|
+
push: (to, options) => navigate(to, options),
|
|
1174
|
+
replace: (to) => navigate(to, { replace: true }),
|
|
1175
|
+
prefetch: async (to) => {
|
|
1176
|
+
// Under document navigation there is no next render in this page to
|
|
1177
|
+
// fetch a payload for; see `ModuleRouter`'s prefetch.
|
|
1178
|
+
if (!isBrowser() || navigation === "document") {
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
const target = new URL(addressOf(to), window.location.href);
|
|
1182
|
+
if (target.origin !== window.location.origin) {
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
const next = target.pathname + target.search;
|
|
1186
|
+
const from = flightNavigationOrigin(shown.current);
|
|
1187
|
+
// Kept for every navigation to it while it is fresh, when a project set
|
|
1188
|
+
// `app.rendering.staleTime`; otherwise held for the one click after it.
|
|
1189
|
+
if (keepsNavigations()) {
|
|
1190
|
+
const ordinaryKey = navigationKey(target.pathname, target.search);
|
|
1191
|
+
const key = flightNavigationKey(target.pathname, target.search, from);
|
|
1192
|
+
await (
|
|
1193
|
+
flightNavigations.read(key) ??
|
|
1194
|
+
keepFlight(key, fetchFlight(next, flightFetchOptions(from)), ordinaryKey)
|
|
1314
1195
|
);
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
await prefetchFlight(next, from);
|
|
1199
|
+
},
|
|
1200
|
+
refresh: async () => {
|
|
1201
|
+
if (!isBrowser()) {
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
if (navigation === "document") {
|
|
1205
|
+
window.location.reload();
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
// Everything a navigation kept is older than what this asks for, so none
|
|
1209
|
+
// of it is shown again; see `./navigation-cache.js`.
|
|
1210
|
+
clearNavigationCache();
|
|
1211
|
+
const from = root.route.interception?.from ?? null;
|
|
1212
|
+
const ordinaryKey = navigationKey(window.location.pathname, window.location.search);
|
|
1213
|
+
const fetched = await keepFlight(
|
|
1214
|
+
flightNavigationKey(window.location.pathname, window.location.search, from),
|
|
1215
|
+
fetchFlight(window.location.pathname + window.location.search, flightFetchOptions(from)),
|
|
1216
|
+
ordinaryKey,
|
|
1217
|
+
);
|
|
1218
|
+
if (fetched.kind === "document") {
|
|
1219
|
+
window.location.reload();
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
const payload = fetched.root;
|
|
1223
|
+
const nextRoot = await payload;
|
|
1224
|
+
if (fromAnotherDeployment(nextRoot.deployment)) {
|
|
1225
|
+
window.location.reload();
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
// No view transition: a refresh is the same URL rendered again. See
|
|
1229
|
+
// `ModuleRouter`'s refresh.
|
|
1230
|
+
startTransition(() => {
|
|
1231
|
+
show(payload, nextRoot);
|
|
1232
|
+
});
|
|
1233
|
+
},
|
|
1234
|
+
back: () => {
|
|
1235
|
+
if (isBrowser()) {
|
|
1236
|
+
window.history.back();
|
|
1237
|
+
}
|
|
1238
|
+
},
|
|
1239
|
+
forward: () => {
|
|
1240
|
+
if (isBrowser()) {
|
|
1241
|
+
window.history.forward();
|
|
1242
|
+
}
|
|
1243
|
+
},
|
|
1244
|
+
};
|
|
1332
1245
|
|
|
1333
|
-
const value =
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1246
|
+
const value: RouterState = {
|
|
1247
|
+
route: root.route,
|
|
1248
|
+
view: { kind: "flight", tree: root.tree },
|
|
1249
|
+
router,
|
|
1250
|
+
pending,
|
|
1251
|
+
navigation,
|
|
1252
|
+
};
|
|
1337
1253
|
return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
|
|
1338
1254
|
}
|
|
1339
1255
|
|
|
1340
|
-
|
|
1256
|
+
/**
|
|
1257
|
+
* Payloads a `Link` fetched on intent, kept for the navigation that follows it.
|
|
1258
|
+
*
|
|
1259
|
+
* Bounded and short-lived, because a payload is the rendering of a route at one
|
|
1260
|
+
* moment: one old enough to disagree with the server is one a navigation should
|
|
1261
|
+
* not show, and a page with a hundred links hovered over must not hold a hundred
|
|
1262
|
+
* renderings. Taken rather than read, so a prefetched payload serves exactly one
|
|
1263
|
+
* navigation and the next visit to the same URL asks again.
|
|
1264
|
+
*/
|
|
1265
|
+
const PREFETCH_LIMIT = 32;
|
|
1266
|
+
const PREFETCH_LIFETIME_MS = 30000;
|
|
1267
|
+
const prefetchedFlights: Map<
|
|
1268
|
+
string,
|
|
1269
|
+
{| readonly fetched: Promise<FetchedFlight>, readonly at: number |},
|
|
1270
|
+
> = new Map();
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* `fetched`, kept under `key` for as long as `app.rendering.staleTime` says,
|
|
1274
|
+
* and forgotten again if it turns out not to be a route to show twice: a
|
|
1275
|
+
* request that failed, an answer that was a document rather than a payload, or
|
|
1276
|
+
* a payload React could not read.
|
|
1277
|
+
*/
|
|
1278
|
+
function keepFlight(
|
|
1279
|
+
key: string,
|
|
1280
|
+
fetched: Promise<FetchedFlight>,
|
|
1281
|
+
ordinaryKey?: string,
|
|
1282
|
+
): Promise<FetchedFlight> {
|
|
1283
|
+
if (!keepsNavigations()) {
|
|
1284
|
+
return fetched;
|
|
1285
|
+
}
|
|
1286
|
+
flightNavigations.store(key, fetched);
|
|
1287
|
+
const forget = () => {
|
|
1288
|
+
flightNavigations.forget(key, fetched);
|
|
1289
|
+
if (ordinaryKey != null) {
|
|
1290
|
+
flightNavigations.forget(ordinaryKey, fetched);
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
void fetched.then((answer) => {
|
|
1294
|
+
if (answer.kind === "document") {
|
|
1295
|
+
forget();
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
// A route that answered with its error or not-found boundary is shown this
|
|
1299
|
+
// once: asked again, it may have recovered.
|
|
1300
|
+
void answer.root.then((root) => {
|
|
1301
|
+
if (root.route.status !== 200) {
|
|
1302
|
+
forget();
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
if (ordinaryKey != null && root.route.interception == null) {
|
|
1306
|
+
flightNavigations.store(ordinaryKey, fetched);
|
|
1307
|
+
}
|
|
1308
|
+
}, forget);
|
|
1309
|
+
}, forget);
|
|
1310
|
+
return fetched;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* `resolved`, kept under `key` for as long as `app.rendering.staleTime` says,
|
|
1315
|
+
* and forgotten again if it did not resolve to a page: a loader that threw or
|
|
1316
|
+
* redirected, or a route that answered with its error or not-found boundary.
|
|
1317
|
+
*/
|
|
1318
|
+
function keepRoute(key: string, resolved: Promise<ResolvedRoute>): Promise<ResolvedRoute> {
|
|
1319
|
+
if (!keepsNavigations()) {
|
|
1320
|
+
return resolved;
|
|
1321
|
+
}
|
|
1322
|
+
routeNavigations.store(key, resolved);
|
|
1323
|
+
const forget = () => {
|
|
1324
|
+
routeNavigations.forget(key, resolved);
|
|
1325
|
+
};
|
|
1326
|
+
void resolved.then((route) => {
|
|
1327
|
+
if (route.status !== 200) {
|
|
1328
|
+
forget();
|
|
1329
|
+
}
|
|
1330
|
+
}, forget);
|
|
1331
|
+
return resolved;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function prefetchedFlightKey(url: string, from: ?string): string {
|
|
1335
|
+
return from == null ? url : `${url}\0${from}`;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function prefetchFlight(url: string, from: ?string): Promise<FetchedFlight> {
|
|
1339
|
+
const key = prefetchedFlightKey(url, from);
|
|
1340
|
+
const existing = prefetchedFlights.get(key);
|
|
1341
|
+
if (existing != null && Date.now() - existing.at < PREFETCH_LIFETIME_MS) {
|
|
1342
|
+
return existing.fetched;
|
|
1343
|
+
}
|
|
1344
|
+
if (existing == null && prefetchedFlights.size >= PREFETCH_LIMIT) {
|
|
1345
|
+
const oldest = prefetchedFlights.keys().next();
|
|
1346
|
+
if (oldest.done !== true) {
|
|
1347
|
+
prefetchedFlights.delete(oldest.value);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
const fetched = fetchFlight(url, flightFetchOptions(from));
|
|
1351
|
+
prefetchedFlights.set(key, { fetched, at: Date.now() });
|
|
1352
|
+
fetched.catch(() => {
|
|
1353
|
+
prefetchedFlights.delete(key);
|
|
1354
|
+
});
|
|
1355
|
+
return fetched;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function takePrefetched(url: string, from: ?string): Promise<FetchedFlight> | null {
|
|
1359
|
+
const key = prefetchedFlightKey(url, from);
|
|
1360
|
+
const entry = prefetchedFlights.get(key);
|
|
1361
|
+
prefetchedFlights.delete(key);
|
|
1362
|
+
if (entry == null || Date.now() - entry.at >= PREFETCH_LIFETIME_MS) {
|
|
1363
|
+
return null;
|
|
1364
|
+
}
|
|
1365
|
+
return entry.fetched;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
export hook useRouterState(): RouterState {
|
|
1341
1369
|
const state = useContext(RouterContext);
|
|
1342
1370
|
if (state == null) {
|
|
1343
1371
|
throw new Error(
|
|
@@ -1349,17 +1377,38 @@ hook useRouterState(): RouterState {
|
|
|
1349
1377
|
|
|
1350
1378
|
/** The current route. */
|
|
1351
1379
|
export hook useRoute(): RouteInfo {
|
|
1352
|
-
const {
|
|
1380
|
+
const { route, pending } = useRouterState();
|
|
1353
1381
|
return {
|
|
1354
|
-
path:
|
|
1355
|
-
pathname:
|
|
1356
|
-
params:
|
|
1357
|
-
searchParams:
|
|
1358
|
-
data:
|
|
1382
|
+
path: route.path,
|
|
1383
|
+
pathname: route.pathname,
|
|
1384
|
+
params: route.params,
|
|
1385
|
+
searchParams: route.searchParams,
|
|
1386
|
+
data: useResolvedData(route),
|
|
1359
1387
|
pending,
|
|
1360
1388
|
};
|
|
1361
1389
|
}
|
|
1362
1390
|
|
|
1391
|
+
/**
|
|
1392
|
+
* The loader's answer, waiting for it if the router deferred it.
|
|
1393
|
+
*
|
|
1394
|
+
* Both hooks that expose the data go through here, and both therefore suspend
|
|
1395
|
+
* when the answer is not in yet. That is the conservative choice rather than
|
|
1396
|
+
* the clever one: the alternative is handing back `undefined` for a value that
|
|
1397
|
+
* is on its way, which is a page reading a field that is about to exist and
|
|
1398
|
+
* finding nothing there, with nothing anywhere to say why.
|
|
1399
|
+
*
|
|
1400
|
+
* Suspending costs a caller *above* the innermost `<Suspense>` — a layout, a
|
|
1401
|
+
* masthead — the streaming it would otherwise have got, because React holds the
|
|
1402
|
+
* shell for a component that suspends with no boundary above it. That is
|
|
1403
|
+
* exactly what such a route did before the loader could be deferred at all, so
|
|
1404
|
+
* it is a benefit not taken rather than a regression, and it is visible: the
|
|
1405
|
+
* fallback does not appear.
|
|
1406
|
+
*/
|
|
1407
|
+
hook useResolvedData(route: RouteState): mixed {
|
|
1408
|
+
const loader = route.deferred;
|
|
1409
|
+
return loader == null ? route.data : use(loader);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1363
1412
|
/** Navigation. */
|
|
1364
1413
|
export hook useRouter(): Router {
|
|
1365
1414
|
return useRouterState().router;
|
|
@@ -1386,204 +1435,394 @@ export hook useRouter(): Router {
|
|
|
1386
1435
|
* same file, keyed by route. Until it is there, this says what is true.
|
|
1387
1436
|
*/
|
|
1388
1437
|
export hook useLoaderData(): mixed {
|
|
1389
|
-
return useRouterState().
|
|
1438
|
+
return useResolvedData(useRouterState().route);
|
|
1390
1439
|
}
|
|
1391
1440
|
|
|
1441
|
+
/**
|
|
1442
|
+
* Whether this bundle marks the boundaries it renders.
|
|
1443
|
+
*
|
|
1444
|
+
* `import.meta.hot` is the same gate `../client.js` uses for the hydration
|
|
1445
|
+
* report and the DevTools check, chosen there for the reason it is chosen here:
|
|
1446
|
+
* Vite defines it while serving and replaces it with `undefined` in a build, so
|
|
1447
|
+
* every branch below is statically dead in a production bundle and the module
|
|
1448
|
+
* behind it — `@uniflowed/router` is `sideEffects: false` — is dropped rather
|
|
1449
|
+
* than shipped unused. Node leaves it undefined, so a host that imports this
|
|
1450
|
+
* file without a bundler gets the production path, and so does the test suite.
|
|
1451
|
+
*
|
|
1452
|
+
* It is a module constant rather than a per-render question because the branch
|
|
1453
|
+
* has to be foldable, and it may answer differently in the browser and on the
|
|
1454
|
+
* server without costing anything: a mark renders nothing until it has mounted,
|
|
1455
|
+
* so neither the server's markup nor the tree React hydrates against it can
|
|
1456
|
+
* contain one. See `./boundaries.js`, which has the argument.
|
|
1457
|
+
*/
|
|
1458
|
+
const BOUNDARY_MARKS: boolean = import.meta.hot != null;
|
|
1459
|
+
|
|
1392
1460
|
/**
|
|
1393
1461
|
* Renders the matched page inside its layouts, innermost last, with the
|
|
1394
1462
|
* document metadata as hoistable head elements.
|
|
1395
1463
|
*
|
|
1396
|
-
*
|
|
1397
|
-
*
|
|
1398
|
-
*
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
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
|
-
*
|
|
1405
|
-
* # Where the error boundaries go
|
|
1406
|
-
*
|
|
1407
|
-
* Two, and they are not the same thing twice. The inner one is the project's
|
|
1408
|
-
* `_uf.error.js`, placed at the depth the file sits at, so the layouts above
|
|
1409
|
-
* it stay mounted and interactive while the subtree below is replaced — that
|
|
1410
|
-
* placement *is* the feature. The outer one has no module and so renders the
|
|
1411
|
-
* framework's page; it is what stands between a throw in a root layout, or in
|
|
1412
|
-
* the error component itself, and an unmounted document. A single boundary
|
|
1413
|
-
* cannot be both: put it outside and a page's throw takes the navigation down
|
|
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.
|
|
1464
|
+
* The walk itself is `composeRoute` in `./compose.js`, which has the whole
|
|
1465
|
+
* argument for where each boundary goes. What is left here is the half that
|
|
1466
|
+
* reads the router: which route, the page element that carries the loader's
|
|
1467
|
+
* answer and the payload the browser hydrates it from, and — under `uf dev` —
|
|
1468
|
+
* the boundary marks and the report that watches them. See ubugeeei-prod/uf#520.
|
|
1431
1469
|
*/
|
|
1432
1470
|
export component RouteView() {
|
|
1433
|
-
const {
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1471
|
+
const { view } = useRouterState();
|
|
1472
|
+
// A tree a server composed for React Server Components is the whole of it:
|
|
1473
|
+
// the boundaries, the fallbacks, the marks and the head were placed by
|
|
1474
|
+
// `composeRoute` on the server, before any of it was written into the payload.
|
|
1475
|
+
if (view.kind === "flight") {
|
|
1476
|
+
return view.tree;
|
|
1477
|
+
}
|
|
1478
|
+
const resolved = view.resolved;
|
|
1479
|
+
const loader = resolved.deferred;
|
|
1480
|
+
// The route's boundaries, named once and read by both the marks the
|
|
1481
|
+
// composition places and the report that watches them. `installedTable`
|
|
1482
|
+
// rather than [`routeTable`], which throws: a test may render this view
|
|
1483
|
+
// without an entry having installed a table, and an error boundary named by
|
|
1484
|
+
// its depth alone is worth less than one named by its file rather than wrong.
|
|
1485
|
+
const marks = BOUNDARY_MARKS
|
|
1486
|
+
? routeBoundaries(
|
|
1487
|
+
resolved,
|
|
1488
|
+
nearestBoundary(installedTable?.errors ?? [], resolved.pathname)?.file,
|
|
1489
|
+
)
|
|
1490
|
+
: null;
|
|
1491
|
+
const page =
|
|
1492
|
+
loader == null ? <RenderedPage data={resolved.data} /> : <AwaitedPage loader={loader} />;
|
|
1493
|
+
return (
|
|
1494
|
+
<>
|
|
1495
|
+
{composeRoute(resolved, { page, marks })}
|
|
1496
|
+
{/* After the tree rather than before it, so its effect runs once every
|
|
1497
|
+
mark below has had its own — which is the commit the marks are in. */}
|
|
1498
|
+
{BOUNDARY_MARKS && marks != null ? (
|
|
1499
|
+
<BoundaryReporter path={resolved.path} boundaries={marks} />
|
|
1500
|
+
) : null}
|
|
1501
|
+
</>
|
|
1438
1502
|
);
|
|
1503
|
+
}
|
|
1439
1504
|
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
if (depth > 0) {
|
|
1464
|
-
const Layout = layoutComponent(resolved.layouts[depth - 1]);
|
|
1465
|
-
element = <Layout params={resolved.params}>{element}</Layout>;
|
|
1466
|
-
}
|
|
1505
|
+
/**
|
|
1506
|
+
* The page, with the loader's answer and the copy of it the browser hydrates
|
|
1507
|
+
* from.
|
|
1508
|
+
*
|
|
1509
|
+
* The two are rendered together because they are one fact told twice, and
|
|
1510
|
+
* anything that could put them out of step is a page whose first client render
|
|
1511
|
+
* disagrees with the document it was sent. Being one component is what keeps
|
|
1512
|
+
* the script in the same position in the tree on both sides — inside the
|
|
1513
|
+
* innermost `<Suspense>` when the route deferred its loader on the server, and
|
|
1514
|
+
* exactly there again on the client, where the data is already in hand and
|
|
1515
|
+
* nothing suspends at all.
|
|
1516
|
+
*
|
|
1517
|
+
* "The loader's answer" is now a payload rather than a value, so what
|
|
1518
|
+
* [`payloadElements`] renders is that script plus one boundary per value the
|
|
1519
|
+
* answer deferred. The same argument covers all of them: the browser's copy of
|
|
1520
|
+
* this component renders the same rows in the same places, from the values it
|
|
1521
|
+
* read out of those very elements.
|
|
1522
|
+
*/
|
|
1523
|
+
component RenderedPage(data: mixed) {
|
|
1524
|
+
const { view } = useRouterState();
|
|
1525
|
+
// Only ever rendered by `RouteView` for a route resolved from its modules.
|
|
1526
|
+
if (view.kind !== "modules") {
|
|
1527
|
+
return null;
|
|
1467
1528
|
}
|
|
1529
|
+
const resolved = view.resolved;
|
|
1530
|
+
const Page = pageComponent(resolved.page);
|
|
1531
|
+
// The route module's own export, looked up by route: `pageComponent` hands
|
|
1532
|
+
// back its `default` or `Page` as it is, so this is the same component on
|
|
1533
|
+
// every render of the same route. The React Compiler cannot see through the
|
|
1534
|
+
// lookup and reports a component created during render. The block form,
|
|
1535
|
+
// because the finding is on a JSX child and a `//` comment cannot stand
|
|
1536
|
+
// between JSX children without becoming text.
|
|
1537
|
+
// uf-lint-disable react-compiler/static-components
|
|
1468
1538
|
return (
|
|
1469
1539
|
<>
|
|
1470
|
-
<
|
|
1471
|
-
|
|
1472
|
-
{element}
|
|
1473
|
-
</RouteErrorBoundary>
|
|
1540
|
+
<Page params={resolved.params} searchParams={resolved.searchParams} data={data} />
|
|
1541
|
+
{payloadElements(data)}
|
|
1474
1542
|
</>
|
|
1475
1543
|
);
|
|
1544
|
+
// uf-lint-enable react-compiler/static-components
|
|
1476
1545
|
}
|
|
1477
1546
|
|
|
1478
1547
|
/**
|
|
1479
|
-
* The
|
|
1480
|
-
*
|
|
1548
|
+
* The same page, once the loader the router deferred has answered.
|
|
1549
|
+
*
|
|
1550
|
+
* A component of its own rather than a `use` guarded by an `if` inside
|
|
1551
|
+
* [`RenderedPage`], so the call is unconditional where it is written: this one
|
|
1552
|
+
* is rendered only when there is a promise, and `RouteView` chooses between
|
|
1553
|
+
* them. `use` may legally be called conditionally, and code that reads as
|
|
1554
|
+
* though it may not is worth avoiding anyway.
|
|
1481
1555
|
*/
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
if (component == null) {
|
|
1485
|
-
throw new Error(
|
|
1486
|
-
"@uniflowed/router: a page module must export a component as `default` or `Page`",
|
|
1487
|
-
);
|
|
1488
|
-
}
|
|
1489
|
-
return renderable(component);
|
|
1556
|
+
component AwaitedPage(loader: Promise<mixed>) {
|
|
1557
|
+
return <RenderedPage data={use(loader)} />;
|
|
1490
1558
|
}
|
|
1491
1559
|
|
|
1492
1560
|
/**
|
|
1493
|
-
* The
|
|
1561
|
+
* The loader's answer, embedded for the browser to hydrate from.
|
|
1562
|
+
*
|
|
1563
|
+
* In the tree rather than in the head, which is the third of the three options
|
|
1564
|
+
* ubugeeei-prod/uf#373 weighed and the only one that survives a deferred
|
|
1565
|
+
* loader. `server.js` wrote this into the head from the resolved route, and a
|
|
1566
|
+
* deferred answer does not exist when the head goes out — losing it would mean
|
|
1567
|
+
* every deferred route's loader running a second time in the browser, on the
|
|
1568
|
+
* way in, for data the document already contained.
|
|
1569
|
+
*
|
|
1570
|
+
* The two rejected options are worth naming. Writing it at the end of the body
|
|
1571
|
+
* from outside React would have worked — uf's client entry is a module script,
|
|
1572
|
+
* so it runs after parsing either way — but it would be markup inside the
|
|
1573
|
+
* hydration root that React did not render, which is the definition of a
|
|
1574
|
+
* mismatch. Emitting it through `bootstrapScriptContent` as a global is
|
|
1575
|
+
* React's own documented pattern and costs the one property this element has
|
|
1576
|
+
* that matters: `application/json` is data a browser does not execute, and a
|
|
1577
|
+
* script that is executed is a script a content security policy has to allow.
|
|
1578
|
+
*
|
|
1579
|
+
* `<` is escaped inside the JSON so a string holding `</script>` cannot end the
|
|
1580
|
+
* element early, and U+2028 and U+2029 because a JSON document is not
|
|
1581
|
+
* JavaScript source but is sometimes read as if it were — the escape moved to
|
|
1582
|
+
* `./payload.js` when the model stopped being the only thing written that way.
|
|
1583
|
+
* `dangerouslySetInnerHTML` rather than a text child because React escapes a
|
|
1584
|
+
* text child and `"` is not JSON any more. `security/no-dangerously-set-
|
|
1585
|
+
* inner-html` is about markup that came from somewhere and has to be sanitized
|
|
1586
|
+
* before a browser parses it as HTML; this is `JSON.stringify`'s output with
|
|
1587
|
+
* `<` escaped, in an element the browser never parses as HTML and never runs.
|
|
1588
|
+
* `docs/app/$layout.js` carries the same suppression for the same reason.
|
|
1589
|
+
*
|
|
1590
|
+
* # And the rows the model deferred
|
|
1591
|
+
*
|
|
1592
|
+
* A promise anywhere in the loader's answer used to be `JSON.stringify`'d to
|
|
1593
|
+
* `{}`. It is now a `"$P<n>"` reference in the element above and a `<script
|
|
1594
|
+
* data-uf-row="n">` of its own, inside a `<Suspense fallback={null}>` — which
|
|
1595
|
+
* is what makes React stream it at the moment the promise settles rather than
|
|
1596
|
+
* holding the document for it. Each row is its own boundary, so two deferred
|
|
1597
|
+
* values arrive in the order they resolved in and not in the order they were
|
|
1598
|
+
* written. `./payload.js` is the format; `./payload-rows.js` is the browser
|
|
1599
|
+
* reading them back.
|
|
1600
|
+
*
|
|
1601
|
+
* The boundaries sit after the page rather than before it, where the data
|
|
1602
|
+
* element already was. A page that suspends with no `$loading.js` above it
|
|
1603
|
+
* holds the whole shell — that is React's rule and uf does not work around it
|
|
1604
|
+
* — so the position buys nothing either way, and "the scripts are where the
|
|
1605
|
+
* script was" is worth more than a rearrangement that is not.
|
|
1606
|
+
*
|
|
1607
|
+
* # Why the rows are inside an element
|
|
1494
1608
|
*
|
|
1495
|
-
*
|
|
1496
|
-
*
|
|
1497
|
-
*
|
|
1498
|
-
*
|
|
1609
|
+
* Because a `<Suspense>` that is a direct child of the *render root* stops the
|
|
1610
|
+
* shell being flushed at all. React's renderer can only write a segment once
|
|
1611
|
+
* the segment is complete, and the root segment holds an unresolved boundary
|
|
1612
|
+
* open: measured against React 19.2.8, a tree of `[<div>, <Suspense>]` writes
|
|
1613
|
+
* its first byte when the boundary resolves, and the same tree with the
|
|
1614
|
+
* boundary inside any host element writes it immediately. Every component
|
|
1615
|
+
* between the root and here — `RenderProvider`, `RouterProvider`, `RouteView`,
|
|
1616
|
+
* `RouteErrorBoundary` — renders no element of its own, so without this
|
|
1617
|
+
* `<span>` the rows would be exactly that first shape and a payload would have
|
|
1618
|
+
* streamed nothing.
|
|
1619
|
+
*
|
|
1620
|
+
* `hidden` because it holds no content a reader is meant to see: `<script
|
|
1621
|
+
* type="application/json">` renders nothing either way, and the attribute is
|
|
1622
|
+
* what says so to anything that inspects the document. One element for all the
|
|
1623
|
+
* rows rather than one each — the boundaries inside it still resolve
|
|
1624
|
+
* independently, since each is its own.
|
|
1625
|
+
*
|
|
1626
|
+
* The same rule catches a route whose `$loading.js` sits above no layout, so
|
|
1627
|
+
* `RouteView` wraps that specific root shape in `RootStreamFrame`.
|
|
1499
1628
|
*/
|
|
1500
|
-
function
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1629
|
+
function payloadElements(data: mixed): React.Node {
|
|
1630
|
+
if (data === undefined) {
|
|
1631
|
+
return null;
|
|
1632
|
+
}
|
|
1633
|
+
const { model, rows } = encodePayload(data, "the route's loader data");
|
|
1634
|
+
// Before anything renders, so a promise that has already rejected is one
|
|
1635
|
+
// somebody is listening to. `settledRow` is memoized, so the components
|
|
1636
|
+
// below get these same promises rather than a second set.
|
|
1637
|
+
for (const row of rows) {
|
|
1638
|
+
settledRow(row.value);
|
|
1506
1639
|
}
|
|
1507
|
-
|
|
1640
|
+
const html = { __html: payloadJson(model) };
|
|
1641
|
+
// uf-lint-disable-next-line security/no-dangerously-set-inner-html
|
|
1642
|
+
const row0 = <script id={DATA_ID} type="application/json" dangerouslySetInnerHTML={html} />;
|
|
1643
|
+
return (
|
|
1644
|
+
<>
|
|
1645
|
+
{row0}
|
|
1646
|
+
{rows.length === 0 ? null : (
|
|
1647
|
+
<span hidden>
|
|
1648
|
+
{rows.map((row) => (
|
|
1649
|
+
<Suspense key={row.id} fallback={null}>
|
|
1650
|
+
<PayloadRow id={row.id} value={row.value} />
|
|
1651
|
+
</Suspense>
|
|
1652
|
+
))}
|
|
1653
|
+
</span>
|
|
1654
|
+
)}
|
|
1655
|
+
</>
|
|
1656
|
+
);
|
|
1508
1657
|
}
|
|
1509
1658
|
|
|
1510
|
-
/**
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1659
|
+
/**
|
|
1660
|
+
* One deferred value, written when it settles.
|
|
1661
|
+
*
|
|
1662
|
+
* Rendered on both sides, which is the thing to keep in mind about it. On the
|
|
1663
|
+
* server `value` is the loader's own promise; in the browser it is the promise
|
|
1664
|
+
* `./payload-rows.js` created for this row and resolved out of this very
|
|
1665
|
+
* element. Both then write the element from the settled result through the
|
|
1666
|
+
* same [`payloadJson`], so the bytes agree and hydration has nothing to
|
|
1667
|
+
* report. `encodeRowValue` is what re-applies the reference escape to a value
|
|
1668
|
+
* the browser has already had it removed from.
|
|
1669
|
+
*
|
|
1670
|
+
* It never rejects. `use` on a rejected promise throws, and a throw here would
|
|
1671
|
+
* put the *row's* boundary into the error boundary above it — which is the
|
|
1672
|
+
* page, for a value the page may not even be reading. The failure travels as a
|
|
1673
|
+
* row instead, and the page's own `use` of the same promise is what reaches
|
|
1674
|
+
* the page's boundary, exactly as it would have without a payload.
|
|
1675
|
+
*/
|
|
1676
|
+
component PayloadRow(id: number, value: Promise<mixed>) {
|
|
1677
|
+
const message = use(settledRow(value));
|
|
1678
|
+
const html = { __html: payloadJson(message) };
|
|
1679
|
+
return (
|
|
1680
|
+
// uf-lint-disable-next-line security/no-dangerously-set-inner-html
|
|
1681
|
+
<script type="application/json" data-uf-row={String(id)} dangerouslySetInnerHTML={html} />
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* The message a row will carry, as a promise that always fulfils.
|
|
1687
|
+
*
|
|
1688
|
+
* Keyed by the promise rather than recomputed, because `use` wants the same
|
|
1689
|
+
* promise every render and a render is repeated: React renders a component
|
|
1690
|
+
* again after it suspends, and Strict Mode renders it twice more. A `WeakMap`
|
|
1691
|
+
* so a route that has navigated away takes its rows with it.
|
|
1692
|
+
*/
|
|
1693
|
+
const settledRows: WeakMap<Promise<mixed>, Promise<PayloadRowMessage>> = new WeakMap();
|
|
1694
|
+
|
|
1695
|
+
function settledRow(value: Promise<mixed>): Promise<PayloadRowMessage> {
|
|
1696
|
+
const existing = settledRows.get(value);
|
|
1697
|
+
if (existing != null) {
|
|
1698
|
+
return existing;
|
|
1517
1699
|
}
|
|
1518
|
-
|
|
1700
|
+
const settled = value.then(
|
|
1701
|
+
(resolved) => ({ value: encodeRowValue(resolved, "a deferred value") }),
|
|
1702
|
+
(error) => ({ error: rowFailure(error) }),
|
|
1703
|
+
);
|
|
1704
|
+
settledRows.set(value, settled);
|
|
1705
|
+
return settled;
|
|
1519
1706
|
}
|
|
1520
1707
|
|
|
1521
1708
|
/**
|
|
1522
|
-
*
|
|
1523
|
-
*
|
|
1524
|
-
*
|
|
1525
|
-
*
|
|
1526
|
-
*
|
|
1527
|
-
*
|
|
1528
|
-
*
|
|
1529
|
-
*
|
|
1530
|
-
*
|
|
1531
|
-
*
|
|
1532
|
-
*
|
|
1533
|
-
*
|
|
1534
|
-
* Here it is one line, and everything on either side of it is checked: what a
|
|
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.
|
|
1709
|
+
* What a row says when the value failed.
|
|
1710
|
+
*
|
|
1711
|
+
* A fixed sentence in a build, and the error's own words where
|
|
1712
|
+
* `import.meta.hot` says a developer is reading them — the same gate
|
|
1713
|
+
* [`BOUNDARY_MARKS`] uses, and the same argument: a message that came out of a
|
|
1714
|
+
* loader can name a table, a query or a file path, and a browser is not where
|
|
1715
|
+
* any of those belong.
|
|
1716
|
+
*
|
|
1717
|
+
* A `PayloadRowError` short-circuits both, and has to. That error is what the
|
|
1718
|
+
* browser's reader rejects with, carrying the row's own text, so echoing it is
|
|
1719
|
+
* what makes the element the browser renders equal the one the server sent
|
|
1720
|
+
* whichever of the two builds was the development one.
|
|
1538
1721
|
*/
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
):
|
|
1542
|
-
|
|
1543
|
-
|
|
1722
|
+
const ROW_FAILURE = "@uniflowed/router: a deferred value failed on the server.";
|
|
1723
|
+
|
|
1724
|
+
function rowFailure(error: mixed): string {
|
|
1725
|
+
if (error instanceof PayloadRowError) {
|
|
1726
|
+
return error.wire;
|
|
1727
|
+
}
|
|
1728
|
+
if (!BOUNDARY_MARKS) {
|
|
1729
|
+
return ROW_FAILURE;
|
|
1730
|
+
}
|
|
1731
|
+
return error instanceof Error ? `${ROW_FAILURE} ${error.message}` : ROW_FAILURE;
|
|
1544
1732
|
}
|
|
1545
1733
|
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1734
|
+
/**
|
|
1735
|
+
* Head elements a component contributes while it is rendering.
|
|
1736
|
+
*
|
|
1737
|
+
* `metadata` and `generateMetadata` are how a *route* says what it is, and
|
|
1738
|
+
* both are resolved before anything renders — which is what makes them work
|
|
1739
|
+
* for a crawler that runs no JavaScript. They are also declarations by the
|
|
1740
|
+
* route module, and part of what a page has to say is decided further in: a
|
|
1741
|
+
* paginated list knows its `prev` and `next` in the component that draws the
|
|
1742
|
+
* pager, and a breadcrumb knows the trail it has just walked.
|
|
1743
|
+
*
|
|
1744
|
+
* So this returns elements rather than writing to the head. Writing would have
|
|
1745
|
+
* to happen in an effect, an effect does not run on a server, and the result
|
|
1746
|
+
* would be a page whose tags are right in a browser and missing from the
|
|
1747
|
+
* crawler — `packages/web/head.js` is that escape hatch and says so at the top
|
|
1748
|
+
* of the file. Rendering is what puts a tag in a server-rendered head, so the
|
|
1749
|
+
* caller renders what comes back:
|
|
1750
|
+
*
|
|
1751
|
+
* export component Pager(page: number, of: number) {
|
|
1752
|
+
* const seo = useSeo({
|
|
1753
|
+
* pagination: {
|
|
1754
|
+
* prev: page > 1 ? `/posts?page=${page - 1}` : undefined,
|
|
1755
|
+
* next: page < of ? `/posts?page=${page + 1}` : undefined,
|
|
1756
|
+
* },
|
|
1757
|
+
* });
|
|
1758
|
+
* return <nav className="pager">{seo}…</nav>;
|
|
1759
|
+
* }
|
|
1760
|
+
*
|
|
1761
|
+
* The argument is a `Metadata` — the same type a route exports — because there
|
|
1762
|
+
* is one vocabulary for what a page says about itself, and a second one would
|
|
1763
|
+
* be a second place for it to be wrong. What this adds over rendering the tags
|
|
1764
|
+
* by hand is the thing a component three levels down cannot know:
|
|
1765
|
+
* `metadataBase`, which the root layout declared, and against which the
|
|
1766
|
+
* relative URLs written here are resolved.
|
|
1767
|
+
*/
|
|
1768
|
+
export hook useSeo(seo: Metadata): React.Node {
|
|
1769
|
+
const { route } = useRouterState();
|
|
1770
|
+
const base = seo.metadataBase ?? route.metadata.metadataBase;
|
|
1771
|
+
return <Head metadata={base == null ? seo : { ...seo, metadataBase: base }} />;
|
|
1561
1772
|
}
|
|
1562
1773
|
|
|
1563
1774
|
/** When a `Link` loads the route it points at. */
|
|
1564
1775
|
export type LinkPrefetch = "off" | "intent" | "render";
|
|
1565
1776
|
|
|
1777
|
+
const LinkStatusContext: React.Context<boolean> = createContext(false);
|
|
1778
|
+
|
|
1779
|
+
/** Whether the containing Link is waiting for the navigation it started. */
|
|
1780
|
+
export hook useLinkStatus(): {| readonly pending: boolean |} {
|
|
1781
|
+
return { pending: useContext(LinkStatusContext) };
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1566
1784
|
/**
|
|
1567
1785
|
* A client-side navigation.
|
|
1568
1786
|
*
|
|
1569
1787
|
* Renders a real anchor, so the link works before hydration and for a right
|
|
1570
1788
|
* click, and takes over only a plain left click. `prefetch="intent"` (the
|
|
1571
|
-
* default) loads the destination's chunks on hover or focus
|
|
1789
|
+
* default) loads the destination's chunks on hover or focus, and
|
|
1790
|
+
* `transition={false}` makes this one navigation a cut — most navigations are
|
|
1791
|
+
* a link, so the opt-out in [`NavigateOptions`] has to be reachable from one.
|
|
1792
|
+
*
|
|
1793
|
+
* # Under `app.rendering.navigation: "document"` it is only the anchor
|
|
1794
|
+
*
|
|
1795
|
+
* No click handler of uf's, no `preventDefault`, no prefetch listeners: the
|
|
1796
|
+
* element the browser gets is the one it would have got from `<a href>` in the
|
|
1797
|
+
* source. That is the whole of what changing the mode does to a component,
|
|
1798
|
+
* which is the point — a project moving between the two rewrites its
|
|
1799
|
+
* `uf.config.js` and none of its pages, and a component library built on
|
|
1800
|
+
* `Link` works in both without knowing which it is in.
|
|
1801
|
+
*
|
|
1802
|
+
* It matters that the handler is *absent* rather than a handler that calls
|
|
1803
|
+
* `location.assign`. The two look the same for a left click and are not the
|
|
1804
|
+
* same link: `preventDefault` and a scripted navigation lose `download`, lose
|
|
1805
|
+
* a `target`, and change what the browser does with a middle click and with a
|
|
1806
|
+
* gesture uf has not heard of. An ordinary link is not an approximation of an
|
|
1807
|
+
* ordinary link.
|
|
1572
1808
|
*/
|
|
1573
1809
|
export component Link(
|
|
1574
1810
|
to: string,
|
|
1575
1811
|
prefetch?: LinkPrefetch = "intent",
|
|
1576
1812
|
replace?: boolean = false,
|
|
1813
|
+
transition?: boolean = true,
|
|
1577
1814
|
children?: React.Node,
|
|
1578
1815
|
className?: string,
|
|
1579
1816
|
onClick?: (event: SyntheticMouseEvent<HTMLAnchorElement>) => mixed,
|
|
1580
1817
|
...rest: { readonly [string]: mixed }
|
|
1581
1818
|
) {
|
|
1582
|
-
const router =
|
|
1583
|
-
const
|
|
1819
|
+
const { router, navigation } = useRouterState();
|
|
1820
|
+
const [linkPending, startLinkTransition] = useTransition();
|
|
1821
|
+
const prefetched = useRef(false);
|
|
1822
|
+
const drives = navigation === "client";
|
|
1584
1823
|
|
|
1585
1824
|
const doPrefetch = () => {
|
|
1586
|
-
if (prefetch === "off" || prefetched.current || isExternal(to)) {
|
|
1825
|
+
if (!drives || prefetch === "off" || prefetched.current || isExternal(to)) {
|
|
1587
1826
|
return;
|
|
1588
1827
|
}
|
|
1589
1828
|
prefetched.current = true;
|
|
@@ -1607,28 +1846,43 @@ export component Link(
|
|
|
1607
1846
|
event.ctrlKey ||
|
|
1608
1847
|
event.shiftKey ||
|
|
1609
1848
|
event.altKey ||
|
|
1849
|
+
(rest.target != null && rest.target !== "_self") ||
|
|
1850
|
+
rest.download != null ||
|
|
1610
1851
|
isExternal(to)
|
|
1611
1852
|
) {
|
|
1612
1853
|
return;
|
|
1613
1854
|
}
|
|
1614
1855
|
event.preventDefault();
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1856
|
+
startLinkTransition(async () => {
|
|
1857
|
+
try {
|
|
1858
|
+
await router.push(to, { replace, transition });
|
|
1859
|
+
} catch (error) {
|
|
1860
|
+
// A failed navigation falls back to the browser doing it.
|
|
1861
|
+
console.error(error);
|
|
1862
|
+
window.location.assign(addressOf(to));
|
|
1863
|
+
}
|
|
1619
1864
|
});
|
|
1620
1865
|
};
|
|
1621
1866
|
|
|
1867
|
+
// The caller's own `onClick` still runs under document navigation — it is
|
|
1868
|
+
// theirs, and an application that closes a menu when a link is clicked is
|
|
1869
|
+
// not asking uf to take the navigation over — so it is passed through rather
|
|
1870
|
+
// than dropped with the rest of the behaviour.
|
|
1622
1871
|
return (
|
|
1623
1872
|
<a
|
|
1624
1873
|
{...rest}
|
|
1625
|
-
|
|
1874
|
+
// `to` is an application path; the anchor is the address, with the base
|
|
1875
|
+
// path in front and the trailing-slash policy's spelling, so a link that
|
|
1876
|
+
// works before hydration and for a right click goes where this one does.
|
|
1877
|
+
href={addressOf(to)}
|
|
1626
1878
|
className={className}
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1879
|
+
aria-busy={linkPending || undefined}
|
|
1880
|
+
data-pending={linkPending ? "" : undefined}
|
|
1881
|
+
onClick={drives ? handleClick : onClick}
|
|
1882
|
+
onMouseEnter={drives && prefetch === "intent" ? doPrefetch : undefined}
|
|
1883
|
+
onFocus={drives && prefetch === "intent" ? doPrefetch : undefined}
|
|
1630
1884
|
>
|
|
1631
|
-
{children}
|
|
1885
|
+
<LinkStatusContext value={linkPending}>{children}</LinkStatusContext>
|
|
1632
1886
|
</a>
|
|
1633
1887
|
);
|
|
1634
1888
|
}
|
|
@@ -1643,44 +1897,42 @@ function isExternal(to: string): boolean {
|
|
|
1643
1897
|
* The argument documents where the routes live; the table itself is generated
|
|
1644
1898
|
* from that directory at build time and installed by the entry that starts
|
|
1645
1899
|
* the app, so the component only has to render it.
|
|
1900
|
+
*
|
|
1901
|
+
* # Why the render anchor is here
|
|
1902
|
+
*
|
|
1903
|
+
* `RenderProvider` fixes the render's instant, time zone and random seed once,
|
|
1904
|
+
* writes them into the markup and reads them back on the client, which is what
|
|
1905
|
+
* makes `useRenderedAt` and `useRandom` agree across hydration. An application
|
|
1906
|
+
* that did not render one got no error — it got the old behaviour, which is a
|
|
1907
|
+
* silent hydration mismatch in every page with a clock or a shuffle on it. A
|
|
1908
|
+
* guarantee that depends on remembering to opt in is not one, so the router
|
|
1909
|
+
* provides it and an application that wants different values *replaces* it by
|
|
1910
|
+
* rendering its own inside this one. See ubugeeei-prod/uf#559.
|
|
1911
|
+
*
|
|
1912
|
+
* Above `RouterProvider` rather than below it, because the route's own
|
|
1913
|
+
* modules — layouts as much as pages — are things that read a clock, and a
|
|
1914
|
+
* masthead showing the time is the first component anybody writes that does.
|
|
1915
|
+
*
|
|
1916
|
+
* It is safe above a root layout that renders `<html>` only because the
|
|
1917
|
+
* envelope's carrier is a `<meta>`: React hoists one into the head of a
|
|
1918
|
+
* document it rendered, and to the front of a tree that is not one, where uf's
|
|
1919
|
+
* shell lifts it into the head it wrote itself. `packages/hooks/render.js` has
|
|
1920
|
+
* the argument, and it is the reason the carrier is no longer a `<script>`.
|
|
1646
1921
|
*/
|
|
1647
1922
|
export function routerView(root: string): React.ComponentType<AppProps> {
|
|
1648
1923
|
void root;
|
|
1649
|
-
component App(url: string, initial
|
|
1924
|
+
component App(url: string, initial?: ResolvedRoute, flight?: Promise<FlightRoot>) {
|
|
1650
1925
|
return (
|
|
1651
|
-
<
|
|
1652
|
-
<
|
|
1653
|
-
|
|
1926
|
+
<RenderProvider>
|
|
1927
|
+
<RouterProvider url={url} initial={initial} flight={flight}>
|
|
1928
|
+
<RouteView />
|
|
1929
|
+
</RouterProvider>
|
|
1930
|
+
</RenderProvider>
|
|
1654
1931
|
);
|
|
1655
1932
|
}
|
|
1656
1933
|
return App;
|
|
1657
1934
|
}
|
|
1658
1935
|
|
|
1659
|
-
/** Stop rendering the current page and show the not-found page instead. */
|
|
1660
|
-
export function notFound(): empty {
|
|
1661
|
-
throw new NotFoundError();
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
|
-
/** Stop rendering the current page and show the error boundary, as a 401. */
|
|
1665
|
-
export function unauthorized(): empty {
|
|
1666
|
-
throw new UnauthorizedError();
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
|
-
/** Stop rendering the current page and show the error boundary, as a 403. */
|
|
1670
|
-
export function forbidden(): empty {
|
|
1671
|
-
throw new ForbiddenError();
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
/** Stop rendering the current page and send the visitor elsewhere. */
|
|
1675
|
-
export function redirect(to: string): empty {
|
|
1676
|
-
throw new RedirectError(to, false);
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
/** `redirect`, with a permanent status. */
|
|
1680
|
-
export function permanentRedirect(to: string): empty {
|
|
1681
|
-
throw new RedirectError(to, true);
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
1936
|
/**
|
|
1685
1937
|
* Whether the app is being rendered on the server.
|
|
1686
1938
|
*
|