@uniflowed/router 0.0.0-alpha.17 → 0.0.0-alpha.20

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
@@ -1,12 +1,31 @@
1
1
  // @flow
2
2
  //
3
- // Hydrating the document in the browser.
3
+ // Starting the application in the browser.
4
+ //
5
+ // Two entry points, and which one `virtual:uf/client` calls is decided by
6
+ // `app.rendering.modes`. `hydrate` is the one every uf build has used: a server
7
+ // or a prerender wrote the markup, and React attaches to it. `render` is for
8
+ // `["csr"]`, where the build wrote one shell with an empty root and nothing has
9
+ // been rendered anywhere yet; see its own comment for why that is not `hydrate`
10
+ // with a flag.
4
11
  //
5
12
  // `virtual:uf/client` calls `hydrate` with the app root and the route table.
6
13
  // The current route's chunks are loaded and its embedded loader data read
7
14
  // *before* `hydrateRoot`, so the first client render is synchronous and
8
15
  // matches the server's markup exactly.
9
16
  //
17
+ // # What "its loader data" means once the loader can defer
18
+ //
19
+ // It is a payload rather than a value: `internal/payload.js` writes the model
20
+ // into `<script id="__uf_data">` with a `"$P<n>"` reference wherever the loader
21
+ // left a promise, and each of those arrives later in a `<script data-uf-row>`
22
+ // of its own. So two things happen before `hydrateRoot` rather than one — the
23
+ // model is decoded, and `internal/payload-rows.js` starts watching for the
24
+ // rows it referred to. Both have to be first: the decoded model is what the
25
+ // first render is handed, and a row that landed while nothing was watching
26
+ // would be a boundary that never resolves. A document with nothing deferred
27
+ // has no references, so the reader is handed no ids and installs nothing.
28
+ //
10
29
  // # A hydration that fails says what differed
11
30
  //
12
31
  // React reports a mismatch with one sentence and a list of the six things that
@@ -49,6 +68,17 @@
49
68
  // `app.react.strictMode: false` in `uf.config.js` turns it off. See
50
69
  // ubugeeei-prod/uf#516.
51
70
  //
71
+ // # And an application can decline to be navigated
72
+ //
73
+ // `app.rendering.navigation: "document"` is the whole application saying what
74
+ // the paragraph below says about one route: the document the server wrote is
75
+ // what a link produces, and the browser fetches the next one. It is *not* the
76
+ // same as declining to hydrate — the page still hydrates, so a `"use client"`
77
+ // component is still interactive — and what it removes is the takeover. The
78
+ // flag reaches the runtime through `installNavigation` before the first render;
79
+ // see `internal/runtime.js` for what `RouterProvider` and `Link` then do, and
80
+ // `docs/app/guide/rendering` for when a project wants it.
81
+ //
52
82
  // # A route can decline to be hydrated
53
83
  //
54
84
  // uf's server-component analysis decides which routes have a `"use client"`
@@ -61,17 +91,23 @@
61
91
 
62
92
  import * as React from "react";
63
93
  import { StrictMode, startTransition } from "react";
64
- import { hydrateRoot } from "react-dom/client";
94
+ import { createRoot, hydrateRoot } from "react-dom/client";
65
95
 
66
96
  import {
67
97
  type AppProps,
98
+ type Navigation,
68
99
  type RouteTable,
100
+ RedirectError,
69
101
  hasClientPage,
102
+ installNavigation,
70
103
  installRoutes,
71
104
  matchRoute,
105
+ resolveFailure,
72
106
  resolveMatch,
73
107
  } from "./internal/runtime.js";
74
108
  import { DATA_ID, ROOT_ID } from "./internal/document.js";
109
+ import { decodePayload } from "./internal/payload.js";
110
+ import { createPayloadReader, domObserver } from "./internal/payload-rows.js";
75
111
 
