@rethunk/react-load-hooks 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- 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 +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/sameValue.d.ts +7 -0
- package/dist/sameValue.d.ts.map +1 -0
- package/dist/sameValue.js +27 -0
- package/dist/useLoaded.d.ts +8 -2
- package/dist/useLoaded.d.ts.map +1 -1
- package/dist/useLoaded.js +5 -3
- package/dist/when.d.ts +11 -0
- package/dist/when.d.ts.map +1 -0
- package/dist/when.js +60 -0
- package/package.json +1 -1
- package/src/coalescer.ts +34 -0
- package/src/index.ts +4 -1
- package/src/sameValue.ts +30 -0
- package/src/useLoaded.ts +27 -15
- package/src/when.ts +82 -0
- package/src/loader.test.ts +0 -78
- package/src/useDebouncedLoad.test.ts +0 -26
- package/src/useLoaded.test.tsx +0 -125
- package/src/useNow.test.ts +0 -58
- package/src/useStoredState.test.ts +0 -43
package/README.md
CHANGED
|
@@ -33,6 +33,8 @@ A worked example and the contracts: [HUMANS.md](HUMANS.md).
|
|
|
33
33
|
- `loader` is the same logic without React, for a store or a class.
|
|
34
34
|
- `useStoredState` persists UI state in `localStorage`.
|
|
35
35
|
- `useWidth` tracks an element's width before paint; `useDebouncedValue` settles a fast-changing value.
|
|
36
|
+
- `sameValue`, `keepUnchanged` and `keepFields` keep the stored value when a fresh backend answer is equal, so subscribers do not re-render.
|
|
37
|
+
- `clockWhen`, `absoluteWhen` and `relativeWhen` label a time through `Intl`: "3 minutes ago" within a week, a date after.
|
|
36
38
|
- `useNowValue` and `useSecondValue` share one timer per period and re-render only on a changed answer.
|
|
37
39
|
|
|
38
40
|
## Documentation
|
|
@@ -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
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
export { coalescer } from './coalescer.ts';
|
|
1
2
|
export { type LoadSink, loader } from './loader.ts';
|
|
3
|
+
export { keepFields, keepUnchanged, sameValue } from './sameValue.ts';
|
|
2
4
|
export { useDebouncedLoad } from './useDebouncedLoad.ts';
|
|
3
5
|
export { useDebouncedValue } from './useDebouncedValue.ts';
|
|
4
|
-
export { useLoaded } from './useLoaded.ts';
|
|
6
|
+
export { type LoadedOptions, useLoaded } from './useLoaded.ts';
|
|
5
7
|
export { useNowValue, useSecondValue } from './useNow.ts';
|
|
6
8
|
export { readStored, useStoredState, writeStored } from './useStoredState.ts';
|
|
7
9
|
export { useWidth } from './useWidth.ts';
|
|
10
|
+
export { absoluteWhen, clockWhen, relativeWhen, type WhenOptions } from './when.ts';
|
|
8
11
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,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,SAAS,EAAE,MAAM,gBAAgB,CAAA;
|
|
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,UAAU,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AACrE,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;AACxC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,KAAK,WAAW,EAAE,MAAM,WAAW,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
export { coalescer } from './coalescer.js';
|
|
1
2
|
export { loader } from './loader.js';
|
|
3
|
+
export { keepFields, keepUnchanged, sameValue } from './sameValue.js';
|
|
2
4
|
export { useDebouncedLoad } from './useDebouncedLoad.js';
|
|
3
5
|
export { useDebouncedValue } from './useDebouncedValue.js';
|
|
4
6
|
export { useLoaded } from './useLoaded.js';
|
|
5
7
|
export { useNowValue, useSecondValue } from './useNow.js';
|
|
6
8
|
export { readStored, useStoredState, writeStored } from './useStoredState.js';
|
|
7
9
|
export { useWidth } from './useWidth.js';
|
|
10
|
+
export { absoluteWhen, clockWhen, relativeWhen } from './when.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function sameValue(a: unknown, b: unknown): boolean;
|
|
2
|
+
/** next, with each item equal to the previous item of the same key replaced by that previous object. */
|
|
3
|
+
export declare function keepUnchanged<T>(prev: readonly T[], next: T[], keyOf: (item: T) => string): T[];
|
|
4
|
+
/** next, with each field equal to the same field of prev kept as prev's value, so a selector of an unchanged field
|
|
5
|
+
* keeps returning the same object. */
|
|
6
|
+
export declare function keepFields<T extends object>(prev: T, next: T): T;
|
|
7
|
+
//# sourceMappingURL=sameValue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sameValue.d.ts","sourceRoot":"","sources":["../src/sameValue.ts"],"names":[],"mappings":"AAGA,wBAAgB,SAAS,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,GAAG,OAAO,CAEzD;AAED,wGAAwG;AACxG,wBAAgB,aAAa,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,KAAK,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,MAAM,GAAG,CAAC,EAAE,CAS/F;AAED;sCACsC;AACtC,wBAAgB,UAAU,CAAC,CAAC,SAAS,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC,CAQhE"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Backend answers arrive as fresh objects even when nothing changed; storing one as-is re-renders every subscriber
|
|
2
|
+
// and defeats memoised rows. These keep the stored value whenever the answer is equal to it.
|
|
3
|
+
export function sameValue(a, b) {
|
|
4
|
+
return a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
5
|
+
}
|
|
6
|
+
/** next, with each item equal to the previous item of the same key replaced by that previous object. */
|
|
7
|
+
export function keepUnchanged(prev, next, keyOf) {
|
|
8
|
+
const old = new Map(prev.map((item) => [keyOf(item), item]));
|
|
9
|
+
const kept = next.map((item) => {
|
|
10
|
+
const was = old.get(keyOf(item));
|
|
11
|
+
return was !== undefined && sameValue(was, item) ? was : item;
|
|
12
|
+
});
|
|
13
|
+
return kept.length === prev.length && kept.every((item, i) => item === prev[i])
|
|
14
|
+
? prev
|
|
15
|
+
: kept;
|
|
16
|
+
}
|
|
17
|
+
/** next, with each field equal to the same field of prev kept as prev's value, so a selector of an unchanged field
|
|
18
|
+
* keeps returning the same object. */
|
|
19
|
+
export function keepFields(prev, next) {
|
|
20
|
+
const out = { ...next };
|
|
21
|
+
for (const k of Object.keys(next)) {
|
|
22
|
+
if (sameValue(prev[k], next[k])) {
|
|
23
|
+
out[k] = prev[k];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
package/dist/useLoaded.d.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
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
|
+
}
|
|
2
7
|
/**
|
|
3
8
|
* Runs `load` whenever `deps` change (never while `load` is null) and drops the answer of any run that has been
|
|
4
9
|
* superseded or unmounted. New deps and a null load reset `data` to `initial`; a failed load keeps the last good `data`
|
|
5
10
|
* and sets `error`. `reload` runs it again with the same deps and keeps the old value until the answer arrives;
|
|
6
|
-
* `setData` edits the loaded value in place.
|
|
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).
|
|
7
13
|
*/
|
|
8
|
-
export declare function useLoaded<T>(load: (() => Promise<T>) | null, deps: DependencyList, initial: T,
|
|
14
|
+
export declare function useLoaded<T>(load: (() => Promise<T>) | null, deps: DependencyList, initial: T, options?: ((error: unknown) => void) | LoadedOptions): {
|
|
9
15
|
data: T;
|
|
10
16
|
setData: import("react").Dispatch<import("react").SetStateAction<T>>;
|
|
11
17
|
loading: boolean;
|
package/dist/useLoaded.d.ts.map
CHANGED
|
@@ -1 +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
|
|
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"}
|
package/dist/useLoaded.js
CHANGED
|
@@ -4,9 +4,11 @@ import { loader } from './loader.js';
|
|
|
4
4
|
* Runs `load` whenever `deps` change (never while `load` is null) and drops the answer of any run that has been
|
|
5
5
|
* superseded or unmounted. New deps and a null load reset `data` to `initial`; a failed load keeps the last good `data`
|
|
6
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.
|
|
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).
|
|
8
9
|
*/
|
|
9
|
-
export function useLoaded(load, deps, initial,
|
|
10
|
+
export function useLoaded(load, deps, initial, options) {
|
|
11
|
+
const { onError, keepAcrossKeys = false } = typeof options === 'function' ? { onError: options } : (options ?? {});
|
|
10
12
|
const [data, setData] = useState(initial);
|
|
11
13
|
const [loading, setLoading] = useState(load !== null);
|
|
12
14
|
const [error, setError] = useState(null);
|
|
@@ -25,7 +27,7 @@ export function useLoaded(load, deps, initial, onError) {
|
|
|
25
27
|
}
|
|
26
28
|
},
|
|
27
29
|
reset: () => setData(() => latest.current.initial),
|
|
28
|
-
}));
|
|
30
|
+
}, { keepAcrossKeys }));
|
|
29
31
|
// Runs after every render and starts a load only when the caller's deps changed: an effect cannot take a
|
|
30
32
|
// caller-supplied dependency list.
|
|
31
33
|
useEffect(() => {
|
package/dist/when.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface WhenOptions {
|
|
2
|
+
withTime?: boolean;
|
|
3
|
+
now?: number;
|
|
4
|
+
locale?: string;
|
|
5
|
+
fewSeconds?: string;
|
|
6
|
+
absolute?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function clockWhen(value: string | number | Date, locale?: string): string;
|
|
9
|
+
export declare function absoluteWhen(value: string | number | Date, locale?: string): string;
|
|
10
|
+
export declare function relativeWhen(value: string | number | Date, { withTime, now, locale, fewSeconds, absolute, }?: WhenOptions): string;
|
|
11
|
+
//# sourceMappingURL=when.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"when.d.ts","sourceRoot":"","sources":["../src/when.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,MAAM,CAAA;IAEf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAGD,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,EAAE,MAAM,SAAO,GAAG,MAAM,CAG9E;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,EAAE,MAAM,SAAO,GAAG,MAAM,CASjF;AAID,wBAAgB,YAAY,CAC1B,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,EAC7B,EACE,QAAgB,EAChB,GAAgB,EAChB,MAAa,EACb,UAAgC,EAChC,QAAgB,GACjB,GAAE,WAAgB,GAClB,MAAM,CA6BR"}
|
package/dist/when.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const MINUTE = 60_000;
|
|
2
|
+
const HOUR = 60 * MINUTE;
|
|
3
|
+
const DAY = 24 * HOUR;
|
|
4
|
+
const DAYS_PER_WEEK = 7;
|
|
5
|
+
const WEEK = DAYS_PER_WEEK * DAY;
|
|
6
|
+
const FIRST_YEAR = 1970;
|
|
7
|
+
function parseWhen(value) {
|
|
8
|
+
const d = new Date(value);
|
|
9
|
+
if (Number.isNaN(d.getTime()) || d.getUTCFullYear() < FIRST_YEAR) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
return d;
|
|
13
|
+
}
|
|
14
|
+
// clockWhen is the time of day as the locale writes it ("9:05 AM", "09:05"), '' for a zero or unparseable time.
|
|
15
|
+
export function clockWhen(value, locale = 'en') {
|
|
16
|
+
const d = parseWhen(value);
|
|
17
|
+
return d ? new Intl.DateTimeFormat(locale, { timeStyle: 'short' }).format(d) : '';
|
|
18
|
+
}
|
|
19
|
+
export function absoluteWhen(value, locale = 'en') {
|
|
20
|
+
const d = parseWhen(value);
|
|
21
|
+
if (!d) {
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
return new Intl.DateTimeFormat(locale, {
|
|
25
|
+
dateStyle: 'medium',
|
|
26
|
+
timeStyle: 'short',
|
|
27
|
+
}).format(d);
|
|
28
|
+
}
|
|
29
|
+
// relativeWhen reads a moment as "3 minutes ago" or "yesterday" within the last week and as a date before that,
|
|
30
|
+
// with the time too when withTime is set. A zero or unparseable time gives ''.
|
|
31
|
+
export function relativeWhen(value, { withTime = false, now = Date.now(), locale = 'en', fewSeconds = 'a few seconds ago', absolute = false, } = {}) {
|
|
32
|
+
const d = parseWhen(value);
|
|
33
|
+
if (!d) {
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
if (absolute) {
|
|
37
|
+
return new Intl.DateTimeFormat(locale, {
|
|
38
|
+
dateStyle: 'medium',
|
|
39
|
+
...(withTime ? { timeStyle: 'short' } : {}),
|
|
40
|
+
}).format(d);
|
|
41
|
+
}
|
|
42
|
+
const elapsed = now - d.getTime();
|
|
43
|
+
if (elapsed > -MINUTE && elapsed < MINUTE) {
|
|
44
|
+
return fewSeconds;
|
|
45
|
+
}
|
|
46
|
+
if (elapsed >= MINUTE && elapsed < WEEK) {
|
|
47
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
|
48
|
+
if (elapsed < HOUR) {
|
|
49
|
+
return rtf.format(-Math.floor(elapsed / MINUTE), 'minute');
|
|
50
|
+
}
|
|
51
|
+
if (elapsed < DAY) {
|
|
52
|
+
return rtf.format(-Math.floor(elapsed / HOUR), 'hour');
|
|
53
|
+
}
|
|
54
|
+
return rtf.format(-Math.floor(elapsed / DAY), 'day');
|
|
55
|
+
}
|
|
56
|
+
return new Intl.DateTimeFormat(locale, {
|
|
57
|
+
dateStyle: 'medium',
|
|
58
|
+
...(withTime ? { timeStyle: 'short' } : {}),
|
|
59
|
+
}).format(d);
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rethunk/react-load-hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "React hooks for async loads that never land out of order, plus the small state hooks an app builds screens from.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Rethunk.Tech, LLC",
|
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
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
export { coalescer } from './coalescer.ts'
|
|
1
2
|
export { type LoadSink, loader } from './loader.ts'
|
|
3
|
+
export { keepFields, keepUnchanged, sameValue } from './sameValue.ts'
|
|
2
4
|
export { useDebouncedLoad } from './useDebouncedLoad.ts'
|
|
3
5
|
export { useDebouncedValue } from './useDebouncedValue.ts'
|
|
4
|
-
export { useLoaded } from './useLoaded.ts'
|
|
6
|
+
export { type LoadedOptions, useLoaded } from './useLoaded.ts'
|
|
5
7
|
export { useNowValue, useSecondValue } from './useNow.ts'
|
|
6
8
|
export { readStored, useStoredState, writeStored } from './useStoredState.ts'
|
|
7
9
|
export { useWidth } from './useWidth.ts'
|
|
10
|
+
export { absoluteWhen, clockWhen, relativeWhen, type WhenOptions } from './when.ts'
|
package/src/sameValue.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Backend answers arrive as fresh objects even when nothing changed; storing one as-is re-renders every subscriber
|
|
2
|
+
// and defeats memoised rows. These keep the stored value whenever the answer is equal to it.
|
|
3
|
+
|
|
4
|
+
export function sameValue(a: unknown, b: unknown): boolean {
|
|
5
|
+
return a === b || JSON.stringify(a) === JSON.stringify(b)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** next, with each item equal to the previous item of the same key replaced by that previous object. */
|
|
9
|
+
export function keepUnchanged<T>(prev: readonly T[], next: T[], keyOf: (item: T) => string): T[] {
|
|
10
|
+
const old = new Map(prev.map((item) => [keyOf(item), item]))
|
|
11
|
+
const kept = next.map((item) => {
|
|
12
|
+
const was = old.get(keyOf(item))
|
|
13
|
+
return was !== undefined && sameValue(was, item) ? was : item
|
|
14
|
+
})
|
|
15
|
+
return kept.length === prev.length && kept.every((item, i) => item === prev[i])
|
|
16
|
+
? (prev as T[])
|
|
17
|
+
: kept
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** next, with each field equal to the same field of prev kept as prev's value, so a selector of an unchanged field
|
|
21
|
+
* keeps returning the same object. */
|
|
22
|
+
export function keepFields<T extends object>(prev: T, next: T): T {
|
|
23
|
+
const out = { ...next }
|
|
24
|
+
for (const k of Object.keys(next) as (keyof T)[]) {
|
|
25
|
+
if (sameValue(prev[k], next[k])) {
|
|
26
|
+
out[k] = prev[k]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out
|
|
30
|
+
}
|
package/src/useLoaded.ts
CHANGED
|
@@ -1,38 +1,50 @@
|
|
|
1
1
|
import { type DependencyList, useCallback, useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { loader } from './loader.ts'
|
|
3
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
|
+
|
|
4
10
|
/**
|
|
5
11
|
* Runs `load` whenever `deps` change (never while `load` is null) and drops the answer of any run that has been
|
|
6
12
|
* superseded or unmounted. New deps and a null load reset `data` to `initial`; a failed load keeps the last good `data`
|
|
7
13
|
* and sets `error`. `reload` runs it again with the same deps and keeps the old value until the answer arrives;
|
|
8
|
-
* `setData` edits the loaded value in place.
|
|
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).
|
|
9
16
|
*/
|
|
10
17
|
export function useLoaded<T>(
|
|
11
18
|
load: (() => Promise<T>) | null,
|
|
12
19
|
deps: DependencyList,
|
|
13
20
|
initial: T,
|
|
14
|
-
|
|
21
|
+
options?: ((error: unknown) => void) | LoadedOptions,
|
|
15
22
|
) {
|
|
23
|
+
const { onError, keepAcrossKeys = false } =
|
|
24
|
+
typeof options === 'function' ? { onError: options } : (options ?? {})
|
|
16
25
|
const [data, setData] = useState(initial)
|
|
17
26
|
const [loading, setLoading] = useState(load !== null)
|
|
18
27
|
const [error, setError] = useState<unknown>(null)
|
|
19
28
|
const latest = useRef({ load, initial, onError })
|
|
20
29
|
latest.current = { load, initial, onError }
|
|
21
30
|
const [runs] = useState(() =>
|
|
22
|
-
loader<T>(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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),
|
|
33
45
|
},
|
|
34
|
-
|
|
35
|
-
|
|
46
|
+
{ keepAcrossKeys },
|
|
47
|
+
),
|
|
36
48
|
)
|
|
37
49
|
// Runs after every render and starts a load only when the caller's deps changed: an effect cannot take a
|
|
38
50
|
// caller-supplied dependency list.
|
package/src/when.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const MINUTE = 60_000
|
|
2
|
+
const HOUR = 60 * MINUTE
|
|
3
|
+
const DAY = 24 * HOUR
|
|
4
|
+
const DAYS_PER_WEEK = 7
|
|
5
|
+
const WEEK = DAYS_PER_WEEK * DAY
|
|
6
|
+
const FIRST_YEAR = 1970
|
|
7
|
+
|
|
8
|
+
function parseWhen(value: string | number | Date): Date | undefined {
|
|
9
|
+
const d = new Date(value)
|
|
10
|
+
if (Number.isNaN(d.getTime()) || d.getUTCFullYear() < FIRST_YEAR) {
|
|
11
|
+
return undefined
|
|
12
|
+
}
|
|
13
|
+
return d
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface WhenOptions {
|
|
17
|
+
withTime?: boolean
|
|
18
|
+
now?: number
|
|
19
|
+
locale?: string
|
|
20
|
+
// The translated text for under a minute ago, which Intl words as "now".
|
|
21
|
+
fewSeconds?: string
|
|
22
|
+
absolute?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// clockWhen is the time of day as the locale writes it ("9:05 AM", "09:05"), '' for a zero or unparseable time.
|
|
26
|
+
export function clockWhen(value: string | number | Date, locale = 'en'): string {
|
|
27
|
+
const d = parseWhen(value)
|
|
28
|
+
return d ? new Intl.DateTimeFormat(locale, { timeStyle: 'short' }).format(d) : ''
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function absoluteWhen(value: string | number | Date, locale = 'en'): string {
|
|
32
|
+
const d = parseWhen(value)
|
|
33
|
+
if (!d) {
|
|
34
|
+
return ''
|
|
35
|
+
}
|
|
36
|
+
return new Intl.DateTimeFormat(locale, {
|
|
37
|
+
dateStyle: 'medium',
|
|
38
|
+
timeStyle: 'short',
|
|
39
|
+
}).format(d)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// relativeWhen reads a moment as "3 minutes ago" or "yesterday" within the last week and as a date before that,
|
|
43
|
+
// with the time too when withTime is set. A zero or unparseable time gives ''.
|
|
44
|
+
export function relativeWhen(
|
|
45
|
+
value: string | number | Date,
|
|
46
|
+
{
|
|
47
|
+
withTime = false,
|
|
48
|
+
now = Date.now(),
|
|
49
|
+
locale = 'en',
|
|
50
|
+
fewSeconds = 'a few seconds ago',
|
|
51
|
+
absolute = false,
|
|
52
|
+
}: WhenOptions = {},
|
|
53
|
+
): string {
|
|
54
|
+
const d = parseWhen(value)
|
|
55
|
+
if (!d) {
|
|
56
|
+
return ''
|
|
57
|
+
}
|
|
58
|
+
if (absolute) {
|
|
59
|
+
return new Intl.DateTimeFormat(locale, {
|
|
60
|
+
dateStyle: 'medium',
|
|
61
|
+
...(withTime ? { timeStyle: 'short' as const } : {}),
|
|
62
|
+
}).format(d)
|
|
63
|
+
}
|
|
64
|
+
const elapsed = now - d.getTime()
|
|
65
|
+
if (elapsed > -MINUTE && elapsed < MINUTE) {
|
|
66
|
+
return fewSeconds
|
|
67
|
+
}
|
|
68
|
+
if (elapsed >= MINUTE && elapsed < WEEK) {
|
|
69
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
|
|
70
|
+
if (elapsed < HOUR) {
|
|
71
|
+
return rtf.format(-Math.floor(elapsed / MINUTE), 'minute')
|
|
72
|
+
}
|
|
73
|
+
if (elapsed < DAY) {
|
|
74
|
+
return rtf.format(-Math.floor(elapsed / HOUR), 'hour')
|
|
75
|
+
}
|
|
76
|
+
return rtf.format(-Math.floor(elapsed / DAY), 'day')
|
|
77
|
+
}
|
|
78
|
+
return new Intl.DateTimeFormat(locale, {
|
|
79
|
+
dateStyle: 'medium',
|
|
80
|
+
...(withTime ? { timeStyle: 'short' as const } : {}),
|
|
81
|
+
}).format(d)
|
|
82
|
+
}
|
package/src/loader.test.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import { expect, test } from 'bun:test'
|
|
2
|
-
import { loader } from './loader.ts'
|
|
3
|
-
|
|
4
|
-
const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|
5
|
-
|
|
6
|
-
function harness(keepAcrossKeys: boolean) {
|
|
7
|
-
const log: string[] = []
|
|
8
|
-
const pending: (() => void)[] = []
|
|
9
|
-
const runs = loader<string>(
|
|
10
|
-
{
|
|
11
|
-
value: (v) => log.push(`value ${v}`),
|
|
12
|
-
error: (e) => log.push(`error ${String(e)}`),
|
|
13
|
-
loading: (on) => log.push(on ? 'loading' : 'idle'),
|
|
14
|
-
reset: () => log.push('reset'),
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
keepAcrossKeys,
|
|
18
|
-
// A deferred start waits in `pending` until the test runs it; cancelling empties its slot.
|
|
19
|
-
defer: (go) => {
|
|
20
|
-
const i = pending.push(go) - 1
|
|
21
|
-
return () => {
|
|
22
|
-
pending[i] = () => undefined
|
|
23
|
-
}
|
|
24
|
-
},
|
|
25
|
-
},
|
|
26
|
-
)
|
|
27
|
-
return { log, pending, runs }
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// The branches useLoaded and useDebouncedLoad share: a superseded answer and error are dropped, new deps reset or keep
|
|
31
|
-
// the value, a null load resets, a start cancelled by new deps never runs, and stop drops what is in flight.
|
|
32
|
-
test('only the newest load lands', async () => {
|
|
33
|
-
for (const keep of [false, true]) {
|
|
34
|
-
const { log, pending, runs } = harness(keep)
|
|
35
|
-
const a = Promise.withResolvers<string>()
|
|
36
|
-
const b = Promise.withResolvers<string>()
|
|
37
|
-
runs.deps(['a'], () => a.promise)
|
|
38
|
-
runs.deps(['a'], () => b.promise)
|
|
39
|
-
expect(pending).toHaveLength(1)
|
|
40
|
-
pending[0]?.()
|
|
41
|
-
runs.deps(['b'], () => b.promise)
|
|
42
|
-
pending[1]?.()
|
|
43
|
-
b.resolve('b')
|
|
44
|
-
a.reject(new Error('a failed'))
|
|
45
|
-
await flush()
|
|
46
|
-
expect(log).toEqual([
|
|
47
|
-
'loading',
|
|
48
|
-
'loading',
|
|
49
|
-
...(keep ? [] : ['reset']),
|
|
50
|
-
'loading',
|
|
51
|
-
'loading',
|
|
52
|
-
'value b',
|
|
53
|
-
'idle',
|
|
54
|
-
])
|
|
55
|
-
|
|
56
|
-
log.length = 0
|
|
57
|
-
const c = Promise.withResolvers<string>()
|
|
58
|
-
const older = runs.run(() => c.promise)
|
|
59
|
-
const newest = runs.run(() => Promise.reject(new Error('newest')))
|
|
60
|
-
c.resolve('c')
|
|
61
|
-
await Promise.all([older, newest])
|
|
62
|
-
expect(log).toEqual(['loading', 'loading', 'error Error: newest', 'idle'])
|
|
63
|
-
|
|
64
|
-
log.length = 0
|
|
65
|
-
runs.deps(['c'], () => c.promise)
|
|
66
|
-
runs.deps(['d'], null)
|
|
67
|
-
pending.at(-1)?.()
|
|
68
|
-
await flush()
|
|
69
|
-
expect(log).toEqual([...(keep ? [] : ['reset']), 'loading', 'reset', 'idle'])
|
|
70
|
-
|
|
71
|
-
log.length = 0
|
|
72
|
-
await runs.run(null)
|
|
73
|
-
const late = runs.run(() => Promise.resolve('late'))
|
|
74
|
-
runs.stop()
|
|
75
|
-
await late
|
|
76
|
-
expect(log).toEqual(['reset', 'idle', 'loading'])
|
|
77
|
-
}
|
|
78
|
-
})
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { afterAll, expect, test } from 'bun:test'
|
|
2
|
-
|
|
3
|
-
const testing = await import('@testing-library/react')
|
|
4
|
-
const { useDebouncedLoad } = await import('./useDebouncedLoad.ts')
|
|
5
|
-
|
|
6
|
-
afterAll(() => {
|
|
7
|
-
testing.cleanup()
|
|
8
|
-
})
|
|
9
|
-
|
|
10
|
-
const sleep = (ms: number) => testing.act(async () => new Promise<void>((r) => setTimeout(r, ms)))
|
|
11
|
-
|
|
12
|
-
test('a delayMs that changes between renders applies to the next scheduled load', async () => {
|
|
13
|
-
const view = testing.renderHook(
|
|
14
|
-
({ key, delayMs }) =>
|
|
15
|
-
useDebouncedLoad(() => Promise.resolve(key), [key], { initial: '', delayMs }),
|
|
16
|
-
{ initialProps: { key: 'a', delayMs: 1 } },
|
|
17
|
-
)
|
|
18
|
-
await sleep(20)
|
|
19
|
-
expect(view.result.current.data).toBe('a')
|
|
20
|
-
|
|
21
|
-
view.rerender({ key: 'b', delayMs: 150 })
|
|
22
|
-
await sleep(40)
|
|
23
|
-
expect(view.result.current.data).toBe('a')
|
|
24
|
-
await sleep(200)
|
|
25
|
-
expect(view.result.current.data).toBe('b')
|
|
26
|
-
})
|
package/src/useLoaded.test.tsx
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import { afterAll, expect, test } from 'bun:test'
|
|
2
|
-
|
|
3
|
-
const testing = await import('@testing-library/react')
|
|
4
|
-
const { useLoaded } = await import('./useLoaded.ts')
|
|
5
|
-
|
|
6
|
-
afterAll(() => {
|
|
7
|
-
testing.cleanup()
|
|
8
|
-
})
|
|
9
|
-
|
|
10
|
-
// Flushes pending promises inside act, so the state update they make renders before the test reads it.
|
|
11
|
-
const settle = () => testing.act(async () => new Promise<void>((resolve) => setTimeout(resolve, 0)))
|
|
12
|
-
|
|
13
|
-
function deferred<T>() {
|
|
14
|
-
let resolve: (v: T) => void = () => undefined
|
|
15
|
-
let reject: (e: unknown) => void = () => undefined
|
|
16
|
-
const promise = new Promise<T>((res, rej) => {
|
|
17
|
-
resolve = res
|
|
18
|
-
reject = rej
|
|
19
|
-
})
|
|
20
|
-
return { promise, resolve, reject }
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
test('loads, then reloads with the same deps', async () => {
|
|
24
|
-
let n = 0
|
|
25
|
-
const next = () => {
|
|
26
|
-
n += 1
|
|
27
|
-
return Promise.resolve(n)
|
|
28
|
-
}
|
|
29
|
-
const { result } = testing.renderHook(() => useLoaded(next, [], 0))
|
|
30
|
-
expect(result.current.loading).toBe(true)
|
|
31
|
-
await settle()
|
|
32
|
-
expect(result.current.data).toBe(1)
|
|
33
|
-
expect(result.current.loading).toBe(false)
|
|
34
|
-
await testing.act(() => result.current.reload())
|
|
35
|
-
await settle()
|
|
36
|
-
expect(result.current.data).toBe(2)
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
test('a run superseded by new deps is dropped', async () => {
|
|
40
|
-
const first = deferred<string>()
|
|
41
|
-
const second = deferred<string>()
|
|
42
|
-
const { result, rerender } = testing.renderHook(
|
|
43
|
-
({ key }) => useLoaded(() => (key === 'a' ? first.promise : second.promise), [key], ''),
|
|
44
|
-
{ initialProps: { key: 'a' } },
|
|
45
|
-
)
|
|
46
|
-
rerender({ key: 'b' })
|
|
47
|
-
second.resolve('b')
|
|
48
|
-
await settle()
|
|
49
|
-
expect(result.current.data).toBe('b')
|
|
50
|
-
first.resolve('a')
|
|
51
|
-
await testing.act(() => first.promise)
|
|
52
|
-
expect(result.current.data).toBe('b')
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
test('an answer after unmount is ignored and errors reach onError once', async () => {
|
|
56
|
-
const late = deferred<string>()
|
|
57
|
-
const seen: unknown[] = []
|
|
58
|
-
const gone = testing.renderHook(() =>
|
|
59
|
-
useLoaded(
|
|
60
|
-
() => late.promise,
|
|
61
|
-
[],
|
|
62
|
-
'',
|
|
63
|
-
(e) => seen.push(e),
|
|
64
|
-
),
|
|
65
|
-
)
|
|
66
|
-
gone.unmount()
|
|
67
|
-
late.reject(new Error('late'))
|
|
68
|
-
await late.promise.catch(() => undefined)
|
|
69
|
-
expect(seen).toEqual([])
|
|
70
|
-
|
|
71
|
-
const boom = new Error('boom')
|
|
72
|
-
const { result } = testing.renderHook(() =>
|
|
73
|
-
useLoaded(
|
|
74
|
-
() => Promise.reject(boom),
|
|
75
|
-
[],
|
|
76
|
-
'fallback',
|
|
77
|
-
(e) => seen.push(e),
|
|
78
|
-
),
|
|
79
|
-
)
|
|
80
|
-
await settle()
|
|
81
|
-
expect(result.current.error).toBe(boom)
|
|
82
|
-
expect(result.current.data).toBe('fallback')
|
|
83
|
-
expect(result.current.loading).toBe(false)
|
|
84
|
-
expect(seen).toEqual([boom])
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
test('new deps clear the old answer until the new one arrives', async () => {
|
|
88
|
-
const second = deferred<string>()
|
|
89
|
-
const { result, rerender } = testing.renderHook(
|
|
90
|
-
({ key }) => useLoaded(() => (key === 'a' ? Promise.resolve('a') : second.promise), [key], ''),
|
|
91
|
-
{ initialProps: { key: 'a' } },
|
|
92
|
-
)
|
|
93
|
-
await settle()
|
|
94
|
-
expect(result.current.data).toBe('a')
|
|
95
|
-
rerender({ key: 'b' })
|
|
96
|
-
await settle()
|
|
97
|
-
expect(result.current.data).toBe('')
|
|
98
|
-
second.resolve('b')
|
|
99
|
-
await settle()
|
|
100
|
-
expect(result.current.data).toBe('b')
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
test('a null loader never runs', () => {
|
|
104
|
-
const { result } = testing.renderHook(() => useLoaded<number>(null, [], 7))
|
|
105
|
-
expect(result.current).toMatchObject({ data: 7, loading: false })
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
test('a failed reload keeps the last good data', async () => {
|
|
109
|
-
let fail: boolean
|
|
110
|
-
fail = false
|
|
111
|
-
const { result } = testing.renderHook(() =>
|
|
112
|
-
useLoaded(
|
|
113
|
-
() => (fail ? Promise.reject(new Error('offline')) : Promise.resolve('good')),
|
|
114
|
-
[],
|
|
115
|
-
'',
|
|
116
|
-
),
|
|
117
|
-
)
|
|
118
|
-
await settle()
|
|
119
|
-
expect(result.current.data).toBe('good')
|
|
120
|
-
fail = true
|
|
121
|
-
await testing.act(() => result.current.reload())
|
|
122
|
-
await settle()
|
|
123
|
-
expect(result.current.data).toBe('good')
|
|
124
|
-
expect(result.current.error).toBeInstanceOf(Error)
|
|
125
|
-
})
|
package/src/useNow.test.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { afterAll, afterEach, beforeEach, expect, jest, setSystemTime, test } from 'bun:test'
|
|
2
|
-
|
|
3
|
-
const testing = await import('@testing-library/react')
|
|
4
|
-
const { useNowValue, useSecondValue } = await import('./useNow.ts')
|
|
5
|
-
|
|
6
|
-
beforeEach(() => {
|
|
7
|
-
jest.useFakeTimers()
|
|
8
|
-
setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
|
9
|
-
})
|
|
10
|
-
afterEach(() => {
|
|
11
|
-
testing.cleanup()
|
|
12
|
-
jest.useRealTimers()
|
|
13
|
-
setSystemTime()
|
|
14
|
-
})
|
|
15
|
-
afterAll(() => {
|
|
16
|
-
testing.cleanup()
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
test('a watcher re-renders only when its answer changes, and the clock stops with the last one', () => {
|
|
20
|
-
let renders = 0
|
|
21
|
-
const view = testing.renderHook(() => {
|
|
22
|
-
renders += 1
|
|
23
|
-
return useSecondValue((now) => Math.floor(now / 10_000), true)
|
|
24
|
-
})
|
|
25
|
-
const first = view.result.current
|
|
26
|
-
const afterMount = renders
|
|
27
|
-
|
|
28
|
-
testing.act(() => {
|
|
29
|
-
setSystemTime(new Date('2026-01-01T00:00:03Z'))
|
|
30
|
-
jest.advanceTimersByTime(3000)
|
|
31
|
-
})
|
|
32
|
-
expect(view.result.current).toBe(first)
|
|
33
|
-
expect(renders).toBe(afterMount)
|
|
34
|
-
|
|
35
|
-
testing.act(() => {
|
|
36
|
-
setSystemTime(new Date('2026-01-01T00:00:10Z'))
|
|
37
|
-
jest.advanceTimersByTime(7000)
|
|
38
|
-
})
|
|
39
|
-
expect(view.result.current).toBe(first + 1)
|
|
40
|
-
|
|
41
|
-
view.unmount()
|
|
42
|
-
expect(jest.getTimerCount()).toBe(0)
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
test('the minute clock ticks once for every watcher and is idle when none is left', () => {
|
|
46
|
-
const a = testing.renderHook(() => useNowValue((now) => Math.floor(now / 60_000)))
|
|
47
|
-
const b = testing.renderHook(() => useNowValue((now) => Math.floor(now / 60_000)))
|
|
48
|
-
expect(jest.getTimerCount()).toBe(1)
|
|
49
|
-
a.unmount()
|
|
50
|
-
expect(jest.getTimerCount()).toBe(1)
|
|
51
|
-
b.unmount()
|
|
52
|
-
expect(jest.getTimerCount()).toBe(0)
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
test('a clock switched off never ticks', () => {
|
|
56
|
-
testing.renderHook(() => useSecondValue(() => 'off', false))
|
|
57
|
-
expect(jest.getTimerCount()).toBe(0)
|
|
58
|
-
})
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { afterAll, expect, test } from 'bun:test'
|
|
2
|
-
|
|
3
|
-
const testing = await import('@testing-library/react')
|
|
4
|
-
const { readStored, useStoredState, writeStored } = await import('./useStoredState.ts')
|
|
5
|
-
|
|
6
|
-
afterAll(() => {
|
|
7
|
-
testing.cleanup()
|
|
8
|
-
})
|
|
9
|
-
|
|
10
|
-
const isText = (v: unknown): v is string => typeof v === 'string'
|
|
11
|
-
|
|
12
|
-
test('a stored string reads back whether it was written as JSON or bare', () => {
|
|
13
|
-
localStorage.setItem('stored-test.json', JSON.stringify('run-1'))
|
|
14
|
-
localStorage.setItem('stored-test.bare', 'run-2')
|
|
15
|
-
expect(readStored('stored-test.json', '', isText)).toBe('run-1')
|
|
16
|
-
expect(readStored('stored-test.bare', '', isText)).toBe('run-2')
|
|
17
|
-
expect(readStored('stored-test.missing', 'none', isText)).toBe('none')
|
|
18
|
-
localStorage.removeItem('stored-test.json')
|
|
19
|
-
localStorage.removeItem('stored-test.bare')
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
test('the value follows the key when the key changes', () => {
|
|
23
|
-
localStorage.setItem('stored-test.a', JSON.stringify('one'))
|
|
24
|
-
localStorage.setItem('stored-test.b', JSON.stringify('two'))
|
|
25
|
-
const view = testing.renderHook(({ key }) => useStoredState(key, '', isText), {
|
|
26
|
-
initialProps: { key: 'stored-test.a' },
|
|
27
|
-
})
|
|
28
|
-
expect(view.result.current[0]).toBe('one')
|
|
29
|
-
view.rerender({ key: 'stored-test.b' })
|
|
30
|
-
expect(view.result.current[0]).toBe('two')
|
|
31
|
-
testing.act(() => view.result.current[1]('three'))
|
|
32
|
-
expect(localStorage.getItem('stored-test.b')).toBe('"three"')
|
|
33
|
-
expect(localStorage.getItem('stored-test.a')).toBe('"one"')
|
|
34
|
-
localStorage.removeItem('stored-test.a')
|
|
35
|
-
localStorage.removeItem('stored-test.b')
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
test('the caller key reaches localStorage verbatim, with no prefix', () => {
|
|
39
|
-
writeStored('stored-test.verbatim', { a: 1 })
|
|
40
|
-
expect(localStorage.getItem('stored-test.verbatim')).toBe(JSON.stringify({ a: 1 }))
|
|
41
|
-
expect(localStorage.length).toBe(1)
|
|
42
|
-
localStorage.removeItem('stored-test.verbatim')
|
|
43
|
-
})
|