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

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
@@ -108,6 +108,7 @@ import {
108
108
  import { DATA_ID, ROOT_ID } from "./internal/document.js";
109
109
  import { decodePayload } from "./internal/payload.js";
110
110
  import { createPayloadReader, domObserver } from "./internal/payload-rows.js";
111
+ import { installBrowserModules, readDocumentPayload } from "./internal/flight-browser.js";
111
112
 
112
113
  /**
113
114
  * Hydrate the current document.
@@ -217,6 +218,70 @@ export async function hydrate(options: {|
217
218
  }
218
219
  }
219
220
 
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
+
220
285
  function prepareDocumentForHydration(document: Document): void {
221
286
  const head = document.head;
222
287
  const envelope = head.querySelector('meta[name="uf:render"]');
package/index.js CHANGED
@@ -22,6 +22,12 @@
22
22
  // URL the page is, so one URL renders two subtrees at once, and
23
23
  // `$default.js` is what a slot renders when the URL matched none of its
24
24
  // routes. See ubugeeei-prod/uf#267.
25
+ //
26
+ // A directory named `(.)photo` inside a slot is an intercepting route: a client
27
+ // navigation that starts on a page the slot is on, and reaches the URL the
28
+ // directory stands in for, renders it in the slot and leaves the page
29
+ // underneath where it was. A document request for that URL — a reload, a
30
+ // shared link, a prerender — renders the ordinary page.
25
31
 
26
32
  import * as React from "react";
27
33
 
@@ -31,6 +37,7 @@ export type {
31
37
  AppProps,
32
38
  ErrorBoundary,
33
39
  ErrorModule,
40
+ Interception,
34
41
  JsonLd,
35
42
  LayoutModule,
36
43
  LinkPrefetch,
@@ -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.