76
112
  /**
77
113
  * Hydrate the current document.
@@ -86,6 +122,7 @@ export async function hydrate(options: {|
86
122
  readonly notFound: RouteTable["notFound"],
87
123
  readonly errors: RouteTable["errors"],
88
124
  readonly strictMode?: boolean,
125
+ readonly navigation?: Navigation,
89
126
  |}): Promise<void> {
90
127
  const table: RouteTable = {
91
128
  routes: options.routes,
@@ -93,6 +130,11 @@ export async function hydrate(options: {|
93
130
  errors: options.errors,
94
131
  };
95
132
  installRoutes(table);
133
+ // Beside the table, and before anything renders. `"client"` when the entry
134
+ // says nothing, which is what `virtual:uf/client` generated before
135
+ // `app.rendering.navigation` existed and what a hand-written entry still
136
+ // means: the default is the behaviour, not the absence of one.
137
+ installNavigation(options.navigation ?? "client");
96
138
 
97
139
  // Before the loader data is read and before `resolveMatch` is called: both
98
140
  // would go looking for a page module that is not in this bundle.
@@ -102,12 +144,28 @@ export async function hydrate(options: {|
102
144
  }
103
145
 
104
146
  const url = window.location.pathname + window.location.search;
147
+ // Row 0 of the payload, and the reader that will fill in the rows it refers
148
+ // to. Both before `hydrateRoot`, and in this order: `decodePayload` is what
149
+ // tells the reader which rows the page is waiting for, and `watch` is what
150
+ // makes it notice the ones the server has not written yet. A document with
151
+ // nothing deferred has no references, so the reader is handed no ids, and
152
+ // `watch` returns without installing anything — see `internal/payload.js`.
105
153
  const embedded = document.getElementById(DATA_ID);
106
- const data = embedded != null ? JSON.parse(embedded.textContent ?? "null") : undefined;
154
+ const reader = createPayloadReader(document, domObserver(document));
155
+ const data =
156
+ embedded != null
157
+ ? decodePayload(
158
+ JSON.parse(embedded.textContent ?? "null"),
159
+ reader.resolve,
160
+ "the route's loader data",
161
+ )
162
+ : undefined;
163
+ reader.watch();
107
164
  const resolved = await resolveMatch(table, url, { data, skipLoader: embedded != null });
108
165
 
109
166
  const { App } = options;
110
167
  const container = document.getElementById(ROOT_ID) ?? document;
168
+ prepareDocumentForHydration(document);
111
169
 
112
170
  // The server's markup, and the reporter that will read it, in development
113
171
  // only. Both have to be in place *before* `hydrateRoot`: React repairs a
@@ -117,13 +175,16 @@ export async function hydrate(options: {|
117
175
  // `import.meta.hot` is the gate because it is the one signal that is right in
118
176
  // all three places this module is evaluated. Vite defines it while serving
119
177
  // and replaces it with `undefined` in a build, so the branch is statically
120
- // dead there; Node leaves it undefined, so `tests/library/rsc-split.test.js`
178
+ // dead there; Node leaves it undefined, so `packages/vite/rsc-split.test.js`
121
179
  // imports this file without a bundler and gets the production path. The
122
180
  // import is dynamic so that the overlay is not merely shaken out of a
123
181
  // production bundle but never reachable from one.
124
182
  let recovery = null;
183
+ let restoreDevHead = null;
125
184
  if (import.meta.hot != null) {
126
- const { captureServerMarkup, hydrationErrorHandler } = await import("./internal/hydration.js");
185
+ const { captureServerMarkup, hydrationErrorHandler, prepareDevHeadForHydration } =
186
+ await import("./internal/hydration.js");
187
+ restoreDevHead = prepareDevHeadForHydration(document);
127
188
  recovery = hydrationErrorHandler(container, captureServerMarkup(container), document);
128
189
  }
129
190
 
@@ -138,6 +199,9 @@ export async function hydrate(options: {|
138
199
  options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
139
200
  recovery == null ? undefined : { onRecoverableError: recovery },
140
201
  );
202
+ if (restoreDevHead != null) {
203
+ setTimeout(restoreDevHead, 250);
204
+ }
141
205
  });
142
206
 
143
207
  // And, in development only, whether the panel a developer is about to open
@@ -152,3 +216,121 @@ export async function hydrate(options: {|
152
216
  reportDevtools(window);
153
217
  }
154
218
  }
219
+
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
+ /**
257
+ * Render the current route into an empty shell.
258
+ *
259
+ * The single-page entry point: `app.rendering.modes: ["csr"]` writes one
260
+ * document with an empty root and no markup in it, and this is what fills it.
261
+ *
262
+ * # Why it is not `hydrate` with a flag
263
+ *
264
+ * Because hydration is React comparing what it renders against what a server
265
+ * sent, and here no server sent anything. `hydrateRoot` against an empty
266
+ * container is a mismatch on the first node of every page — React would report
267
+ * it, throw the shell away and render from scratch, which is this function
268
+ * with a warning in front of it. `createRoot` says what is actually happening:
269
+ * the browser is the only renderer this application has.
270
+ *
271
+ * Three more things follow from there, and each of them is a line below rather
272
+ * than an omission:
273
+ *
274
+ * * **The loader runs here.** `resolveMatch` fetches the route's modules and
275
+ * runs its loader in the browser, because there was no server render to run
276
+ * it in and no `<script id="__uf_data">` for it to have left an answer in.
277
+ * * **A URL that matches nothing is the not-found boundary**, resolved the
278
+ * way a server resolves it. The host served this shell for a URL it had no
279
+ * file for, so "nothing matched" is a perfectly ordinary arrival here
280
+ * rather than the exception it is during hydration.
281
+ * * **A redirect is the browser's.** `redirect()` from a loader throws before
282
+ * anything is rendered; on a server that becomes a 307 and here it becomes
283
+ * `location.replace`, which is the same instruction to the same browser.
284
+ */
285
+ export async function render(options: {|
286
+ readonly App: React.ComponentType<AppProps>,
287
+ readonly routes: RouteTable["routes"],
288
+ readonly notFound: RouteTable["notFound"],
289
+ readonly errors: RouteTable["errors"],
290
+ readonly strictMode?: boolean,
291
+ readonly navigation?: Navigation,
292
+ |}): Promise<void> {
293
+ const table: RouteTable = {
294
+ routes: options.routes,
295
+ notFound: options.notFound,
296
+ errors: options.errors,
297
+ };
298
+ installRoutes(table);
299
+ installNavigation(options.navigation ?? "client");
300
+
301
+ const url = window.location.pathname + window.location.search;
302
+ let resolved;
303
+ try {
304
+ resolved = await resolveMatch(table, url);
305
+ } catch (error) {
306
+ if (error instanceof RedirectError) {
307
+ window.location.replace(error.to);
308
+ return;
309
+ }
310
+ // The error boundary, chosen the same way the server chooses it. A throw
311
+ // from a loader is a page that cannot render, and rendering the boundary is
312
+ // what this application has instead of a 500.
313
+ resolved = await resolveFailure(table, url, error);
314
+ }
315
+
316
+ const { App } = options;
317
+ // An element, and never `document` — which is the other difference from
318
+ // `hydrate` above. `hydrateRoot` takes a document, because an app whose root
319
+ // layout renders `<html>` owns the whole of one and the server wrote it;
320
+ // `createRoot` does not, because creating a root *is* replacing the
321
+ // container's children and the container here would be the document. The
322
+ // shell always writes this element, so its absence means the document being
323
+ // rendered into is not one this build produced.
324
+ const container = document.getElementById(ROOT_ID);
325
+ if (container == null) {
326
+ throw new Error(
327
+ `@uniflowed/router: no #${ROOT_ID} in this document, so there is nothing to render into. ` +
328
+ "A single-page build writes the shell that carries it; this document came from " +
329
+ "somewhere else.",
330
+ );
331
+ }
332
+ const tree = <App url={url} initial={resolved} />;
333
+ createRoot(container).render(
334
+ options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
335
+ );
336
+ }
package/handler.js CHANGED
@@ -2,13 +2,13 @@
2
2
  //
