@rethunk/react-load-hooks 0.1.1 → 0.3.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/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/loader.d.ts +4 -2
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +7 -1
- package/dist/sameValue.d.ts +7 -0
- package/dist/sameValue.d.ts.map +1 -0
- package/dist/sameValue.js +27 -0
- package/dist/useDebouncedLoad.d.ts +3 -2
- package/dist/useDebouncedLoad.d.ts.map +1 -1
- package/dist/useLoaded.d.ts +2 -1
- package/dist/useLoaded.d.ts.map +1 -1
- 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/index.ts +3 -1
- package/src/loader.ts +13 -4
- package/src/sameValue.ts +30 -0
- package/src/useDebouncedLoad.ts +3 -3
- package/src/useLoaded.ts +2 -2
- package/src/when.ts +82 -0
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
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export { coalescer } from './coalescer.ts';
|
|
2
|
-
export { type LoadSink, loader } from './loader.ts';
|
|
2
|
+
export { type Load, type LoadSink, loader } from './loader.ts';
|
|
3
|
+
export { keepFields, keepUnchanged, sameValue } from './sameValue.ts';
|
|
3
4
|
export { useDebouncedLoad } from './useDebouncedLoad.ts';
|
|
4
5
|
export { useDebouncedValue } from './useDebouncedValue.ts';
|
|
5
6
|
export { type LoadedOptions, useLoaded } from './useLoaded.ts';
|
|
6
7
|
export { useNowValue, useSecondValue } from './useNow.ts';
|
|
7
8
|
export { readStored, useStoredState, writeStored } from './useStoredState.ts';
|
|
8
9
|
export { useWidth } from './useWidth.ts';
|
|
10
|
+
export { absoluteWhen, clockWhen, relativeWhen, type WhenOptions } from './when.ts';
|
|
9
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,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,EAAE,MAAM,aAAa,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,IAAI,EAAE,KAAK,QAAQ,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAC9D,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,8 +1,10 @@
|
|
|
1
1
|
export { coalescer } from './coalescer.js';
|
|
2
2
|
export { loader } from './loader.js';
|
|
3
|
+
export { keepFields, keepUnchanged, sameValue } from './sameValue.js';
|
|
3
4
|
export { useDebouncedLoad } from './useDebouncedLoad.js';
|
|
4
5
|
export { useDebouncedValue } from './useDebouncedValue.js';
|
|
5
6
|
export { useLoaded } from './useLoaded.js';
|
|
6
7
|
export { useNowValue, useSecondValue } from './useNow.js';
|
|
7
8
|
export { readStored, useStoredState, writeStored } from './useStoredState.js';
|
|
8
9
|
export { useWidth } from './useWidth.js';
|
|
10
|
+
export { absoluteWhen, clockWhen, relativeWhen } from './when.js';
|
package/dist/loader.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface LoadSink<T> {
|
|
|
6
6
|
/** Shows the hook's initial value again. */
|
|
7
7
|
reset: () => void;
|
|
8
8
|
}
|
|
9
|
+
/** `signal` aborts when a later load, new deps or stop supersedes this one: pass it to fetch to stop the abandoned work. */
|
|
10
|
+
export type Load<T> = (signal: AbortSignal) => Promise<T>;
|
|
9
11
|
/**
|
|
10
12
|
* useLoaded's and useDebouncedLoad's logic without React. Only the newest load lands: a later load, new deps or stop
|
|
11
13
|
* drops the answer, error and end of every one before it. New deps reset the value unless keepAcrossKeys, and a null
|
|
@@ -15,9 +17,9 @@ export declare function loader<T>(sink: LoadSink<T>, { keepAcrossKeys, defer, }?
|
|
|
15
17
|
keepAcrossKeys?: boolean;
|
|
16
18
|
defer?: (go: () => void) => () => void;
|
|
17
19
|
}): {
|
|
18
|
-
deps(deps: DependencyList, load:
|
|
20
|
+
deps(deps: DependencyList, load: Load<T> | null): void;
|
|
19
21
|
/** Runs a load at once with the same deps, keeping the value shown until it answers; resolves when it has. */
|
|
20
|
-
run(load:
|
|
22
|
+
run(load: Load<T> | null): Promise<void>;
|
|
21
23
|
stop(): void;
|
|
22
24
|
};
|
|
23
25
|
//# sourceMappingURL=loader.d.ts.map
|
package/dist/loader.d.ts.map
CHANGED
|
@@ -1 +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;
|
|
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;AAED,4HAA4H;AAC5H,MAAM,MAAM,IAAI,CAAC,CAAC,IAAI,CAAC,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,CAAA;AAOzD;;;;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;IA6C1E,IAAI,OAAO,cAAc,QAAQ,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI;IAoB/C,8GAA8G;IAC9G,GAAG,OAAO,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAOxC,IAAI;EAKP"}
|
package/dist/loader.js
CHANGED
|
@@ -12,16 +12,22 @@ export function loader(sink, { keepAcrossKeys = false, defer = now, } = {}) {
|
|
|
12
12
|
let generation = 0;
|
|
13
13
|
let seen = null;
|
|
14
14
|
let cancel = () => undefined;
|
|
15
|
+
let controller = null;
|
|
15
16
|
const drop = () => {
|
|
16
17
|
generation += 1;
|
|
17
18
|
cancel();
|
|
19
|
+
controller?.abort();
|
|
20
|
+
controller = null;
|
|
18
21
|
};
|
|
19
22
|
const start = (load) => {
|
|
20
23
|
generation += 1;
|
|
24
|
+
controller?.abort();
|
|
21
25
|
const mine = generation;
|
|
22
26
|
const current = () => generation === mine;
|
|
27
|
+
const mineController = new AbortController();
|
|
28
|
+
controller = mineController;
|
|
23
29
|
sink.loading(true);
|
|
24
|
-
return load()
|
|
30
|
+
return load(mineController.signal)
|
|
25
31
|
.then((value) => {
|
|
26
32
|
if (current()) {
|
|
27
33
|
sink.value(value);
|
|
@@ -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
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { type DependencyList } from 'react';
|
|
2
|
+
import { type Load } from './loader.ts';
|
|
2
3
|
/**
|
|
3
4
|
* useLoaded for input that changes on every key: `load` runs `delayMs` (read afresh each time a load is scheduled)
|
|
4
5
|
* after `deps` last changed, and `data` keeps the last answer meanwhile. Only the newest run lands, so `onError` hears
|
|
5
6
|
* only the newest failure. A null `load` resets `data` to `initial`. `run` starts another load at once, such as the
|
|
6
7
|
* next page, under the same rule.
|
|
7
8
|
*/
|
|
8
|
-
export declare function useDebouncedLoad<T>(load:
|
|
9
|
+
export declare function useDebouncedLoad<T>(load: Load<T> | null, deps: DependencyList, { initial, delayMs, onError, }: {
|
|
9
10
|
initial: T;
|
|
10
11
|
delayMs: number;
|
|
11
12
|
onError?: (error: unknown) => void;
|
|
@@ -13,6 +14,6 @@ export declare function useDebouncedLoad<T>(load: (() => Promise<T>) | null, dep
|
|
|
13
14
|
data: T;
|
|
14
15
|
loading: boolean;
|
|
15
16
|
error: unknown;
|
|
16
|
-
run: (next:
|
|
17
|
+
run: (next: Load<T>) => Promise<void>;
|
|
17
18
|
};
|
|
18
19
|
//# sourceMappingURL=useDebouncedLoad.d.ts.map
|
|
@@ -1 +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;
|
|
1
|
+
{"version":3,"file":"useDebouncedLoad.d.ts","sourceRoot":"","sources":["../src/useDebouncedLoad.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,cAAc,EAA+B,MAAM,OAAO,CAAA;AACxE,OAAO,EAAE,KAAK,IAAI,EAAU,MAAM,aAAa,CAAA;AAE/C;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAChC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,EACpB,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,IAAI,CAAC,CAAC,CAAC;EAEtB"}
|
package/dist/useLoaded.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type DependencyList } from 'react';
|
|
2
|
+
import { type Load } from './loader.ts';
|
|
2
3
|
export interface LoadedOptions {
|
|
3
4
|
onError?: (error: unknown) => void;
|
|
4
5
|
/** Keep `data` across new deps until the new answer lands. */
|
|
@@ -11,7 +12,7 @@ export interface LoadedOptions {
|
|
|
11
12
|
* `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
13
|
* (a null load still resets it).
|
|
13
14
|
*/
|
|
14
|
-
export declare function useLoaded<T>(load:
|
|
15
|
+
export declare function useLoaded<T>(load: Load<T> | null, deps: DependencyList, initial: T, options?: ((error: unknown) => void) | LoadedOptions): {
|
|
15
16
|
data: T;
|
|
16
17
|
setData: import("react").Dispatch<import("react").SetStateAction<T>>;
|
|
17
18
|
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;
|
|
1
|
+
{"version":3,"file":"useLoaded.d.ts","sourceRoot":"","sources":["../src/useLoaded.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,cAAc,EAA4C,MAAM,OAAO,CAAA;AACrF,OAAO,EAAE,KAAK,IAAI,EAAU,MAAM,aAAa,CAAA;AAE/C,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,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,EACpB,IAAI,EAAE,cAAc,EACpB,OAAO,EAAE,CAAC,EACV,OAAO,CAAC,EAAE,CAAC,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC,GAAG,aAAa;;;;;;EAoCrD"}
|
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.3.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/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export { coalescer } from './coalescer.ts'
|
|
2
|
-
export { type LoadSink, loader } from './loader.ts'
|
|
2
|
+
export { type Load, type LoadSink, loader } from './loader.ts'
|
|
3
|
+
export { keepFields, keepUnchanged, sameValue } from './sameValue.ts'
|
|
3
4
|
export { useDebouncedLoad } from './useDebouncedLoad.ts'
|
|
4
5
|
export { useDebouncedValue } from './useDebouncedValue.ts'
|
|
5
6
|
export { type LoadedOptions, useLoaded } from './useLoaded.ts'
|
|
6
7
|
export { useNowValue, useSecondValue } from './useNow.ts'
|
|
7
8
|
export { readStored, useStoredState, writeStored } from './useStoredState.ts'
|
|
8
9
|
export { useWidth } from './useWidth.ts'
|
|
10
|
+
export { absoluteWhen, clockWhen, relativeWhen, type WhenOptions } from './when.ts'
|
package/src/loader.ts
CHANGED
|
@@ -11,6 +11,9 @@ export interface LoadSink<T> {
|
|
|
11
11
|
reset: () => void
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** `signal` aborts when a later load, new deps or stop supersedes this one: pass it to fetch to stop the abandoned work. */
|
|
15
|
+
export type Load<T> = (signal: AbortSignal) => Promise<T>
|
|
16
|
+
|
|
14
17
|
const now = (go: () => void) => {
|
|
15
18
|
go()
|
|
16
19
|
return () => undefined
|
|
@@ -31,16 +34,22 @@ export function loader<T>(
|
|
|
31
34
|
let generation = 0
|
|
32
35
|
let seen: DependencyList | null = null
|
|
33
36
|
let cancel: () => void = () => undefined
|
|
37
|
+
let controller: AbortController | null = null
|
|
34
38
|
const drop = () => {
|
|
35
39
|
generation += 1
|
|
36
40
|
cancel()
|
|
41
|
+
controller?.abort()
|
|
42
|
+
controller = null
|
|
37
43
|
}
|
|
38
|
-
const start = (load:
|
|
44
|
+
const start = (load: Load<T>): Promise<void> => {
|
|
39
45
|
generation += 1
|
|
46
|
+
controller?.abort()
|
|
40
47
|
const mine = generation
|
|
41
48
|
const current = () => generation === mine
|
|
49
|
+
const mineController = new AbortController()
|
|
50
|
+
controller = mineController
|
|
42
51
|
sink.loading(true)
|
|
43
|
-
return load()
|
|
52
|
+
return load(mineController.signal)
|
|
44
53
|
.then(
|
|
45
54
|
(value) => {
|
|
46
55
|
if (current()) {
|
|
@@ -65,7 +74,7 @@ export function loader<T>(
|
|
|
65
74
|
sink.loading(false)
|
|
66
75
|
}
|
|
67
76
|
return {
|
|
68
|
-
deps(deps: DependencyList, load:
|
|
77
|
+
deps(deps: DependencyList, load: Load<T> | null) {
|
|
69
78
|
if (sameDeps(seen, deps)) {
|
|
70
79
|
return
|
|
71
80
|
}
|
|
@@ -86,7 +95,7 @@ export function loader<T>(
|
|
|
86
95
|
})
|
|
87
96
|
},
|
|
88
97
|
/** Runs a load at once with the same deps, keeping the value shown until it answers; resolves when it has. */
|
|
89
|
-
run(load:
|
|
98
|
+
run(load: Load<T> | null): Promise<void> {
|
|
90
99
|
if (load === null) {
|
|
91
100
|
skip()
|
|
92
101
|
return Promise.resolve()
|
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/useDebouncedLoad.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type DependencyList, useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { loader } from './loader.ts'
|
|
2
|
+
import { type Load, loader } from './loader.ts'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* useLoaded for input that changes on every key: `load` runs `delayMs` (read afresh each time a load is scheduled)
|
|
@@ -8,7 +8,7 @@ import { loader } from './loader.ts'
|
|
|
8
8
|
* next page, under the same rule.
|
|
9
9
|
*/
|
|
10
10
|
export function useDebouncedLoad<T>(
|
|
11
|
-
load:
|
|
11
|
+
load: Load<T> | null,
|
|
12
12
|
deps: DependencyList,
|
|
13
13
|
{
|
|
14
14
|
initial,
|
|
@@ -54,6 +54,6 @@ export function useDebouncedLoad<T>(
|
|
|
54
54
|
data,
|
|
55
55
|
loading,
|
|
56
56
|
error,
|
|
57
|
-
run: (next:
|
|
57
|
+
run: (next: Load<T>) => runs.run(next),
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/useLoaded.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type DependencyList, useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { loader } from './loader.ts'
|
|
2
|
+
import { type Load, loader } from './loader.ts'
|
|
3
3
|
|
|
4
4
|
export interface LoadedOptions {
|
|
5
5
|
onError?: (error: unknown) => void
|
|
@@ -15,7 +15,7 @@ export interface LoadedOptions {
|
|
|
15
15
|
* (a null load still resets it).
|
|
16
16
|
*/
|
|
17
17
|
export function useLoaded<T>(
|
|
18
|
-
load:
|
|
18
|
+
load: Load<T> | null,
|
|
19
19
|
deps: DependencyList,
|
|
20
20
|
initial: T,
|
|
21
21
|
options?: ((error: unknown) => void) | LoadedOptions,
|
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
|
+
}
|