@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 CHANGED
@@ -118,6 +118,9 @@ export hook useAsync<T>(
118
118
  // Guarded rather than unconditional: a reload that arrives while a call is
119
119
  // already in flight would otherwise build a new state object, and a new
120
120
  // object is a re-render that changes nothing anyone can see.
121
+ // This effect owns the request lifecycle; the synchronous write marks that
122
+ // external request as pending before its callbacks settle it.
123
+ // uf-lint-disable-next-line react-compiler/set-state-in-effect
121
124
  setState((current) => (current.pending ? current : { ...current, pending: true }));
122
125
 
123
126
  const run = (tries: number) => {
package/browser.js CHANGED
@@ -13,17 +13,52 @@
13
13
  // # What belongs in this module
14
14
  //
15
15
  // A reading of the one browser the page is in: its size, its scroll offset,
16
- // its connection, its visibility, its position on the earth, the preferences
17
- // the reader set, the permissions the reader granted. There is exactly one
18
- // answer at a time, nobody has to pass anything in to ask, and a server has no
19
- // answer at all — which is why every hook here either takes a server value
20
- // from the caller or states an honest default.
16
+ // its connection, its visibility, its position on the earth, the fragment in
17
+ // its address bar, the preferences the reader set, the permissions the reader
18
+ // granted. There is exactly one answer at a time, nobody has to pass anything
19
+ // in to ask, and a server has no answer at all — which is why every hook here
20
+ // either takes a server value from the caller or states an honest default.
21
21
  //
22
22
  // Not here: anything about a specific element, which needs a ref and lives in
23
23
  // `dom.js`. `useDocumentVisible` is the closest call in the package and stays
24
24
  // here, because the document is the environment rather than a node a caller
25
25
  // chose.
26
26
  //
27
+ // The path and the query are not here either, and that one is a boundary rather
28
+ // than a filing decision: they are on the request, so `@uniflowed/router` has
29
+ // already resolved them and `useRoute` is where a page reads them. What is here
30
+ // is the fragment, which the browser strips before the request goes out and
31
+ // which therefore nothing on the server has ever seen. See `useHash`.
32
+ //
33
+ // # What the first render produces
34
+ //
35
+ // Every hook below answers twice with the same value — once during the
36
+ // prerender, once during the client's hydrating render — and only then with
37
+ // what the browser actually says. That second column is not a detail: it is the
38
+ // value React compares the two trees at, so a hook whose first client render
39
+ // disagreed with the server's would report a mismatch on a page that did
40
+ // nothing wrong.
41
+ //
42
+ // | Hook | Prerender, and the first client render | Afterwards |
43
+ // | --- | --- | --- |
44
+ // | `useSupported` | `false` | what `probe` says |
45
+ // | `useMediaQuery` | `serverValue`, default `false` | whether it matches |
46
+ // | `usePreferredColorScheme` | `serverValue`, default `"light"` | the reader's setting |
47
+ // | `usePrefersReducedMotion` | `serverValue`, default `false` | the reader's setting |
48
+ // | `useOnline` | `serverValue`, default `true` | `navigator.onLine` |
49
+ // | `useDocumentVisible` | `serverValue`, default `true` | the visibility state |
50
+ // | `useHash` | `""`, and no way to say otherwise | the fragment |
51
+ // | `useWindowSize` | `serverValue`, default `0x0` | the viewport |
52
+ // | `useWindowScroll` | `serverValue`, default `0,0` | the offset |
53
+ // | `useNetwork` | `{ online: serverValue, measured: null }` | what Chromium measured |
54
+ // | `useGeolocation` | `"unsupported"` | `"pending"`, then a fix or a failure |
55
+ // | `usePermission` | `"unknown"` | what the browser answers |
56
+ // | `useScrollLock` | nothing at all — effects do not run in a prerender | the page is held |
57
+ //
58
+ // `packages/hooks/hooks-ssr.test.js` renders every one of them in a process with
59
+ // no document and asserts the markup, so a row of this table that stopped being
60
+ // true would fail there rather than in somebody's browser.
61
+ //
27
62
  // # The one that writes
28
63
  //
29
64
  // `useScrollLock` is the exception to "reading", and it is here rather than in
@@ -41,6 +76,15 @@
41
76
  // subscriptions whose *first* value only arrives asynchronously, so there is
42
77
  // nothing for a snapshot to return until it does, and `useScrollLock` writes.
43
78
  // Each says so where it is defined.
79
+ //
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.
44
88
 
45
89
  import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "@uniflowed/react";
46
90
 
@@ -76,6 +120,58 @@ export type BrowserNavigator = {
76
120
  ...
77
121
  };
78
122
 
123
+ /**
124
+ * The part of `location` this module reads.
125
+ *
126
+ * `href` and the fragment, and nothing else. The path and the query are on the
127
+ * request, so `@uniflowed/router` has already resolved them and a second
128
+ * reading of them here would be a second answer to a settled question — see
129
+ * `useHash`.
130
+ */
131
+ export type BrowserLocation = {
132
+ readonly href: string,
133
+ readonly hash: string,
134
+ ...
135
+ };
136
+
137
+ /**
138
+ * The part of `history` this module writes through.
139
+ *
140
+ * `state` is read so that a fragment written from here hands back whatever a
141
+ * router put there, rather than clearing it. The second parameter of both
142
+ * methods has been ignored by every browser since the API shipped, and is
143
+ * typed rather than omitted because it is positional.
144
+ */
145
+ export type BrowserHistory = {
146
+ readonly state: mixed,
147
+ readonly pushState: (state: mixed, unused: string, url: string) => void,
148
+ readonly replaceState: (state: mixed, unused: string, url: string) => void,
149
+ ...
150
+ };
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
+
79
175
  /** The Network Information object, which only Chromium has. */
80
176
  export type NetworkConnection = {
81
177
  readonly downlink?: number,
@@ -98,6 +194,9 @@ export type NetworkConnection = {
98
194
  export type BrowserWindow = {
99
195
  readonly document: Document,
100
196
  readonly navigator: BrowserNavigator,
197
+ readonly location?: ?BrowserLocation,
198
+ readonly history?: ?BrowserHistory,
199
+ readonly navigation?: ?BrowserNavigation,
101
200
  readonly localStorage?: ?Storage,
102
201
  readonly sessionStorage?: ?Storage,
103
202
  readonly innerWidth: number,
@@ -269,6 +368,176 @@ export hook useDocumentVisible(serverValue: boolean = true): boolean {
269
368
  return useSyncExternalStore(subscribe, snapshot, () => serverValue);
270
369
  }
271
370
 
371
+ /**
372
+ * Everybody watching the fragment in this tab.
373
+ *
374
+ * Module-level, because neither `history.pushState` nor `history.replaceState`
375
+ * fires anything: a component that writes the fragment has to tell the others
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.
389
+ */
390
+ const fragmentListeners: Set<() => void> = new Set();
391
+
392
+ /** Listen for every change to the fragment this tab can hear about. */
393
+ function subscribeToFragment(notify: () => void): () => void {
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;
399
+ fragmentListeners.add(notify);
400
+ // `hashchange` covers an anchor the reader clicked and an address bar they
401
+ // edited; `popstate` covers back and forward, which fires only the second of
402
+ // the two when the entry it lands on differs by more than the fragment.
403
+ win?.addEventListener("hashchange", notify);
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);
410
+ return () => {
411
+ fragmentListeners.delete(notify);
412
+ win?.removeEventListener("hashchange", notify);
413
+ win?.removeEventListener("popstate", notify);
414
+ navigation?.removeEventListener("currententrychange", notify);
415
+ };
416
+ }
417
+
418
+ /** The fragment, without its `#`, decoded where the escapes are valid. */
419
+ function readFragment(): string {
420
+ const raw = browserWindow()?.location?.hash ?? "";
421
+ const text = raw.startsWith("#") ? raw.slice(1) : raw;
422
+ try {
423
+ return decodeURIComponent(text);
424
+ } catch {
425
+ // A fragment is whatever somebody typed into the address bar, and a lone
426
+ // `%` is not a reason to fail a render. The text as written is closer to
427
+ // what the reader meant than a throw is. `@uniflowed/web`'s cookie parser
428
+ // makes the same trade at the same kind of boundary.
429
+ return text;
430
+ }
431
+ }
432
+
433
+ /** What a server render, and the client's hydrating render, both report. */
434
+ function noFragment(): string {
435
+ return "";
436
+ }
437
+
438
+ /**
439
+ * Write the fragment, without moving the page.
440
+ *
441
+ * Through `URL` rather than by concatenation, which is the whole safety
442
+ * argument: the setter percent-escapes what it is handed and can only put it
443
+ * after the `#`, so a caller who passes `?admin=1`, `//elsewhere.example` or a
444
+ * whole second URL writes a fragment that says so rather than a query, an
445
+ * origin or a path. `history.pushState` refuses a cross-origin URL, but that is
446
+ * the second line of defence and not one worth relying on for a same-origin
447
+ * path rewrite, which it permits.
448
+ *
449
+ * Module-level, so it is the same function on every render and nothing has to
450
+ * memoize it.
451
+ */
452
+ function writeFragment(next: string, options?: {| readonly replace?: boolean |}): void {
453
+ const win = browserWindow();
454
+ const location = win?.location;
455
+ const history = win?.history;
456
+ if (location == null || history == null) {
457
+ return;
458
+ }
459
+ const url = new URL(location.href);
460
+ url.hash = next;
461
+ // `history.state` is handed back rather than cleared: a router put it there,
462
+ // and changing which tab is showing is not a reason to lose it.
463
+ if (options?.replace === true) {
464
+ history.replaceState(history.state, "", url.href);
465
+ } else {
466
+ history.pushState(history.state, "", url.href);
467
+ }
468
+ notifyFragment();
469
+ }
470
+
471
+ function notifyFragment(): void {
472
+ for (const listener of fragmentListeners) {
473
+ listener();
474
+ }
475
+ }
476
+
477
+ /**
478
+ * The fragment in the address bar, and a way to change it.
479
+ *
480
+ * The one part of the URL that needs a hook. A request carries the path and the
481
+ * query, so a server render already knows both and `@uniflowed/router`'s
482
+ * `useRoute` has resolved them; the fragment is never sent — the browser strips
483
+ * it before the request goes out — so there is no value for a server to know
484
+ * and no caller who could supply a better one. That is why this takes no
485
+ * `serverValue` where `useMediaQuery` and `useOnline` do: `""` is not a default
486
+ * chosen for want of a better one, it is the only honest answer, and the
487
+ * client's hydrating render reports it too before re-rendering with the truth.
488
+ *
489
+ * Returned without the `#`, and percent-decoded, so it compares directly
490
+ * against the `id` a section was given.
491
+ *
492
+ * Writing does not scroll, and that is deliberate. Moving to a section and
493
+ * recording which tab is open are two intentions, and the platform couples them
494
+ * only because assigning `location.hash` is the old way to do both at once — so
495
+ * a tab strip that writes `billing` would jump the page. Call
496
+ * `element.scrollIntoView()` where scrolling is what was wanted.
497
+ *
498
+ * `replace` overwrites the current history entry instead of adding one, which
499
+ * is what a tab strip wants: eleven tab clicks should not be eleven presses of
500
+ * the back button.
501
+ *
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.
532
+ */
533
+ export hook useHash(): [
534
+ string,
535
+ (next: string, options?: {| readonly replace?: boolean |}) => void,
536
+ ] {
537
+ const fragment = useSyncExternalStore(subscribeToFragment, readFragment, noFragment);
538
+ return [fragment, writeFragment];
539
+ }
540
+
272
541
  /**
273
542
  * How far something has been scrolled.
274
543
  *
@@ -429,16 +698,35 @@ export hook useScrollLock(locked: boolean): void {
429
698
  /** How good the connection is, as the browser grades it. */
430
699
  export type EffectiveConnectionType = "slow-2g" | "2g" | "3g" | "4g";
431
700
 
432
- /** What the browser will say about the connection. */
433
- export type Network = {|
434
- readonly online: boolean,
701
+ /**
702
+ * What Network Information measured, where there is such a thing.
703
+ *
704
+ * Its own type rather than three more fields on [`Network`], because the three
705
+ * of them arrive together or not at all: they come from `navigator.connection`,
706
+ * which is Chromium's alone. A server, and every other browser, has no object
707
+ * to read them off.
708
+ */
709
+ export type NetworkMeasurement = {|
435
710
  /** Estimated bandwidth in megabits per second, where the browser reports it. */
436
711
  readonly downlink: number | null,
437
712
  readonly effectiveType: EffectiveConnectionType | null,
438
713
  /** Whether the reader has asked for less data to be used. */
439
714
  readonly saveData: boolean,
440
- /** Whether anything beyond `online` was actually measured. */
441
- readonly supported: boolean,
715
+ |};
716
+
717
+ /**
718
+ * What the browser will say about the connection.
719
+ *
720
+ * `measured` used to be three flat fields and a `supported` boolean beside
721
+ * them, which is the shape this package now refuses: a caller had to read one
722
+ * field to learn whether three others meant anything, and `downlink: null` said
723
+ * both "this browser does not measure bandwidth" and "it does, and has not
724
+ * decided yet". A null here says one thing — nothing on this side can answer —
725
+ * and the fields that would have been guesses are not reachable to be read.
726
+ */
727
+ export type Network = {|
728
+ readonly online: boolean,
729
+ readonly measured: NetworkMeasurement | null,
442
730
  |};
443
731
 
444
732
  const EFFECTIVE_TYPES: $ReadOnlyArray<EffectiveConnectionType> = ["slow-2g", "2g", "3g", "4g"];
@@ -455,10 +743,11 @@ function asEffectiveType(value: string): EffectiveConnectionType | null {
455
743
  /**
456
744
  * What the browser will say about the connection.
457
745
  *
458
- * Only Chromium implements Network Information, so `supported` is part of the
459
- * answer rather than something a caller has to find out by seeing nulls. The
460
- * `online` half is everywhere, and stays true even where the rest is unknown —
461
- * which is the field almost every caller wants.
746
+ * Only Chromium implements Network Information, so a browser that does not
747
+ * reports `measured: null` rather than three fields that are indistinguishable
748
+ * from a slow connection. `online` stays flat and is answered everywhere — it
749
+ * is the field almost every caller wants, and burying it behind a narrowing
750
+ * would have made the common case pay for the rare one.
462
751
  *
463
752
  * The snapshot is a packed string for the reason `useWindowSize` gives: an
464
753
  * object rebuilt on every check never compares equal, and `useSyncExternalStore`
@@ -481,39 +770,49 @@ export hook useNetwork(serverValue: boolean = true): Network {
481
770
  };
482
771
  }, []);
483
772
 
484
- const server = useCallback(() => `${serverValue ? "1" : "0"}|||0`, [serverValue]);
773
+ // Whether there was a connection object to read is its own field, because
774
+ // "this browser does not measure" and "it measures and reported nothing yet"
775
+ // pack to the same three empty values and are not the same answer.
776
+ // `effectiveType` is last so that it is the only field a `|` in browser text
777
+ // could run into.
778
+ const server = useCallback(() => `${serverValue ? "1" : "0"}|0|0||`, [serverValue]);
485
779
 
486
780
  const packed = useSyncExternalStore(
487
781
  subscribe,
488
782
  useCallback(() => {
489
783
  const navigator = browserWindow()?.navigator;
490
784
  if (navigator == null) {
491
- return `${serverValue ? "1" : "0"}|||0`;
785
+ return `${serverValue ? "1" : "0"}|0|0||`;
492
786
  }
493
787
  const connection = navigator.connection;
494
788
  const online = navigator.onLine ?? serverValue;
495
789
  if (connection == null) {
496
- return `${online ? "1" : "0"}|||0`;
790
+ return `${online ? "1" : "0"}|0|0||`;
497
791
  }
498
792
  const downlink = connection.downlink;
499
793
  return [
500
794
  online ? "1" : "0",
795
+ "1",
796
+ connection.saveData === true ? "1" : "0",
501
797
  downlink == null ? "" : String(downlink),
502
798
  connection.effectiveType ?? "",
503
- connection.saveData === true ? "1" : "0",
504
799
  ].join("|");
505
800
  }, [serverValue]),
506
801
  server,
507
802
  );
508
803
 
509
804
  return useMemo(() => {
510
- const [online, downlink, effectiveType, saveData] = packed.split("|");
805
+ const [online, measured, saveData, downlink, effectiveType] = packed.split("|");
511
806
  return {
512
807
  online: online === "1",
513
- downlink: downlink === "" ? null : Number(downlink),
514
- effectiveType: asEffectiveType(effectiveType),
515
- saveData: saveData === "1",
516
- supported: downlink !== "" || effectiveType !== "",
808
+ measured:
809
+ measured === "1"
810
+ ? {
811
+ downlink: downlink === "" ? null : Number(downlink),
812
+ effectiveType: asEffectiveType(effectiveType),
813
+ saveData: saveData === "1",
814
+ }
815
+ : null,
517
816
  };
518
817
  }, [packed]);
519
818
  }
@@ -527,12 +826,47 @@ export type Geoposition = {|
527
826
  readonly timestamp: number,
528
827
  |};
529
828
 
530
- /** What `useGeolocation` knows so far. */
531
- export type GeolocationReading = {|
532
- readonly position: Geoposition | null,
533
- readonly error: Error | null,
534
- readonly supported: boolean,
535
- |};
829
+ /**
830
+ * What `useGeolocation` knows so far.
831
+ *
832
+ * A union rather than a record of nullable fields, and the difference is the
833
+ * whole point of the type. The record it replaced —
834
+ * `{| position: Geoposition | null, error: Error | null, supported: boolean |}`
835
+ * — could represent eight states, of which four could never happen, and it
836
+ * asked every caller to work out from three fields which of the four real ones
837
+ * they were in. `position == null` meant "no browser", "not asked", "asked and
838
+ * refused" and "waiting for the reader to decide", and a page that wanted to
839
+ * say something different for each had to reconstruct the distinction the hook
840
+ * had thrown away.
841
+ *
842
+ * Five states, each of which a page does something different about, and Flow
843
+ * refuses to read a field the state does not have.
844
+ */
845
+ export type GeolocationReading =
846
+ /**
847
+ * Not watching, because the caller passed `enabled: false`. Reported before
848
+ * `"unsupported"` is even considered, so it is the same on both sides of a
849
+ * hydration.
850
+ */
851
+ | {| readonly status: "idle" |}
852
+ /** No geolocation object here at all: a server render, or a browser without one. */
853
+ | {| readonly status: "unsupported" |}
854
+ /** Watching. The reader has been asked and has not answered yet. */
855
+ | {| readonly status: "pending" |}
856
+ /**
857
+ * A fix. `error` is the failure of a *later* reading, and its presence means
858
+ * the position beside it is the last good one rather than the current one.
859
+ */
860
+ | {| readonly status: "located", readonly position: Geoposition, readonly error: Error | null |}
861
+ /** Refused, unavailable or timed out, with no earlier fix to fall back on. */
862
+ | {| readonly status: "failed", readonly error: Error |};
863
+
864
+ // The three states with nothing in them, allocated once. A hook that returns a
865
+ // fresh object for "nothing has happened" makes every caller's dependency array
866
+ // change on every render.
867
+ const UNSUPPORTED: GeolocationReading = { status: "unsupported" };
868
+ const IDLE: GeolocationReading = { status: "idle" };
869
+ const PENDING: GeolocationReading = { status: "pending" };
536
870
 
537
871
  /**
538
872
  * Watch where the reader is.
@@ -541,8 +875,14 @@ export type GeolocationReading = {|
541
875
  * stylistic: there is no snapshot to read. The browser has no "current
542
876
  * position" property to ask — the first value arrives in a callback, after a
543
877
  * permission prompt the reader may take a minute to answer or never answer at
544
- * all. So `position` is `null` until one arrives, on a server and in a browser
545
- * alike, which is also what makes it hydration-safe.
878
+ * all. So there is nothing but `"unsupported"` to report during a prerender and
879
+ * in the client's first render, which is also what makes it hydration-safe: the
880
+ * server writes the markup for a page that does not know where anybody is, and
881
+ * the hydrating render agrees with it.
882
+ *
883
+ * Turning `enabled` off after a fix reports `"idle"` rather than keeping the
884
+ * position, because a reading nobody is watching is a reading nobody should be
885
+ * shown. A caller who wants the last one to stay on screen holds it.
546
886
  *
547
887
  * Mounting this asks the reader for permission. Mount it on the page that
548
888
  * needs a position, not at the top of an application.
@@ -591,10 +931,28 @@ export hook useGeolocation(options?: {|
591
931
  return () => geolocation.clearWatch(watch);
592
932
  }, [enabled, highAccuracy, maximumAge, timeout]);
593
933
 
594
- return useMemo(
595
- () => ({ position: reading.position, error: reading.error, supported }),
596
- [reading, supported],
597
- );
934
+ // Derived during the render from what the effect has recorded, rather than
935
+ // kept as a second copy of it in state: a status that had to be written by
936
+ // the same `setReading` call would be a second thing to keep in step with
937
+ // `enabled` and with `supported`, neither of which the effect can see.
938
+ return useMemo(() => {
939
+ // `enabled` is asked about before `supported`, because it is the caller's
940
+ // own decision and is therefore the same on both sides of a hydration: a
941
+ // watch nobody asked for is idle on a server and idle in a browser, where
942
+ // "unsupported" would have been true on the server and false a moment
943
+ // later.
944
+ if (!enabled) {
945
+ return IDLE;
946
+ }
947
+ if (!supported) {
948
+ return UNSUPPORTED;
949
+ }
950
+ const { position, error } = reading;
951
+ if (position != null) {
952
+ return { status: "located", position, error };
953
+ }
954
+ return error == null ? PENDING : { status: "failed", error };
955
+ }, [supported, enabled, reading]);
598
956
  }
599
957
 
600
958
  /** A permission this hook knows how to ask about. */