3
3
  // Route handlers: a path that answers a request instead of rendering a page.
4
4
  //
5
- // `app/api/users/_uf.route.js` exporting `GET` and `POST` serves
5
+ // `app/api/users/$route.js` exporting `GET` and `POST` serves
6
6
  // `/api/users`. A handler takes a `Request` and returns a `Response` — the
7
7
  // platform's own types, not a framework's wrapper — because that is what runs
8
8
  // unchanged on Node.js, Bun, Deno and a Cloudflare Worker, and uf's whole
9
9
  // position is that the host is a capability rather than a target.
10
10
  //
11
- // // app/api/users/[id]/_uf.route.js
11
+ // // app/api/users/[id]/$route.js
12
12
  // // @flow
13
13
  // export async function GET(request: Request, context: HandlerContext) {
14
14
  // const user = await find(context.params.id);
package/index.js CHANGED
@@ -2,19 +2,26 @@
2
2
  //
3
3
  // `@uniflowed/router`: the file-system router.
4
4
  //
5
- // Pages live in `app/` as `_uf.page.js` (or `.mdx`), layouts as
6
- // `_uf.layout.js`, and `app.js` exports `routerView("./app")`. The route table
5
+ // Pages live in `app/` as `$page.js` (or `.mdx`), layouts as
6
+ // `$layout.js`, and `app.js` exports `routerView("./app")`. The route table
7
7
  // is generated from the directory at build time; this module is the runtime
