@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/async.js +3 -0
- package/browser.js +393 -35
- package/events.js +300 -0
- package/index.js +43 -5
- package/lifecycle.js +6 -0
- package/package.json +7 -3
- package/render.js +354 -0
- package/state.js +35 -2
- package/timing.js +45 -10
package/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
|
-
//
|
|
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,
|
|
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
|
|
98
|
-
//
|
|
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.
|
|
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/
|
|
31
|
+
"@uniflowed/core": "0.1.0",
|
|
32
|
+
"@uniflowed/react": "0.1.0"
|
|
29
33
|
},
|
|
30
34
|
"peerDependencies": {
|
|
31
35
|
"react": ">=19"
|