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