@uniflowed/router 0.0.0-alpha.32 → 0.0.0-alpha.34

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/native.js ADDED
@@ -0,0 +1,408 @@
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
+
32
+ export type NativeNavigationKind = "push" | "replace" | "prefetch";
33
+
34
+ export type NativeNavigationEvent = {|
35
+ readonly kind: NativeNavigationKind,
36
+ readonly href: string,
37
+ readonly pathname: string,
38
+ readonly search: string,
39
+ readonly route: string,
40
+ readonly params: RouteParams,
41
+ |};
42
+
43
+ export type NativeScreenMap = {
44
+ readonly [route: string]: string,
45
+ };
46
+
47
+ export type NativeScreenEntry = {|
48
+ readonly screen: string,
49
+ readonly route: string,
50
+ readonly file: string,
51
+ |};
52
+
53
+ export type NativeScreenManifest = {|
54
+ readonly screens: NativeScreenMap,
55
+ readonly entries: $ReadOnlyArray<NativeScreenEntry>,
56
+ |};
57
+
58
+ export type NativeScreenNameRoute = {|
59
+ readonly path: string,
60
+ readonly file: string,
61
+ |};
62
+
63
+ export type NativeScreenManifestOptions = {|
64
+ readonly name?: (route: NativeScreenNameRoute) => string,
65
+ |};
66
+
67
+ export type NativeScreenPayload = {|
68
+ readonly screen: string,
69
+ readonly href: string,
70
+ readonly pathname: string,
71
+ readonly search: string,
72
+ readonly route: string,
73
+ readonly params: RouteParams,
74
+ |};
75
+
76
+ export type NativeScreenNavigationState = {|
77
+ readonly params: RouteParams,
78
+ readonly href: string,
79
+ readonly pathname: string,
80
+ readonly search: string,
81
+ readonly route: string,
82
+ |};
83
+
84
+ export type NativeNavigator = {|
85
+ readonly push?: (event: NativeNavigationEvent) => mixed | Promise<mixed>,
86
+ readonly replace?: (event: NativeNavigationEvent) => mixed | Promise<mixed>,
87
+ readonly prefetch?: (event: NativeNavigationEvent) => mixed | Promise<mixed>,
88
+ |};
89
+
90
+ export type NativeScreenNavigator = {|
91
+ readonly push?: (screen: string, state: NativeScreenNavigationState) => mixed | Promise<mixed>,
92
+ readonly replace?: (screen: string, state: NativeScreenNavigationState) => mixed | Promise<mixed>,
93
+ readonly prefetch?: (
94
+ screen: string,
95
+ state: NativeScreenNavigationState,
96
+ ) => mixed | Promise<mixed>,
97
+ |};
98
+
99
+ export type NativeRouter = {|
100
+ readonly push: (to: string) => Promise<void>,
101
+ readonly replace: (to: string) => Promise<void>,
102
+ readonly prefetch: (to: string) => Promise<void>,
103
+ readonly resolve: (to: string, kind?: NativeNavigationKind) => NativeNavigationEvent,
104
+ |};
105
+
106
+ export type NativeNavigationErrorCode =
107
+ | "duplicate-screen"
108
+ | "external-url"
109
+ | "fragment"
110
+ | "relative-url"
111
+ | "missing-route"
112
+ | "missing-screen"
113
+ | "server-only-route"
114
+ | "missing-navigator-method";
115
+
116
+ export class NativeNavigationError extends Error {
117
+ code: NativeNavigationErrorCode;
118
+ href: string;
119
+ route: ?string;
120
+
121
+ constructor(code: NativeNavigationErrorCode, message: string, href: string, route?: ?string) {
122
+ super(message);
123
+ this.name = "NativeNavigationError";
124
+ this.code = code;
125
+ this.href = href;
126
+ this.route = route ?? null;
127
+ }
128
+ }
129
+
130
+ export function createNativeRouter(
131
+ table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
132
+ navigator: NativeNavigator,
133
+ ): NativeRouter {
134
+ const resolve = (to: string, kind?: NativeNavigationKind = "push"): NativeNavigationEvent =>
135
+ resolveNativeNavigation(table, to, kind);
136
+
137
+ return {
138
+ resolve,
139
+ push: async (to) => {
140
+ const event = resolve(to, "push");
141
+ await invokeNavigator(navigator, event);
142
+ },
143
+ replace: async (to) => {
144
+ const event = resolve(to, "replace");
145
+ await invokeNavigator(navigator, event);
146
+ },
147
+ prefetch: async (to) => {
148
+ const event = resolve(to, "prefetch");
149
+ await loadNativeRoute(table, event.pathname);
150
+ if (typeof navigator.prefetch === "function") {
151
+ await navigator.prefetch(event);
152
+ }
153
+ },
154
+ };
155
+ }
156
+
157
+ export function createNativeScreenRouter(
158
+ table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
159
+ screens: NativeScreenMap,
160
+ navigator: NativeScreenNavigator,
161
+ ): NativeRouter {
162
+ return createNativeRouter(table, {
163
+ push: (event) => invokeScreenNavigator(navigator, screens, event),
164
+ replace: (event) => invokeScreenNavigator(navigator, screens, event),
165
+ prefetch: (event) => {
166
+ if (typeof navigator.prefetch !== "function") return;
167
+ return invokeScreenNavigator(navigator, screens, event);
168
+ },
169
+ });
170
+ }
171
+
172
+ export function createNativeScreenManifest(
173
+ table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
174
+ options?: NativeScreenManifestOptions,
175
+ ): NativeScreenManifest {
176
+ const screens: { [string]: string } = {};
177
+ const entries: Array<NativeScreenEntry> = [];
178
+ const seen = new Map<string, string>();
179
+ for (const route of table.routes) {
180
+ if (!hasClientPage(route)) {
181
+ continue;
182
+ }
183
+ const screen = screenNameFor(route, options);
184
+ const already = seen.get(screen);
185
+ if (already != null) {
186
+ throw new NativeNavigationError(
187
+ "duplicate-screen",
188
+ `@uniflowed/router/native: ${route.path} and ${already} both map to native screen ${screen}`,
189
+ route.path,
190
+ route.path,
191
+ );
192
+ }
193
+ seen.set(screen, route.path);
194
+ screens[route.path] = screen;
195
+ entries.push({ screen, route: route.path, file: route.file });
196
+ }
197
+ return { screens, entries };
198
+ }
199
+
200
+ export function nativeScreenName(routePath: string): string {
201
+ const segments = routePath.split("/").filter((segment) => segment !== "");
202
+ if (segments.length === 0) {
203
+ return "Home";
204
+ }
205
+ const name = segments
206
+ .map((segment) => {
207
+ if (segment.startsWith(":") && segment.endsWith("*")) {
208
+ return `All${titlePart(segment.slice(1, -1))}`;
209
+ }
210
+ if (segment.startsWith(":")) {
211
+ return `By${titlePart(segment.slice(1))}`;
212
+ }
213
+ return titlePart(segment);
214
+ })
215
+ .join("");
216
+ return name === "" ? "Screen" : name;
217
+ }
218
+
219
+ export function resolveNativeNavigation(
220
+ table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
221
+ to: string,
222
+ kind?: NativeNavigationKind = "push",
223
+ ): NativeNavigationEvent {
224
+ const href = normalizeNativeHref(to);
225
+ const { pathname, search } = splitUrl(href);
226
+ const matched = matchRoute(table.routes, pathname);
227
+ if (matched == null) {
228
+ throw new NativeNavigationError(
229
+ "missing-route",
230
+ `@uniflowed/router/native: ${href} does not match a native route in this table`,
231
+ href,
232
+ );
233
+ }
234
+ if (!hasClientPage(matched.route)) {
235
+ throw new NativeNavigationError(
236
+ "server-only-route",
237
+ `@uniflowed/router/native: ${matched.route.path} is not in the native route table; ` +
238
+ "it has no page module for this bundle",
239
+ href,
240
+ matched.route.path,
241
+ );
242
+ }
243
+ return {
244
+ kind,
245
+ href,
246
+ pathname,
247
+ search,
248
+ route: matched.route.path,
249
+ params: matched.params,
250
+ };
251
+ }
252
+
253
+ export function nativeScreenPayload(
254
+ event: NativeNavigationEvent,
255
+ screens: NativeScreenMap,
256
+ ): NativeScreenPayload {
257
+ const screen = screens[event.route];
258
+ if (typeof screen !== "string" || screen === "") {
259
+ throw new NativeNavigationError(
260
+ "missing-screen",
261
+ `@uniflowed/router/native: ${event.route} has no native screen mapping`,
262
+ event.href,
263
+ event.route,
264
+ );
265
+ }
266
+ return {
267
+ screen,
268
+ href: event.href,
269
+ pathname: event.pathname,
270
+ search: event.search,
271
+ route: event.route,
272
+ params: event.params,
273
+ };
274
+ }
275
+
276
+ export function nativeScreenNavigationState(
277
+ payload: NativeScreenPayload,
278
+ ): NativeScreenNavigationState {
279
+ return {
280
+ params: payload.params,
281
+ href: payload.href,
282
+ pathname: payload.pathname,
283
+ search: payload.search,
284
+ route: payload.route,
285
+ };
286
+ }
287
+
288
+ function normalizeNativeHref(to: string): string {
289
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(to) || to.startsWith("//")) {
290
+ throw new NativeNavigationError(
291
+ "external-url",
292
+ `@uniflowed/router/native: ${to} is an external URL, and a native navigator needs an app route`,
293
+ to,
294
+ );
295
+ }
296
+ if (!to.startsWith("/")) {
297
+ throw new NativeNavigationError(
298
+ "relative-url",
299
+ `@uniflowed/router/native: ${to} is relative, and native navigation has no document URL to resolve it against`,
300
+ to,
301
+ );
302
+ }
303
+ if (to.includes("#")) {
304
+ throw new NativeNavigationError(
305
+ "fragment",
306
+ `@uniflowed/router/native: ${to} contains a fragment, and native routes do not have document anchors`,
307
+ to,
308
+ );
309
+ }
310
+ return splitUrl(to).pathname + splitUrl(to).search;
311
+ }
312
+
313
+ function screenNameFor(
314
+ route: RouteRecord<mixed, mixed, mixed, mixed, mixed>,
315
+ options?: NativeScreenManifestOptions,
316
+ ): string {
317
+ const screen =
318
+ options?.name?.({ path: route.path, file: route.file }) ?? nativeScreenName(route.path);
319
+ if (screen === "") {
320
+ throw new NativeNavigationError(
321
+ "missing-screen",
322
+ `@uniflowed/router/native: ${route.path} mapped to an empty native screen name`,
323
+ route.path,
324
+ route.path,
325
+ );
326
+ }
327
+ return screen;
328
+ }
329
+
330
+ function titlePart(segment: string): string {
331
+ const cleaned = segment
332
+ .replace(/^\[+|\]+$/g, "")
333
+ .replace(/[^A-Za-z0-9]+/g, " ")
334
+ .trim();
335
+ if (cleaned === "") {
336
+ return "Segment";
337
+ }
338
+ return cleaned
339
+ .split(/\s+/)
340
+ .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
341
+ .join("");
342
+ }
343
+
344
+ async function invokeNavigator(
345
+ navigator: NativeNavigator,
346
+ event: NativeNavigationEvent,
347
+ ): Promise<void> {
348
+ if (event.kind === "push") {
349
+ if (typeof navigator.push !== "function") {
350
+ throw missingMethod(event);
351
+ }
352
+ await navigator.push(event);
353
+ return;
354
+ }
355
+ if (event.kind === "replace") {
356
+ if (typeof navigator.replace !== "function") {
357
+ throw missingMethod(event);
358
+ }
359
+ await navigator.replace(event);
360
+ }
361
+ }
362
+
363
+ function missingMethod(event: NativeNavigationEvent): NativeNavigationError {
364
+ return new NativeNavigationError(
365
+ "missing-navigator-method",
366
+ `@uniflowed/router/native: the native navigator does not implement ${event.kind}()`,
367
+ event.href,
368
+ event.route,
369
+ );
370
+ }
371
+
372
+ async function invokeScreenNavigator(
373
+ navigator: NativeScreenNavigator,
374
+ screens: NativeScreenMap,
375
+ event: NativeNavigationEvent,
376
+ ): Promise<void> {
377
+ const payload = nativeScreenPayload(event, screens);
378
+ const state = nativeScreenNavigationState(payload);
379
+ if (event.kind === "push") {
380
+ if (typeof navigator.push !== "function") {
381
+ throw missingMethod(event);
382
+ }
383
+ await navigator.push(payload.screen, state);
384
+ return;
385
+ }
386
+ if (event.kind === "replace") {
387
+ if (typeof navigator.replace !== "function") {
388
+ throw missingMethod(event);
389
+ }
390
+ await navigator.replace(payload.screen, state);
391
+ return;
392
+ }
393
+ if (typeof navigator.prefetch !== "function") {
394
+ throw missingMethod(event);
395
+ }
396
+ await navigator.prefetch(payload.screen, state);
397
+ }
398
+
399
+ async function loadNativeRoute(
400
+ table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
401
+ pathname: string,
402
+ ): Promise<void> {
403
+ const matched = matchRoute(table.routes, pathname);
404
+ if (matched == null || !hasClientPage(matched.route)) {
405
+ return;
406
+ }
407
+ await Promise.all([matched.route.page?.(), ...matched.route.layouts.map((layout) => layout())]);
408
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.32",
3
+ "version": "0.0.0-alpha.34",
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",
@@ -14,6 +14,7 @@
14
14
  ".": "./index.js",
15
15
  "./action": "./action.js",
16
16
  "./client": "./client.js",
17
+ "./native": "./native.js",
17
18
  "./server": "./server.js",
18
19
  "./routing": "./routing.js",
19
20
  "./package.json": "./package.json",
@@ -27,6 +28,7 @@
27
28
  "index.js",
28
29
  "internal",
29
30
  "middleware.js",
31
+ "native.js",
30
32
  "routing.js",
31
33
  "server.js",
32
34
  "!*.test.js"
@@ -36,7 +38,7 @@
36
38
  "react-dom": ">=19"
37
39
  },
