@uniflowed/hooks 0.0.0-alpha.13 → 0.0.0-alpha.14

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.
Files changed (4) hide show
  1. package/browser.js +85 -14
  2. package/index.js +1 -1
  3. package/package.json +3 -3
  4. package/render.js +151 -71
package/browser.js CHANGED
@@ -77,11 +77,14 @@
77
77
  // nothing for a snapshot to return until it does, and `useScrollLock` writes.
78
78
  // Each says so where it is defined.
79
79
  //
80
- // `useHash` is a store whose event the browser only half provides —
81
- // `hashchange` and `popstate` cover what a reader does, and a `pushState` fires
82
- // neither — so it keeps a registry of its own subscribers and announces its own
83
- // writes, the way `useStorage` does. That is a store with a gap named in it,
84
- // not a fourth kind of hook.
80
+ // `useHash` is a store with three subscriptions instead of one, because no
81
+ // single event covers the fragment. `hashchange` and `popstate` cover what a
82
+ // reader does; `history.pushState` fires neither, so a registry of its own
83
+ // subscribers covers what the hook itself writes; and `currententrychange`,
84
+ // where the Navigation API exists, covers the case neither of those reaches —
85
+ // a `pushState` made by other code, the router's own included. That is a store
86
+ // whose gap has shrunk to the browsers without the Navigation API, not a
87
+ // fourth kind of hook.
85
88
 
86
89
  import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "@uniflowed/react";
87
90
 
@@ -146,6 +149,29 @@ export type BrowserHistory = {
146
149
  ...
147
150
  };
148
151
 
152
+ /**
153
+ * The part of the Navigation API this module listens to.
154
+ *
155
+ * One event, and deliberately only one. `currententrychange` fires after the
156
+ * current history entry has changed *for any reason* — a link, the back
157
+ * button, and the two calls that fire nothing else, `history.pushState` and
158
+ * `history.replaceState`. It is the only thing the platform offers that hears
159
+ * a fragment written by code other than the writer, which is what makes
160
+ * `useHash` able to see `@uniflowed/router`'s own navigation.
161
+ *
162
+ * `navigate` and `navigateerror` are not here: intercepting a navigation is
163
+ * the router's business, and a hook that reads the fragment has no opinion
164
+ * about whether one should happen.
165
+ *
166
+ * Optional on [`BrowserWindow`], because this is an addition rather than the
167
+ * base — see `useHash` for which browsers have it and what the others get.
168
+ */
169
+ export type BrowserNavigation = {
170
+ readonly addEventListener: (type: "currententrychange", listener: () => mixed) => void,
171
+ readonly removeEventListener: (type: "currententrychange", listener: () => mixed) => void,
172
+ ...
173
+ };
174
+
149
175
  /** The Network Information object, which only Chromium has. */
