@rethunk/react-load-hooks 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +49 -2
- package/dist/coalescer.d.ts +6 -0
- package/dist/coalescer.d.ts.map +1 -0
- package/dist/coalescer.js +27 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/loader.d.ts +23 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +79 -0
- package/dist/useDebouncedLoad.d.ts +18 -0
- package/dist/useDebouncedLoad.d.ts.map +1 -0
- package/dist/useDebouncedLoad.js +45 -0
- package/dist/useDebouncedValue.d.ts +3 -0
- package/dist/useDebouncedValue.d.ts.map +1 -0
- package/dist/useDebouncedValue.js +10 -0
- package/dist/useLoaded.d.ts +21 -0
- package/dist/useLoaded.d.ts.map +1 -0
- package/dist/useLoaded.js +39 -0
- package/dist/useNow.d.ts +6 -0
- package/dist/useNow.d.ts.map +1 -0
- package/dist/useNow.js +46 -0
- package/dist/useStoredState.d.ts +6 -0
- package/dist/useStoredState.d.ts.map +1 -0
- package/dist/useStoredState.js +48 -0
- package/dist/useWidth.d.ts +5 -0
- package/dist/useWidth.d.ts.map +1 -0
- package/dist/useWidth.js +22 -0
- package/package.json +45 -4
- package/src/coalescer.ts +34 -0
- package/src/index.ts +8 -0
- package/src/loader.ts +101 -0
- package/src/useDebouncedLoad.ts +59 -0
- package/src/useDebouncedValue.ts +11 -0
- package/src/useLoaded.ts +57 -0
- package/src/useNow.ts +62 -0
- package/src/useStoredState.ts +54 -0
- package/src/useWidth.ts +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rethunk.Tech, LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">react-load-hooks</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://www.npmjs.com/package/@rethunk/react-load-hooks)
|
|
7
|
+
[](tsconfig.json)
|
|
8
|
+
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
React hooks for async loads that never land out of order, plus the small state hooks an app builds screens from. Package `@rethunk/react-load-hooks`. First consumer: Mortar.
|
|
14
|
+
|
|
15
|
+
Only the newest load lands: a slower, older answer or error never overwrites a newer one, and an
|
|
16
|
+
unmounted component hears nothing.
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
const { data, loading, error, reload } = useLoaded(() => fetchRepos(owner), [owner], [])
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bun add @rethunk/react-load-hooks
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
A worked example and the contracts: [HUMANS.md](HUMANS.md).
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- `useLoaded` and `useDebouncedLoad` fetch on dependency changes and drop superseded answers.
|
|
33
|
+
- `loader` is the same logic without React, for a store or a class.
|
|
34
|
+
- `useStoredState` persists UI state in `localStorage`.
|
|
35
|
+
- `useWidth` tracks an element's width before paint; `useDebouncedValue` settles a fast-changing value.
|
|
36
|
+
- `useNowValue` and `useSecondValue` share one timer per period and re-render only on a changed answer.
|
|
37
|
+
|
|
38
|
+
## Documentation
|
|
39
|
+
|
|
40
|
+
| Doc | Audience |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| [HUMANS.md](HUMANS.md) | Running and using the package |
|
|
43
|
+
| [AGENTS.md](AGENTS.md) | Internals, invariants, and why |
|
|
44
|
+
| [CONTRIBUTING.md](CONTRIBUTING.md) | Process — commits, tests, docs |
|
|
45
|
+
| [SECURITY.md](SECURITY.md) | Vulnerability disclosure |
|
|
46
|
+
| [CHANGELOG.md](CHANGELOG.md) | Release notes |
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT — see [LICENSE](LICENSE). © 2026 Rethunk.Tech, LLC.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs one slow task per key at a time: an ask made while one runs waits for it and then runs once more, however many
|
|
3
|
+
* ask meanwhile, so a result is never older than its ask but identical slow calls do not pile up.
|
|
4
|
+
*/
|
|
5
|
+
export declare function coalescer(): (key: string, run: () => Promise<void>) => Promise<void>;
|
|
6
|
+
//# sourceMappingURL=coalescer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"coalescer.d.ts","sourceRoot":"","sources":["../src/coalescer.ts"],"names":[],"mappings":"AAQA;;;GAGG;AACH,wBAAgB,SAAS,UAaV,MAAM,OAAO,MAAM,OAAO,CAAC,IAAI,CAAC,KAAG,OAAO,CAAC,IAAI,CAAC,CAQ9D"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const ignore = () => undefined;
|
|
2
|
+
/**
|
|
3
|
+
* Runs one slow task per key at a time: an ask made while one runs waits for it and then runs once more, however many
|
|
4
|
+
* ask meanwhile, so a result is never older than its ask but identical slow calls do not pile up.
|
|
5
|
+
*/
|
|
6
|
+
export function coalescer() {
|
|
7
|
+
const running = new Map();
|
|
8
|
+
const start = (key, run) => {
|
|
9
|
+
const p = run();
|
|
10
|
+
const slot = { settled: Promise.resolve(), next: null };
|
|
11
|
+
slot.settled = p.then(ignore, ignore).then(() => {
|
|
12
|
+
if (running.get(key) === slot) {
|
|
13
|
+
running.delete(key);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
running.set(key, slot);
|
|
17
|
+
return p;
|
|
18
|
+
};
|
|
19
|
+
return (key, run) => {
|
|
20
|
+
const slot = running.get(key);
|
|
21
|
+
if (!slot) {
|
|
22
|
+
return start(key, run);
|
|
23
|
+
}
|
|
24
|
+
slot.next ??= slot.settled.then(() => start(key, run));
|
|
25
|
+
return slot.next;
|
|
26
|
+
};
|
|
27
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { coalescer } from './coalescer.ts';
|
|
2
|
+
export { type LoadSink, loader } from './loader.ts';
|
|
3
|
+
export { useDebouncedLoad } from './useDebouncedLoad.ts';
|
|
4
|
+
export { useDebouncedValue } from './useDebouncedValue.ts';
|
|
5
|
+
export { type LoadedOptions, useLoaded } from './useLoaded.ts';
|
|
6
|
+
export { useNowValue, useSecondValue } from './useNow.ts';
|
|
7
|
+
export { readStored, useStoredState, writeStored } from './useStoredState.ts';
|
|
8
|
+
export { useWidth } from './useWidth.ts';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACnD,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AACxD,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAC1D,OAAO,EAAE,KAAK,aAAa,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC9D,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AACzD,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAC7E,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { coalescer } from './coalescer.js';
|
|
2
|
+
export { loader } from './loader.js';
|
|
3
|
+
export { useDebouncedLoad } from './useDebouncedLoad.js';
|
|
4
|
+
export { useDebouncedValue } from './useDebouncedValue.js';
|
|
5
|
+
export { useLoaded } from './useLoaded.js';
|
|
6
|
+
export { useNowValue, useSecondValue } from './useNow.js';
|
|
7
|
+
export { readStored, useStoredState, writeStored } from './useStoredState.js';
|
|
8
|
+
export { useWidth } from './useWidth.js';
|
package/dist/loader.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { DependencyList } from 'react';
|
|
2
|
+
export interface LoadSink<T> {
|
|
3
|
+
value: (value: T) => void;
|
|
4
|
+
error: (error: unknown) => void;
|
|
5
|
+
loading: (on: boolean) => void;
|
|
6
|
+
/** Shows the hook's initial value again. */
|
|
7
|
+
reset: () => void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* useLoaded's and useDebouncedLoad's logic without React. Only the newest load lands: a later load, new deps or stop
|
|
11
|
+
* drops the answer, error and end of every one before it. New deps reset the value unless keepAcrossKeys, and a null
|
|
12
|
+
* load always does. `defer` starts a load, now or later, and returns what cancels the start.
|
|
13
|
+
*/
|
|
14
|
+
export declare function loader<T>(sink: LoadSink<T>, { keepAcrossKeys, defer, }?: {
|
|
15
|
+
keepAcrossKeys?: boolean;
|
|
16
|
+
defer?: (go: () => void) => () => void;
|
|
17
|
+
}): {
|
|
18
|
+
deps(deps: DependencyList, load: (() => Promise<T>) | null): void;
|
|
19
|
+
/** Runs a load at once with the same deps, keeping the value shown until it answers; resolves when it has. */
|
|
20
|
+
run(load: (() => Promise<T>) | null): Promise<void>;
|
|
21
|
+
stop(): void;
|
|
22
|
+
};
|
|
23
|
+
//# sourceMappingURL=loader.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../src/loader.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,OAAO,CAAA;AAK3C,MAAM,WAAW,QAAQ,CAAC,CAAC;IACzB,KAAK,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAA;IACzB,KAAK,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;IAC/B,OAAO,EAAE,CAAC,EAAE,EAAE,OAAO,KAAK,IAAI,CAAA;IAC9B,4CAA4C;IAC5C,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB;AAOD;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,CAAC,EACtB,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,EACjB,EACE,cAAsB,EACtB,KAAW,GACZ,GAAE;IAAE,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAA;CAAO;IAuC1E,IAAI,OAAO,cAAc,QAAQ,CAAC,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI;IAoB1D,8GAA8G;IAC9G,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAOnD,IAAI;EAKP"}
|
package/dist/loader.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const sameDeps = (a, b) => a !== null && a.length === b.length && b.every((dep, i) => Object.is(dep, a[i]));
|
|
2
|
+
const now = (go) => {
|
|
3
|
+
go();
|
|
4
|
+
return () => undefined;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* useLoaded's and useDebouncedLoad's logic without React. Only the newest load lands: a later load, new deps or stop
|
|
8
|
+
* drops the answer, error and end of every one before it. New deps reset the value unless keepAcrossKeys, and a null
|
|
9
|
+
* load always does. `defer` starts a load, now or later, and returns what cancels the start.
|
|
10
|
+
*/
|
|
11
|
+
export function loader(sink, { keepAcrossKeys = false, defer = now, } = {}) {
|
|
12
|
+
let generation = 0;
|
|
13
|
+
let seen = null;
|
|
14
|
+
let cancel = () => undefined;
|
|
15
|
+
const drop = () => {
|
|
16
|
+
generation += 1;
|
|
17
|
+
cancel();
|
|
18
|
+
};
|
|
19
|
+
const start = (load) => {
|
|
20
|
+
generation += 1;
|
|
21
|
+
const mine = generation;
|
|
22
|
+
const current = () => generation === mine;
|
|
23
|
+
sink.loading(true);
|
|
24
|
+
return load()
|
|
25
|
+
.then((value) => {
|
|
26
|
+
if (current()) {
|
|
27
|
+
sink.value(value);
|
|
28
|
+
}
|
|
29
|
+
}, (error) => {
|
|
30
|
+
if (current()) {
|
|
31
|
+
sink.error(error);
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
.finally(() => {
|
|
35
|
+
if (current()) {
|
|
36
|
+
sink.loading(false);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
const skip = () => {
|
|
41
|
+
drop();
|
|
42
|
+
sink.reset();
|
|
43
|
+
sink.loading(false);
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
deps(deps, load) {
|
|
47
|
+
if (sameDeps(seen, deps)) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const fresh = seen !== null;
|
|
51
|
+
seen = deps;
|
|
52
|
+
if (load === null) {
|
|
53
|
+
skip();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
drop();
|
|
57
|
+
if (fresh && !keepAcrossKeys) {
|
|
58
|
+
sink.reset();
|
|
59
|
+
}
|
|
60
|
+
sink.loading(true);
|
|
61
|
+
// start settles through the sink, so only a throwing sink handler reaches this catch.
|
|
62
|
+
cancel = defer(() => {
|
|
63
|
+
start(load).catch(sink.error);
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
/** Runs a load at once with the same deps, keeping the value shown until it answers; resolves when it has. */
|
|
67
|
+
run(load) {
|
|
68
|
+
if (load === null) {
|
|
69
|
+
skip();
|
|
70
|
+
return Promise.resolve();
|
|
71
|
+
}
|
|
72
|
+
return start(load);
|
|
73
|
+
},
|
|
74
|
+
stop() {
|
|
75
|
+
drop();
|
|
76
|
+
seen = null;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type DependencyList } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* useLoaded for input that changes on every key: `load` runs `delayMs` (read afresh each time a load is scheduled)
|
|
4
|
+
* after `deps` last changed, and `data` keeps the last answer meanwhile. Only the newest run lands, so `onError` hears
|
|
5
|
+
* only the newest failure. A null `load` resets `data` to `initial`. `run` starts another load at once, such as the
|
|
6
|
+
* next page, under the same rule.
|
|
7
|
+
*/
|
|
8
|
+
export declare function useDebouncedLoad<T>(load: (() => Promise<T>) | null, deps: DependencyList, { initial, delayMs, onError, }: {
|
|
9
|
+
initial: T;
|
|
10
|
+
delayMs: number;
|
|
11
|
+
onError?: (error: unknown) => void;
|
|
12
|
+
}): {
|
|
13
|
+
data: T;
|
|
14
|
+
loading: boolean;
|
|
15
|
+
error: unknown;
|
|
16
|
+
run: (next: () => Promise<T>) => Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=useDebouncedLoad.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useDebouncedLoad.d.ts","sourceRoot":"","sources":["../src/useDebouncedLoad.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,cAAc,EAA+B,MAAM,OAAO,CAAA;AAGxE;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAChC,IAAI,EAAE,CAAC,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,EAC/B,IAAI,EAAE,cAAc,EACpB,EACE,OAAO,EACP,OAAO,EACP,OAAO,GACR,EAAE;IAAE,OAAO,EAAE,CAAC,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;CAAE;;;;gBAwCxD,MAAM,OAAO,CAAC,CAAC,CAAC;EAE/B"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { loader } from './loader.js';
|
|
3
|
+
/**
|
|
4
|
+
* useLoaded for input that changes on every key: `load` runs `delayMs` (read afresh each time a load is scheduled)
|
|
5
|
+
* after `deps` last changed, and `data` keeps the last answer meanwhile. Only the newest run lands, so `onError` hears
|
|
6
|
+
* only the newest failure. A null `load` resets `data` to `initial`. `run` starts another load at once, such as the
|
|
7
|
+
* next page, under the same rule.
|
|
8
|
+
*/
|
|
9
|
+
export function useDebouncedLoad(load, deps, { initial, delayMs, onError, }) {
|
|
10
|
+
const [data, setData] = useState(initial);
|
|
11
|
+
const [loading, setLoading] = useState(load !== null);
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
const latest = useRef({ initial, onError, delayMs });
|
|
14
|
+
latest.current = { initial, onError, delayMs };
|
|
15
|
+
const [runs] = useState(() => loader({
|
|
16
|
+
value: (value) => {
|
|
17
|
+
setData(() => value);
|
|
18
|
+
setError(null);
|
|
19
|
+
},
|
|
20
|
+
error: (e) => {
|
|
21
|
+
setError(e);
|
|
22
|
+
latest.current.onError?.(e);
|
|
23
|
+
},
|
|
24
|
+
loading: setLoading,
|
|
25
|
+
reset: () => setData(() => latest.current.initial),
|
|
26
|
+
}, {
|
|
27
|
+
keepAcrossKeys: true,
|
|
28
|
+
defer: (go) => {
|
|
29
|
+
const timer = globalThis.setTimeout(go, latest.current.delayMs);
|
|
30
|
+
return () => globalThis.clearTimeout(timer);
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
// Runs after every render and acts only when the caller's deps changed: an effect cannot take a caller-supplied
|
|
34
|
+
// dependency list.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
runs.deps(deps, load);
|
|
37
|
+
});
|
|
38
|
+
useEffect(() => () => runs.stop(), [runs]);
|
|
39
|
+
return {
|
|
40
|
+
data,
|
|
41
|
+
loading,
|
|
42
|
+
error,
|
|
43
|
+
run: (next) => runs.run(next),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useDebouncedValue.d.ts","sourceRoot":"","sources":["../src/useDebouncedValue.ts"],"names":[],"mappings":"AAEA,oFAAoF;AACpF,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,GAAG,CAAC,CAO5D"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
/** `value` once it has held still for `ms`; the first render returns it at once. */
|
|
3
|
+
export function useDebouncedValue(value, ms) {
|
|
4
|
+
const [settled, setSettled] = useState(value);
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
const timer = setTimeout(() => setSettled(value), ms);
|
|
7
|
+
return () => clearTimeout(timer);
|
|
8
|
+
}, [value, ms]);
|
|
9
|
+
return settled;
|
|
10
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type DependencyList } from 'react';
|
|
2
|
+
export interface LoadedOptions {
|
|
3
|
+
onError?: (error: unknown) => void;
|
|
4
|
+
/** Keep `data` across new deps until the new answer lands. */
|
|
5
|
+
keepAcrossKeys?: boolean;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Runs `load` whenever `deps` change (never while `load` is null) and drops the answer of any run that has been
|
|
9
|
+
* superseded or unmounted. New deps and a null load reset `data` to `initial`; a failed load keeps the last good `data`
|
|
10
|
+
* and sets `error`. `reload` runs it again with the same deps and keeps the old value until the answer arrives;
|
|
11
|
+
* `setData` edits the loaded value in place. The fourth argument is `onError` or `{ onError, keepAcrossKeys }`. With `keepAcrossKeys`, new deps keep `data` until the new answer lands
|
|
12
|
+
* (a null load still resets it).
|
|
13
|
+
*/
|
|
14
|
+
export declare function useLoaded<T>(load: (() => Promise<T>) | null, deps: DependencyList, initial: T, options?: ((error: unknown) => void) | LoadedOptions): {
|
|
15
|
+
data: T;
|
|
16
|
+
setData: import("react").Dispatch<import("react").SetStateAction<T>>;
|
|
17
|
+
loading: boolean;
|
|
18
|
+
error: unknown;
|
|
19
|
+
reload: () => Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=useLoaded.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useLoaded.d.ts","sourceRoot":"","sources":["../src/useLoaded.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,cAAc,EAA4C,MAAM,OAAO,CAAA;AAGrF,MAAM,WAAW,aAAa;IAC5B,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;IAClC,8DAA8D;IAC9D,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,CAAC,EACzB,IAAI,EAAE,CAAC,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,EAC/B,IAAI,EAAE,cAAc,EACpB,OAAO,EAAE,CAAC,EACV,OAAO,CAAC,EAAE,CAAC,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC,GAAG,aAAa;;;;;;EAoCrD"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { loader } from './loader.js';
|
|
3
|
+
/**
|
|
4
|
+
* Runs `load` whenever `deps` change (never while `load` is null) and drops the answer of any run that has been
|
|
5
|
+
* superseded or unmounted. New deps and a null load reset `data` to `initial`; a failed load keeps the last good `data`
|
|
6
|
+
* and sets `error`. `reload` runs it again with the same deps and keeps the old value until the answer arrives;
|
|
7
|
+
* `setData` edits the loaded value in place. The fourth argument is `onError` or `{ onError, keepAcrossKeys }`. With `keepAcrossKeys`, new deps keep `data` until the new answer lands
|
|
8
|
+
* (a null load still resets it).
|
|
9
|
+
*/
|
|
10
|
+
export function useLoaded(load, deps, initial, options) {
|
|
11
|
+
const { onError, keepAcrossKeys = false } = typeof options === 'function' ? { onError: options } : (options ?? {});
|
|
12
|
+
const [data, setData] = useState(initial);
|
|
13
|
+
const [loading, setLoading] = useState(load !== null);
|
|
14
|
+
const [error, setError] = useState(null);
|
|
15
|
+
const latest = useRef({ load, initial, onError });
|
|
16
|
+
latest.current = { load, initial, onError };
|
|
17
|
+
const [runs] = useState(() => loader({
|
|
18
|
+
value: (value) => setData(() => value),
|
|
19
|
+
error: (e) => {
|
|
20
|
+
setError(e);
|
|
21
|
+
latest.current.onError?.(e);
|
|
22
|
+
},
|
|
23
|
+
loading: (on) => {
|
|
24
|
+
setLoading(on);
|
|
25
|
+
if (on) {
|
|
26
|
+
setError(null);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
reset: () => setData(() => latest.current.initial),
|
|
30
|
+
}, { keepAcrossKeys }));
|
|
31
|
+
// Runs after every render and starts a load only when the caller's deps changed: an effect cannot take a
|
|
32
|
+
// caller-supplied dependency list.
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
runs.deps(deps, load);
|
|
35
|
+
});
|
|
36
|
+
useEffect(() => () => runs.stop(), [runs]);
|
|
37
|
+
const reload = useCallback(() => runs.run(latest.current.load), [runs]);
|
|
38
|
+
return { data, setData, loading, error, reload };
|
|
39
|
+
}
|
package/dist/useNow.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** compute's answer from the minute clock; the component re-renders only when the answer changes, so compute returns a
|
|
2
|
+
* primitive (a label rather than the time). */
|
|
3
|
+
export declare function useNowValue<T extends string | number | boolean>(compute: (now: number) => T): T;
|
|
4
|
+
/** useNowValue on a clock that ticks every second while `on`; compute should return a constant while off. */
|
|
5
|
+
export declare function useSecondValue<T extends string | number | boolean>(compute: (now: number) => T, on: boolean): T;
|
|
6
|
+
//# sourceMappingURL=useNow.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useNow.d.ts","sourceRoot":"","sources":["../src/useNow.ts"],"names":[],"mappings":"AAiDA;+CAC+C;AAC/C,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,CAAC,GAAG,CAAC,CAE/F;AAED,6GAA6G;AAC7G,wBAAgB,cAAc,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,GAAG,OAAO,EAChE,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,CAAC,EAC3B,EAAE,EAAE,OAAO,GACV,CAAC,CAEH"}
|
package/dist/useNow.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
const second = 1000;
|
|
3
|
+
const minute = 60_000;
|
|
4
|
+
// One shared timer per period, running only while some component watches it.
|
|
5
|
+
function tickingClock(period) {
|
|
6
|
+
const listeners = new Set();
|
|
7
|
+
let now = Date.now();
|
|
8
|
+
let timer;
|
|
9
|
+
function tick() {
|
|
10
|
+
now = Date.now();
|
|
11
|
+
for (const listener of listeners) {
|
|
12
|
+
listener();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function subscribe(listener) {
|
|
16
|
+
listeners.add(listener);
|
|
17
|
+
if (listeners.size === 1) {
|
|
18
|
+
// The clock stood still while nobody watched it.
|
|
19
|
+
now = Date.now();
|
|
20
|
+
timer = setInterval(tick, period);
|
|
21
|
+
}
|
|
22
|
+
return () => {
|
|
23
|
+
listeners.delete(listener);
|
|
24
|
+
if (listeners.size === 0 && timer !== undefined) {
|
|
25
|
+
clearInterval(timer);
|
|
26
|
+
timer = undefined;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return { subscribe, read: () => (listeners.size === 0 ? Date.now() : now) };
|
|
31
|
+
}
|
|
32
|
+
const minuteClock = tickingClock(minute);
|
|
33
|
+
const secondClock = tickingClock(second);
|
|
34
|
+
const unwatched = () => () => undefined;
|
|
35
|
+
function useClockValue(clock, compute, on) {
|
|
36
|
+
return useSyncExternalStore(on ? clock.subscribe : unwatched, () => compute(clock.read()));
|
|
37
|
+
}
|
|
38
|
+
/** compute's answer from the minute clock; the component re-renders only when the answer changes, so compute returns a
|
|
39
|
+
* primitive (a label rather than the time). */
|
|
40
|
+
export function useNowValue(compute) {
|
|
41
|
+
return useClockValue(minuteClock, compute, true);
|
|
42
|
+
}
|
|
43
|
+
/** useNowValue on a clock that ticks every second while `on`; compute should return a constant while off. */
|
|
44
|
+
export function useSecondValue(compute, on) {
|
|
45
|
+
return useClockValue(secondClock, compute, on);
|
|
46
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
declare function read<T>(key: string, fallback: T, valid: (value: unknown) => value is T): T;
|
|
2
|
+
declare function write(key: string, value: unknown): void;
|
|
3
|
+
/** State that survives a restart in localStorage. A stored value that fails `valid` is ignored. */
|
|
4
|
+
export declare function useStoredState<T>(key: string, fallback: T, valid: (value: unknown) => value is T): [T, (value: T) => void];
|
|
5
|
+
export { read as readStored, write as writeStored };
|
|
6
|
+
//# sourceMappingURL=useStoredState.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useStoredState.d.ts","sourceRoot":"","sources":["../src/useStoredState.ts"],"names":[],"mappings":"AAEA,iBAAS,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,KAAK,IAAI,CAAC,GAAG,CAAC,CAWnF;AAWD,iBAAS,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,QAMzC;AAED,mGAAmG;AACnG,wBAAgB,cAAc,CAAC,CAAC,EAC9B,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,CAAC,EACX,KAAK,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,KAAK,IAAI,CAAC,GACpC,CAAC,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC,CAczB;AAED,OAAO,EAAE,IAAI,IAAI,UAAU,EAAE,KAAK,IAAI,WAAW,EAAE,CAAA"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
function read(key, fallback, valid) {
|
|
3
|
+
try {
|
|
4
|
+
const raw = localStorage.getItem(key);
|
|
5
|
+
if (raw === null) {
|
|
6
|
+
return fallback;
|
|
7
|
+
}
|
|
8
|
+
const parsed = parseOrRaw(raw);
|
|
9
|
+
return valid(parsed) ? parsed : fallback;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return fallback;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// Other code may have written a bare string, which is not JSON.
|
|
16
|
+
function parseOrRaw(raw) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(raw);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return raw;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function write(key, value) {
|
|
25
|
+
try {
|
|
26
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Storage can be blocked or full; the choice then lasts until the app closes.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** State that survives a restart in localStorage. A stored value that fails `valid` is ignored. */
|
|
33
|
+
export function useStoredState(key, fallback, valid) {
|
|
34
|
+
const [stored, setStored] = useState(() => ({ key, value: read(key, fallback, valid) }));
|
|
35
|
+
let value = stored.value;
|
|
36
|
+
if (stored.key !== key) {
|
|
37
|
+
value = read(key, fallback, valid);
|
|
38
|
+
setStored({ key, value });
|
|
39
|
+
}
|
|
40
|
+
return [
|
|
41
|
+
value,
|
|
42
|
+
(next) => {
|
|
43
|
+
setStored({ key, value: next });
|
|
44
|
+
write(key, next);
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
export { read as readStored, write as writeStored };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
/** The client width of the element the ref is put on, kept current as it resizes; 0 until measured. */
|
|
3
|
+
declare function useWidth<T extends HTMLElement>(): [RefObject<T | null>, number];
|
|
4
|
+
export { useWidth };
|
|
5
|
+
//# sourceMappingURL=useWidth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useWidth.d.ts","sourceRoot":"","sources":["../src/useWidth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAqC,MAAM,OAAO,CAAA;AAGzE,uGAAuG;AACvG,iBAAS,QAAQ,CAAC,CAAC,SAAS,WAAW,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,EAAE,MAAM,CAAC,CAiBxE;AAED,OAAO,EAAE,QAAQ,EAAE,CAAA"}
|
package/dist/useWidth.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { flushSync } from 'react-dom';
|
|
3
|
+
/** The client width of the element the ref is put on, kept current as it resizes; 0 until measured. */
|
|
4
|
+
function useWidth() {
|
|
5
|
+
const ref = useRef(null);
|
|
6
|
+
const [width, setWidth] = useState(0);
|
|
7
|
+
// Measured before paint, so the first frame is laid out for the real width rather than 0.
|
|
8
|
+
useLayoutEffect(() => {
|
|
9
|
+
const el = ref.current;
|
|
10
|
+
if (!el) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
// Rendered before the browser paints the resize: a default-priority update would paint one frame laid out for the
|
|
14
|
+
// old width, with whatever columns or scrollbar that width implies.
|
|
15
|
+
const ro = new ResizeObserver(() => flushSync(() => setWidth(el.clientWidth)));
|
|
16
|
+
ro.observe(el);
|
|
17
|
+
setWidth(el.clientWidth);
|
|
18
|
+
return () => ro.disconnect();
|
|
19
|
+
}, []);
|
|
20
|
+
return [ref, width];
|
|
21
|
+
}
|
|
22
|
+
export { useWidth };
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rethunk/react-load-hooks",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "React hooks for async loads that never land out of order, plus the small state hooks an app builds screens from.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Rethunk.Tech, LLC",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Rethunk-Tech/react-load-hooks.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"packageManager": "bun@1.4.4",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"lint": "biome check --error-on-warnings .",
|
|
26
|
+
"typecheck": "tsc -p tsconfig.test.json",
|
|
27
|
+
"test": "bun test",
|
|
28
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
29
|
+
"pack:check": "bun scripts/check-pack.ts"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@biomejs/biome": "^2.6.4",
|
|
33
|
+
"@happy-dom/global-registrator": "^21.0.4",
|
|
34
|
+
"@testing-library/dom": "^10.4.2",
|
|
35
|
+
"@testing-library/react": "^16.3.3",
|
|
36
|
+
"@types/bun": "^1.4.4",
|
|
37
|
+
"@types/react": "^19.3.0",
|
|
38
|
+
"@types/react-dom": "^19.3.0",
|
|
39
|
+
"react": "^19.3.1",
|
|
40
|
+
"react-dom": "^19.3.1",
|
|
41
|
+
"typescript": "^7.0.2"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"react": "^19.0.0",
|
|
45
|
+
"react-dom": "^19.0.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/coalescer.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
interface Slot {
|
|
2
|
+
settled: Promise<void>
|
|
3
|
+
// The follow-up run asked for while this one runs, shared by every later asker.
|
|
4
|
+
next: Promise<void> | null
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const ignore = () => undefined
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runs one slow task per key at a time: an ask made while one runs waits for it and then runs once more, however many
|
|
11
|
+
* ask meanwhile, so a result is never older than its ask but identical slow calls do not pile up.
|
|
12
|
+
*/
|
|
13
|
+
export function coalescer() {
|
|
14
|
+
const running = new Map<string, Slot>()
|
|
15
|
+
const start = (key: string, run: () => Promise<void>): Promise<void> => {
|
|
16
|
+
const p = run()
|
|
17
|
+
const slot: Slot = { settled: Promise.resolve(), next: null }
|
|
18
|
+
slot.settled = p.then(ignore, ignore).then(() => {
|
|
19
|
+
if (running.get(key) === slot) {
|
|
20
|
+
running.delete(key)
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
running.set(key, slot)
|
|
24
|
+
return p
|
|
25
|
+
}
|
|
26
|
+
return (key: string, run: () => Promise<void>): Promise<void> => {
|
|
27
|
+
const slot = running.get(key)
|
|
28
|
+
if (!slot) {
|
|
29
|
+
return start(key, run)
|
|
30
|
+
}
|
|
31
|
+
slot.next ??= slot.settled.then(() => start(key, run))
|
|
32
|
+
return slot.next
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { coalescer } from './coalescer.ts'
|
|
2
|
+
export { type LoadSink, loader } from './loader.ts'
|
|
3
|
+
export { useDebouncedLoad } from './useDebouncedLoad.ts'
|
|
4
|
+
export { useDebouncedValue } from './useDebouncedValue.ts'
|
|
5
|
+
export { type LoadedOptions, useLoaded } from './useLoaded.ts'
|
|
6
|
+
export { useNowValue, useSecondValue } from './useNow.ts'
|
|
7
|
+
export { readStored, useStoredState, writeStored } from './useStoredState.ts'
|
|
8
|
+
export { useWidth } from './useWidth.ts'
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { DependencyList } from 'react'
|
|
2
|
+
|
|
3
|
+
const sameDeps = (a: DependencyList | null, b: DependencyList) =>
|
|
4
|
+
a !== null && a.length === b.length && b.every((dep, i) => Object.is(dep, a[i]))
|
|
5
|
+
|
|
6
|
+
export interface LoadSink<T> {
|
|
7
|
+
value: (value: T) => void
|
|
8
|
+
error: (error: unknown) => void
|
|
9
|
+
loading: (on: boolean) => void
|
|
10
|
+
/** Shows the hook's initial value again. */
|
|
11
|
+
reset: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const now = (go: () => void) => {
|
|
15
|
+
go()
|
|
16
|
+
return () => undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* useLoaded's and useDebouncedLoad's logic without React. Only the newest load lands: a later load, new deps or stop
|
|
21
|
+
* drops the answer, error and end of every one before it. New deps reset the value unless keepAcrossKeys, and a null
|
|
22
|
+
* load always does. `defer` starts a load, now or later, and returns what cancels the start.
|
|
23
|
+
*/
|
|
24
|
+
export function loader<T>(
|
|
25
|
+
sink: LoadSink<T>,
|
|
26
|
+
{
|
|
27
|
+
keepAcrossKeys = false,
|
|
28
|
+
defer = now,
|
|
29
|
+
}: { keepAcrossKeys?: boolean; defer?: (go: () => void) => () => void } = {},
|
|
30
|
+
) {
|
|
31
|
+
let generation = 0
|
|
32
|
+
let seen: DependencyList | null = null
|
|
33
|
+
let cancel: () => void = () => undefined
|
|
34
|
+
const drop = () => {
|
|
35
|
+
generation += 1
|
|
36
|
+
cancel()
|
|
37
|
+
}
|
|
38
|
+
const start = (load: () => Promise<T>): Promise<void> => {
|
|
39
|
+
generation += 1
|
|
40
|
+
const mine = generation
|
|
41
|
+
const current = () => generation === mine
|
|
42
|
+
sink.loading(true)
|
|
43
|
+
return load()
|
|
44
|
+
.then(
|
|
45
|
+
(value) => {
|
|
46
|
+
if (current()) {
|
|
47
|
+
sink.value(value)
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
(error: unknown) => {
|
|
51
|
+
if (current()) {
|
|
52
|
+
sink.error(error)
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
.finally(() => {
|
|
57
|
+
if (current()) {
|
|
58
|
+
sink.loading(false)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
const skip = () => {
|
|
63
|
+
drop()
|
|
64
|
+
sink.reset()
|
|
65
|
+
sink.loading(false)
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
deps(deps: DependencyList, load: (() => Promise<T>) | null) {
|
|
69
|
+
if (sameDeps(seen, deps)) {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
const fresh = seen !== null
|
|
73
|
+
seen = deps
|
|
74
|
+
if (load === null) {
|
|
75
|
+
skip()
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
drop()
|
|
79
|
+
if (fresh && !keepAcrossKeys) {
|
|
80
|
+
sink.reset()
|
|
81
|
+
}
|
|
82
|
+
sink.loading(true)
|
|
83
|
+
// start settles through the sink, so only a throwing sink handler reaches this catch.
|
|
84
|
+
cancel = defer(() => {
|
|
85
|
+
start(load).catch(sink.error)
|
|
86
|
+
})
|
|
87
|
+
},
|
|
88
|
+
/** Runs a load at once with the same deps, keeping the value shown until it answers; resolves when it has. */
|
|
89
|
+
run(load: (() => Promise<T>) | null): Promise<void> {
|
|
90
|
+
if (load === null) {
|
|
91
|
+
skip()
|
|
92
|
+
return Promise.resolve()
|
|
93
|
+
}
|
|
94
|
+
return start(load)
|
|
95
|
+
},
|
|
96
|
+
stop() {
|
|
97
|
+
drop()
|
|
98
|
+
seen = null
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type DependencyList, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { loader } from './loader.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* useLoaded for input that changes on every key: `load` runs `delayMs` (read afresh each time a load is scheduled)
|
|
6
|
+
* after `deps` last changed, and `data` keeps the last answer meanwhile. Only the newest run lands, so `onError` hears
|
|
7
|
+
* only the newest failure. A null `load` resets `data` to `initial`. `run` starts another load at once, such as the
|
|
8
|
+
* next page, under the same rule.
|
|
9
|
+
*/
|
|
10
|
+
export function useDebouncedLoad<T>(
|
|
11
|
+
load: (() => Promise<T>) | null,
|
|
12
|
+
deps: DependencyList,
|
|
13
|
+
{
|
|
14
|
+
initial,
|
|
15
|
+
delayMs,
|
|
16
|
+
onError,
|
|
17
|
+
}: { initial: T; delayMs: number; onError?: (error: unknown) => void },
|
|
18
|
+
) {
|
|
19
|
+
const [data, setData] = useState(initial)
|
|
20
|
+
const [loading, setLoading] = useState(load !== null)
|
|
21
|
+
const [error, setError] = useState<unknown>(null)
|
|
22
|
+
const latest = useRef({ initial, onError, delayMs })
|
|
23
|
+
latest.current = { initial, onError, delayMs }
|
|
24
|
+
const [runs] = useState(() =>
|
|
25
|
+
loader<T>(
|
|
26
|
+
{
|
|
27
|
+
value: (value) => {
|
|
28
|
+
setData(() => value)
|
|
29
|
+
setError(null)
|
|
30
|
+
},
|
|
31
|
+
error: (e) => {
|
|
32
|
+
setError(e)
|
|
33
|
+
latest.current.onError?.(e)
|
|
34
|
+
},
|
|
35
|
+
loading: setLoading,
|
|
36
|
+
reset: () => setData(() => latest.current.initial),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
keepAcrossKeys: true,
|
|
40
|
+
defer: (go) => {
|
|
41
|
+
const timer = globalThis.setTimeout(go, latest.current.delayMs)
|
|
42
|
+
return () => globalThis.clearTimeout(timer)
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
// Runs after every render and acts only when the caller's deps changed: an effect cannot take a caller-supplied
|
|
48
|
+
// dependency list.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
runs.deps(deps, load)
|
|
51
|
+
})
|
|
52
|
+
useEffect(() => () => runs.stop(), [runs])
|
|
53
|
+
return {
|
|
54
|
+
data,
|
|
55
|
+
loading,
|
|
56
|
+
error,
|
|
57
|
+
run: (next: () => Promise<T>) => runs.run(next),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/** `value` once it has held still for `ms`; the first render returns it at once. */
|
|
4
|
+
export function useDebouncedValue<T>(value: T, ms: number): T {
|
|
5
|
+
const [settled, setSettled] = useState(value)
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const timer = setTimeout(() => setSettled(value), ms)
|
|
8
|
+
return () => clearTimeout(timer)
|
|
9
|
+
}, [value, ms])
|
|
10
|
+
return settled
|
|
11
|
+
}
|
package/src/useLoaded.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type DependencyList, useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { loader } from './loader.ts'
|
|
3
|
+
|
|
4
|
+
export interface LoadedOptions {
|
|
5
|
+
onError?: (error: unknown) => void
|
|
6
|
+
/** Keep `data` across new deps until the new answer lands. */
|
|
7
|
+
keepAcrossKeys?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Runs `load` whenever `deps` change (never while `load` is null) and drops the answer of any run that has been
|
|
12
|
+
* superseded or unmounted. New deps and a null load reset `data` to `initial`; a failed load keeps the last good `data`
|
|
13
|
+
* and sets `error`. `reload` runs it again with the same deps and keeps the old value until the answer arrives;
|
|
14
|
+
* `setData` edits the loaded value in place. The fourth argument is `onError` or `{ onError, keepAcrossKeys }`. With `keepAcrossKeys`, new deps keep `data` until the new answer lands
|
|
15
|
+
* (a null load still resets it).
|
|
16
|
+
*/
|
|
17
|
+
export function useLoaded<T>(
|
|
18
|
+
load: (() => Promise<T>) | null,
|
|
19
|
+
deps: DependencyList,
|
|
20
|
+
initial: T,
|
|
21
|
+
options?: ((error: unknown) => void) | LoadedOptions,
|
|
22
|
+
) {
|
|
23
|
+
const { onError, keepAcrossKeys = false } =
|
|
24
|
+
typeof options === 'function' ? { onError: options } : (options ?? {})
|
|
25
|
+
const [data, setData] = useState(initial)
|
|
26
|
+
const [loading, setLoading] = useState(load !== null)
|
|
27
|
+
const [error, setError] = useState<unknown>(null)
|
|
28
|
+
const latest = useRef({ load, initial, onError })
|
|
29
|
+
latest.current = { load, initial, onError }
|
|
30
|
+
const [runs] = useState(() =>
|
|
31
|
+
loader<T>(
|
|
32
|
+
{
|
|
33
|
+
value: (value) => setData(() => value),
|
|
34
|
+
error: (e) => {
|
|
35
|
+
setError(e)
|
|
36
|
+
latest.current.onError?.(e)
|
|
37
|
+
},
|
|
38
|
+
loading: (on) => {
|
|
39
|
+
setLoading(on)
|
|
40
|
+
if (on) {
|
|
41
|
+
setError(null)
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
reset: () => setData(() => latest.current.initial),
|
|
45
|
+
},
|
|
46
|
+
{ keepAcrossKeys },
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
// Runs after every render and starts a load only when the caller's deps changed: an effect cannot take a
|
|
50
|
+
// caller-supplied dependency list.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
runs.deps(deps, load)
|
|
53
|
+
})
|
|
54
|
+
useEffect(() => () => runs.stop(), [runs])
|
|
55
|
+
const reload = useCallback(() => runs.run(latest.current.load), [runs])
|
|
56
|
+
return { data, setData, loading, error, reload }
|
|
57
|
+
}
|
package/src/useNow.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
|
|
3
|
+
const second = 1000
|
|
4
|
+
const minute = 60_000
|
|
5
|
+
|
|
6
|
+
// One shared timer per period, running only while some component watches it.
|
|
7
|
+
function tickingClock(period: number) {
|
|
8
|
+
const listeners = new Set<() => void>()
|
|
9
|
+
let now = Date.now()
|
|
10
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
11
|
+
|
|
12
|
+
function tick() {
|
|
13
|
+
now = Date.now()
|
|
14
|
+
for (const listener of listeners) {
|
|
15
|
+
listener()
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function subscribe(listener: () => void) {
|
|
20
|
+
listeners.add(listener)
|
|
21
|
+
if (listeners.size === 1) {
|
|
22
|
+
// The clock stood still while nobody watched it.
|
|
23
|
+
now = Date.now()
|
|
24
|
+
timer = setInterval(tick, period)
|
|
25
|
+
}
|
|
26
|
+
return () => {
|
|
27
|
+
listeners.delete(listener)
|
|
28
|
+
if (listeners.size === 0 && timer !== undefined) {
|
|
29
|
+
clearInterval(timer)
|
|
30
|
+
timer = undefined
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { subscribe, read: () => (listeners.size === 0 ? Date.now() : now) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const minuteClock = tickingClock(minute)
|
|
39
|
+
const secondClock = tickingClock(second)
|
|
40
|
+
const unwatched = () => () => undefined
|
|
41
|
+
|
|
42
|
+
function useClockValue<T extends string | number | boolean>(
|
|
43
|
+
clock: ReturnType<typeof tickingClock>,
|
|
44
|
+
compute: (now: number) => T,
|
|
45
|
+
on: boolean,
|
|
46
|
+
): T {
|
|
47
|
+
return useSyncExternalStore(on ? clock.subscribe : unwatched, () => compute(clock.read()))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** compute's answer from the minute clock; the component re-renders only when the answer changes, so compute returns a
|
|
51
|
+
* primitive (a label rather than the time). */
|
|
52
|
+
export function useNowValue<T extends string | number | boolean>(compute: (now: number) => T): T {
|
|
53
|
+
return useClockValue(minuteClock, compute, true)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** useNowValue on a clock that ticks every second while `on`; compute should return a constant while off. */
|
|
57
|
+
export function useSecondValue<T extends string | number | boolean>(
|
|
58
|
+
compute: (now: number) => T,
|
|
59
|
+
on: boolean,
|
|
60
|
+
): T {
|
|
61
|
+
return useClockValue(secondClock, compute, on)
|
|
62
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
|
|
3
|
+
function read<T>(key: string, fallback: T, valid: (value: unknown) => value is T): T {
|
|
4
|
+
try {
|
|
5
|
+
const raw = localStorage.getItem(key)
|
|
6
|
+
if (raw === null) {
|
|
7
|
+
return fallback
|
|
8
|
+
}
|
|
9
|
+
const parsed = parseOrRaw(raw)
|
|
10
|
+
return valid(parsed) ? parsed : fallback
|
|
11
|
+
} catch {
|
|
12
|
+
return fallback
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Other code may have written a bare string, which is not JSON.
|
|
17
|
+
function parseOrRaw(raw: string): unknown {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(raw)
|
|
20
|
+
} catch {
|
|
21
|
+
return raw
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function write(key: string, value: unknown) {
|
|
26
|
+
try {
|
|
27
|
+
localStorage.setItem(key, JSON.stringify(value))
|
|
28
|
+
} catch {
|
|
29
|
+
// Storage can be blocked or full; the choice then lasts until the app closes.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** State that survives a restart in localStorage. A stored value that fails `valid` is ignored. */
|
|
34
|
+
export function useStoredState<T>(
|
|
35
|
+
key: string,
|
|
36
|
+
fallback: T,
|
|
37
|
+
valid: (value: unknown) => value is T,
|
|
38
|
+
): [T, (value: T) => void] {
|
|
39
|
+
const [stored, setStored] = useState(() => ({ key, value: read(key, fallback, valid) }))
|
|
40
|
+
let value = stored.value
|
|
41
|
+
if (stored.key !== key) {
|
|
42
|
+
value = read(key, fallback, valid)
|
|
43
|
+
setStored({ key, value })
|
|
44
|
+
}
|
|
45
|
+
return [
|
|
46
|
+
value,
|
|
47
|
+
(next) => {
|
|
48
|
+
setStored({ key, value: next })
|
|
49
|
+
write(key, next)
|
|
50
|
+
},
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export { read as readStored, write as writeStored }
|
package/src/useWidth.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type RefObject, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import { flushSync } from 'react-dom'
|
|
3
|
+
|
|
4
|
+
/** The client width of the element the ref is put on, kept current as it resizes; 0 until measured. */
|
|
5
|
+
function useWidth<T extends HTMLElement>(): [RefObject<T | null>, number] {
|
|
6
|
+
const ref = useRef<T | null>(null)
|
|
7
|
+
const [width, setWidth] = useState(0)
|
|
8
|
+
// Measured before paint, so the first frame is laid out for the real width rather than 0.
|
|
9
|
+
useLayoutEffect(() => {
|
|
10
|
+
const el = ref.current
|
|
11
|
+
if (!el) {
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
// Rendered before the browser paints the resize: a default-priority update would paint one frame laid out for the
|
|
15
|
+
// old width, with whatever columns or scrollbar that width implies.
|
|
16
|
+
const ro = new ResizeObserver(() => flushSync(() => setWidth(el.clientWidth)))
|
|
17
|
+
ro.observe(el)
|
|
18
|
+
setWidth(el.clientWidth)
|
|
19
|
+
return () => ro.disconnect()
|
|
20
|
+
}, [])
|
|
21
|
+
return [ref, width]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { useWidth }
|