@uniflowed/router 0.0.0-alpha.40 → 0.0.0-alpha.42

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.
@@ -0,0 +1,217 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import { Pressable } from "react-native";
4
+ import {
5
+ CommonActions,
6
+ NavigationContainer,
7
+ StackActions,
8
+ createNavigationContainerRef,
9
+ useRoute,
10
+ } from "@react-navigation/native";
11
+ import { resolveNativeNavigation, createNativeRouter, createNativeLinking } from "./native.js";
12
+ import type { NativeLinkSource } from "./native.js";
13
+ import type { RouteTable } from "./internal/routing.js";
14
+ import type {
15
+ NativeLayout,
16
+ NativeNode,
17
+ NativeTree,
18
+ NavigationPayload,
19
+ NavigationState,
20
+ } from "./internal/native-tree.js";
21
+ import { nativeTree, stateForPath, hrefFromState, paramsForPath } from "./internal/native-tree.js";
22
+
23
+ export interface NavigatorPair {
24
+ readonly Navigator: React.ComponentType<{ ... }>;
25
+ readonly Screen: React.ComponentType<{ ... }>;
26
+ }
27
+ export type FileRouter = {
28
+ readonly push: (href: string) => void,
29
+ readonly replace: (href: string) => void,
30
+ readonly back: () => void,
31
+ readonly prefetch: (href: string) => Promise<void>,
32
+ };
33
+ type Scope = {
34
+ tree: NativeTree,
35
+ node: NativeNode,
36
+ screens: Map<NativeNode, React.ComponentType<{ ... }>>,
37
+ stack: NavigatorPair,
38
+ tabs: ?NavigatorPair,
39
+ router: FileRouter,
40
+ };
41
+ const ScopeContext: React.Context<Scope | null> = React.createContext(null);
42
+
43
+ function useScope(): Scope {
44
+ const scope = React.useContext(ScopeContext);
45
+ if (scope == null)
46
+ throw new Error(
47
+ "Native navigation: mount createNativeNavigation().Root before using its navigator or hooks",
48
+ );
49
+ return scope;
50
+ }
51
+
52
+ /** The application supplies its own React Navigation navigator factories. */
53
+ export function createNativeNavigation(options: {
54
+ table: RouteTable<>,
55
+ layouts: $ReadOnlyArray<NativeLayout>,
56
+ stack: NavigatorPair,
57
+ tabs?: NavigatorPair,
58
+ initialHref?: string,
59
+ links?: NativeLinkSource,
60
+ }): { Root: React.ComponentType<{ ... }>, router: FileRouter, linking: { ... } } {
61
+ const { table, layouts, stack, tabs } = options;
62
+ const tree = nativeTree(table, layouts);
63
+ const ref = createNavigationContainerRef();
64
+ const screens = new Map<NativeNode, React.ComponentType<{ ... }>>();
65
+ const resolve = (href: string): { names: $ReadOnlyArray<string>, payload: NavigationPayload } => {
66
+ const event = resolveNativeNavigation(table, href);
67
+ const names = tree.paths.get(event.route);
68
+ if (names == null) throw new Error(`Native navigation: no screen for ${event.route}`);
69
+ return { names, payload: { ufHref: event.href, ufParams: event.params } };
70
+ };
71
+ function actionFor(
72
+ names: $ReadOnlyArray<string>,
73
+ payload: NavigationPayload,
74
+ kind: "push" | "replace",
75
+ ): { readonly [string]: mixed } {
76
+ if (!ref.isReady()) throw new Error("Native navigation: NavigationContainer is not ready");
77
+ let state: NavigationState = ref.getRootState();
78
+ let depth = 0;
79
+ while (depth < names.length - 1) {
80
+ const active = state.routes[state.index ?? 0];
81
+ if (active?.name !== names[depth] || active?.state == null) break;
82
+ state = active.state;
83
+ depth += 1;
84
+ }
85
+ const name = names[depth];
86
+ const params = paramsForPath(names.slice(depth + 1), payload);
87
+ const action =
88
+ state.type === "stack"
89
+ ? StackActions[kind](name, params)
90
+ : CommonActions.navigate({ name, params });
91
+ return { ...action, target: state.key };
92
+ }
93
+ function navigate(href: string, kind: "push" | "replace"): void {
94
+ const { names, payload } = resolve(href);
95
+ ref.dispatch(actionFor(names, payload, kind));
96
+ }
97
+ const router: FileRouter = {
98
+ push: (href) => navigate(href, "push"),
99
+ replace: (href) => navigate(href, "replace"),
100
+ back: () => {
101
+ if (ref.canGoBack()) ref.goBack();
102
+ },
103
+ prefetch: createNativeRouter(table, {}).prefetch,
104
+ };
105
+ const source = options.links == null ? null : createNativeLinking(table, options.links);
106
+ const linking = {
107
+ prefixes: [""],
108
+ getInitialURL: source?.getInitialURL ?? (async () => null),
109
+ subscribe: source?.subscribe ?? (() => () => {}),
110
+ getStateFromPath: (href: string) => {
111
+ const { names, payload } = resolve(
112
+ href.startsWith("/") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(href) ? href : `/${href}`,
113
+ );
114
+ return stateForPath(names, payload);
115
+ },
116
+ getPathFromState: (state: NavigationState) => hrefFromState(state) ?? "/",
117
+ getActionFromState: (state: NavigationState) => {
118
+ const href = hrefFromState(state);
119
+ if (href == null) return undefined;
120
+ const { names, payload } = resolve(href);
121
+ return actionFor(names, payload, "push");
122
+ },
123
+ };
124
+ function build(node: NativeNode): React.ComponentType<{ ... }> {
125
+ const load = node.module;
126
+ const Content =
127
+ load == null
128
+ ? Stack
129
+ : React.lazy(async () => {
130
+ const module: $FlowFixMe = await load();
131
+ const Component = module.default ?? (node.layout ? module.Layout : module.Page);
132
+ if (
133
+ typeof Component !== "function" &&
134
+ (Component == null || typeof Component !== "object")
135
+ ) {
136
+ throw new Error(
137
+ `Native navigation: ${node.name} must export ${node.layout ? "Layout" : "Page"} or default`,
138
+ );
139
+ }
140
+ return { default: Component };
141
+ });
142
+ function Screen(props: { ... }): React.Node {
143
+ return (
144
+ <ScopeContext value={{ tree, node, screens, stack, tabs, router }}>
145
+ <React.Suspense fallback={null}>
146
+ <Content {...props} />
147
+ </React.Suspense>
148
+ </ScopeContext>
149
+ );
150
+ }
151
+ screens.set(node, Screen);
152
+ for (const child of node.children) build(child);
153
+ return Screen;
154
+ }
155
+ const RootScreen = build(tree.root);
156
+ const initialState =
157
+ options.initialHref == null ? undefined : linking.getStateFromPath(options.initialHref);
158
+ function Root(props: { ... }): React.Node {
159
+ return (
160
+ <NavigationContainer {...props} ref={ref} linking={linking} initialState={initialState}>
161
+ <RootScreen />
162
+ </NavigationContainer>
163
+ );
164
+ }
165
+ return { Root, router, linking };
166
+ }
167
+
168
+ /** Any React Navigation navigator can consume this segment's direct children. */
169
+ export component Navigator(navigator: NavigatorPair, ...props: { ... }) {
170
+ const scope = useScope();
171
+ const Container = navigator.Navigator;
172
+ const Screen = navigator.Screen;
173
+ return (
174
+ <Container {...props}>
175
+ {scope.node.children.map((node) => (
176
+ <Screen key={node.name} name={node.name} component={scope.screens.get(node)} />
177
+ ))}
178
+ </Container>
179
+ );
180
+ }
181
+
182
+ export component Stack(...props: { ... }) {
183
+ return <Navigator {...props} navigator={useScope().stack} />;
184
+ }
185
+
186
+ export component Tabs(...props: { ... }) {
187
+ const tabs = useScope().tabs;
188
+ if (tabs == null)
189
+ throw new Error("Native navigation: supply the app's tab navigator to createNativeNavigation");
190
+ return <Navigator {...props} navigator={tabs} />;
191
+ }
192
+
193
+ export function useNativeRouter(): FileRouter {
194
+ return useScope().router;
195
+ }
196
+ export function useParams(): { readonly [string]: string | $ReadOnlyArray<string> } {
197
+ const route = useRoute();
198
+ return route.params?.ufParams ?? {};
199
+ }
200
+
201
+ export component Link(
202
+ href: string,
203
+ children: React.Node,
204
+ replace: boolean = false,
205
+ ...props: { ... }
206
+ ) {
207
+ const router = useNativeRouter();
208
+ return (
209
+ <Pressable
210
+ {...props}
211
+ accessibilityRole="link"
212
+ onPress={() => (replace ? router.replace(href) : router.push(href))}
213
+ >
214
+ {children}
215
+ </Pressable>
216
+ );
217
+ }
package/native.js CHANGED
@@ -28,6 +28,9 @@
28
28
 
