@uniflowed/router 0.0.0-alpha.34 → 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,6 +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";
119
+ import { prepareDocumentForHydration } from "./internal/prepare-document.js";
120
+ import { type TrailingSlash, addressOf, applicationPathOf } from "./internal/base-path.js";
111
121
 
112
122
  /**
113
123
  * Hydrate the current document.
@@ -123,6 +133,8 @@ export async function hydrate(options: {|
123
133
  readonly errors: RouteTable["errors"],
124
134
  readonly strictMode?: boolean,
125
135
  readonly navigation?: Navigation,
136
+ readonly basePath?: string,
137
+ readonly trailingSlash?: TrailingSlash,
126
138
  |}): Promise<void> {
127
139
  const table: RouteTable = {
128
140
  routes: options.routes,
@@ -135,15 +147,18 @@ export async function hydrate(options: {|
135
147
  // `app.rendering.navigation` existed and what a hand-written entry still
136
148
  // means: the default is the behaviour, not the absence of one.
137
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;
138
153
 
139
154
  // Before the loader data is read and before `resolveMatch` is called: both
140
155
  // would go looking for a page module that is not in this bundle.
141
- const matched = matchRoute(table.routes, window.location.pathname);
156
+ const matched = matchRoute(table.routes, applicationPath);
142
157
  if (matched != null && !hasClientPage(matched.route)) {
143
158
  return;
144
159
  }
145
160
 
146
- const url = window.location.pathname + window.location.search;
161
+ const url = applicationPath + window.location.search;
147
162
  // Row 0 of the payload, and the reader that will fill in the rows it refers
148
163
  // to. Both before `hydrateRoot`, and in this order: `decodePayload` is what
149
164
  // tells the reader which rows the page is waiting for, and `watch` is what
@@ -217,42 +232,6 @@ export async function hydrate(options: {|
217
232
  }
218
233
  }
219
234
 
220
- function prepareDocumentForHydration(document: Document): void {
221
- const head = document.head;
222
- const envelope = head.querySelector('meta[name="uf:render"]');
223
- if (envelope != null && head.firstChild !== envelope) {
224
- head.insertBefore(envelope, head.firstChild);
225
- }
226
- moveLayoutMetaAfterRouteHead(head, head.querySelector("meta[charset]"));
227
- moveLayoutMetaAfterRouteHead(head, head.querySelector('meta[name="viewport"]'));
228
- document.getElementById("_R_")?.remove();
229
- normalizeReactFormActions(document);
230
- }
231
-
232
- function moveLayoutMetaAfterRouteHead(head: HTMLHeadElement, meta: Element | null): void {
233
- if (meta == null) {
234
- return;
235
- }
236
- const colorScheme = head.querySelector('meta[name="color-scheme"]');
237
- if (colorScheme != null && colorScheme !== meta) {
238
- head.insertBefore(meta, colorScheme);
239
- return;
240
- }
241
- head.appendChild(meta);
242
- }
243
-
244
- const SERVER_FORM_PLACEHOLDER = "javascript:throw new Error('React form unexpectedly submitted.')";
245
- const CLIENT_FORM_PLACEHOLDER =
246
- "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().')";
247
-
248
- function normalizeReactFormActions(document: Document): void {
249
- for (const form of document.querySelectorAll("form")) {
250
- if (form.getAttribute("action") === SERVER_FORM_PLACEHOLDER) {
251
- form.setAttribute("action", CLIENT_FORM_PLACEHOLDER);
252
- }
253
- }
254
- }
255
-
256
235
  /**
257
236
  * Render the current route into an empty shell.
258
237
  *
@@ -289,6 +268,8 @@ export async function render(options: {|
289
268
  readonly errors: RouteTable["errors"],
290
269
  readonly strictMode?: boolean,
291
270
  readonly navigation?: Navigation,
271
+ readonly basePath?: string,
272
+ readonly trailingSlash?: TrailingSlash,
292
273
  |}): Promise<void> {
293
274
  const table: RouteTable = {
294
275
  routes: options.routes,
@@ -297,14 +278,17 @@ export async function render(options: {|
297
278
  };
298
279
  installRoutes(table);
299
280
  installNavigation(options.navigation ?? "client");
281
+ installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
300
282
 
301
- const url = window.location.pathname + window.location.search;
283
+ const url =
284
+ (applicationPathOf(window.location.pathname) ?? window.location.pathname) +
285
+ window.location.search;
302
286
  let resolved;
303
287
  try {
304
288
  resolved = await resolveMatch(table, url);
305
289
  } catch (error) {
306
290
  if (error instanceof RedirectError) {
307
- window.location.replace(error.to);
291
+ window.location.replace(addressOf(error.to));
308
292
  return;
309
293
  }
310
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
+ }
@@ -83,8 +83,10 @@
83
83
  // package is `sideEffects: false`, so with the references folded away the
84
84
  // module is dropped rather than merely unused.
85
85
 
86
+ "use client";
87
+
86
88
  import * as React from "react";
87
- import { useEffect, useState } from "react";
89
+ import { useEffect, useSyncExternalStore } from "react";
88
90
 
89
91
  import { SYNTHESISED_SOURCE } from "./boundary-data.js";
90
92
  import { reportDiagnostic } from "./diagnostics.js";
@@ -114,13 +116,32 @@ export const BOUNDARY_GLOBAL: string = "__ufBoundaries";
114
116
  /**
115
117
  * Whether an edge that mounts now should be in the DOM immediately.
116
118
  *
117
- * Latched by the first edge to mount and never cleared. Read through
118
- * `useState`'s initialiser rather than during the render body, which is the
119
- * difference between "this component's first state" and "a module variable a
119
+ * Latched by the first edge to mount and never cleared, and read through
120
+ * `useSyncExternalStore` rather than during the render body, which is the
121
+ * difference between "a value React asked for" and "a module variable a
120
122
  * memoising compiler is entitled to hold on to".
121
123
  */
