@uniflowed/hooks 0.0.0-alpha.9 → 0.1.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/events.js ADDED
@@ -0,0 +1,300 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/hooks/events`: a stream the server is pushing.
4
+ //
5
+ // One hook, and it is here rather than in `./channels.js` for a reason that
6
+ // module's header states from the other side: what belongs there is a value
7
+ // crossing the page's boundary that is *not* the server, because a request is
8
+ // `@uniflowed/query`'s and `@uniflowed/fetch`'s and comes with caching, retry
9
+ // and revalidation that a broadcast does not want. An event stream is the
10
+ // server and has none of that either — there is no cache entry, nothing to
11
+ // revalidate, and no request to repeat. It is a connection, which is a third
12
+ // thing, so it is a third file.
13
+ //
14
+ // `@uniflowed/server/events` is the other end of it.
15
+ //
16
+ // # What the platform already does, and the one thing it does not
17
+ //
18
+ // `EventSource` reconnects. It backs off, it sends the last `id:` it saw back
19
+ // as `Last-Event-ID`, and it needs no help — for as long as the server keeps
20
+ // answering. There is exactly one case where it gives up permanently: a
21
+ // response that is not a `200` with `content-type: text/event-stream`. Then it
22
+ // fires `error`, moves to `CLOSED`, and never tries again. A deployment
23
+ // restarting behind a load balancer answers `502` for a second or two, and
24
+ // every open stream in every tab is dead until somebody reloads the page.
25
+ //
26
+ // That is the case this hook exists for. When the browser gives up, it opens a
27
+ // new `EventSource` on a doubling delay; when the browser is retrying on its
28
+ // own it stays out of the way, because two reconnection schedules on one
29
+ // connection is worse than either.
30
+ //
31
+ // The other half is that none of the platform's reconnecting is visible. An
32
+ // application that wants to draw "reconnecting…" cannot ask `EventSource`
33
+ // anything but `readyState`, and only from an event handler. [`status`] is that
34
+ // as state.
35
+ //
36
+ // # What a reconnection this hook makes cannot carry
37
+ //
38
+ // `Last-Event-ID`. The browser sends it on its *own* retry and there is no way
39
+ // to set it on a new `EventSource` — the constructor takes a URL and one flag.
40
+ // So [`lastEventId`] is handed back, and a stream that needs to resume puts it
41
+ // in the URL itself:
42
+ //
43
+ // const stream = useEventSource(`/api/feed?after=${cursor ?? ""}`);
44
+ //
45
+ // Written down rather than worked around: a hook that silently appended a query
46
+ // parameter would be inventing a protocol on the application's behalf.
47
+ //
48
+ // # Before hydration
49
+ //
50
+ // `status` is `"idle"` on the server and on the client's first render, through
51
+ // `useSupported` — see the reason there. Nothing is opened during a render; the
52
+ // connection is made in an effect, so a prerender neither connects nor pretends
53
+ // it did. `"idle"` rather than `"connecting"` because they are different facts
54
+ // and only one of them is true: nothing has been attempted here.
55
+
56
+ import { useCallback, useEffect, useMemo, useRef, useState } from "@uniflowed/react";
57
+
58
+ import { useSupported } from "./browser.js";
59
+ import { useStableCallback } from "./lifecycle.js";
60
+
61
+ /**
62
+ * The part of `EventSource` this module uses.
63
+ *
64
+ * Declared here rather than taken from Flow's library definition, for the
65
+ * reason `./channels.js` gives about `BroadcastChannel`: the constructor is
66
+ * looked up at runtime and may be absent, so it has to be a value with a known
67
+ * constructor signature, and the listener is typed with the two fields that are
68
+ * read — which removes an `instanceof MessageEvent` whose name is not defined
69
+ * in every host these tests run in.
70
+ */
71
+ type StreamEvent = { data?: mixed, lastEventId?: mixed, ... };
72
+
73
+ declare class Stream {
74
+ constructor(url: string, options?: { withCredentials?: boolean, ... }): void;
75
+ readonly readyState: number;
76
+ close(): void;
77
+ addEventListener(type: string, listener: (event: StreamEvent) => mixed): void;
78
+ removeEventListener(type: string, listener: (event: StreamEvent) => mixed): void;
79
+ }
80
+
81
+ /**
82
+ * `EventSource`, which is the host's rather than the document's.
83
+ *
84
+ * The same split `./channels.js` describes: in a browser the two are one
85
+ * object, and in a process where a document has been installed onto another
86
+ * runtime's global they are not — an event stream is a facility of the runtime,
87
+ * and a hosted `Window` is not required to carry one.
88
+ */
89
+ declare var EventSource: Class<Stream> | void;
90
+
91
+ function streamConstructor(): Class<Stream> | null {
92
+ return typeof EventSource === "undefined" ? null : (EventSource ?? null);
93
+ }
94
+
95
+ /** `EventSource.CLOSED`, spelled as the number so no global is read for it. */
96
+ const CLOSED = 2;
97
+
98
+ /** Where the connection is. */
99
+ export type EventStreamStatus =
100
+ /** Nothing has been attempted: a prerender, no URL, or `enabled: false`. */
101
+ | "idle"
102
+ /** Opening, or reopening — by the browser or by this hook. */
103
+ | "connecting"
104
+ /** Receiving. */
105
+ | "open"
106
+ /** Closed on purpose, by `close()`. It will not reopen on its own. */
107
+ | "closed";
108
+
109
+ /** One message, as the three fields the format carries. */
110
+ export type ServerEvent = {|
111
+ /** The `event:` name, or `message` where the stream sent none. */
112
+ readonly name: string,
113
+ /** The `data:` payload, as text. */
114
+ readonly data: string,
115
+ /** The `id:`, or `null`. */
116
+ readonly id: string | null,
117
+ |};
118
+
119
+ /** How the connection behaves. */
120
+ export type EventSourceOptions = {|
121
+ /**
122
+ * Named events to listen for, besides the unnamed `message`.
123
+ *
124
+ * A stream that sends `event: progress` delivers nothing to a `message`
125
+ * listener, which is the single most common way an event stream appears to
126
+ * be connected and silent.
127
+ */
128
+ readonly events?: $ReadOnlyArray<string>,
129
+ /** Send cookies to another origin. Same-origin streams do not need it. */
130
+ readonly withCredentials?: boolean,
131
+ /** `false` closes the connection and opens none; `null` for the URL does too. */
132
+ readonly enabled?: boolean,
133
+ /** Called for every event, for a stream whose messages are events. */
134
+ readonly onEvent?: (event: ServerEvent) => mixed,
135
+ /** Milliseconds before this hook's first reopen; doubles. Default 1000. */
136
+ readonly retryDelay?: number,
137
+ /** The ceiling the doubling stops at. Default 30000. */
138
+ readonly maxRetryDelay?: number,
139
+ |};
140
+
141
+ /** What `useEventSource` hands back. */
142
+ export type UseEventSourceReturn = {|
143
+ readonly status: EventStreamStatus,
144
+ /**
145
+ * The most recent event, or `null` before one arrives.
146
+ *
147
+ * `./channels.js` deliberately does not hand back the last broadcast, and
148
+ * the argument there is that a message is an event: a tab that just opened
149
+ * has never received one and cannot ask. A stream is not that. It is a
150
+ * connection with a lifetime, and for as long as it is open "the latest
151
+ * message" is defined — which is what a progress bar, a price and a presence
152
+ * indicator all are. `onEvent` is there for the streams that really are
153
+ * events.
154
+ */
155
+ readonly last: ServerEvent | null,
156
+ /**
157
+ * The last `id:` seen, for a stream that resumes.
158
+ *
159
+ * The browser sends this itself when *it* reconnects. It is here for the
160
+ * reconnection this hook makes, which cannot; see the module header.
161
+ */
162
+ readonly lastEventId: string | null,
163
+ /** Close it. Nothing reopens until `enabled` or the URL changes. */
164
+ readonly close: () => void,
165
+ /** Whether this runtime has `EventSource` at all. */
166
+ readonly supported: boolean,
167
+ |};
168
+
169
+ const DEFAULT_RETRY = 1_000;
170
+ const DEFAULT_MAX_RETRY = 30_000;
171
+
172
+ /**
173
+ * Subscribe to a server-sent event stream.
174
+ *
175
+ * `url` is re-read when it changes, and a changed URL is a new connection —
176
+ * which is what makes the resume in the module header work at all. Pass `null`
177
+ * to connect to nothing, which is how a stream that depends on something not
178
+ * loaded yet waits without a second hook.
179
+ */
180
+ export hook useEventSource(url: string | null, options?: EventSourceOptions): UseEventSourceReturn {
181
+ const supported = useSupported(() => streamConstructor() != null);
182
+ const [status, setStatus] = useState<EventStreamStatus>("idle");
183
+ const [last, setLast] = useState<ServerEvent | null>(null);
184
+ const [lastEventId, setLastEventId] = useState<string | null>(null);
185
+ // Bumped to ask for a new connection, which is the only thing that can
186
+ // reopen one the browser has given up on: `EventSource` in `CLOSED` cannot
187
+ // be restarted, so the effect has to run again with a new object.
188
+ const [attempt, setAttempt] = useState(0);
189
+ const [stopped, setStopped] = useState(false);
190
+
191
+ const onEvent = options?.onEvent;
192
+ const stable = useStableCallback((event: ServerEvent) => {
193
+ onEvent?.(event);
194
+ });
195
+
196
+ // The names are carried as one string rather than as the array the caller
197
+ // passed, and rebuilt inside the effect from it. An array literal in a
198
+ // render is a new array every time, so depending on one would tear the
199
+ // connection down on every keystroke anywhere in the component — and a ref
200
+ // written during a render is the other way to get this wrong, which this
201
+ // package's header rules out.
202
+ const key = (options?.events ?? []).join(",");
203
+ const withCredentials = options?.withCredentials === true;
204
+ const retryDelay = options?.retryDelay ?? DEFAULT_RETRY;
205
+ const maxRetryDelay = options?.maxRetryDelay ?? DEFAULT_MAX_RETRY;
206
+ // How many times *this hook* has reopened since the last success. Not
207
+ // `attempt`, which also moves for a URL change, and not state, because
208
+ // resetting it on `open` must not be a render.
209
+ const backoff = useRef(0);
210
+
211
+ const enabled = (options?.enabled ?? true) && !stopped;
212
+
213
+ useEffect(() => {
214
+ const Constructor = streamConstructor();
215
+ if (Constructor == null || url == null || !enabled) {
216
+ // The connection state mirrors whether the external stream exists.
217
+ // uf-lint-disable-next-line react-compiler/set-state-in-effect
218
+ setStatus(stopped ? "closed" : "idle");
219
+ return;
220
+ }
221
+
222
+ const source = new Constructor(url, { withCredentials });
223
+ setStatus("connecting");
224
+ let reopen: TimeoutID | null = null;
225
+
226
+ const onOpen = () => {
227
+ // The delay is reset here rather than on the first message: a stream
228
+ // that connects and sends nothing for an hour has still connected, and
229
+ // treating it as a failure would put the next real outage at the top of
230
+ // the backoff.
231
+ backoff.current = 0;
232
+ setStatus("open");
233
+ };
234
+
235
+ const onMessage = (name: string) => (event: StreamEvent) => {
236
+ const sent = event.lastEventId;
237
+ const id = typeof sent === "string" && sent !== "" ? sent : null;
238
+ const received: ServerEvent = { name, data: String(event.data ?? ""), id };
239
+ setLast(received);
240
+ if (id != null) {
241
+ setLastEventId(id);
242
+ }
243
+ stable(received);
244
+ };
245
+
246
+ const onError = () => {
247
+ if (source.readyState !== CLOSED) {
248
+ // The browser is retrying on its own, with `Last-Event-ID`, which is
249
+ // strictly better than anything this hook can do. Say so and wait.
250
+ setStatus("connecting");
251
+ return;
252
+ }
253
+ // It has given up — a non-200, or a body that was not an event stream.
254
+ // Nothing reopens this object, so a new one is asked for on a delay.
255
+ setStatus("connecting");
256
+ const wait = Math.min(retryDelay * 2 ** backoff.current, maxRetryDelay);
257
+ backoff.current += 1;
258
+ reopen = setTimeout(() => setAttempt((count) => count + 1), wait);
259
+ };
260
+
261
+ const listeners: Array<[string, (event: StreamEvent) => mixed]> = [
262
+ ["message", onMessage("message")],
263
+ ];
264
+ for (const name of key === "" ? [] : key.split(",")) {
265
+ listeners.push([name, onMessage(name)]);
266
+ }
267
+
268
+ source.addEventListener("open", onOpen);
269
+ source.addEventListener("error", onError);
270
+ for (const [name, listener] of listeners) {
271
+ source.addEventListener(name, listener);
272
+ }
273
+
274
+ return () => {
275
+ if (reopen != null) {
276
+ clearTimeout(reopen);
277
+ }
278
+ source.removeEventListener("open", onOpen);
279
+ source.removeEventListener("error", onError);
280
+ for (const [name, listener] of listeners) {
281
+ source.removeEventListener(name, listener);
282
+ }
283
+ // Closed as well as unsubscribed: an `EventSource` nobody is listening
284
+ // to still holds a connection and still reconnects, so leaving it open
285
+ // is a socket per navigation for the life of the tab.
286
+ source.close();
287
+ };
288
+ // `key` stands in for `options.events`, and `attempt` is what a reopen
289
+ // moves. `backoff` is a ref, and `stable` never changes identity.
290
+ }, [url, enabled, stopped, key, attempt, retryDelay, maxRetryDelay, withCredentials, stable]);
291
+
292
+ const close = useCallback(() => {
293
+ setStopped(true);
294
+ }, []);
295
+
296
+ return useMemo(
297
+ () => ({ status, last, lastEventId, close, supported }),
298
+ [status, last, lastEventId, close, supported],
299
+ );
300
+ }
package/index.js CHANGED
@@ -32,6 +32,12 @@
32
32
  // its sidebar under 48rem wants `false` on the server and one that renders a
33
33
  // mobile menu wants `true`, and a library cannot know which.
34
34
  //
35
+ // Where there is exactly one honest answer, the caller is not asked. `useHash`
36
+ // takes no server value because the browser strips the fragment before the
37
+ // request goes out, so `""` is not a default standing in for something better —
38
+ // it is what the server knows, and offering an override would have invited a
39
+ // caller to state a value that cannot be true.
40
+ //
35
41
  // The hooks built on effects rather than stores — everything in `dom.js`, and
36
42
  // the three in `browser.js` whose first value only arrives in a callback — do
37
43
  // nothing at all before hydration, and report the same starting value on both
@@ -50,7 +56,7 @@
50
56
  //
51
57
  // # How the package is laid out
52
58
  //
53
- // Eight modules beside this one, split by what a hook's subject is — because
59
+ // Nine modules beside this one, split by what a hook's subject is — because
54
60
  // that is the question a reader looking for one actually asks:
55
61
  //
56
62
  // - `lifecycle.js` — the component itself: mounted, previous, run once.
@@ -58,14 +64,22 @@
58
64
  // toggle, counter, list, set, cycle, undo/redo, storage.
59
65
  // - `timing.js` — when something runs: intervals, timeouts, debounce,
60
66
  // throttle, frames, idleness, and the clock behind "3 minutes ago".
67
+ // - `render.js` — what a render has to fix rather than derive: the instant it
68
+ // was made at, and the seed anything random on the page is drawn from.
61
69
  // - `async.js` — one promise: its states, its abort signal, its retries.
62
70
  // - `browser.js` — the ambient environment: viewport, scroll, connection,
63
- // position, permissions, preferences.
71
+ // position, permissions, preferences, the fragment in the address bar. Its
72
+ // header carries the table of what every one of them renders before
73
+ // hydration.
64
74
  // - `dom.js` — one element the caller holds a ref to: listen, measure,
65
75
  // observe, press.
66
76
  // - `keyboard.js` — what is being pressed: a chord, and a held key.
67
77
  // - `channels.js` — a value that came from outside the page: another tab, the
68
78
  // system clipboard.
79
+ // - `events.js` — a stream the server is pushing, and where its reconnection
80
+ // is. Not `channels.js`, whose boundary is deliberately everything except
81
+ // the server, and not `@uniflowed/query`, because there is no cache entry
82
+ // and nothing to revalidate: a connection is a third thing.
69
83
  //
70
84
  // The two that are easiest to confuse are `browser.js` and `dom.js`, so each
71
85
  // says so in its own header: `browser.js` needs no ref because there is one
@@ -90,12 +104,15 @@
90
104
  // **Implemented and tested.** The component's life; the state shapes; every
91
105
  // timer, including the adaptive schedule behind `useTimeAgo`; `useAsync` with
92
106
  // abort and retry; media queries, colour scheme, reduced motion, online,
93
- // document visibility, window size and scroll, scroll lock; element size,
107
+ // document visibility, window size and scroll, the address bar's fragment,
108
+ // scroll lock; element size,
94
109
  // intersection, mutations, hover, focus-within, click-outside, long press,
95
110
  // element scroll, the element as state; key chords and held keys; storage with
96
111
  // cross-tab sync;
97
- // broadcast channels; the clipboard. `tests/library/hooks.test.js` covers
98
- // behaviour and cleanup, and `tests/library/hooks-ssr.test.js` renders the
112
+ // broadcast channels; the clipboard; a server-sent event stream, including the
113
+ // one case the platform's own reconnection gives up on; the render anchor and
114
+ // the seeded stream in `render.js`. `packages/hooks/hooks.test.js` covers
115
+ // behaviour and cleanup, and `packages/hooks/hooks-ssr.test.js` renders the
99
116
  // whole surface in a process that has no DOM at all.
100
117
  //
101
118
  // **Experimental.** `useGeolocation`, `useNetwork` and `usePermission`. The
@@ -120,6 +137,8 @@
120
137
 
121
138
  export type { Async, AsyncOptions } from "./async.js";
122
139
  export type {
140
+ BrowserHistory,
141
+ BrowserLocation,
123
142
  BrowserNavigator,
124
143
  BrowserWindow,
125
144
  EffectiveConnectionType,
@@ -127,6 +146,7 @@ export type {
127
146
  Geoposition,
128
147
  Network,
129
148
  NetworkConnection,
149
+ NetworkMeasurement,
130
150
  PermissionAnswer,
131
151
  PermissionName,
132
152
  ScrollOffset,
@@ -134,7 +154,14 @@ export type {
134
154
  } from "./browser.js";
135
155
  export type { UseBroadcastReturn, UseClipboardReturn } from "./channels.js";
136
156
  export type { ListenerOptions, ListenerTarget, MutationOptions, Ref } from "./dom.js";
157
+ export type {
158
+ EventSourceOptions,
159
+ EventStreamStatus,
160
+ ServerEvent,
161
+ UseEventSourceReturn,
162
+ } from "./events.js";
137
163
  export type { KeyComboOptions } from "./keyboard.js";
164
+ export type { RenderEnvelope } from "./render.js";
138
165
  export type {
139
166
  UseCounterReturn,
140
167
  UseCycleReturn,
@@ -169,6 +196,7 @@ export {
169
196
  browserWindow,
170
197
  useDocumentVisible,
171
198
  useGeolocation,
199
+ useHash,
172
200
  useMediaQuery,
173
201
  useNetwork,
174
202
  useOnline,
@@ -194,7 +222,17 @@ export {
194
222
  useScroll,
195
223
  } from "./dom.js";
196
224
  export { useKeyCombo, useKeyHeld } from "./keyboard.js";
225
+ export {
226
+ RENDER_META,
227
+ RenderProvider,
228
+ useRandom,
229
+ useRenderEnvelope,
230
+ useRenderTimeZone,
231
+ useRenderedAt,
232
+ useShuffled,
233
+ } from "./render.js";
197
234
  export { useBroadcast, useClipboard } from "./channels.js";
235
+ export { useEventSource } from "./events.js";
198
236
  export {
199
237
  useCounter,
200
238
  useCycle,
package/lifecycle.js CHANGED
@@ -70,6 +70,9 @@ export hook usePrevious<T>(value: T): T | void {
70
70
  useEffect(() => {
71
71
  previous.current = value;
72
72
  }, [value]);
73
+ // This hook's public value is the last committed render; reading the ref
74
+ // during render is the contract rather than hidden reactive input.
75
+ // uf-lint-disable-next-line react-compiler/refs
73
76
  return previous.current;
74
77
  }
75
78
 
@@ -83,6 +86,9 @@ export hook usePrevious<T>(value: T): T | void {
83
86
  export hook useMounted(): boolean {
84
87
  const [mounted, setMounted] = useState(false);
85
88
  useEffect(() => {
89
+ // The first client render intentionally matches the server, then flips
90
+ // after mount for hydration-sensitive values.
91
+ // uf-lint-disable-next-line react-compiler/set-state-in-effect
86
92
  setMounted(true);
87
93
  }, []);
88
94
  return mounted;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/hooks",
3
- "version": "0.0.0-alpha.9",
3
+ "version": "0.1.0",
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",
@@ -16,16 +16,20 @@
16
16
  "./browser": "./browser.js",
17
17
  "./channels": "./channels.js",
18
18
  "./dom": "./dom.js",
19
+ "./events": "./events.js",
19
20
  "./keyboard": "./keyboard.js",
20
21
  "./lifecycle": "./lifecycle.js",
22
+ "./render": "./render.js",
21
23
  "./state": "./state.js",
22
24
  "./timing": "./timing.js"
23
25
  },
24
26
  "files": [
25
- "*.js"
27
+ "*.js",
28
+ "!*.test.js"
26
29
  ],
27
30
  "dependencies": {
28
- "@uniflowed/react": "0.0.0-alpha.9"
31
+ "@uniflowed/core": "0.1.0",
32
+ "@uniflowed/react": "0.1.0"
29
33
  },
30
34
  "peerDependencies": {
31
35
  "react": ">=19"