@uniflowed/hooks 0.0.0-alpha.4 → 0.0.0-alpha.41
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 +99 -16
- package/browser.js +934 -60
- package/channels.js +224 -0
- package/dom.js +262 -58
- package/events.js +300 -0
- package/index.js +176 -21
- package/keyboard.js +328 -0
- package/lifecycle.js +12 -6
- package/package.json +9 -3
- package/render.js +354 -0
- package/state.js +378 -31
- package/timing.js +332 -17
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
|
@@ -2,11 +2,22 @@
|
|
|
2
2
|
//
|
|
3
3
|
// `@uniflowed/hooks`: the hooks a React application writes anyway.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// Every hook here is one that people write by hand in every project and get
|
|
6
|
+
// subtly wrong in the same way each time — a timer that calls a stale closure,
|
|
7
|
+
// a subscription re-established on every keystroke, a slow request overwriting
|
|
8
|
+
// a fast one, persisted state that differs between the server render and the
|
|
9
|
+
// first paint, a shortcut that fires while somebody is typing, a dialog that
|
|
10
|
+
// unlocks the page while a second dialog is still open.
|
|
11
|
+
//
|
|
12
|
+
// VueUse is the benchmark, and most of what it offers is not here on purpose.
|
|
13
|
+
// Its `Reactivity`, `Watch` and `Array` categories exist because Vue's `ref`
|
|
14
|
+
// is a mutable box that has to be wrapped, unwrapped, synced and derived;
|
|
15
|
+
// React has no box, and `useMemo` over a plain array is the whole of
|
|
16
|
+
// `useArrayFilter`. Its `Component` category is Vue's template machinery —
|
|
17
|
+
// `templateRef`, `unrefElement`, `useVModel` — which is `ref` and props here.
|
|
18
|
+
// Porting either would have produced hooks whose only purpose was to look
|
|
19
|
+
// familiar. The "Readiness" section below says what is here, what is
|
|
20
|
+
// deliberately elsewhere in uf, and what is simply not built.
|
|
10
21
|
//
|
|
11
22
|
// # Prerendering is the constraint that shapes the surface
|
|
12
23
|
//
|
|
@@ -21,23 +32,61 @@
|
|
|
21
32
|
// its sidebar under 48rem wants `false` on the server and one that renders a
|
|
22
33
|
// mobile menu wants `true`, and a library cannot know which.
|
|
23
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
|
+
//
|
|
41
|
+
// The hooks built on effects rather than stores — everything in `dom.js`, and
|
|
42
|
+
// the three in `browser.js` whose first value only arrives in a callback — do
|
|
43
|
+
// nothing at all before hydration, and report the same starting value on both
|
|
44
|
+
// sides for that reason. Each module's header says which it is and why.
|
|
45
|
+
//
|
|
46
|
+
// # React's rules are the design, not a constraint on it
|
|
47
|
+
//
|
|
48
|
+
// Nothing here writes during a render, reads a ref during a render, or depends
|
|
49
|
+
// on a render having happened exactly once. Callbacks that cross into effects
|
|
50
|
+
// go through `useStableCallback`, whose ref is written in an insertion effect
|
|
51
|
+
// rather than in the body, so Strict Mode's double render and a render the
|
|
52
|
+
// React Compiler skips are both correct. Every effect's cleanup removes
|
|
53
|
+
// exactly what its body added, which is what makes Strict Mode's
|
|
54
|
+
// mount-unmount-mount balanced — including the module-level counters behind
|
|
55
|
+
// `useScrollLock` and `useStorage`.
|
|
56
|
+
//
|
|
24
57
|
// # How the package is laid out
|
|
25
58
|
//
|
|
26
|
-
//
|
|
59
|
+
// Nine modules beside this one, split by what a hook's subject is — because
|
|
27
60
|
// that is the question a reader looking for one actually asks:
|
|
28
61
|
//
|
|
29
62
|
// - `lifecycle.js` — the component itself: mounted, previous, run once.
|
|
30
|
-
// - `state.js` — a value the component owns, with the operations that suit it
|
|
63
|
+
// - `state.js` — a value the component owns, with the operations that suit it:
|
|
64
|
+
// toggle, counter, list, set, cycle, undo/redo, storage.
|
|
31
65
|
// - `timing.js` — when something runs: intervals, timeouts, debounce,
|
|
32
|
-
// throttle.
|
|
33
|
-
// - `
|
|
34
|
-
//
|
|
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.
|
|
69
|
+
// - `async.js` — one promise: its states, its abort signal, its retries.
|
|
70
|
+
// - `browser.js` — the ambient environment: viewport, scroll, connection,
|
|
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.
|
|
35
74
|
// - `dom.js` — one element the caller holds a ref to: listen, measure,
|
|
36
|
-
// observe.
|
|
75
|
+
// observe, press.
|
|
76
|
+
// - `keyboard.js` — what is being pressed: a chord, and a held key.
|
|
77
|
+
// - `channels.js` — a value that came from outside the page: another tab, the
|
|
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.
|
|
37
83
|
//
|
|
38
|
-
// The two that are easiest to confuse are
|
|
39
|
-
// own header: `browser.js` needs no ref because there is one
|
|
40
|
-
// `dom.js` needs one because there are as many answers as there
|
|
84
|
+
// The two that are easiest to confuse are `browser.js` and `dom.js`, so each
|
|
85
|
+
// says so in its own header: `browser.js` needs no ref because there is one
|
|
86
|
+
// browser, and `dom.js` needs one because there are as many answers as there
|
|
87
|
+
// are elements. `keyboard.js` is neither, which is why it is a third file
|
|
88
|
+
// rather than a corner of one of them, and its header says what the four hard
|
|
89
|
+
// parts of a shortcut are.
|
|
41
90
|
//
|
|
42
91
|
// They sit here rather than under an `internal/`, and each has its own
|
|
43
92
|
// subpath. Every name in them is exported from this file, so calling them
|
|
@@ -45,12 +94,82 @@
|
|
|
45
94
|
// hop to reach the first line of code. `internal/` is for a module consumers
|
|
46
95
|
// must not reach; this package has none.
|
|
47
96
|
//
|
|
48
|
-
// `lifecycle.js`
|
|
49
|
-
// `
|
|
50
|
-
// than
|
|
51
|
-
// component's life,
|
|
97
|
+
// `lifecycle.js` and `browser.js` are the two the others import —
|
|
98
|
+
// `useStableCallback` and `browserWindow` — and both are still subjects rather
|
|
99
|
+
// than bags of shared helpers. A hook goes in `lifecycle.js` because it is
|
|
100
|
+
// about the component's life, never because more than one file wanted it.
|
|
101
|
+
//
|
|
102
|
+
// # Readiness
|
|
103
|
+
//
|
|
104
|
+
// **Implemented and tested.** The component's life; the state shapes; every
|
|
105
|
+
// timer, including the adaptive schedule behind `useTimeAgo`; `useAsync` with
|
|
106
|
+
// abort and retry; media queries, colour scheme, reduced motion, online,
|
|
107
|
+
// document visibility, window size and scroll, the address bar's fragment,
|
|
108
|
+
// scroll lock; element size,
|
|
109
|
+
// intersection, mutations, hover, focus-within, click-outside, long press,
|
|
110
|
+
// element scroll, the element as state; key chords and held keys; storage with
|
|
111
|
+
// cross-tab sync;
|
|
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
|
|
116
|
+
// whole surface in a process that has no DOM at all.
|
|
117
|
+
//
|
|
118
|
+
// **Experimental.** `useGeolocation`, `useNetwork` and `usePermission`. The
|
|
119
|
+
// shapes are settled and the cleanup is right, but the browsers disagree about
|
|
120
|
+
// them more than the rest of this package does: Network Information is
|
|
121
|
+
// Chromium's alone, the Permissions API rejects rather than answers for names
|
|
122
|
+
// it does not know, and geolocation cannot be exercised end to end in a
|
|
123
|
+
// headless document — the tests cover the unsupported path, the subscription
|
|
124
|
+
// and its teardown, not a real fix. Treat the fields as advisory.
|
|
125
|
+
//
|
|
126
|
+
// **Not implemented, and not planned here.** Everything whose subject is
|
|
127
|
+
// somewhere else in uf: a request with a cache is `@uniflowed/query`, a form
|
|
128
|
+
// field is `@uniflowed/form`, an atom two routes read is `@uniflowed/state`, a
|
|
129
|
+
// rendered instant is `@uniflowed/web`'s `Time`, styling and dark mode are
|
|
130
|
+
// `@uniflowed/stylex`, and a virtual list or an infinite scroller is
|
|
131
|
+
// `@uniflowed/ui`. Beyond those: the device APIs VueUse wraps that a general
|
|
132
|
+
// application does not reach for — Bluetooth, gamepads, USB, speech, wake
|
|
133
|
+
// lock, screen capture, web workers, battery, vibration — are absent rather
|
|
134
|
+
// than shallow. A wrapper over one of those is three lines and a `supported`
|
|
135
|
+
// flag; what makes it worth shipping is knowing the failure modes, and this
|
|
136
|
+
// package does not yet.
|
|
52
137
|
|
|
53
|
-
export type { Async } from "./async.js";
|
|
138
|
+
export type { Async, AsyncOptions } from "./async.js";
|
|
139
|
+
export type {
|
|
140
|
+
BrowserHistory,
|
|
141
|
+
BrowserLocation,
|
|
142
|
+
BrowserNavigator,
|
|
143
|
+
BrowserWindow,
|
|
144
|
+
EffectiveConnectionType,
|
|
145
|
+
GeolocationReading,
|
|
146
|
+
Geoposition,
|
|
147
|
+
Network,
|
|
148
|
+
NetworkConnection,
|
|
149
|
+
NetworkMeasurement,
|
|
150
|
+
PermissionAnswer,
|
|
151
|
+
PermissionName,
|
|
152
|
+
ScrollOffset,
|
|
153
|
+
Size,
|
|
154
|
+
} from "./browser.js";
|
|
155
|
+
export type { UseBroadcastReturn, UseClipboardReturn } from "./channels.js";
|
|
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";
|
|
163
|
+
export type { KeyComboOptions } from "./keyboard.js";
|
|
164
|
+
export type { RenderEnvelope } from "./render.js";
|
|
165
|
+
export type {
|
|
166
|
+
UseCounterReturn,
|
|
167
|
+
UseCycleReturn,
|
|
168
|
+
UseListReturn,
|
|
169
|
+
UseSetReturn,
|
|
170
|
+
UseToggleReturn,
|
|
171
|
+
UseUndoableReturn,
|
|
172
|
+
} from "./state.js";
|
|
54
173
|
|
|
55
174
|
export { useAsync } from "./async.js";
|
|
56
175
|
export {
|
|
@@ -63,27 +182,63 @@ export {
|
|
|
63
182
|
useUnmount,
|
|
64
183
|
} from "./lifecycle.js";
|
|
65
184
|
export {
|
|
185
|
+
useAnimationFrame,
|
|
66
186
|
useDebouncedCallback,
|
|
67
187
|
useDebouncedValue,
|
|
188
|
+
useIdle,
|
|
68
189
|
useInterval,
|
|
190
|
+
useNow,
|
|
69
191
|
useThrottledCallback,
|
|
192
|
+
useTimeAgo,
|
|
70
193
|
useTimeout,
|
|
71
194
|
} from "./timing.js";
|
|
72
195
|
export {
|
|
196
|
+
browserWindow,
|
|
73
197
|
useDocumentVisible,
|
|
198
|
+
useGeolocation,
|
|
199
|
+
useHash,
|
|
74
200
|
useMediaQuery,
|
|
201
|
+
useNetwork,
|
|
75
202
|
useOnline,
|
|
76
|
-
|
|
203
|
+
usePermission,
|
|
77
204
|
usePreferredColorScheme,
|
|
205
|
+
usePrefersReducedMotion,
|
|
206
|
+
useScrollLock,
|
|
207
|
+
useSupported,
|
|
208
|
+
useWindowScroll,
|
|
78
209
|
useWindowSize,
|
|
79
210
|
} from "./browser.js";
|
|
80
211
|
export {
|
|
81
212
|
useClickOutside,
|
|
82
213
|
useElementRef,
|
|
83
214
|
useElementSize,
|
|
215
|
+
useElementState,
|
|
84
216
|
useEventListener,
|
|
85
217
|
useFocusWithin,
|
|
86
218
|
useHover,
|
|
87
219
|
useIntersecting,
|
|
220
|
+
useLongPress,
|
|
221
|
+
useMutationObserver,
|
|
222
|
+
useScroll,
|
|
88
223
|
} from "./dom.js";
|
|
89
|
-
export {
|
|
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";
|
|
234
|
+
export { useBroadcast, useClipboard } from "./channels.js";
|
|
235
|
+
export { useEventSource } from "./events.js";
|
|
236
|
+
export {
|
|
237
|
+
useCounter,
|
|
238
|
+
useCycle,
|
|
239
|
+
useList,
|
|
240
|
+
useSet,
|
|
241
|
+
useStorage,
|
|
242
|
+
useToggle,
|
|
243
|
+
useUndoable,
|
|
244
|
+
} from "./state.js";
|