@uniflowed/router 0.0.0-alpha.8 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/action.js +324 -0
- package/client.js +261 -7
- package/handler.js +113 -99
- package/http-client.js +104 -0
- package/index.js +49 -6
- package/instrumentation.js +92 -0
- package/internal/action-endpoint.js +438 -0
- package/internal/action-wire.js +608 -0
- package/internal/base-path.js +175 -0
- package/internal/boundaries.js +481 -0
- package/internal/boundary-data.js +88 -0
- package/internal/compose.js +490 -0
- package/internal/deployment.js +160 -0
- package/internal/devtools.js +131 -0
- package/internal/diagnostics.js +169 -0
- package/internal/error-view.js +193 -0
- package/internal/flight-browser.js +242 -0
- package/internal/flight-chunks.js +205 -0
- package/internal/flight-rows.js +135 -0
- package/internal/flight-ssr.js +91 -0
- package/internal/flight.js +192 -0
- package/internal/head.js +219 -0
- package/internal/hydration.js +1085 -0
- package/internal/inspector.js +626 -0
- package/internal/native-links.js +67 -0
- package/internal/native-tree.js +89 -0
- package/internal/navigation-cache.js +181 -0
- package/internal/payload-rows.js +270 -0
- package/internal/payload.js +685 -0
- package/internal/prepare-document.js +54 -0
- package/internal/react-version.js +77 -0
- package/internal/resolve.js +1617 -0
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +478 -0
- package/internal/runtime.js +1597 -1329
- package/internal/server-instrumentation.js +12 -0
- package/internal/server-route.js +58 -0
- package/internal/shell.js +125 -0
- package/internal/stream.js +754 -21
- package/middleware.js +161 -22
- package/native-navigation.js +217 -0
- package/native.js +416 -0
- package/package.json +48 -7
- package/routing.js +51 -0
- package/rsc-client.js +120 -0
- package/rsc-ssr.js +637 -0
- package/rsc.js +402 -0
- package/server-components.js +159 -0
- package/server.js +254 -106
package/native.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/router/native`: the React Native navigator contract.
|
|
4
|
+
//
|
|
5
|
+
// Native routes use the same generated table as the web router, but not the
|
|
6
|
+
// browser's URL bar or History API. This adapter keeps the shared part small:
|
|
7
|
+
// it resolves a uf route path against the table, refuses destinations the
|
|
8
|
+
// native bundle cannot render, and hands a navigator-shaped event to the app's
|
|
9
|
+
// own navigation runtime. Rendering the tree is still the renderer/host-config
|
|
10
|
+
// half of the React Native target.
|
|
11
|
+
//
|
|
12
|
+
// # No interception here, deliberately
|
|
13
|
+
//
|
|
14
|
+
// An intercepting route — a slot's `(.)photo` — is not one of this adapter's
|
|
15
|
+
// features, and that is a decision rather than a gap. What interception does
|
|
16
|
+
// is a browser's: the address bar says `/feed/photo/1` while the page
|
|
17
|
+
// underneath stays on screen with the photo in a slot over it, and a reload
|
|
18
|
+
// renders the page the URL names instead. A native navigator has no address bar
|
|
19
|
+
// for the screen to disagree with, and it already has the thing interception
|
|
20
|
+
// imitates — a screen presented modally over the one below it, with its own
|
|
21
|
+
// back gesture. Doing both would give a native app two answers to "open this
|
|
22
|
+
// over the feed", one of them the router's and one the navigator's.
|
|
23
|
+
//
|
|
24
|
+
// So `resolveNativeNavigation` matches `table.routes` and nothing else:
|
|
25
|
+
// `/feed/photo/1` resolves to the ordinary `app/feed/photo/[id]` route every
|
|
26
|
+
// time, a slot's `intercepts` are never read, and presenting that screen as a
|
|
27
|
+
// modal is the navigator's call, made where the rest of its presentation is.
|
|
28
|
+
|
|
29
|
+
import type { RouteParams, RouteRecord, RouteTable } from "./internal/routing.js";
|
|
30
|
+
import { hasClientPage, matchRoute, splitUrl } from "./internal/routing.js";
|
|
31
|
+
import { nativeLinkHref } from "./internal/native-links.js";
|
|
32
|
+
export { createNativeLinking } from "./internal/native-links.js";
|
|
33
|
+
export type { NativeLinkSource } from "./internal/native-links.js";
|
|
34
|
+
|
|
35
|
+
export type NativeNavigationKind = "push" | "replace" | "prefetch";
|
|
36
|
+
|
|
37
|
+
export type NativeNavigationEvent = {|
|
|
38
|
+
readonly kind: NativeNavigationKind,
|
|
39
|
+
readonly href: string,
|
|
40
|
+
readonly pathname: string,
|
|
41
|
+
readonly search: string,
|
|
42
|
+
readonly route: string,
|
|
43
|
+
readonly params: RouteParams,
|
|
44
|
+
|};
|
|
45
|
+
|
|
46
|
+
export type NativeScreenMap = {
|
|
47
|
+
readonly [route: string]: string,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type NativeScreenEntry = {|
|
|
51
|
+
readonly screen: string,
|
|
52
|
+
readonly route: string,
|
|
53
|
+
readonly file: string,
|
|
54
|
+
|};
|
|
55
|
+
|
|
56
|
+
export type NativeScreenManifest = {|
|
|
57
|
+
readonly screens: NativeScreenMap,
|
|
58
|
+
readonly entries: $ReadOnlyArray<NativeScreenEntry>,
|
|
59
|
+
|};
|
|
60
|
+
|
|
61
|
+
export type NativeScreenNameRoute = {|
|
|
62
|
+
readonly path: string,
|
|
63
|
+
readonly file: string,
|
|
64
|
+
|};
|
|
65
|
+
|
|
66
|
+
export type NativeScreenManifestOptions = {|
|
|
67
|
+
readonly name?: (route: NativeScreenNameRoute) => string,
|
|
68
|
+
|};
|
|
69
|
+
|
|
70
|
+
export type NativeScreenPayload = {|
|
|
71
|
+
readonly screen: string,
|
|
72
|
+
readonly href: string,
|
|
73
|
+
readonly pathname: string,
|
|
74
|
+
readonly search: string,
|
|
75
|
+
readonly route: string,
|
|
76
|
+
readonly params: RouteParams,
|
|
77
|
+
|};
|
|
78
|
+
|
|
79
|
+
export type NativeScreenNavigationState = {|
|
|
80
|
+
readonly params: RouteParams,
|
|
81
|
+
readonly href: string,
|
|
82
|
+
readonly pathname: string,
|
|
83
|
+
readonly search: string,
|
|
84
|
+
readonly route: string,
|
|
85
|
+
|};
|
|
86
|
+
|
|
87
|
+
export type NativeNavigator = {|
|
|
88
|
+
readonly push?: (event: NativeNavigationEvent) => mixed | Promise<mixed>,
|
|
89
|
+
readonly replace?: (event: NativeNavigationEvent) => mixed | Promise<mixed>,
|
|
90
|
+
readonly prefetch?: (event: NativeNavigationEvent) => mixed | Promise<mixed>,
|
|
91
|
+
|};
|
|
92
|
+
|
|
93
|
+
export type NativeScreenNavigator = {|
|
|
94
|
+
readonly push?: (screen: string, state: NativeScreenNavigationState) => mixed | Promise<mixed>,
|
|
95
|
+
readonly replace?: (screen: string, state: NativeScreenNavigationState) => mixed | Promise<mixed>,
|
|
96
|
+
readonly prefetch?: (
|
|
97
|
+
screen: string,
|
|
98
|
+
state: NativeScreenNavigationState,
|
|
99
|
+
) => mixed | Promise<mixed>,
|
|
100
|
+
|};
|
|
101
|
+
|
|
102
|
+
export type NativeRouter = {|
|
|
103
|
+
readonly push: (to: string) => Promise<void>,
|
|
104
|
+
readonly replace: (to: string) => Promise<void>,
|
|
105
|
+
readonly prefetch: (to: string) => Promise<void>,
|
|
106
|
+
readonly resolve: (to: string, kind?: NativeNavigationKind) => NativeNavigationEvent,
|
|
107
|
+
|};
|
|
108
|
+
|
|
109
|
+
export type NativeNavigationErrorCode =
|
|
110
|
+
| "duplicate-screen"
|
|
111
|
+
| "external-url"
|
|
112
|
+
| "fragment"
|
|
113
|
+
| "relative-url"
|
|
114
|
+
| "missing-route"
|
|
115
|
+
| "missing-screen"
|
|
116
|
+
| "server-only-route"
|
|
117
|
+
| "missing-navigator-method";
|
|
118
|
+
|
|
119
|
+
export class NativeNavigationError extends Error {
|
|
120
|
+
code: NativeNavigationErrorCode;
|
|
121
|
+
href: string;
|
|
122
|
+
route: ?string;
|
|
123
|
+
|
|
124
|
+
constructor(code: NativeNavigationErrorCode, message: string, href: string, route?: ?string) {
|
|
125
|
+
super(message);
|
|
126
|
+
this.name = "NativeNavigationError";
|
|
127
|
+
this.code = code;
|
|
128
|
+
this.href = href;
|
|
129
|
+
this.route = route ?? null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createNativeRouter(
|
|
134
|
+
table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
|
|
135
|
+
navigator: NativeNavigator,
|
|
136
|
+
): NativeRouter {
|
|
137
|
+
const resolve = (to: string, kind?: NativeNavigationKind = "push"): NativeNavigationEvent =>
|
|
138
|
+
resolveNativeNavigation(table, to, kind);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
resolve,
|
|
142
|
+
push: async (to) => {
|
|
143
|
+
const event = resolve(to, "push");
|
|
144
|
+
await invokeNavigator(navigator, event);
|
|
145
|
+
},
|
|
146
|
+
replace: async (to) => {
|
|
147
|
+
const event = resolve(to, "replace");
|
|
148
|
+
await invokeNavigator(navigator, event);
|
|
149
|
+
},
|
|
150
|
+
prefetch: async (to) => {
|
|
151
|
+
const event = resolve(to, "prefetch");
|
|
152
|
+
await loadNativeRoute(table, event.pathname);
|
|
153
|
+
if (typeof navigator.prefetch === "function") {
|
|
154
|
+
await navigator.prefetch(event);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function createNativeScreenRouter(
|
|
161
|
+
table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
|
|
162
|
+
screens: NativeScreenMap,
|
|
163
|
+
navigator: NativeScreenNavigator,
|
|
164
|
+
): NativeRouter {
|
|
165
|
+
return createNativeRouter(table, {
|
|
166
|
+
push: (event) => invokeScreenNavigator(navigator, screens, event),
|
|
167
|
+
replace: (event) => invokeScreenNavigator(navigator, screens, event),
|
|
168
|
+
prefetch: (event) => {
|
|
169
|
+
if (typeof navigator.prefetch !== "function") return;
|
|
170
|
+
return invokeScreenNavigator(navigator, screens, event);
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function createNativeScreenManifest(
|
|
176
|
+
table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
|
|
177
|
+
options?: NativeScreenManifestOptions,
|
|
178
|
+
): NativeScreenManifest {
|
|
179
|
+
const screens: { [string]: string } = {};
|
|
180
|
+
const entries: Array<NativeScreenEntry> = [];
|
|
181
|
+
const seen = new Map<string, string>();
|
|
182
|
+
for (const route of table.routes) {
|
|
183
|
+
if (!hasClientPage(route)) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const screen = screenNameFor(route, options);
|
|
187
|
+
const already = seen.get(screen);
|
|
188
|
+
if (already != null) {
|
|
189
|
+
throw new NativeNavigationError(
|
|
190
|
+
"duplicate-screen",
|
|
191
|
+
`@uniflowed/router/native: ${route.path} and ${already} both map to native screen ${screen}`,
|
|
192
|
+
route.path,
|
|
193
|
+
route.path,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
seen.set(screen, route.path);
|
|
197
|
+
screens[route.path] = screen;
|
|
198
|
+
entries.push({ screen, route: route.path, file: route.file });
|
|
199
|
+
}
|
|
200
|
+
return { screens, entries };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function nativeScreenName(routePath: string): string {
|
|
204
|
+
const segments = routePath.split("/").filter((segment) => segment !== "");
|
|
205
|
+
if (segments.length === 0) {
|
|
206
|
+
return "Home";
|
|
207
|
+
}
|
|
208
|
+
const name = segments
|
|
209
|
+
.map((segment) => {
|
|
210
|
+
if (segment.startsWith(":") && segment.endsWith("*")) {
|
|
211
|
+
return `All${titlePart(segment.slice(1, -1))}`;
|
|
212
|
+
}
|
|
213
|
+
if (segment.startsWith(":")) {
|
|
214
|
+
return `By${titlePart(segment.slice(1))}`;
|
|
215
|
+
}
|
|
216
|
+
return titlePart(segment);
|
|
217
|
+
})
|
|
218
|
+
.join("");
|
|
219
|
+
return name === "" ? "Screen" : name;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function resolveNativeNavigation(
|
|
223
|
+
table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
|
|
224
|
+
to: string,
|
|
225
|
+
kind?: NativeNavigationKind = "push",
|
|
226
|
+
): NativeNavigationEvent {
|
|
227
|
+
const href = normalizeNativeHref(to, table);
|
|
228
|
+
const { pathname, search } = splitUrl(href);
|
|
229
|
+
const matched = matchRoute(table.routes, pathname);
|
|
230
|
+
if (matched == null) {
|
|
231
|
+
throw new NativeNavigationError(
|
|
232
|
+
"missing-route",
|
|
233
|
+
`@uniflowed/router/native: ${href} does not match a native route in this table`,
|
|
234
|
+
href,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (!hasClientPage(matched.route)) {
|
|
238
|
+
throw new NativeNavigationError(
|
|
239
|
+
"server-only-route",
|
|
240
|
+
`@uniflowed/router/native: ${matched.route.path} is not in the native route table; ` +
|
|
241
|
+
"it has no page module for this bundle",
|
|
242
|
+
href,
|
|
243
|
+
matched.route.path,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
kind,
|
|
248
|
+
href,
|
|
249
|
+
pathname,
|
|
250
|
+
search,
|
|
251
|
+
route: matched.route.path,
|
|
252
|
+
params: matched.params,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function nativeScreenPayload(
|
|
257
|
+
event: NativeNavigationEvent,
|
|
258
|
+
screens: NativeScreenMap,
|
|
259
|
+
): NativeScreenPayload {
|
|
260
|
+
const screen = screens[event.route];
|
|
261
|
+
if (typeof screen !== "string" || screen === "") {
|
|
262
|
+
throw new NativeNavigationError(
|
|
263
|
+
"missing-screen",
|
|
264
|
+
`@uniflowed/router/native: ${event.route} has no native screen mapping`,
|
|
265
|
+
event.href,
|
|
266
|
+
event.route,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
screen,
|
|
271
|
+
href: event.href,
|
|
272
|
+
pathname: event.pathname,
|
|
273
|
+
search: event.search,
|
|
274
|
+
route: event.route,
|
|
275
|
+
params: event.params,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function nativeScreenNavigationState(
|
|
280
|
+
payload: NativeScreenPayload,
|
|
281
|
+
): NativeScreenNavigationState {
|
|
282
|
+
return {
|
|
283
|
+
params: payload.params,
|
|
284
|
+
href: payload.href,
|
|
285
|
+
pathname: payload.pathname,
|
|
286
|
+
search: payload.search,
|
|
287
|
+
route: payload.route,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function normalizeNativeHref(
|
|
292
|
+
to: string,
|
|
293
|
+
table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
|
|
294
|
+
): string {
|
|
295
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(to) || to.startsWith("//")) {
|
|
296
|
+
const claimed = nativeLinkHref(table, to);
|
|
297
|
+
if (claimed != null) return claimed;
|
|
298
|
+
throw new NativeNavigationError(
|
|
299
|
+
"external-url",
|
|
300
|
+
`@uniflowed/router/native: ${to} is an external URL, and a native navigator needs an app route`,
|
|
301
|
+
to,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
if (!to.startsWith("/")) {
|
|
305
|
+
throw new NativeNavigationError(
|
|
306
|
+
"relative-url",
|
|
307
|
+
`@uniflowed/router/native: ${to} is relative, and native navigation has no document URL to resolve it against`,
|
|
308
|
+
to,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
if (to.includes("#")) {
|
|
312
|
+
throw new NativeNavigationError(
|
|
313
|
+
"fragment",
|
|
314
|
+
`@uniflowed/router/native: ${to} contains a fragment, and native routes do not have document anchors`,
|
|
315
|
+
to,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return splitUrl(to).pathname + splitUrl(to).search;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function screenNameFor(
|
|
322
|
+
route: RouteRecord<mixed, mixed, mixed, mixed, mixed>,
|
|
323
|
+
options?: NativeScreenManifestOptions,
|
|
324
|
+
): string {
|
|
325
|
+
const screen =
|
|
326
|
+
options?.name?.({ path: route.path, file: route.file }) ?? nativeScreenName(route.path);
|
|
327
|
+
if (screen === "") {
|
|
328
|
+
throw new NativeNavigationError(
|
|
329
|
+
"missing-screen",
|
|
330
|
+
`@uniflowed/router/native: ${route.path} mapped to an empty native screen name`,
|
|
331
|
+
route.path,
|
|
332
|
+
route.path,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
return screen;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function titlePart(segment: string): string {
|
|
339
|
+
const cleaned = segment
|
|
340
|
+
.replace(/^\[+|\]+$/g, "")
|
|
341
|
+
.replace(/[^A-Za-z0-9]+/g, " ")
|
|
342
|
+
.trim();
|
|
343
|
+
if (cleaned === "") {
|
|
344
|
+
return "Segment";
|
|
345
|
+
}
|
|
346
|
+
return cleaned
|
|
347
|
+
.split(/\s+/)
|
|
348
|
+
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
|
349
|
+
.join("");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function invokeNavigator(
|
|
353
|
+
navigator: NativeNavigator,
|
|
354
|
+
event: NativeNavigationEvent,
|
|
355
|
+
): Promise<void> {
|
|
356
|
+
if (event.kind === "push") {
|
|
357
|
+
if (typeof navigator.push !== "function") {
|
|
358
|
+
throw missingMethod(event);
|
|
359
|
+
}
|
|
360
|
+
await navigator.push(event);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (event.kind === "replace") {
|
|
364
|
+
if (typeof navigator.replace !== "function") {
|
|
365
|
+
throw missingMethod(event);
|
|
366
|
+
}
|
|
367
|
+
await navigator.replace(event);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function missingMethod(event: NativeNavigationEvent): NativeNavigationError {
|
|
372
|
+
return new NativeNavigationError(
|
|
373
|
+
"missing-navigator-method",
|
|
374
|
+
`@uniflowed/router/native: the native navigator does not implement ${event.kind}()`,
|
|
375
|
+
event.href,
|
|
376
|
+
event.route,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function invokeScreenNavigator(
|
|
381
|
+
navigator: NativeScreenNavigator,
|
|
382
|
+
screens: NativeScreenMap,
|
|
383
|
+
event: NativeNavigationEvent,
|
|
384
|
+
): Promise<void> {
|
|
385
|
+
const payload = nativeScreenPayload(event, screens);
|
|
386
|
+
const state = nativeScreenNavigationState(payload);
|
|
387
|
+
if (event.kind === "push") {
|
|
388
|
+
if (typeof navigator.push !== "function") {
|
|
389
|
+
throw missingMethod(event);
|
|
390
|
+
}
|
|
391
|
+
await navigator.push(payload.screen, state);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (event.kind === "replace") {
|
|
395
|
+
if (typeof navigator.replace !== "function") {
|
|
396
|
+
throw missingMethod(event);
|
|
397
|
+
}
|
|
398
|
+
await navigator.replace(payload.screen, state);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (typeof navigator.prefetch !== "function") {
|
|
402
|
+
throw missingMethod(event);
|
|
403
|
+
}
|
|
404
|
+
await navigator.prefetch(payload.screen, state);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function loadNativeRoute(
|
|
408
|
+
table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
|
|
409
|
+
pathname: string,
|
|
410
|
+
): Promise<void> {
|
|
411
|
+
const matched = matchRoute(table.routes, pathname);
|
|
412
|
+
if (matched == null || !hasClientPage(matched.route)) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
await Promise.all([matched.route.page?.(), ...matched.route.layouts.map((layout) => layout())]);
|
|
416
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,26 +11,67 @@
|
|
|
11
11
|
"directory": "packages/router"
|
|
12
12
|
},
|
|
13
13
|
"exports": {
|
|
14
|
-
".":
|
|
14
|
+
".": {
|
|
15
|
+
"react-server": "./server-components.js",
|
|
16
|
+
"default": "./index.js"
|
|
17
|
+
},
|
|
18
|
+
"./action": "./action.js",
|
|
15
19
|
"./client": "./client.js",
|
|
20
|
+
"./native": "./native.js",
|
|
21
|
+
"./rsc": "./rsc.js",
|
|
22
|
+
"./rsc/client": "./rsc-client.js",
|
|
23
|
+
"./rsc/ssr": "./rsc-ssr.js",
|
|
16
24
|
"./server": "./server.js",
|
|
25
|
+
"./routing": "./routing.js",
|
|
17
26
|
"./package.json": "./package.json",
|
|
18
27
|
"./handler": "./handler.js",
|
|
19
|
-
"./middleware": "./middleware.js"
|
|
28
|
+
"./middleware": "./middleware.js",
|
|
29
|
+
"./native-navigation": "./native-navigation.js",
|
|
30
|
+
"./http-client": "./http-client.js",
|
|
31
|
+
"./instrumentation": "./instrumentation.js"
|
|
20
32
|
},
|
|
21
33
|
"files": [
|
|
34
|
+
"action.js",
|
|
22
35
|
"client.js",
|
|
23
36
|
"handler.js",
|
|
24
37
|
"index.js",
|
|
25
38
|
"internal",
|
|
26
39
|
"middleware.js",
|
|
27
|
-
"
|
|
40
|
+
"native.js",
|
|
41
|
+
"routing.js",
|
|
42
|
+
"rsc-client.js",
|
|
43
|
+
"rsc-ssr.js",
|
|
44
|
+
"rsc.js",
|
|
45
|
+
"server-components.js",
|
|
46
|
+
"server.js",
|
|
47
|
+
"native-navigation.js",
|
|
48
|
+
"http-client.js",
|
|
49
|
+
"instrumentation.js",
|
|
50
|
+
"!*.test.js"
|
|
28
51
|
],
|
|
29
52
|
"peerDependencies": {
|
|
30
|
-
"react": ">=19",
|
|
31
|
-
"react-dom": ">=19"
|
|
53
|
+
"react": ">=19.2.3",
|
|
54
|
+
"react-dom": ">=19.2.3",
|
|
55
|
+
"react-server-dom-parcel": ">=19.3.0",
|
|
56
|
+
"react-native": ">=0.86",
|
|
57
|
+
"@react-navigation/native": "^7.0.0"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"react-dom": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"react-server-dom-parcel": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"react-native": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"@react-navigation/native": {
|
|
70
|
+
"optional": true
|
|
71
|
+
}
|
|
32
72
|
},
|
|
33
73
|
"dependencies": {
|
|
34
|
-
"@uniflowed/
|
|
74
|
+
"@uniflowed/hooks": "0.1.0",
|
|
75
|
+
"@uniflowed/server": "0.1.0"
|
|
35
76
|
}
|
|
36
77
|
}
|
package/routing.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/router/routing`: React-free route table helpers.
|
|
4
|
+
//
|
|
5
|
+
// Import this from server-oriented modules that need matching, URL building,
|
|
6
|
+
// or router control errors without importing the client router, React
|
|
7
|
+
// components, or hooks.
|
|
8
|
+
|
|
9
|
+
export type {
|
|
10
|
+
ErrorBoundary,
|
|
11
|
+
LoadingRecord,
|
|
12
|
+
NotFoundBoundary,
|
|
13
|
+
RouteError,
|
|
14
|
+
RouteMatch,
|
|
15
|
+
RouteModule,
|
|
16
|
+
RouteParamSpec,
|
|
17
|
+
RouteParams,
|
|
18
|
+
RouteRecord,
|
|
19
|
+
RouteTable,
|
|
20
|
+
SearchParams,
|
|
21
|
+
SlotRecord,
|
|
22
|
+
SlotRouteRecord,
|
|
23
|
+
TemplateRecord,
|
|
24
|
+
} from "./internal/routing.js";
|
|
25
|
+
export type {
|
|
26
|
+
ResolvedBoundarySummary,
|
|
27
|
+
ResolvedErrorBoundarySummary,
|
|
28
|
+
ResolvedRouteErrorSummary,
|
|
29
|
+
ResolvedRouteSummary,
|
|
30
|
+
ResolvedSlotSummary,
|
|
31
|
+
ResolvedTemplateSummary,
|
|
32
|
+
} from "./internal/resolved-summary.js";
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
ForbiddenError,
|
|
36
|
+
NotFoundError,
|
|
37
|
+
RedirectError,
|
|
38
|
+
UnauthorizedError,
|
|
39
|
+
buildRoute,
|
|
40
|
+
forbidden,
|
|
41
|
+
hasClientPage,
|
|
42
|
+
matchRoute,
|
|
43
|
+
notFound,
|
|
44
|
+
parseSearch,
|
|
45
|
+
permanentRedirect,
|
|
46
|
+
redirect,
|
|
47
|
+
routeErrorStatus,
|
|
48
|
+
splitUrl,
|
|
49
|
+
unauthorized,
|
|
50
|
+
} from "./internal/routing.js";
|
|
51
|
+
export { summarizeResolvedRoute } from "./internal/resolved-summary.js";
|
package/rsc-client.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/router/rsc/client`: starting, in the browser, an application that
|
|
4
|
+
// React Server Components rendered.
|
|
5
|
+
//
|
|
6
|
+
// `virtual:uf/client` imports this entry when routes render as Server
|
|
7
|
+
// Components, which is the default. It imports `@uniflowed/router/client` when
|
|
8
|
+
// they render from their modules (`app.rsc: false`) or into an empty shell
|
|
9
|
+
// (`app.rendering.modes: ["csr"]`). This is an entry of its own because it loads
|
|
10
|
+
// React's Flight client, `react-server-dom-parcel`. That package is an optional
|
|
11
|
+
// peer and needs React 19.3, while the rest of the router runs on the React
|
|
12
|
+
// 19.2.3 that Expo SDK 57 and React Native 0.87 ship (ubugeeei-prod/uf#992). A
|
|
13
|
+
// bundler resolves every import in the graph it is given, whether or not
|
|
14
|
+
// anything calls it, so a browser bundle that renders no Server Component
|
|
15
|
+
// leaves the package out only if nothing it imports names it.
|
|
16
|
+
// `crates/uf_lib/tests/package_surface.rs` holds the router to that.
|
|
17
|
+
//
|
|
18
|
+
// The same reason keeps the payload fetch out of `./internal/runtime.js`.
|
|
19
|
+
// Navigation there serves every application, so `hydrateFlight` hands it the
|
|
20
|
+
// fetch before the first render.
|
|
21
|
+
|
|
22
|
+
import * as React from "react";
|
|
23
|
+
import { StrictMode, startTransition } from "react";
|
|
24
|
+
import { hydrateRoot } from "react-dom/client";
|
|
25
|
+
|
|
26
|
+
import { type TrailingSlash, applicationPathOf } from "./internal/base-path.js";
|
|
27
|
+
import { ROOT_ID } from "./internal/document.js";
|
|
28
|
+
import {
|
|
29
|
+
fetchFlight,
|
|
30
|
+
installBrowserModules,
|
|
31
|
+
readDocumentPayload,
|
|
32
|
+
} from "./internal/flight-browser.js";
|
|
33
|
+
import { domObserver } from "./internal/payload-rows.js";
|
|
34
|
+
import { prepareDocumentForHydration } from "./internal/prepare-document.js";
|
|
35
|
+
import { requireServerComponentsReact } from "./internal/react-version.js";
|
|
36
|
+
import {
|
|
37
|
+
type AppProps,
|
|
38
|
+
type Navigation,
|
|
39
|
+
installFlightFetch,
|
|
40
|
+
installNavigation,
|
|
41
|
+
installRouting,
|
|
42
|
+
installStaleTime,
|
|
43
|
+
} from "./internal/runtime.js";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Hydrate a document React Server Components rendered.
|
|
47
|
+
*
|
|
48
|
+
* `hydrate` in `./client.js` resolves the route from its modules and renders it
|
|
49
|
+
* again over the server's markup. This one resolves nothing and imports no route
|
|
50
|
+
* module: the document carries the Flight payload its tree was rendered from,
|
|
51
|
+
* React's own client reads it, and the tree the browser hydrates is the tree the
|
|
52
|
+
* server rendered — a Server Component is markup and a reference, and a client
|
|
53
|
+
* component is the one kind of module this page loads. See
|
|
54
|
+
* ubugeeei-prod/uf#519.
|
|
55
|
+
*
|
|
56
|
+
* The payload is read while the document is still arriving. Row 0 is in the
|
|
57
|
+
* shell, so hydration starts as soon as the module script runs, and every row
|
|
58
|
+
* after it lands in a later chunk that the reader picks up as it is parsed — so
|
|
59
|
+
* a boundary the server completes after hydration began resolves then, with no
|
|
60
|
+
* second request.
|
|
61
|
+
*
|
|
62
|
+
* Everything else is `hydrate`'s, for the reasons written there: the navigation
|
|
63
|
+
* mode is installed before the first render, the development hydration report
|
|
64
|
+
* captures the server's markup before React repairs it, and Strict Mode wraps
|
|
65
|
+
* the root.
|
|
66
|
+
*
|
|
67
|
+
* On a React older than 19.3 it refuses before it touches the page, naming the
|
|
68
|
+
* version it found; see `./internal/react-version.js`.
|
|
69
|
+
*/
|
|
70
|
+
export async function hydrateFlight(options: {|
|
|
71
|
+
readonly App: React.ComponentType<AppProps>,
|
|
72
|
+
readonly strictMode?: boolean,
|
|
73
|
+
readonly navigation?: Navigation,
|
|
74
|
+
readonly basePath?: string,
|
|
75
|
+
readonly trailingSlash?: TrailingSlash,
|
|
76
|
+
readonly staleTime?: number,
|
|
77
|
+
|}): Promise<void> {
|
|
78
|
+
requireServerComponentsReact("@uniflowed/router/rsc/client");
|
|
79
|
+
installNavigation(options.navigation ?? "client");
|
|
80
|
+
installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
|
|
81
|
+
installStaleTime(options.staleTime ?? 0);
|
|
82
|
+
installFlightFetch(fetchFlight);
|
|
83
|
+
installBrowserModules();
|
|
84
|
+
const flight = readDocumentPayload(document, domObserver(document));
|
|
85
|
+
|
|
86
|
+
// The route table has no base path in it, and the address bar does.
|
|
87
|
+
const url =
|
|
88
|
+
(applicationPathOf(window.location.pathname) ?? window.location.pathname) +
|
|
89
|
+
window.location.search;
|
|
90
|
+
const { App } = options;
|
|
91
|
+
const container = document.getElementById(ROOT_ID) ?? document;
|
|
92
|
+
prepareDocumentForHydration(document);
|
|
93
|
+
|
|
94
|
+
let recovery = null;
|
|
95
|
+
let restoreDevHead = null;
|
|
96
|
+
if (import.meta.hot != null) {
|
|
97
|
+
const { captureServerMarkup, hydrationErrorHandler, prepareDevHeadForHydration } =
|
|
98
|
+
await import("./internal/hydration.js");
|
|
99
|
+
restoreDevHead = prepareDevHeadForHydration(document);
|
|
100
|
+
recovery = hydrationErrorHandler(container, captureServerMarkup(container), document);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const tree = <App url={url} flight={flight} />;
|
|
104
|
+
|
|
105
|
+
startTransition(() => {
|
|
106
|
+
hydrateRoot(
|
|
107
|
+
container,
|
|
108
|
+
options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
|
|
109
|
+
recovery == null ? undefined : { onRecoverableError: recovery },
|
|
110
|
+
);
|
|
111
|
+
if (restoreDevHead != null) {
|
|
112
|
+
setTimeout(restoreDevHead, 250);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (import.meta.hot != null) {
|
|
117
|
+
const { reportDevtools } = await import("./internal/devtools.js");
|
|
118
|
+
reportDevtools(window);
|
|
119
|
+
}
|
|
120
|
+
}
|