38
40
  "dependencies": {
39
- "@uniflowed/hooks": "0.0.0-alpha.32",
40
- "@uniflowed/server": "0.0.0-alpha.32"
41
+ "@uniflowed/hooks": "0.0.0-alpha.34",
42
+ "@uniflowed/server": "0.0.0-alpha.34"
41
43
  }
42
44
  }
package/routing.js CHANGED
@@ -22,6 +22,14 @@ export type {
22
22
  SlotRouteRecord,
23
23
  TemplateRecord,
24
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";
25
33
 
26
34
  export {
27
35
  ForbiddenError,
@@ -40,3 +48,4 @@ export {
40
48
  splitUrl,
41
49
  unauthorized,
42
50
  } from "./internal/routing.js";
51
+ export { summarizeResolvedRoute } from "./internal/resolved-summary.js";
package/server.js CHANGED
@@ -181,13 +181,13 @@ export { DATA_ID, ROOT_ID } from "./internal/document.js";
181
181
  *
182
182
  * Re-exported rather than left to the host to import, and the reason is the
183
183
  * one thing about `@uniflowed/server` that is easy to get wrong: the request
184
- * lives in an `AsyncLocalStorage` belonging to *that module instance*. A host
185
- * that resolved `@uniflowed/server/host` for itself — from its own
186
- * `node_modules`, or from outside the bundle a build produced — would begin a
187
- * request in a second storage, and every `cookies()` in the application would
188
- * still be outside one, silently. Handing it out from here makes the copy the
189
- * host begins with the copy this module dispatches and renders with, because
190
- * it is the same import.
184
+ * store is shared by every copy of one *release* of that package, and no more.
185
+ * A host that resolved `@uniflowed/server/host` for itself — from its own
186
+ * `node_modules`, or from outside the bundle a build produced — may hold a
187
+ * different release, and would begin a request in a store the application
188
+ * never reads, so every `cookies()` in it would still be outside one, silently.
189
+ * Handing it out from here makes the copy the host begins with the copy this
190
+ * module dispatches and renders with, because it is the same import.
191
191
  *
192
192
  * `run` wraps everything that decides the response; `settle` is called once
193
193
  * the response has been *written*, which is a different line in every host.