@uniflowed/hooks 0.0.0-alpha.9 → 0.2.0
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/async.js +3 -0
- package/browser.js +393 -35
- package/events.js +300 -0
- package/index.js +43 -5
- package/lifecycle.js +6 -0
- package/package.json +7 -3
- package/render.js +354 -0
- package/state.js +35 -2
- package/timing.js +45 -10
package/render.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/hooks/render`: the two things a render must decide once.
|
|
4
|
+
//
|
|
5
|
+
// A page that is prerendered is rendered twice — once on a server, once in the
|
|
6
|
+
// browser that hydrates it — and React compares the two. Anything the second
|
|
7
|
+
// render works out for itself differs from the first, and the two that differ
|
|
8
|
+
// in practice are the clock and the random number generator. `new Date()` is a
|
|
9
|
+
// different instant on the two machines and `Math.random()` is a different
|
|
10
|
+
// number by construction, so a countdown, a greeting that depends on the hour,
|
|
11
|
+
// a shuffled list of featured articles and a randomly chosen placeholder are
|
|
12
|
+
// each a hydration mismatch that the application did nothing to deserve.
|
|
13
|
+
//
|
|
14
|
+
// The usual advice is to render nothing until an effect has run. That works,
|
|
15
|
+
// and it costs the page: the value is invisible to a crawler and to a reader
|
|
16
|
+
// with no JavaScript, and it arrives one frame late and moves the layout when
|
|
17
|
+
// it does.
|
|
18
|
+
//
|
|
19
|
+
// This module is the other answer. The render decides both values once, on
|
|
20
|
+
// whichever side goes first, and the other side reads what was decided instead
|
|
21
|
+
// of deciding again. Both renders then produce the same markup, because they
|
|
22
|
+
// are working from the same two numbers.
|
|
23
|
+
//
|
|
24
|
+
// # Why a provider, and not module state
|
|
25
|
+
//
|
|
26
|
+
// A clock installed in `@uniflowed/core/clock` is the process's. That is right
|
|
27
|
+
// for a test and for a runtime, and wrong for a server: a server renders
|
|
28
|
+
// several requests at once, and two responses that shared one "rendered at"
|
|
29
|
+
// would each be stamped with whenever the other one started. React's context is
|
|
30
|
+
// per-render by construction, which is the granularity this actually needs, so
|
|
31
|
+
// the values travel through the tree rather than beside it.
|
|
32
|
+
//
|
|
33
|
+
// # How the value crosses the network
|
|
34
|
+
//
|
|
35
|
+
// `RenderProvider` writes what it decided into the markup, as a `<meta>`, and
|
|
36
|
+
// reads it back on the client before the first render.
|
|
37
|
+
//
|
|
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
|
|
59
|
+
// host, and is correct for the reason that there is nothing to disagree with.
|
|
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
|
+
// `packages/router/streaming.test.js` is a test that compares two renders and says
|
|
81
|
+
// so.
|
|
82
|
+
//
|
|
83
|
+
// # What belongs in this module
|
|
84
|
+
//
|
|
85
|
+
// A value that a render has to fix rather than derive. Two so far, and they are
|
|
86
|
+
// the two React itself has an answer for exactly one of: `useId` solves ids by
|
|
87
|
+
// deriving them from the position in the tree, which works for an id and for
|
|
88
|
+
// nothing that has to be shuffled or counted from.
|
|
89
|
+
//
|
|
90
|
+
// Not here: a hook that reads a clock to schedule work. `useInterval`,
|
|
91
|
+
// `useTimeout` and `useNow` are `timing.js`'s, and they read the *current* time
|
|
92
|
+
// — this module is about the one instant that must not move.
|
|
93
|
+
|
|
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";
|
|
119
|
+
import { currentClock } from "@uniflowed/core/clock";
|
|
120
|
+
import type { Random } from "@uniflowed/core/random";
|
|
121
|
+
import { hostSeed, seededRandom, shuffled } from "@uniflowed/core/random";
|
|
122
|
+
import type { Instant } from "@uniflowed/core/temporal";
|
|
123
|
+
import { Temporal } from "@uniflowed/core/temporal";
|
|
124
|
+
|
|
125
|
+
/** What a render fixes, and what travels to the client. */
|
|
126
|
+
export type RenderEnvelope = {
|
|
127
|
+
/** The instant the render was anchored to, in epoch milliseconds. */
|
|
128
|
+
readonly at: number,
|
|
129
|
+
/** The IANA zone the server was in. Not the reader's — see `Time`. */
|
|
130
|
+
readonly timeZone: string,
|
|
131
|
+
/** The seed both sides replay the same numbers from. */
|
|
132
|
+
readonly seed: string,
|
|
133
|
+
};
|
|
134
|
+
|
|
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";
|
|
143
|
+
|
|
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);
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The envelope the server left in the document, if there is one.
|
|
157
|
+
*
|
|
158
|
+
* Read from the DOM rather than from a global an inline script assigned,
|
|
159
|
+
* because an inline script that runs is a script a content-security policy has
|
|
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.
|
|
165
|
+
*/
|
|
166
|
+
function embedded(): RenderEnvelope | null {
|
|
167
|
+
const document = globalThis.document;
|
|
168
|
+
if (document == null) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const element = document.querySelector(`meta[name="${RENDER_META}"]`);
|
|
172
|
+
if (element == null) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const found = JSON.parse(element.getAttribute("content") ?? "null");
|
|
177
|
+
if (found == null || typeof found.at !== "number" || typeof found.seed !== "string") {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
return { at: found.at, timeZone: String(found.timeZone), seed: found.seed };
|
|
181
|
+
} catch {
|
|
182
|
+
// A truncated document — a stream that was cut off — leaves half a JSON
|
|
183
|
+
// object here. Deciding fresh values is wrong on that page in exactly the
|
|
184
|
+
// way this module exists to prevent, and it is still better than a render
|
|
185
|
+
// that throws: the page renders, and hydration reports what it always
|
|
186
|
+
// would have.
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
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();
|
|
205
|
+
const clock = currentClock();
|
|
206
|
+
return {
|
|
207
|
+
at: given.at ?? found?.at ?? clock.now(),
|
|
208
|
+
timeZone: given.timeZone ?? found?.timeZone ?? clock.timeZone(),
|
|
209
|
+
seed: given.seed ?? found?.seed ?? hostSeed(),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Fix this render's instant, zone and seed, and hand them to the tree.
|
|
215
|
+
*
|
|
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.
|
|
227
|
+
*
|
|
228
|
+
* `at` and `seed` are there for the two cases that are not that. A test passes
|
|
229
|
+
* them to get a page that renders the same bytes every time; an application
|
|
230
|
+
* whose instant comes from somewhere better — a request header, a loader —
|
|
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.
|
|
235
|
+
*
|
|
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.
|
|
239
|
+
*/
|
|
240
|
+
export component RenderProvider(
|
|
241
|
+
at?: number,
|
|
242
|
+
timeZone?: string,
|
|
243
|
+
seed?: string,
|
|
244
|
+
children: React.Node,
|
|
245
|
+
) {
|
|
246
|
+
const enclosing = useContext(RenderContext);
|
|
247
|
+
// The initializer runs once per mount, on both sides, which is what makes
|
|
248
|
+
// this a fixed value rather than a clock: a re-render for any other reason
|
|
249
|
+
// must not move the instant the page has already been drawn with.
|
|
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;
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<RenderContext.Provider value={decided}>
|
|
261
|
+
{outermost ? <meta name={RENDER_META} content={JSON.stringify(decided)} /> : null}
|
|
262
|
+
{children}
|
|
263
|
+
</RenderContext.Provider>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* What this render was anchored to, or `null` outside a `RenderProvider`.
|
|
269
|
+
*
|
|
270
|
+
* Null rather than a fabricated envelope, because "nobody fixed these values"
|
|
271
|
+
* is a fact the hooks in `timing.js` act on: without a provider they read the
|
|
272
|
+
* clock, which is the behaviour they have always had.
|
|
273
|
+
*/
|
|
274
|
+
export hook useRenderEnvelope(): RenderEnvelope | null {
|
|
275
|
+
return useContext(RenderContext);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The instant this page was rendered at, as a `Temporal.Instant`.
|
|
280
|
+
*
|
|
281
|
+
* Constant for the life of the render, on both sides, which is what makes it
|
|
282
|
+
* safe to put in the markup. It is not "now" and does not become "now": a page
|
|
283
|
+
* left open for an hour still reports the instant it was rendered at, and a
|
|
284
|
+
* label that has to stay true while the reader looks at it is `useTimeAgo`.
|
|
285
|
+
*
|
|
286
|
+
* Falls back to the clock outside a provider, which is right for a page that is
|
|
287
|
+
* only ever rendered once — and is a hydration mismatch on one that is
|
|
288
|
+
* prerendered, which is what the provider is for.
|
|
289
|
+
*/
|
|
290
|
+
export hook useRenderedAt(): Instant {
|
|
291
|
+
const found = useRenderEnvelope();
|
|
292
|
+
const at = found?.at;
|
|
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.
|
|
296
|
+
const clockAt = at ?? currentClock().now();
|
|
297
|
+
return Temporal.Instant.fromEpochMilliseconds(clockAt);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The zone the render was made in.
|
|
302
|
+
*
|
|
303
|
+
* The server's, not the reader's, and the distinction is the second half of the
|
|
304
|
+
* hydration problem rather than a detail: markup formatted in the reader's zone
|
|
305
|
+
* cannot match markup formatted in the server's, so a component renders this one
|
|
306
|
+
* and localises after hydration. `@uniflowed/web`'s `Time` is that component.
|
|
307
|
+
*/
|
|
308
|
+
export hook useRenderTimeZone(): string {
|
|
309
|
+
const found = useRenderEnvelope();
|
|
310
|
+
return found?.timeZone ?? currentClock().timeZone();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* A stream of random numbers that both renders produce identically.
|
|
315
|
+
*
|
|
316
|
+
* `label` names the stream, and naming it is what makes it independent of every
|
|
317
|
+
* other one: two components that ask for `"featured"` and `"sidebar"` get the
|
|
318
|
+
* same numbers whatever order they render in, and whatever suspends between
|
|
319
|
+
* them. Sharing one stream would make each component's numbers depend on how
|
|
320
|
+
* many the components above it happened to draw — stable in a synchronous
|
|
321
|
+
* render, and not stable once a boundary resolves at a different moment on the
|
|
322
|
+
* two sides.
|
|
323
|
+
*
|
|
324
|
+
* The stream is stateful, so a component that draws from it during render draws
|
|
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.
|
|
328
|
+
*
|
|
329
|
+
* Outside a provider the seed is a constant rather than the host's, which looks
|
|
330
|
+
* like the wrong default and is the right one: two renders with no envelope
|
|
331
|
+
* between them still have to agree, and a constant is the only seed both of them
|
|
332
|
+
* can arrive at. What it costs is that every such page shuffles the same way,
|
|
333
|
+
* which is a reason to render a provider rather than a reason to be
|
|
334
|
+
* unpredictable here.
|
|
335
|
+
*/
|
|
336
|
+
export hook useRandom(label: string): Random {
|
|
337
|
+
const found = useRenderEnvelope();
|
|
338
|
+
const seed = found?.seed;
|
|
339
|
+
return seededRandom(seed ?? "uf").fork(label);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* `items`, shuffled the same way on both sides of a hydration.
|
|
344
|
+
*
|
|
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.
|
|
350
|
+
*/
|
|
351
|
+
export hook useShuffled<T>(items: $ReadOnlyArray<T>, label: string): Array<T> {
|
|
352
|
+
const random = useRandom(label);
|
|
353
|
+
return shuffled(items, random);
|
|
354
|
+
}
|
package/state.js
CHANGED
|
@@ -390,6 +390,31 @@ function announce(key: string): void {
|
|
|
390
390
|
}
|
|
391
391
|
}
|
|
392
392
|
|
|
393
|
+
/**
|
|
394
|
+
* `localStorage` or `sessionStorage`, or `null` where neither is readable.
|
|
395
|
+
*
|
|
396
|
+
* # Why this guard is written twice
|
|
397
|
+
*
|
|
398
|
+
* `@uniflowed/state`'s `createJSONStorage`
|
|
399
|
+
* (`packages/state/internal/composed.js`) guards the same four hazards — a
|
|
400
|
+
* storage property that throws, a read that throws, a write that throws, and
|
|
401
|
+
* finding the object a `storage` event arrives on — and reaches the same
|
|
402
|
+
* conclusions about each. Merging the two was considered and declined;
|
|
403
|
+
* ubugeeei-prod/uf#318 is the issue, and this is half of the decision. The other half is
|
|
404
|
+
* in `createJSONStorage`, which carries the argument in full.
|
|
405
|
+
*
|
|
406
|
+
* In short: the helper would have to live in a package both may depend on,
|
|
407
|
+
* `@uniflowed/web` is the only candidate, and it is not on npm while this
|
|
408
|
+
* package is — so the edge would make `npm install @uniflowed/hooks` answer
|
|
409
|
+
* `ETARGET`. `tools/ci/publishable.sh` refuses it now.
|
|
410
|
+
*
|
|
411
|
+
* What is *not* shared even in principle is the listener registry above. Two
|
|
412
|
+
* components reading one key in one document have to agree, so a write here
|
|
413
|
+
* announces itself; `createJSONStorage` deliberately does not announce, so
|
|
414
|
+
* that two stores in one process stay two stores. A shared helper would have
|
|
415
|
+
* had to leave that decision to its caller, which is most of what there was
|
|
416
|
+
* to share.
|
|
417
|
+
*/
|
|
393
418
|
function area(session: boolean): Storage | null {
|
|
394
419
|
const win = browserWindow();
|
|
395
420
|
if (win == null) {
|
|
@@ -453,12 +478,20 @@ export hook useStorage<T>(
|
|
|
453
478
|
() => null,
|
|
454
479
|
);
|
|
455
480
|
|
|
456
|
-
const value = useMemo(() => {
|
|
481
|
+
const value = useMemo((): T => {
|
|
457
482
|
if (raw == null) {
|
|
458
483
|
return initial;
|
|
459
484
|
}
|
|
460
485
|
try {
|
|
461
|
-
|
|
486
|
+
// The one unchecked step in this hook, and the comparison with
|
|
487
|
+
// `createJSONStorage` is what turned it up: that one marks the cast and
|
|
488
|
+
// offers a `revive` to close it, this one used to hand `JSON.parse`'s
|
|
489
|
+
// `any` back as a `T` without saying so. The trade is the same and so is
|
|
490
|
+
// the reason — persistence is a cache, and a cache that refuses to start
|
|
491
|
+
// because an older version of the application wrote the key is worse
|
|
492
|
+
// than one that is occasionally stale — but it is a trade, so it is
|
|
493
|
+
// named.
|
|
494
|
+
return JSON.parse(raw) as $FlowFixMe;
|
|
462
495
|
} catch {
|
|
463
496
|
return initial;
|
|
464
497
|
}
|
package/timing.js
CHANGED
|
@@ -32,11 +32,27 @@
|
|
|
32
32
|
// `@uniflowed/web` to get there, because a hook library that pulled in a
|
|
33
33
|
// component library would be the wrong direction for the one arrow between
|
|
34
34
|
// them.
|
|
35
|
+
//
|
|
36
|
+
// # Where the time comes from
|
|
37
|
+
//
|
|
38
|
+
// Not from `Date.now()`. Every read in this module goes through
|
|
39
|
+
// `@uniflowed/core/clock`, which is a seam a test, a server or a runtime can
|
|
40
|
+
// put its own clock behind — so "3 minutes ago" is a value a test can assert
|
|
41
|
+
// rather than a value it has to wait three minutes for, and a server render is
|
|
42
|
+
// reproducible rather than being stamped with whenever it happened to run.
|
|
43
|
+
//
|
|
44
|
+
// A throttle measured against an installed clock is a throttle that does not
|
|
45
|
+
// elapse while that clock is stopped. That is not a defect to work around: a
|
|
46
|
+
// fixed clock means time is not passing, and a rate limit that fired anyway
|
|
47
|
+
// would be measuring something other than the time the caller said it was.
|
|
48
|
+
// `manualClock` is the one to install when a test wants the window to close.
|
|
35
49
|
|
|
36
50
|
import { useEffect, useMemo, useRef, useState } from "@uniflowed/react";
|
|
51
|
+
import { currentClock } from "@uniflowed/core/clock";
|
|
37
52
|
|
|
38
53
|
import { browserWindow } from "./browser.js";
|
|
39
54
|
import { useMounted, useStableCallback } from "./lifecycle.js";
|
|
55
|
+
import { useRenderEnvelope } from "./render.js";
|
|
40
56
|
|
|
41
57
|
/**
|
|
42
58
|
* Call `body` every `millis`, or not at all when `millis` is null.
|
|
@@ -103,7 +119,7 @@ export hook useThrottledCallback<TArgs extends $ReadOnlyArray<mixed>>(
|
|
|
103
119
|
const last = useRef(0);
|
|
104
120
|
|
|
105
121
|
return useStableCallback<TArgs, void>((...args: TArgs) => {
|
|
106
|
-
const now =
|
|
122
|
+
const now = currentClock().now();
|
|
107
123
|
if (now - last.current >= millis) {
|
|
108
124
|
last.current = now;
|
|
109
125
|
stable(...args);
|
|
@@ -262,26 +278,42 @@ export hook useIdle(
|
|
|
262
278
|
* bounded: it happens once, the value is never re-read during a render, and a
|
|
263
279
|
* render React throws away is replaced by another whose clock is just as valid.
|
|
264
280
|
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
281
|
+
* On a prerendered page the two renders are at two different instants, so the
|
|
282
|
+
* first one on each side has to be the *same* instant or React reports a
|
|
283
|
+
* mismatch. Under a `RenderProvider` that happens by itself — the anchor the
|
|
284
|
+
* server fixed travels in the markup, both sides start from it, and the real
|
|
285
|
+
* time arrives with the first effect. `serverValue` is the same choice made by
|
|
286
|
+
* hand, for a caller who has the instant from somewhere else and for a tree
|
|
287
|
+
* with no provider above it; it wins over the anchor when both are there,
|
|
288
|
+
* because an argument at the call site is a decision and a context is a
|
|
289
|
+
* default.
|
|
290
|
+
*
|
|
291
|
+
* A `Date` rather than a `Temporal.Instant`, and deliberately: this value's
|
|
292
|
+
* consumers subtract it from another one to decide when to run again, which is
|
|
293
|
+
* millisecond arithmetic on a number. The Temporal-shaped reading of the same
|
|
294
|
+
* anchor is `useRenderedAt` in `render.js`, and rendering an instant is
|
|
295
|
+
* `@uniflowed/web`'s `Time`.
|
|
270
296
|
*/
|
|
271
297
|
export hook useNow(millis: number | null = 1000, serverValue: Date | null = null): Date {
|
|
272
298
|
// The instant rather than the object: a caller writing `new Date(...)` in
|
|
273
299
|
// the call passes a different object every render, and a dependency on it
|
|
274
300
|
// would re-run the effect forever.
|
|
275
|
-
const
|
|
276
|
-
const
|
|
301
|
+
const anchored = useRenderEnvelope()?.at ?? null;
|
|
302
|
+
const since = serverValue == null ? anchored : serverValue.getTime();
|
|
303
|
+
const [now, setNow] = useState<Date>(
|
|
304
|
+
() => new Date(since == null ? currentClock().now() : since),
|
|
305
|
+
);
|
|
277
306
|
|
|
278
307
|
useEffect(() => {
|
|
279
308
|
if (since != null) {
|
|
280
|
-
|
|
309
|
+
// A prerender starts from the server's timestamp for hydration, then
|
|
310
|
+
// adopts the live clock once effects can run.
|
|
311
|
+
// uf-lint-disable-next-line react-compiler/set-state-in-effect
|
|
312
|
+
setNow(new Date(currentClock().now()));
|
|
281
313
|
}
|
|
282
314
|
}, [since]);
|
|
283
315
|
|
|
284
|
-
useInterval(() => setNow(new Date()), millis);
|
|
316
|
+
useInterval(() => setNow(new Date(currentClock().now())), millis);
|
|
285
317
|
return now;
|
|
286
318
|
}
|
|
287
319
|
|
|
@@ -398,6 +430,9 @@ export hook useTimeAgo(
|
|
|
398
430
|
|
|
399
431
|
const wanted = cadence(Math.abs(now.getTime() - instant));
|
|
400
432
|
useEffect(() => {
|
|
433
|
+
// The schedule is derived from the ticking external clock. Storing it here
|
|
434
|
+
// lets the interval slow down without adding a second clock source.
|
|
435
|
+
// uf-lint-disable-next-line react-compiler/set-state-in-effect
|
|
401
436
|
setSchedule(wanted);
|
|
402
437
|
}, [wanted]);
|
|
403
438
|
|