@uniflowed/router 0.0.0-alpha.35 → 0.0.0-alpha.37

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/client.js CHANGED
@@ -14,6 +14,13 @@
14
14
  // *before* `hydrateRoot`, so the first client render is synchronous and
15
15
  // matches the server's markup exactly.
16
16
  //
17
+ // An application whose routes render as React Server Components, the default,
18
+ // starts from neither: `hydrateFlight` in `./rsc-client.js` hydrates the Flight
19
+ // payload its document carries. That is a separate entry so that this one,
20
+ // which every other web application imports, never names React's Flight
21
+ // client. The client is an optional peer that needs React 19.3, and a project
22
+ // on React 19.2 does not install it (ubugeeei-prod/uf#992).
23
+ //
17
24
  // # What "its loader data" means once the loader can defer
18
25
  //
19
26
  // It is a payload rather than a value: `internal/payload.js` writes the model
@@ -101,6 +108,7 @@ import {
101
108
  hasClientPage,
102
109
  installNavigation,
103
110
  installRoutes,
111
+ installRouting,
104
112
  matchRoute,
105
113
  resolveFailure,
106
114
  resolveMatch,
@@ -108,7 +116,8 @@ import {
108
116
  import { DATA_ID, ROOT_ID } from "./internal/document.js";
109
117
  import { decodePayload } from "./internal/payload.js";
110
118
  import { createPayloadReader, domObserver } from "./internal/payload-rows.js";
111
- import { installBrowserModules, readDocumentPayload } from "./internal/flight-browser.js";
119
+ import { prepareDocumentForHydration } from "./internal/prepare-document.js";
120
+ import { type TrailingSlash, addressOf, applicationPathOf } from "./internal/base-path.js";
112
121
 
113
122
  /**
114
123
  * Hydrate the current document.
@@ -124,6 +133,8 @@ export async function hydrate(options: {|
124
133
  readonly errors: RouteTable["errors"],
125
134
  readonly strictMode?: boolean,
126
135
  readonly navigation?: Navigation,
136
+ readonly basePath?: string,
137
+ readonly trailingSlash?: TrailingSlash,
127
138
  |}): Promise<void> {
128
139
  const table: RouteTable = {
129
140
  routes: options.routes,
@@ -136,15 +147,18 @@ export async function hydrate(options: {|
136
147
  // `app.rendering.navigation` existed and what a hand-written entry still
137
148
  // means: the default is the behaviour, not the absence of one.
138
149
  installNavigation(options.navigation ?? "client");
150
+ installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
151
+ // The route table has no base path in it, and the address bar does.
152
+ const applicationPath = applicationPathOf(window.location.pathname) ?? window.location.pathname;
139
153
 
140
154
  // Before the loader data is read and before `resolveMatch` is called: both
141
155
  // would go looking for a page module that is not in this bundle.
142
- const matched = matchRoute(table.routes, window.location.pathname);
156
+ const matched = matchRoute(table.routes, applicationPath);
143
157
  if (matched != null && !hasClientPage(matched.route)) {
144
158
  return;
145
159
  }
146
160
 
147
- const url = window.location.pathname + window.location.search;
161
+ const url = applicationPath + window.location.search;
148
162
  // Row 0 of the payload, and the reader that will fill in the rows it refers
149
163
  // to. Both before `hydrateRoot`, and in this order: `decodePayload` is what
150
164
  // tells the reader which rows the page is waiting for, and `watch` is what
@@ -218,106 +232,6 @@ export async function hydrate(options: {|
218
232
  }
219
233
  }
220
234
 
221
- /**
222
- * Hydrate a document React Server Components rendered.
223
- *
224
- * [`hydrate`] resolves the route from its modules and renders it again over the
225
- * server's markup. This one resolves nothing and imports no route module: the
226
- * document carries the Flight payload its tree was rendered from, React's own
227
- * client reads it, and the tree the browser hydrates is the tree the server
228
- * rendered — a Server Component is markup and a reference, and a client
229
- * component is the one kind of module this page loads. See
230
- * ubugeeei-prod/uf#519.
231
- *
232
- * The payload is read while the document is still arriving. Row 0 is in the
233
- * shell, so hydration starts as soon as the module script runs, and every row
234
- * after it lands in a later chunk that the reader picks up as it is parsed — so
235
- * a boundary the server completes after hydration began resolves then, with no
236
- * second request.
237
- *
238
- * Everything else is [`hydrate`]'s, for the reasons written there: the
239
- * navigation mode is installed before the first render, the development
240
- * hydration report captures the server's markup before React repairs it, and
241
- * Strict Mode wraps the root.
242
- */
243
- export async function hydrateFlight(options: {|
244
- readonly App: React.ComponentType<AppProps>,
245
- readonly strictMode?: boolean,
246
- readonly navigation?: Navigation,
247
- |}): Promise<void> {
248
- installNavigation(options.navigation ?? "client");
249
- installBrowserModules();
250
- const flight = readDocumentPayload(document, domObserver(document));
251
-
252
- const url = window.location.pathname + window.location.search;
253
- const { App } = options;
254
- const container = document.getElementById(ROOT_ID) ?? document;
255
- prepareDocumentForHydration(document);
256
-
257
- let recovery = null;
258
- let restoreDevHead = null;
259
- if (import.meta.hot != null) {
260
- const { captureServerMarkup, hydrationErrorHandler, prepareDevHeadForHydration } =
261
- await import("./internal/hydration.js");
262
- restoreDevHead = prepareDevHeadForHydration(document);
263
- recovery = hydrationErrorHandler(container, captureServerMarkup(container), document);
264
- }
265
-
266
- const tree = <App url={url} flight={flight} />;
267
-
268
- startTransition(() => {
269
- hydrateRoot(
270
- container,
271
- options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
272
- recovery == null ? undefined : { onRecoverableError: recovery },
273
- );
274
- if (restoreDevHead != null) {
275
- setTimeout(restoreDevHead, 250);
276
- }
277
- });
278
-
279
- if (import.meta.hot != null) {
280
- const { reportDevtools } = await import("./internal/devtools.js");
281
- reportDevtools(window);
282
- }
283
- }
284
-
285
- function prepareDocumentForHydration(document: Document): void {
286
- const head = document.head;
287
- const envelope = head.querySelector('meta[name="uf:render"]');
288
- if (envelope != null && head.firstChild !== envelope) {
289
- head.insertBefore(envelope, head.firstChild);
290
- }
291
- moveLayoutMetaAfterRouteHead(head, head.querySelector("meta[charset]"));
292
- moveLayoutMetaAfterRouteHead(head, head.querySelector('meta[name="viewport"]'));
293
- document.getElementById("_R_")?.remove();
294
- normalizeReactFormActions(document);
295
- }
296
-
297
- function moveLayoutMetaAfterRouteHead(head: HTMLHeadElement, meta: Element | null): void {
298
- if (meta == null) {
299
- return;
300
- }
301
- const colorScheme = head.querySelector('meta[name="color-scheme"]');
302
- if (colorScheme != null && colorScheme !== meta) {
303
- head.insertBefore(meta, colorScheme);
304
- return;
305
- }
306
- head.appendChild(meta);
307
- }
308
-
309
- const SERVER_FORM_PLACEHOLDER = "javascript:throw new Error('React form unexpectedly submitted.')";
310
- const CLIENT_FORM_PLACEHOLDER =
311
- "javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')";
312
-
313
- function normalizeReactFormActions(document: Document): void {
314
- for (const form of document.querySelectorAll("form")) {
315
- if (form.getAttribute("action") === SERVER_FORM_PLACEHOLDER) {
316
- form.setAttribute("action", CLIENT_FORM_PLACEHOLDER);
317
- }
318
- }
319
- }
320
-
321
235
  /**
322
236
  * Render the current route into an empty shell.
323
237
  *
@@ -354,6 +268,8 @@ export async function render(options: {|
354
268
  readonly errors: RouteTable["errors"],
355
269
  readonly strictMode?: boolean,
356
270
  readonly navigation?: Navigation,
271
+ readonly basePath?: string,
272
+ readonly trailingSlash?: TrailingSlash,
357
273
  |}): Promise<void> {
358
274
  const table: RouteTable = {
359
275
  routes: options.routes,
@@ -362,14 +278,17 @@ export async function render(options: {|
362
278
  };
363
279
  installRoutes(table);
364
280
  installNavigation(options.navigation ?? "client");
281
+ installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
365
282
 
366
- const url = window.location.pathname + window.location.search;
283
+ const url =
284
+ (applicationPathOf(window.location.pathname) ?? window.location.pathname) +
285
+ window.location.search;
367
286
  let resolved;
368
287
  try {
369
288
  resolved = await resolveMatch(table, url);
370
289
  } catch (error) {
371
290
  if (error instanceof RedirectError) {
372
- window.location.replace(error.to);
291
+ window.location.replace(addressOf(error.to));
373
292
  return;
374
293
  }
375
294
  // The error boundary, chosen the same way the server chooses it. A throw
package/index.js CHANGED
@@ -73,6 +73,7 @@ export {
73
73
  RouteView,
74
74
  RouterProvider,
75
75
  UnauthorizedError,
76
+ basePath,
76
77
  buildRoute,
77
78
  forbidden,
78
79
  hasClientPage,
@@ -0,0 +1,175 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: `app.router.basePath` and
4
+ // `app.router.trailingSlash`, as the router reads them.
5
+ //
6
+ // The route table has no base in it — `/guide` is `/guide` whether the site is
7
+ // served at `/` or at `/docs` — and the browser's address bar has one. This
8
+ // module is the one place those two spellings meet: a `Link` and a navigation
9
+ // turn an application path into the address, and hydration, `popstate` and a
10
+ // refresh turn the address back into an application path before the table is
11
+ // asked. Every other part of the router speaks application paths.
12
+ //
13
+ // The policy decides the spelling of a path a link writes, so that a page is
14
+ // linked by the address its server answers without a redirect. The server's
15
+ // half — the `308` for the other spelling, and the `404` outside the base — is
16
+ // `@uniflowed/server`'s `internal/routing.js`.
17
+ //
18
+ // # Installed, like navigation
19
+ //
20
+ // Module state beside `installNavigation`, set once by the entry that started
21
+ // the application: `virtual:uf/client` in the browser and `virtual:uf/server`
22
+ // in the graph that renders documents. `@uniflowed/vite` writes both calls from
23
+ // `uf.config.js`, so a dev server and a build link the same way. A test that
24
+ // installs nothing gets the root and `"ignore"`, which is what every
25
+ // application had before the setting existed.
26
+
27
+ /** Which spelling of a path is the page; see `app.router.trailingSlash`. */
28
+ export type TrailingSlash = "never" | "always" | "ignore";
29
+
30
+ /** What an entry installs. */
31
+ export type RoutingSettings = {|
32
+ readonly basePath?: string,
33
+ readonly trailingSlash?: TrailingSlash,
34
+ |};
35
+
36
+ let installedBase: string = "";
37
+ let installedSlash: TrailingSlash = "ignore";
38
+
39
+ /**
40
+ * Say where this application is served and how its paths are spelled. Called
41
+ * once, by the entry that starts it.
42
+ */
43
+ export function installRouting(settings: RoutingSettings): void {
44
+ installedBase = normalizeBase(settings.basePath ?? "");
45
+ installedSlash = settings.trailingSlash ?? "ignore";
46
+ }
47
+
48
+ /**
49
+ * The path this application is served under: `""` at the root, `"/docs"`
50
+ * otherwise.
51
+ *
52
+ * For code that builds an absolute address the router did not build for it —
53
+ * a middleware's `Response.redirect(new URL(`${basePath()}/sign-in`, request.url))`
54
+ * — because a route handler and a middleware are handed the application path,
55
+ * and an address built from `/sign-in` alone leaves the base behind.
56
+ */
57
+ export function basePath(): string {
58
+ return installedBase;
59
+ }
60
+
61
+ /** How this application spells a path. */
62
+ export function trailingSlash(): TrailingSlash {
63
+ return installedSlash;
64
+ }
65
+
66
+ /**
67
+ * The address for an application path: the base in front, the policy's
68
+ * spelling, the query and the fragment kept.
69
+ *
70
+ * Only a path that starts with a single `/` is an application path. Anything
71
+ * else — `https://…`, `//cdn…`, `mailto:`, `?page=2`, `#top`, `../up` — is
72
+ * returned as it was written, because the browser resolves it against the
73
+ * address that is already there.
74
+ */
75
+ export function addressOf(to: string): string {
76
+ if (!to.startsWith("/") || to.startsWith("//")) {
77
+ return to;
78
+ }
79
+ const { path, rest } = splitPath(to);
80
+ return `${installedBase}${spellPath(path, installedSlash, installedBase !== "")}${rest}`;
81
+ }
82
+
83
+ /**
84
+ * The application path for an address's pathname, or `null` when the address
85
+ * is outside the base.
86
+ *
87
+ * `/docs/guide` is `/guide` under `/docs`, and `/docs` and `/docs/` are both
88
+ * `/`. `/docsx` is not under `/docs`: a base is whole segments.
89
+ */
90
+ export function applicationPathOf(pathname: string): string | null {
91
+ if (installedBase === "") {
92
+ return pathname;
93
+ }
94
+ if (pathname === installedBase) {
95
+ return "/";
96
+ }
97
+ if (pathname.startsWith(`${installedBase}/`)) {
98
+ return pathname.slice(installedBase.length);
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * A browser pathname in this application's spelling: the base kept, the
105
+ * application path under it spelled by the policy. A pathname outside the
106
+ * base is returned as it was.
107
+ *
108
+ * For an address the router read back rather than wrote — the document path a
109
+ * payload URL names has lost its trailing slash, and the history entry a
110
+ * navigation writes should be the address the server answers without a
111
+ * redirect.
112
+ */
113
+ export function canonicalAddress(pathname: string): string {
114
+ const application = applicationPathOf(pathname);
115
+ if (application == null) {
116
+ return pathname;
117
+ }
118
+ const spelled = spellPath(application, installedSlash, installedBase !== "");
119
+ return `${installedBase}${spelled}`;
120
+ }
121
+
122
+ /**
123
+ * `path` in `policy`'s spelling.
124
+ *
125
+ * The root of an application at the root is `/` whatever the policy says. The
126
+ * root of an application under a base is the base itself — `/docs` — unless
127
+ * the policy is `"always"`, which spells it `/docs/`. A path whose last segment
128
+ * looks like a file keeps what it was written with, because `/robots.txt/` is
129
+ * not a page.
130
+ */
131
+ export function spellPath(path: string, policy: TrailingSlash, underBase: boolean): string {
132
+ let end = path.length;
133
+ while (end > 0 && path.charCodeAt(end - 1) === 47) {
134
+ end -= 1;
135
+ }
136
+ const trimmed = path.slice(0, end);
137
+ if (trimmed === "") {
138
+ return policy === "always" || !underBase ? "/" : "";
139
+ }
140
+ if (policy === "ignore" || looksLikeAFile(trimmed)) {
141
+ return path;
142
+ }
143
+ return policy === "always" ? `${trimmed}/` : trimmed;
144
+ }
145
+
146
+ /** Whether a path's last segment has an extension. */
147
+ function looksLikeAFile(path: string): boolean {
148
+ const last = path.slice(path.lastIndexOf("/") + 1);
149
+ return last.includes(".");
150
+ }
151
+
152
+ function splitPath(to: string): {| readonly path: string, readonly rest: string |} {
153
+ let end = to.length;
154
+ for (let index = 0; index < to.length; index += 1) {
155
+ const code = to.charCodeAt(index);
156
+ // `?` and `#`.
157
+ if (code === 63 || code === 35) {
158
+ end = index;
159
+ break;
160
+ }
161
+ }
162
+ return { path: to.slice(0, end), rest: to.slice(end) };
163
+ }
164
+
165
+ /**
166
+ * A base as `uf_config` accepts one, with a trailing slash forgiven: `""`,
167
+ * `"/"` and absent are the root.
168
+ */
169
+ function normalizeBase(base: string): string {
170
+ let end = base.length;
171
+ while (end > 0 && base.charCodeAt(end - 1) === 47) {
172
+ end -= 1;
173
+ }
174
+ return base.slice(0, end);
175
+ }
@@ -26,11 +26,21 @@
26
26
  import { createFromFetch, createFromReadableStream } from "react-server-dom-parcel/client.browser";
27
27
 
28
28
  import { FLIGHT_CHUNK_ATTRIBUTE, flightChunkBytes } from "./flight-chunks.js";
29
- import { FLIGHT_CONTENT_TYPE, type FlightRoot, documentPathOf, flightUrl } from "./flight.js";
29
+ import {
30
+ FLIGHT_CONTENT_TYPE,
31
+ type FetchedFlight,
32
+ type FlightRoot,
33
+ documentPathOf,
34
+ flightUrl,
35
+ } from "./flight.js";
36
+ import { requireServerComponentsReact } from "./react-version.js";
30
37
 
31
38
  /** A module namespace, as far as the loader looks into one. */
32
39
  type ModuleNamespace = { +[string]: mixed };
33
40
 
41
+ /** The entry a refusal names: the one an application reaches this module through. */
42
+ const ENTRY = "@uniflowed/router/rsc/client";
43
+
34
44
  /**
35
45
  * Install the module hook React's Flight client resolves references through.
36
46
  *
@@ -39,6 +49,7 @@ type ModuleNamespace = { +[string]: mixed };
39
49
  * as an accessor cannot be assigned to, and hydration must not throw over it.
40
50
  */
41
51
  export function installBrowserModules(): void {
52
+ requireServerComponentsReact(ENTRY);
42
53
  const loaded: Map<string, ModuleNamespace> = new Map();
43
54
  // A function with three properties, which is the shape React's Parcel client
44
55
  // calls: `parcelRequire(id)` for a module, `parcelRequire.load(url)` for the
@@ -156,27 +167,10 @@ export function readDocumentPayload(
156
167
  document: DocumentLike,
157
168
  observe: ?(callback: () => void) => (() => void) | null,
158
169
  ): Promise<FlightRoot> {
170
+ requireServerComponentsReact(ENTRY);
159
171
  return createFromReadableStream(documentPayload(document, observe));
160
172
  }
161
173
 
162
- /** What fetching a route's payload turned into. */
163
- export type FetchedFlight =
164
- | {|
165
- readonly kind: "flight",
166
- /** The route the server answered for: a redirect's target, when there was one. */
167
- readonly url: string,
168
- readonly root: Promise<FlightRoot>,
169
- |}
170
- | {|
171
- /**
172
- * The answer was not a payload: a redirect off this origin, or a host
173
- * that had no payload for the URL. The browser should load `url` as a
174
- * document.
175
- */
176
- readonly kind: "document",
177
- readonly url: string,
178
- |};
179
-
180
174
  /**
181
175
  * Fetch `url`'s payload.
182
176
  *
@@ -193,6 +187,10 @@ export type FetchedFlight =
193
187
  * refusal — is a document, whatever its status.
194
188
  */
195
189
  export async function fetchFlight(url: string): Promise<FetchedFlight> {
190
+ // Outside the `try` below, which answers every failure to fetch with a
191
+ // document load: too old a React is not a network failure, and a navigation
192
+ // that quietly reloaded the page would hide it.
193
+ requireServerComponentsReact(ENTRY);
196
194
  let response: Response;
197
195
  try {
198
196
  response = await fetch(flightUrl(url), {
@@ -25,6 +25,7 @@
25
25
  import { createFromReadableStream } from "react-server-dom-parcel/client.edge";
26
26
 
27
27
  import type { FlightRoot } from "./flight.js";
28
+ import { requireServerComponentsReact } from "./react-version.js";
28
29
 
29
30
  /** A module namespace, as far as the hook looks into one. */
30
31
  type ModuleNamespace = { +[string]: mixed };
@@ -32,6 +33,9 @@ type ModuleNamespace = { +[string]: mixed };
32
33
  /** The server copy of the client module at a browser chunk URL. */
33
34
  export type ClientModuleLoader = (url: string) => Promise<ModuleNamespace>;
34
35
 
36
+ /** The entry a refusal names: the one an application reaches this module through. */
37
+ const ENTRY = "@uniflowed/router/rsc/ssr";
38
+
35
39
  /**
36
40
  * Install the module hook React's Flight client resolves references through.
37
41
  *
@@ -40,6 +44,7 @@ export type ClientModuleLoader = (url: string) => Promise<ModuleNamespace>;
40
44
  * stale one.
41
45
  */
42
46
  export function installServerModules(load: ClientModuleLoader): void {
47
+ requireServerComponentsReact(ENTRY);
43
48
  const loaded: Map<string, ModuleNamespace> = new Map();
44
49
  // The browser's hook, on a server; `./flight-browser.js` has the shape.
45
50
  function parcelRequire(id: string): ModuleNamespace {
@@ -68,5 +73,6 @@ export function installServerModules(load: ClientModuleLoader): void {
68
73
 
69
74
  /** Read a payload into its root value, as React's client does in the browser. */
70
75
  export function readPayload(stream: ReadableStream<Uint8Array>): Promise<FlightRoot> {
76
+ requireServerComponentsReact(ENTRY);
71
77
  return createFromReadableStream(stream);
72
78
  }
@@ -60,6 +60,32 @@ export type FlightRoot = {|
60
60
  readonly tree: Node,
61
61
  |};
62
62
 
63
+ /**
64
+ * What fetching a route's payload turned into.
65
+ *
66
+ * Declared here rather than beside the fetch in `./flight-browser.js`, because
67
+ * the router's navigation names this type and must not import React's Flight
68
+ * client: `./runtime.js` is in every application's bundle, and that client is
69
+ * only in the bundles of applications that render Server Components
70
+ * (ubugeeei-prod/uf#992).
71
+ */
72
+ export type FetchedFlight =
73
+ | {|
74
+ readonly kind: "flight",
75
+ /** The route the server answered for: a redirect's target, when there was one. */
76
+ readonly url: string,
77
+ readonly root: Promise<FlightRoot>,
78
+ |}
79
+ | {|
80
+ /**
81
+ * The answer was not a payload: a redirect off this origin, or a host
82
+ * that had no payload for the URL. The browser should load `url` as a
83
+ * document.
84
+ */
85
+ readonly kind: "document",
86
+ readonly url: string,
87
+ |};
88
+
63
89
  /** The part of a resolved route that crosses to the browser. */
64
90
  export function routeState(resolved: ResolvedRoute): RouteState {
65
91
  return {
@@ -0,0 +1,49 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: a server's document, made ready for React to
4
+ // hydrate.
5
+ //
6
+ // Where a server wrote the head's metadata, an element React leaves behind, and
7
+ // the placeholder action React writes into a form whose action is a function
8
+ // (its server and its client spell that placeholder differently): none of these
9
+ // comes from a route, and each would be reported as a mismatch. So each is put
10
+ // right before `hydrateRoot` compares the markup with the tree. Both ways of
11
+ // hydrating do it — `hydrate` in `../client.js`, for a route rendered from its
12
+ // modules, and `hydrateFlight` in `../rsc-client.js`, for one React Server
13
+ // Components rendered — so the code lives here rather than in either entry.
14
+
15
+ export function prepareDocumentForHydration(document: Document): void {
16
+ const head = document.head;
17
+ const envelope = head.querySelector('meta[name="uf:render"]');
18
+ if (envelope != null && head.firstChild !== envelope) {
19
+ head.insertBefore(envelope, head.firstChild);
20
+ }
21
+ moveLayoutMetaAfterRouteHead(head, head.querySelector("meta[charset]"));
22
+ moveLayoutMetaAfterRouteHead(head, head.querySelector('meta[name="viewport"]'));
23
+ document.getElementById("_R_")?.remove();
24
+ normalizeReactFormActions(document);
25
+ }
26
+
27
+ function moveLayoutMetaAfterRouteHead(head: HTMLHeadElement, meta: Element | null): void {
28
+ if (meta == null) {
29
+ return;
30
+ }
31
+ const colorScheme = head.querySelector('meta[name="color-scheme"]');
32
+ if (colorScheme != null && colorScheme !== meta) {
33
+ head.insertBefore(meta, colorScheme);
34
+ return;
35
+ }
36
+ head.appendChild(meta);
37
+ }
38
+
39
+ const SERVER_FORM_PLACEHOLDER = "javascript:throw new Error('React form unexpectedly submitted.')";
40
+ const CLIENT_FORM_PLACEHOLDER =
41
+ "javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')";
42
+
43
+ function normalizeReactFormActions(document: Document): void {
44
+ for (const form of document.querySelectorAll("form")) {
45
+ if (form.getAttribute("action") === SERVER_FORM_PLACEHOLDER) {
46
+ form.setAttribute("action", CLIENT_FORM_PLACEHOLDER);
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,77 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: the React that React Server Components need.
4
+ //
5
+ // The router installs beside React 19.2.3, the React that Expo SDK 57 and React
6
+ // Native 0.87 ship. Matching a URL, the native route table and a route rendered
7
+ // from its modules need nothing newer (ubugeeei-prod/uf#992). React Server
8
+ // Components do. They render through `react-server-dom-parcel`, React's own
9
+ // Flight renderer and client, which is released with React and requires the
10
+ // React it was released with: 19.3. So that package is an optional peer, and
11
+ // every module that loads it asks here first.
12
+ //
13
+ // # Why a check at run time
14
+ //
15
+ // A peer range cannot say it. The router has one range for `react`, and that
16
+ // range has to admit 19.2.3 for a native app, while an optional peer that is
17
+ // absent is never compared with anything. The failure is also not where the
18
+ // mistake is: React's Flight client imports against React 19.2 and fails later,
19
+ // inside a render, in terms of React's internals. So each entry that loads
20
+ // Flight refuses before it does anything, and names the React it found and the
21
+ // one it needs.
22
+ //
23
+ // It is a call rather than a statement at module scope, because no shipped
24
+ // module runs anything when it is imported
25
+ // (`crates/uf_lib/tests/package_surface.rs`).
26
+
27
+ import * as React from "react";
28
+
29
+ /** The oldest React that React Server Components render on. */
30
+ export const SERVER_COMPONENTS_REACT: string = "19.3.0";
31
+
32
+ /**
33
+ * Why `entry` cannot run on React `installed`, or `null` when it can.
34
+ *
35
+ * The release numbers are compared and a prerelease tag is ignored, so a 19.3
36
+ * canary counts as 19.3: React names a canary after the release it leads to.
37
+ */
38
+ export function serverComponentsRefusal(entry: string, installed: string): string | null {
39
+ const found = releaseOf(installed);
40
+ const needed = releaseOf(SERVER_COMPONENTS_REACT);
41
+ if (found != null && needed != null && !isBefore(found, needed)) {
42
+ return null;
43
+ }
44
+ return (
45
+ `@uniflowed/router: ${entry} needs React ${SERVER_COMPONENTS_REACT} or newer for React ` +
46
+ `Server Components, and the React it loaded is ${installed}. Install react, react-dom and ` +
47
+ "react-server-dom-parcel at ^19.3.0, or set `app.rsc: false` in uf.config.js to render " +
48
+ "routes from their modules, which the router supports from React 19.2.3."
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Refuse, with [`serverComponentsRefusal`]'s sentence, unless the React this
54
+ * module loaded can render React Server Components.
55
+ */
56
+ export function requireServerComponentsReact(entry: string): void {
57
+ const refusal = serverComponentsRefusal(entry, React.version);
58
+ if (refusal != null) {
59
+ throw new Error(refusal);
60
+ }
61
+ }
62
+
63
+ /** `[major, minor, patch]` of a version, or `null` when it does not start with one. */
64
+ function releaseOf(version: string): [number, number, number] | null {
65
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
66
+ return match == null ? null : [Number(match[1]), Number(match[2]), Number(match[3])];
67
+ }
68
+
69
+ /** Whether release `a` comes before release `b`. */
70
+ function isBefore(a: [number, number, number], b: [number, number, number]): boolean {
71
+ for (let index = 0; index < 3; index += 1) {
72
+ if (a[index] !== b[index]) {
73
+ return a[index] < b[index];
74
+ }
75
+ }
76
+ return false;
77
+ }