150
176
  export type NetworkConnection = {
151
177
  readonly downlink?: number,
@@ -170,6 +196,7 @@ export type BrowserWindow = {
170
196
  readonly navigator: BrowserNavigator,
171
197
  readonly location?: ?BrowserLocation,
172
198
  readonly history?: ?BrowserHistory,
199
+ readonly navigation?: ?BrowserNavigation,
173
200
  readonly localStorage?: ?Storage,
174
201
  readonly sessionStorage?: ?Storage,
175
202
  readonly innerWidth: number,
@@ -346,25 +373,45 @@ export hook useDocumentVisible(serverValue: boolean = true): boolean {
346
373
  *
347
374
  * Module-level, because neither `history.pushState` nor `history.replaceState`
348
375
  * fires anything: a component that writes the fragment has to tell the others
349
- * itself, and there is nothing in the platform that will do it. The same
350
- * registry shape as `useStorage`'s, and balanced under Strict Mode for the same
351
- * reason — `subscribe` adds and the cleanup it returns removes.
376
+ * itself, and in a browser without the Navigation API there is nothing in the
377
+ * platform that will do it. The same registry shape as `useStorage`'s, and
378
+ * balanced under Strict Mode for the same reason — `subscribe` adds and the
379
+ * cleanup it returns removes.
380
+ *
381
+ * It is kept where `currententrychange` exists rather than being switched off
382
+ * there. The two overlap — a write through this hook is heard twice — and that
383
+ * costs nothing, because `useSyncExternalStore` compares the snapshot and the
384
+ * fragment is a string that has not changed between the two notifications.
385
+ * Switching it off, on the other hand, would make the hook's own writes depend
386
+ * on a feature detection, so a browser that reported a `navigation` object it
387
+ * did not fire events from would silently lose the guarantee that has held
388
+ * since this hook existed.
352
389
  */
353
390
  const fragmentListeners: Set<() => void> = new Set();
354
391
 
355
392
  /** Listen for every change to the fragment this tab can hear about. */
356
393
  function subscribeToFragment(notify: () => void): () => void {
357
394
  const win = browserWindow();
395
+ // Read once and closed over, so the cleanup removes the listener from the
396
+ // object it was added to. A `navigation` that appeared or vanished between
397
+ // the two would otherwise leave a listener behind on a hook that unmounted.
398
+ const navigation = win?.navigation;
358
399
  fragmentListeners.add(notify);
359
400
  // `hashchange` covers an anchor the reader clicked and an address bar they
360
401
  // edited; `popstate` covers back and forward, which fires only the second of
361
402
  // the two when the entry it lands on differs by more than the fragment.
362
403
  win?.addEventListener("hashchange", notify);
363
404
  win?.addEventListener("popstate", notify);
405
+ // And `currententrychange` covers the case the other two and the registry
406
+ // between them still miss: a `pushState` or `replaceState` made by code that
407
+ // is not this hook. `@uniflowed/router` makes exactly that call on every
408
+ // client navigation, which is why the gap was never hypothetical.
409
+ navigation?.addEventListener("currententrychange", notify);
364
410
  return () => {
365
411
  fragmentListeners.delete(notify);
366
412
  win?.removeEventListener("hashchange", notify);
367
413
  win?.removeEventListener("popstate", notify);
414
+ navigation?.removeEventListener("currententrychange", notify);
368
415
  };
369
416
  }
370
417
 
@@ -452,12 +499,36 @@ function notifyFragment(): void {
452
499
  * is what a tab strip wants: eleven tab clicks should not be eleven presses of
453
500
  * the back button.
454
501
  *
455
- * What this cannot see is a fragment some other code changed with
456
- * `history.pushState`, because that fires no event of any kind — not
457
- * `hashchange`, not `popstate`. Writes made through this hook announce
458
- * themselves to every other component using it; a `pushState` made anywhere
459
- * else is invisible to every listener the platform offers, and naming that is
460
- * more use than pretending otherwise.
502
+ * # What it hears, and where
503
+ *
504
+ * `history.pushState` and `history.replaceState` fire no event of any kind —
505
+ * not `hashchange`, not `popstate` — so what a hook can see depends on where
506
+ * the write came from and on what the browser has:
507
+ *
508
+ * | The write | Everywhere | Without the Navigation API |
509
+ * | --- | --- | --- |
510
+ * | the reader: an anchor, the address bar, back and forward | seen | seen |
511
+ * | this hook's own setter | seen | seen |
512
+ * | `pushState` from other code — `@uniflowed/router`'s navigation | seen | **not seen** |
513
+ *
514
+ * The first two are `hashchange`, `popstate` and the module's own registry.
515
+ * The third is `currententrychange`, which fires after the current history
516
+ * entry changes for any reason at all, and which is the only thing the
517
+ * platform offers that hears a write the writer did not announce.
518
+ *
519
+ * "Without the Navigation API" is now a narrow set: Chrome and Edge have had
520
+ * it since 102 (2022), Safari since 26.2 and Firefox since 147 — but a reader
521
+ * on an older Safari or Firefox is a reader this column describes, and there
522
+ * the registry is still the whole answer. A router navigation that changes
523
+ * only the fragment leaves such a page showing the section it was on.
524
+ *
525
+ * The fragment is deliberately not on `RouteInfo` — `useRoute()` cannot answer
526
+ * this question and should not learn to. A `RouteInfo` is what a *request*
527
+ * resolved to, and the browser strips the fragment before the request goes
528
+ * out, so a field for it would be one the server could never fill and the two
529
+ * renders would disagree about. This hook is the one answer, and
530
+ * `currententrychange` is what makes it a complete one on the browsers that
531
+ * have it rather than a second reading of a value the router also holds.
461
532
  */
462
533
  export hook useHash(): [
463
534
  string,
package/index.js CHANGED
@@ -223,7 +223,7 @@ export {
223
223
  } from "./dom.js";
224
224
  export { useKeyCombo, useKeyHeld } from "./keyboard.js";
225
225
  export {
226
- RENDER_ID,
226
+ RENDER_META,
227
227
  RenderProvider,
228
228
  useRandom,
229
229
  useRenderEnvelope,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/hooks",
3
- "version": "0.0.0-alpha.13",
3
+ "version": "0.0.0-alpha.14",
4
4
  "description": "The React hooks an application writes anyway, prerender-safe, part of the Unified Toolchain for Flow.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,8 +27,8 @@
27
27
  "*.js"
28
28
  ],
29
29
  "dependencies": {
30
- "@uniflowed/core": "0.0.0-alpha.13",
31
- "@uniflowed/react": "0.0.0-alpha.13"
30
+ "@uniflowed/core": "0.0.0-alpha.14",
31
+ "@uniflowed/react": "0.0.0-alpha.14"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "react": ">=19"
package/render.js CHANGED
@@ -32,18 +32,54 @@
32
32
  //
33
33
  // # How the value crosses the network
34
34
  //
35
- // `RenderProvider` writes what it decided into the markup, as an inert
36
- // `<script type="application/json">`, and reads it back on the client before
37
- // the first render — the same carrier and the same escaping
38
- // `@uniflowed/router` uses for loader data, because it is the same problem and
39
- // a second mechanism would be a second thing to get wrong. `dangerouslySetInnerHTML`
40
- // rather than a text child, because the HTML parser treats a `<script>` body as
41
- // raw text and does not decode entities: React's escaping of `&` would survive
42
- // into `JSON.parse` and fail there.
35
+ // `RenderProvider` writes what it decided into the markup, as a `<meta>`, and
36
+ // reads it back on the client before the first render.
43
37
  //
44
- // A page nobody prerendered has no script to read, decides both values from the
38
+ // A `<meta>` rather than the `<script type="application/json">` this used to
39
+ // be, and the reason is where the two end up. React hoists a `<title>`, a
40
+ // `<meta>` and a `<link>` and does not hoist a script: in a document React
41
+ // rendered, the meta goes into `<head>`; in a tree that is not a document — a
42
+ // `@uniflowed/router` application whose root layout renders content rather
43
+ // than `<html>` — React writes it at the front, which is the run uf's shell
44
+ // lifts into the head it wrote itself. A script had neither behaviour, so a
45
+ // provider rendered above a root layout that owns `<html>` emitted it *before*
46
+ // the document, and the router could not provide one without deciding where in
47
+ // the tree the application's `<html>` was. That is ubugeeei-prod/uf#559: a
48
+ // guarantee that depends on the application remembering to opt in is not one,
49
+ // and the carrier was the thing standing in the way of it being automatic.
50
+ //
51
+ // It also removes an escaping problem rather than solving one. A `<script>`
52
+ // body is raw text to the HTML parser, so the encoder had to remove `<` and
53
+ // the two line separators itself and the element needed
54
+ // `dangerouslySetInnerHTML`; an attribute value is escaped by React and decoded
55
+ // by the parser, so what `JSON.parse` gets back is what `JSON.stringify`
56
+ // produced, with nothing in between to get wrong.
57
+ //
58
+ // A page nobody prerendered has no meta to read, decides both values from the
45
59
  // host, and is correct for the reason that there is nothing to disagree with.
46
60
  //
61
+ // # Nesting replaces; it does not add
62
+ //
63
+ // The router renders one of these above every application, so an application
64
+ // that renders its own is nested inside that one. A nested provider inherits
65
+ // the envelope above it and overrides only the fields it was given, and it
66
+ // writes no second carrier — which is what makes "a project that wants a
67
+ // different clock or seed *replaces* it" true rather than aspirational. Two
68
+ // carriers would be two answers to one question, and the client reads the
69
+ // first.
70
+ //
71
+ // # What it costs, which is worth saying out loud
72
+ //
73
+ // Every uf document now carries an envelope, so two renders of one route are no
74
+ // longer the same bytes: the instant moved and the seed is a fresh one. That is
75
+ // the same price an application that followed the old advice and wrapped its own
76
+ // tree already paid — what changed is that every application pays it — and it is
77
+ // the price of the guarantee rather than an oversight. A render that has to be
78
+ // reproducible fixes `at` and `seed` itself, which is what those two props are
79
+ // for; `gives the same document however the host takes it` in
80
+ // `tests/library/streaming.test.js` is a test that compares two renders and says
81
+ // so.
82
+ //
47
83
  // # What belongs in this module
48
84
  //
49
85
  // A value that a render has to fix rather than derive. Two so far, and they are
@@ -56,6 +92,30 @@
56
92
  // — this module is about the one instant that must not move.
57
93
 
58
94
  import * as React from "@uniflowed/react";
95
+ // The values by name and the namespace beside it for the types, which is what
96
+ // every other module in this package already does. Not a style choice, and the
97
+ // reason is worth the paragraph.
98
+ //
99
+ // `@uniflowed/react` is `export * from "react"` over a CommonJS package, so its
100
+ // namespace is filled in by the bundle's own initializer at run time rather
101
+ // than being known while the bundle is built. Reaching through the namespace —
102
+ // `React.createContext(…)` — was a reference the bundler could not attribute to
103
+ // an export, so the module's initializer was emitted *without* the call that
104
+ // fills the namespace in, and the first line of this module ran against an
105
+ // empty object:
106
+ //
107
+ // var init_render = __esmMin(() => {
108
+ // init_clock();
109
+ // init_random();
110
+ // RenderContext = react_exports.createContext(null); // TypeError
111
+ // });
112
+ //
113
+ // A named import is a reference the bundler has to resolve while it is
114
+ // bundling, and the initializer comes back. It went unnoticed until the router
115
+ // began rendering a `RenderProvider`, because this module reached the one build
116
+ // where it matters — the single file `uf build --compile` links with Bun —
117
+ // only then, and it failed at startup rather than at a call site.
118
+ import { createContext, useContext, useState } from "@uniflowed/react";
59
119
  import { currentClock } from "@uniflowed/core/clock";
60
120
  import type { Random } from "@uniflowed/core/random";
61
121
  import { hostSeed, seededRandom, shuffled } from "@uniflowed/core/random";
@@ -72,10 +132,25 @@ export type RenderEnvelope = {
72
132
  readonly seed: string,
73
133
  };
74
134
 
75
- /** The element the envelope is written into, and read back out of. */
76
- export const RENDER_ID: string = "__uf_render";
135
+ /**
136
+ * The `<meta>` name the envelope is written under, and read back out of.
137
+ *
138
+ * A name rather than an id because that is what a `<meta>` is addressed by:
139
+ * `document.querySelector('meta[name=…]')` is the read, and React's own
140
+ * hoisting treats the `name`/`content` pair as the element's identity.
141
+ */
142
+ export const RENDER_META: string = "uf:render";
77
143
 
78
- const RenderContext: React.Context<RenderEnvelope | null> = React.createContext(null);
144
+ /**
145
+ * What the render above this one was anchored to, if anything was.
146
+ *
147
+ * Null outside a provider, which is the same question two callers ask of it:
148
+ * [`useRenderEnvelope`] asks whether the values were fixed at all, and
149
+ * [`RenderProvider`] asks whether it is the outermost one and therefore the
150
+ * one that writes the carrier. "Is there one above me" is exactly the question
151
+ * a context answers, and during a render there is no other way to ask it.
152
+ */
153
+ const RenderContext: React.Context<RenderEnvelope | null> = createContext(null);
79
154
 
80
155
  /**
81
156
  * The envelope the server left in the document, if there is one.
@@ -83,18 +158,22 @@ const RenderContext: React.Context<RenderEnvelope | null> = React.createContext(
83
158
  * Read from the DOM rather than from a global an inline script assigned,
84
159
  * because an inline script that runs is a script a content-security policy has
85
160
  * to allow, and this value is worth no relaxation of one.
161
+ *
162
+ * `querySelector` takes the first match rather than requiring the only one: a
163
+ * document that somehow carried two would still have an answer, and the first
164
+ * is the one every other reader of a duplicated `<meta>` takes.
86
165
  */
87
166
  function embedded(): RenderEnvelope | null {
88
167
  const document = globalThis.document;
89
168
  if (document == null) {
90
169
  return null;
91
170
  }
92
- const element = document.getElementById(RENDER_ID);
171
+ const element = document.querySelector(`meta[name="${RENDER_META}"]`);
93
172
  if (element == null) {
94
173
  return null;
95
174
  }
96
175
  try {
97
- const found = JSON.parse(element.textContent ?? "null");
176
+ const found = JSON.parse(element.getAttribute("content") ?? "null");
98
177
  if (found == null || typeof found.at !== "number" || typeof found.seed !== "string") {
99
178
  return null;
100
179
  }
@@ -109,9 +188,20 @@ function embedded(): RenderEnvelope | null {
109
188
  }
110
189
  }
111
190
 
112
- /** Decide what this render is anchored to, from props, from the markup, or from the host. */
113
- function envelope(given: { at?: number, timeZone?: string, seed?: string }): RenderEnvelope {
114
- const found = embedded();
191
+ /**
192
+ * Decide what this render is anchored to.
193
+ *
194
+ * What the caller gave, then what a provider above already fixed, then what
195
+ * the markup carries, then the host — in that order, per field. `inherited`
196
+ * comes before `embedded()` because a provider that overrode `at` for its
197
+ * subtree must not have `timeZone` read back out of the document and quietly
198
+ * paired with somebody else's instant.
199
+ */
200
+ function envelope(
201
+ given: { at?: number, timeZone?: string, seed?: string },
202
+ inherited: RenderEnvelope | null,
203
+ ): RenderEnvelope {
204
+ const found = inherited ?? embedded();
115
205
  const clock = currentClock();
116
206
  return {
117
207
  at: given.at ?? found?.at ?? clock.now(),
@@ -120,47 +210,32 @@ function envelope(given: { at?: number, timeZone?: string, seed?: string }): Ren
120
210
  };
121
211
  }
122
212
 
123
- /**
124
- * `envelope` as the text of a `<script type="application/json">`.
125
- *
126
- * `<` is escaped so that a zone name or a seed holding `</script>` cannot end
127
- * the element early, and the two line separators are escaped because they are
128
- * newlines to a JavaScript parser and are not to `JSON.stringify`. The same
129
- * three replacements `@uniflowed/router` makes, deliberately duplicated rather
130
- * than shared: this package does not depend on the router, and three lines are
131
- * not worth an import that would drag one in.
132
- */
133
- function encode(value: RenderEnvelope): string {
134
- return JSON.stringify(value)
135
- .replace(/</g, "\\u003c")
136
- .replace(/\u2028/g, "\\u2028")
137
- .replace(/\u2029/g, "\\u2029");
138
- }
139
-
140
213
  /**
141
214
  * Fix this render's instant, zone and seed, and hand them to the tree.
142
215
  *
143
- * Render it once, above everything that reads a clock — a root layout is where
144
- * it belongs. Every argument is optional and the defaults are the whole point:
145
- * a server decides, the markup carries what it decided, and the browser reads it
146
- * back before its first render, so neither side has to be told which one it is.
216
+ * A `@uniflowed/router` application already has one: `routerView` renders this
217
+ * above everything, so `useRenderedAt` and `useRandom` agree across hydration
218
+ * without the application saying anything. Rendering one by hand is for the
219
+ * cases that need different values, and it is a *replacement* rather than an
220
+ * addition — a nested provider inherits the envelope above it, overrides only
221
+ * the fields it was given, and writes no second carrier. See
222
+ * ubugeeei-prod/uf#559.
223
+ *
224
+ * Every argument is optional and the defaults are the whole point: a server
225
+ * decides, the markup carries what it decided, and the browser reads it back
226
+ * before its first render, so neither side has to be told which one it is.
147
227
  *
148
228
  * `at` and `seed` are there for the two cases that are not that. A test passes
149
229
  * them to get a page that renders the same bytes every time; an application
150
230
  * whose instant comes from somewhere better — a request header, a loader —
151
- * passes that instead.
231
+ * passes that instead. Both have to be values the *browser* arrives at too:
232
+ * they are not carried, because what is carried is the outermost envelope, and
233
+ * a value only one side can compute is the mismatch this module exists to
234
+ * remove.
152
235
  *
153
- * `security/no-dangerously-set-inner-html` is suppressed on the one line that
154
- * needs it, and the argument is narrow enough to state exactly. The rule is
155
- * about markup that came from somewhere — a comment, a profile, a response —
156
- * and its escape hatch is a `@uniflowed/markdown` sanitizer, which is the right
157
- * answer for markup and no answer at all for JSON. What is written here is
158
- * three fields this module produced: two of them are typed `number` and
159
- * `string` and all three go through `JSON.stringify` and then `encode`, which
160
- * removes the only three characters that can end a `<script>` early or split a
161
- * line inside one. There is also no other spelling — the HTML parser reads a
162
- * `<script>` body as raw text and does not decode entities, so React's escaping
163
- * of a text child would survive into `JSON.parse` and fail there.
236
+ * The carrier is a `<meta>`, which is what lets this be rendered above a root
237
+ * layout that owns `<html>`: React hoists it into the head of the document
238
+ * either way. The header of this file has the whole argument.
164
239
  */
165
240
  export component RenderProvider(
166
241
  at?: number,
@@ -168,19 +243,22 @@ export component RenderProvider(
168
243
  seed?: string,
169
244
  children: React.Node,
170
245
  ) {
246
+ const enclosing = useContext(RenderContext);
171
247
  // The initializer runs once per mount, on both sides, which is what makes
172
248
  // this a fixed value rather than a clock: a re-render for any other reason
173
249
  // must not move the instant the page has already been drawn with.
174
- const [value] = React.useState(() => envelope({ at, timeZone, seed }));
250
+ const [decided] = useState(() => envelope({ at, timeZone, seed }, enclosing));
251
+ // The outermost provider writes the carrier and a nested one does not, so a
252
+ // document holds one envelope however many providers a tree has. Not state,
253
+ // because whether there is a provider above this one is a fact about the
254
+ // shape of the tree: a subtree cannot gain or lose an enclosing provider
255
+ // without being remounted, so this cannot change under a re-render and ask
256
+ // React to add or remove an element the server's markup already settled.
257
+ const outermost = enclosing == null;
175
258
 
176
259
  return (
177
- <RenderContext.Provider value={value}>
178
- <script
179
- id={RENDER_ID}
180
- type="application/json"
181
- // uf-lint-disable-next-line security/no-dangerously-set-inner-html
182
- dangerouslySetInnerHTML={{ __html: encode(value) }}
183
- />
260
+ <RenderContext.Provider value={decided}>
261
+ {outermost ? <meta name={RENDER_META} content={JSON.stringify(decided)} /> : null}
184
262
  {children}
185
263
  </RenderContext.Provider>
186
264
  );
@@ -194,7 +272,7 @@ export component RenderProvider(
194
272
  * clock, which is the behaviour they have always had.
195
273
  */
196
274
  export hook useRenderEnvelope(): RenderEnvelope | null {
197
- return React.useContext(RenderContext);
275
+ return useContext(RenderContext);
198
276
  }
199
277
 
200
278
  /**
@@ -212,10 +290,11 @@ export hook useRenderEnvelope(): RenderEnvelope | null {
212
290
  export hook useRenderedAt(): Instant {
213
291
  const found = useRenderEnvelope();
214
292
  const at = found?.at;
215
- // The number, not the instant, in the dependency: `Instant` is a new object
216
- // every render and depending on it would rebuild this on every one.
293
+ // The number, not the instant, is what the memoization keys on: `Instant` is
294
+ // a new object every render, so a scope that depended on one would rebuild
295
+ // this on every render rather than on every change of clock.
217
296
  const clockAt = at ?? currentClock().now();
218
- return React.useMemo(() => Temporal.Instant.fromEpochMilliseconds(clockAt), [clockAt]);
297
+ return Temporal.Instant.fromEpochMilliseconds(clockAt);
219
298
  }
220
299
 
221
300
  /**
@@ -243,9 +322,9 @@ export hook useRenderTimeZone(): string {
243
322
  * two sides.
244
323
  *
245
324
  * The stream is stateful, so a component that draws from it during render draws
246
- * different numbers on a re-render. Draw in a `useMemo` keyed by what the
247
- * numbers are for, or in an event, and never in the body of a component React
248
- * may render twice.
325
+ * different numbers on a re-render. Draw into a `const` keyed by what the
326
+ * numbers are for — which the React Compiler memoizes — or in an event, and
327
+ * never twice in the body of a component React may render twice.
249
328
  *
250
329
  * Outside a provider the seed is a constant rather than the host's, which looks
251
330
  * like the wrong default and is the right one: two renders with no envelope
@@ -257,18 +336,19 @@ export hook useRenderTimeZone(): string {
257
336
  export hook useRandom(label: string): Random {
258
337
  const found = useRenderEnvelope();
259
338
  const seed = found?.seed;
260
- return React.useMemo(() => seededRandom(seed ?? "uf").fork(label), [seed, label]);
339
+ return seededRandom(seed ?? "uf").fork(label);
261
340
  }
262
341
 
263
342
  /**
264
343
  * `items`, shuffled the same way on both sides of a hydration.
265
344
  *
266
- * The shuffle is a `useMemo` over the seed, the label and the items, so it is
267
- * one shuffle rather than one per render — which matters for more than speed:
268
- * a fresh draw on every render would reorder the list under the reader every
269
- * time anything else on the page changed.
345
+ * The shuffle is memoized over the seed, the label and the items — by the React
346
+ * Compiler, which is where uf's memoization comes from — so it is one shuffle
347
+ * rather than one per render. That matters for more than speed: a fresh draw on
348
+ * every render would reorder the list under the reader every time anything else
349
+ * on the page changed.
270
350
  */
271
351
  export hook useShuffled<T>(items: $ReadOnlyArray<T>, label: string): Array<T> {
272
352
  const random = useRandom(label);
273
- return React.useMemo(() => shuffled(items, random), [items, random]);
353
+ return shuffled(items, random);
274
354
  }