29
29
  import type { RouteParams, RouteRecord, RouteTable } from "./internal/routing.js";
30
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";
31
34
 
32
35
  export type NativeNavigationKind = "push" | "replace" | "prefetch";
33
36
 
@@ -221,7 +224,7 @@ export function resolveNativeNavigation(
221
224
  to: string,
222
225
  kind?: NativeNavigationKind = "push",
223
226
  ): NativeNavigationEvent {
224
- const href = normalizeNativeHref(to);
227
+ const href = normalizeNativeHref(to, table);
225
228
  const { pathname, search } = splitUrl(href);
226
229
  const matched = matchRoute(table.routes, pathname);
227
230
  if (matched == null) {
@@ -285,8 +288,13 @@ export function nativeScreenNavigationState(
285
288
  };
286
289
  }
287
290
 
288
- function normalizeNativeHref(to: string): string {
291
+ function normalizeNativeHref(
292
+ to: string,
293
+ table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
294
+ ): string {
289
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;
290
298
  throw new NativeNavigationError(
291
299
  "external-url",
292
300
  `@uniflowed/router/native: ${to} is an external URL, and a native navigator needs an app route`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.40",
3
+ "version": "0.0.0-alpha.42",
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",
@@ -25,7 +25,10 @@
25
25
  "./routing": "./routing.js",
26
26
  "./package.json": "./package.json",
27
27
  "./handler": "./handler.js",
28
- "./middleware": "./middleware.js"
28
+ "./middleware": "./middleware.js",
29
+ "./native-navigation": "./native-navigation.js",
30
+ "./http-client": "./http-client.js",
31
+ "./instrumentation": "./instrumentation.js"
29
32
  },
30
33
  "files": [
31
34
  "action.js",
@@ -41,12 +44,17 @@
41
44
  "rsc.js",
42
45
  "server-components.js",
43
46
  "server.js",
47
+ "native-navigation.js",
48
+ "http-client.js",
49
+ "instrumentation.js",
44
50
  "!*.test.js"
45
51
  ],
46
52
  "peerDependencies": {
47
53
  "react": ">=19.2.3",
48
54
  "react-dom": ">=19.2.3",
49
- "react-server-dom-parcel": ">=19.3.0"
55
+ "react-server-dom-parcel": ">=19.3.0",
56
+ "react-native": ">=0.86",
57
+ "@react-navigation/native": "^7.0.0"
50
58
  },
51
59
  "peerDependenciesMeta": {
52
60
  "react-dom": {
@@ -54,10 +62,16 @@
54
62
  },
55
63
  "react-server-dom-parcel": {
56
64
  "optional": true
65
+ },
66
+ "react-native": {
67
+ "optional": true
68
+ },
69
+ "@react-navigation/native": {
70
+ "optional": true
57
71
  }
58
72
  },
59
73
  "dependencies": {
60
- "@uniflowed/hooks": "0.0.0-alpha.40",
61
- "@uniflowed/server": "0.0.0-alpha.40"
74
+ "@uniflowed/hooks": "0.0.0-alpha.42",
75
+ "@uniflowed/server": "0.0.0-alpha.42"
62
76
  }
63
77
  }
package/rsc-ssr.js CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  installServerModules,
28
28
  readPayload,
29
29
  } from "./internal/flight-ssr.js";
30
- import { FLIGHT_CONTENT_TYPE, flightUrl } from "./internal/flight.js";
30
+ import { FLIGHT_CONTENT_TYPE, INTERCEPTED_FROM_HEADER, flightUrl } from "./internal/flight.js";
31
31
  import { type StreamRecord, streamReporter } from "./internal/inspector.js";
32
32
  import { requireServerComponentsReact } from "./internal/react-version.js";
33
33
  import { RedirectError } from "./internal/routing.js";
@@ -312,9 +312,15 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
312
312
 
313
313
  async function flight(
314
314
  url: string,
315
- settings?: {| readonly onError?: (error: mixed) => void |},
315
+ settings?: {|
316
+ readonly onError?: (error: mixed) => void,
317
+ readonly interceptedFrom?: string,
318
+ |},
316
319
  ): Promise<FlightResponse> {
317
- const rendered = await renderFlight(url, { onError: settings?.onError });
320
+ const rendered = await renderFlight(url, {
321
+ onError: settings?.onError,
322
+ interceptedFrom: settings?.interceptedFrom,
323
+ });
318
324
  if (rendered.kind === "redirect") {
319
325
  const { location } = rendered;
320
326
  // A redirect on this origin points at its target's payload, so the
@@ -330,7 +336,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
330
336
  }
331
337
  return {
332
338
  status: rendered.status,
333
- headers: { "content-type": FLIGHT_CONTENT_TYPE },
339
+ headers: { "content-type": FLIGHT_CONTENT_TYPE, vary: INTERCEPTED_FROM_HEADER },
334
340
  stream: rendered.stream,
335
341
  error: rendered.failure,
336
342
  };
package/rsc.js CHANGED
@@ -44,6 +44,8 @@
44
44
  // in this graph, and a boundary module without the directive is refused here,
45
45
  // with its file named, rather than by React with a sentence about functions.
46
46
 
47
+ import { traceLoader } from "./internal/server-instrumentation.js";
48
+
47
49
  import * as React from "react";
48
50
  import { use } from "react";
49
51
  // React's Flight server. Only this graph can load it: it refuses to evaluate
@@ -51,6 +53,7 @@ import { use } from "react";
51
53
  import { renderToReadableStream } from "react-server-dom-parcel/server";
52
54
 
53
55
  import { noteRoute } from "@uniflowed/server/host";
56
+ import { reportRequestError } from "@uniflowed/server/instrumentation";
54
57
 
55
58
  import { BoundaryReporter } from "./internal/boundaries.js";
56
59
  import { routeBoundaries } from "./internal/boundary-data.js";
@@ -59,7 +62,7 @@ import { ErrorRoutePage } from "./internal/error-view.js";
59
62
  import { type FlightRoot, routeState } from "./internal/flight.js";
60
63
  import { requireServerComponentsReact } from "./internal/react-version.js";
61
64
  import type { ErrorModule, PageModule, ResolvedRoute, ResolvedSlot } from "./internal/resolve.js";
62
- import { resolveFailure, resolveMatch } from "./internal/resolve.js";
65
+ import { resolveFailure, resolveInterception, resolveMatch } from "./internal/resolve.js";
63
66
  import type { RouteParams, RouteTable, SearchParams } from "./internal/routing.js";
64
67
  import { RedirectError, nearestBoundary } from "./internal/routing.js";
65
68
  import { withServerRoute } from "./internal/server-route.js";
@@ -95,6 +98,14 @@ export type FlightOptions = {|
95
98
  * did not.
96
99
  */
97
100
  readonly failure?: {| readonly error: mixed |},
101
+ /**
102
+ * The page a browser was showing when it asked for this payload.
103
+ *
104
+ * A document request never sets it. A client navigation may, because only the
105
+ * browser knows the page it is navigating from, while only this renderer can
106
+ * render the intercepted tree.
107
+ */
108
+ readonly interceptedFrom?: string,
98
109
  /** Stops the render, for a reader that went away. */
99
110
  readonly signal?: AbortSignal,
100
111
  |};
@@ -155,12 +166,22 @@ export function createFlightRenderer(options: {|
155
166
  let resolved: ResolvedRoute;
156
167
  try {
157
168
  const failure = settings?.failure;
158
- resolved =
159
- failure == null
160
- ? // `onMatch` records the route pattern on the request before the
161
- // loader runs, so every line the loader logs names its route.
162
- await resolveMatch(table, url, { defer: settings?.defer !== false, onMatch: noteRoute })
163
- : await resolveFailure(table, url, failure.error);
169
+ if (failure == null) {
170
+ const defer = settings?.defer !== false;
171
+ const intercepted = await resolveFlightInterception(
172
+ table,
173
+ url,
174
+ settings?.interceptedFrom,
175
+ defer,
176
+ );
177
+ resolved =
178
+ intercepted ??
179
+ // `onMatch` records the route pattern on the request before the
180
+ // loader runs, so every line the loader logs names its route.
181
+ (await resolveMatch(table, url, { defer, onMatch: noteRoute, runLoader: traceLoader }));
182
+ } else {
183
+ resolved = await resolveFailure(table, url, failure.error);
184
+ }
164
185
  } catch (error) {
165
186
  if (error instanceof RedirectError) {
166
187
  return { kind: "redirect", status: error.permanent ? 308 : 307, location: error.to };
@@ -181,13 +202,20 @@ export function createFlightRenderer(options: {|
181
202
  </>
182
203
  );
183
204
  const root: FlightRoot = { route: state, tree };
184
- const report = settings?.onError;
205
+ const failure = renderFailure(route);
206
+ if (failure != null) reportRequestError(failure, "render");
207
+ const report = (error) => {
208
+ reportRequestError(error, "render");
209
+ if (settings?.onError != null) return settings.onError(error);
210
+ console.error(error);
211
+ return undefined;
212
+ };
185
213
  // Inside the route's store, so a server component's `useRoute()` finds the
186
214
  // route however many `await`s into the render it asks.
187
215
  const stream = withServerRoute(state, () =>
188
216
  renderToReadableStream(root, {
189
217
  // Handed over as it is, so that the digest it returns reaches the row.
190
- // Absent, React logs the exception to the console itself.
218
+ // Our callback preserves React's console fallback when none was given.
191
219
  onError: report,
192
220
  signal: settings?.signal,
193
221
  }),
@@ -196,6 +224,39 @@ export function createFlightRenderer(options: {|
196
224
  };
197
225
  }
198
226
 
227
+ async function resolveFlightInterception(
228
+ table: RouteTable,
229
+ url: string,
230
+ from: ?string,
231
+ defer: boolean,
232
+ ): Promise<?ResolvedRoute> {
233
+ const baseUrl = usableInterceptionBase(from);
234
+ if (baseUrl == null) {
235
+ return null;
236
+ }
237
+ try {
238
+ const base = await resolveMatch(table, baseUrl, {
239
+ defer,
240
+ onMatch: noteRoute,
241
+ runLoader: traceLoader,
242
+ });
243
+ return await resolveInterception(table, base, url);
244
+ } catch (error) {
245
+ if (error instanceof RedirectError) {
246
+ return null;
247
+ }
248
+ throw error;
249
+ }
250
+ }
251
+
252
+ function usableInterceptionBase(from: ?string): ?string {
253
+ if (from == null || !from.startsWith("/") || from.startsWith("//")) {
254
+ return null;
255
+ }
256
+ const hash = from.indexOf("#");
257
+ return hash === -1 ? from : from.slice(0, hash);
258
+ }
259
+
199
260
  /**
200
261
  * The page element: the route's page with its data, the deferred page that
201
262
  * waits for its loader, or the error route's page.
@@ -71,7 +71,7 @@ export type {
71
71
  TwitterCard,
72
72
  } from "./internal/runtime.js";
73
73
 
74
- export { Link, RouteView, RouterProvider, routerView } from "./internal/runtime.js";
74
+ export { Link, RouteView, RouterProvider, routerView, useLinkStatus } from "./internal/runtime.js";
75
75
  // `app.router.basePath`, for a Server Component that builds an address itself.
76
76
  // The rsc entry installs it; `./internal/base-path.js` has no directive, so this
77
77
  // graph gets the function rather than a reference to it.
package/server.js CHANGED
@@ -23,7 +23,9 @@
23
23
  // `internal/stream.js` holds the mechanics and says which React renderer serves
24
24
  // which.
25
25
 
26
- import { noteRoute } from "@uniflowed/server/host";
26
+ import { currentNonce, noteRoute } from "@uniflowed/server/host";
27
+ import { traceLoader } from "./internal/server-instrumentation.js";
28
+
27
29
  import * as React from "react";
28
30
 
29
31
  import {
@@ -44,7 +46,6 @@ import {
44
46
  } from "./internal/runtime.js";
45
47
 
46
48
  import { type StreamDiagnostic, streamReporter } from "./internal/inspector.js";
47
- import { currentNonce } from "@uniflowed/server/host";
48
49
 
49
50
  import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
50
51
 
@@ -288,7 +289,10 @@ export type Renderer = {|
288
289
  */
289
290
  readonly flight?: (
290
291
  url: string,
291
- options?: {| readonly onError?: (error: mixed) => void |},
292
+ options?: {|
293
+ readonly onError?: (error: mixed) => void,
294
+ readonly interceptedFrom?: string,
295
+ |},
292
296
  ) => Promise<FlightResponse>,
293
297
  |};
294
298
 
@@ -333,7 +337,11 @@ export function createRenderer(options: {|
333
337
  try {
334
338
  return {
335
339
  kind: "route",
336
- route: await resolveMatch(table, url, { defer, onMatch: noteRoute }),
340
+ route: await resolveMatch(table, url, {
341
+ defer,
342
+ onMatch: noteRoute,
343
+ runLoader: traceLoader,
344
+ }),
337
345
  };
338
346
  } catch (error) {
339
347
  if (error instanceof RedirectError) {
@@ -510,3 +518,9 @@ export function shellDocument(assets: RenderAssets): string {
510
518
  const shell = shellFor(assets);
511
519
  return `${shell.open}${shell.body}${shell.close}`;
512
520
  }
521
+
522
+ export {
523
+ createInstrumentation,
524
+ instrumentRender,
525
+ traceRequestPhase,
526
+ } from "@uniflowed/server/instrumentation";