@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/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
- return JSON.parse(raw);
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 = Date.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
- * A prerendered page that puts this on screen needs `serverValue`, because the
266
- * server's clock and the reader's are not the same number and React compares
267
- * the text. Given one, the first render on both sides is that value and the
268
- * real time arrives with the first effect. `useTimeAgo` has already made this
269
- * choice; prefer it for a label.
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 since = serverValue == null ? null : serverValue.getTime();
276
- const [now, setNow] = useState<Date>(() => (since == null ? new Date() : new Date(since)));
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
- setNow(new Date());
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