8
8
  // that matches, loads, navigates and renders it.
9
9
  //
10
- // `_uf.not-found.js` and `_uf.error.js` are the two boundaries: the page for a
10
+ // `$not-found.js` and `$error.js` are the two boundaries: the page for a
11
11
  // path that matched nothing, and what renders in place of a subtree that threw.
12
12
  // Both are segment files, resolved by the nearest one above the path — and the
13
13
  // router root always has one of each, so a project that declares neither still
14
14
  // answers a 404 inside its own layouts rather than beside them.
15
15
  //
16
- // `_uf.template.js` is a layout that remounts on every navigation, for the
16
+ // `$template.js` is a layout that remounts on every navigation, for the
17
17
  // cases where a layout's persistence is the wrong default.
18
+ //
19
+ // A directory named `@team` is a parallel-route slot: it contributes no URL
20
+ // segment, and the layout of the segment that holds it receives the slot as a
21
+ // `team` prop beside `children`. The slot's pages are matched against the same
22
+ // URL the page is, so one URL renders two subtrees at once, and
23
+ // `$default.js` is what a slot renders when the URL matched none of its
24
+ // routes. See ubugeeei-prod/uf#267.
18
25
 
19
26
  import * as React from "react";
20
27
 
@@ -43,7 +50,10 @@ export type {
43
50
  RouteRecord,
44
51
  RouteTable,
45
52
  Router,
53
+ ResolvedSlot,
46
54
  SearchParams,
55
+ SlotRecord,
56
+ SlotRouteRecord,
47
57
  TemplateModule,
48
58
  TwitterCard,
49
59
  } from "./internal/runtime.js";
@@ -87,13 +97,26 @@ export type PageProps<
87
97
  readonly data: TData,
88
98
  |};
89
99
 
90
- /** Props an `_uf.error.js` component receives. */
100
+ /** Props an `$error.js` component receives. */
91
101
  export type ErrorProps = {|
92
102
  readonly error: RouteError,
93
103
  readonly reset: () => void,
94
104
  |};
95
105
 
96
- /** Props a layout receives. */
106
+ /**
107
+ * Props a layout receives.
108
+ *
109
+ * A layout on a segment that declares parallel-route slots receives one more
110
+ * prop per slot, named after the directory without its `@`, and this exact
111
+ * type does not describe those — the names are the project's. Declare them: a
112
+ * layout beside `@team` and `@analytics` is
113
+ *
114
+ * component Dashboard(children: React.Node, team: React.Node, analytics: React.Node)
115
+ *
116
+ * and the router passes `null` for a slot the URL addressed by neither a route
117
+ * of its own nor a `$default.js`, so `{team ?? <Empty />}` is a thing that
118
+ * can be written and relied on.
119
+ */
97
120
  export type LayoutProps<
98
121
  TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
99
122
  > = {|
@@ -61,9 +61,12 @@
61
61
  //
62
62
  // * **A reference format.** React's Flight payload can carry a reference to a
63
63
  // client module, a promise, or an element, and a decoder that reconstructs
64
- // those is a decoder that constructs attacker-chosen objects. uf has no such
65
- // payload (ubugeeei-prod/uf#252) and this grammar is not the place to grow
66
- // one quietly.
64
+ // those is a decoder that constructs attacker-chosen objects. uf's payload
65
+ // (`./payload.js`) now carries one of the three — a reference to a *row of
66
+ // itself*, which names nothing to construct — and it is a document the
67
+ // server writes rather than a body somebody sends. This grammar is the one
68
+ // an untrusted sender is decoded under, so it still has none, and it is
69
+ // still not the place to grow one quietly (ubugeeei-prod/uf#252).
67
70
  // * **Class instances, `Map`, `Set`, `Date`, `RegExp`, typed arrays.** Each
68
71
  // would need a tag in the payload saying which constructor to call, and a
69
72
  // tag naming a constructor is the oracle every deserialisation CVE is made