122
124
  let marksAreLive = false;
123
125
 
126
+ /** The edges waiting to hear that marks have gone live. */
127
+ const liveListeners: Set<() => void> = new Set();
128
+
129
+ function subscribeToLiveMarks(listener: () => void): () => void {
130
+ liveListeners.add(listener);
131
+ return () => {
132
+ liveListeners.delete(listener);
133
+ };
134
+ }
135
+
136
+ function marksAreLiveNow(): boolean {
137
+ return marksAreLive;
138
+ }
139
+
140
+ /** What a server rendered, and so what every hydrating edge renders: nothing. */
141
+ function noMarksOnTheServer(): boolean {
142
+ return false;
143
+ }
144
+
124
145
  /**
125
146
  * One end of one boundary.
126
147
  *
@@ -128,12 +149,25 @@ let marksAreLive = false;
128
149
  * This used to be a `<template>`, but React 19.3 reports template insertion
129
150
  * during document-root hydration as a browser error. A `span hidden` carries
130
151
  * the same marker data without entering layout or the accessibility tree.
152
+ *
153
+ * # Why the server snapshot, and not a first state
154
+ *
155
+ * An edge used to take `marksAreLive` as its first state, which is right only
156
+ * if every edge on a page hydrates in the same pass. Under React Server
157
+ * Components they do not: a client reference loads when the payload names it,
158
+ * so the part of the tree above it hydrates, commits and runs this effect
159
+ * first, and an edge that hydrates afterwards read `true` and rendered a mark
160
+ * the server never wrote — a hydration mismatch on every page with a boundary
161
+ * below a client component, under `uf dev` only. `useSyncExternalStore` hands a
162
+ * hydrating edge the server's answer whenever it hydrates, and an edge mounted
163
+ * by a navigation or by HMR the live one, in its own commit, as before.
131
164
  */
132
- component BoundaryEdge(boundary: RouteBoundary, edge: "open" | "close") {
133
- const [live, setLive] = useState<boolean>(() => marksAreLive);
165
+ export component BoundaryEdge(boundary: RouteBoundary, edge: "open" | "close") {
166
+ const live = useSyncExternalStore(subscribeToLiveMarks, marksAreLiveNow, noMarksOnTheServer);
134
167
  useEffect(() => {
168
+ if (marksAreLive) return;
135
169
  marksAreLive = true;
136
- setLive(true);
170
+ for (const listener of [...liveListeners]) listener();
137
171
  }, []);
138
172
  if (!live) {
139
173
  return null;
@@ -154,25 +188,14 @@ component BoundaryEdge(boundary: RouteBoundary, edge: "open" | "close") {
154
188
  /**
155
189
  * `children`, between the two marks of `boundary`.
156
190
  *
157
- * `children` unchanged when there is no boundary to mark, so a caller never has
158
- * to ask twice. The marks are the first and last children of a fragment rather
159
- * than a wrapper's, so the nodes between them are siblings of them, and every
160
- * position in the fragment is fixed — an edge going from `null` to a hidden
161
- * mark after mount is an insertion beside `children` and not around it,
162
- * which is why it costs no remount.
191
+ * Kept importable from here, where the marks are, and written in
192
+ * `./compose.js`, where they are placed. This module is a client module — an
193
+ * edge has state and an effect — and a server composing a tree for React Server
194
+ * Components calls the factory rather than rendering it, so the factory has to
195
+ * live in a module that graph evaluates while the edges it places stay
196
+ * references to this one. See ubugeeei-prod/uf#519.
163
197
  */
164
- export function insideBoundary(boundary: ?RouteBoundary, children: React.Node): React.Node {
165
- if (boundary == null) {
166
- return children;
167
- }
168
- return (
169
- <>
170
- <BoundaryEdge boundary={boundary} edge="open" />
171
- {children}
172
- <BoundaryEdge boundary={boundary} edge="close" />
173
- </>
174
- );
175
- }
198
+ export { insideBoundary } from "./compose.js";
176
199
 
177
200
  /**
178
201
  * How far a walk between two marks will go before giving up.