@rethunk/react-load-hooks 0.2.0 → 0.3.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/README.md CHANGED
@@ -39,13 +39,13 @@ A worked example and the contracts: [HUMANS.md](HUMANS.md).
39
39
 
40
40
  ## Documentation
41
41
 
42
- | Doc | Audience |
43
- | --- | --- |
44
- | [HUMANS.md](HUMANS.md) | Running and using the package |
45
- | [AGENTS.md](AGENTS.md) | Internals, invariants, and why |
42
+ | Doc | Audience |
43
+ | ---------------------------------- | ------------------------------ |
44
+ | [HUMANS.md](HUMANS.md) | Running and using the package |
45
+ | [AGENTS.md](AGENTS.md) | Internals, invariants, and why |
46
46
  | [CONTRIBUTING.md](CONTRIBUTING.md) | Process — commits, tests, docs |
47
- | [SECURITY.md](SECURITY.md) | Vulnerability disclosure |
48
- | [CHANGELOG.md](CHANGELOG.md) | Release notes |
47
+ | [SECURITY.md](SECURITY.md) | Vulnerability disclosure |
48
+ | [CHANGELOG.md](CHANGELOG.md) | Release notes |
49
49
 
50
50
  ## License
51
51
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
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
3
  export { keepFields, keepUnchanged, sameValue } from './sameValue.ts';
4
4
  export { useDebouncedLoad } from './useDebouncedLoad.ts';
5
5
  export { useDebouncedValue } from './useDebouncedValue.ts';
@@ -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;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"}
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/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: (() => Promise<T>) | null): void;
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: (() => Promise<T>) | null): Promise<void>;
22
+ run(load: Load<T> | null): Promise<void>;
21
23
  stop(): void;
22
24
  };
23
25
  //# sourceMappingURL=loader.d.ts.map
@@ -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;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"}
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;IA8C1E,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,23 @@ 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
+ // A load that throws before returning a promise settles through the sink like a rejection.
31
+ return new Promise((resolve) => resolve(load(mineController.signal)))
25
32
  .then((value) => {
26
33
  if (current()) {
27
34
  sink.value(value);
@@ -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: (() => Promise<T>) | null, deps: DependencyList, { initial, delayMs, onError, }: {
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: () => Promise<T>) => Promise<void>;
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;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"}
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"}
@@ -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: (() => Promise<T>) | null, deps: DependencyList, initial: T, options?: ((error: unknown) => void) | LoadedOptions): {
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;
@@ -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,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"}
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rethunk/react-load-hooks",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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",
@@ -29,7 +29,7 @@
29
29
  "pack:check": "bun scripts/check-pack.ts"
30
30
  },
31
31
  "devDependencies": {
32
- "@biomejs/biome": "^2.6.4",
32
+ "@biomejs/biome": "^2.6.6",
33
33
  "@happy-dom/global-registrator": "^21.0.4",
34
34
  "@testing-library/dom": "^10.4.2",
35
35
  "@testing-library/react": "^16.3.3",
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
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
3
  export { keepFields, keepUnchanged, sameValue } from './sameValue.ts'
4
4
  export { useDebouncedLoad } from './useDebouncedLoad.ts'
5
5
  export { useDebouncedValue } from './useDebouncedValue.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,23 @@ 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: () => Promise<T>): Promise<void> => {
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
+ // A load that throws before returning a promise settles through the sink like a rejection.
53
+ return new Promise<T>((resolve) => resolve(load(mineController.signal)))
44
54
  .then(
45
55
  (value) => {
46
56
  if (current()) {
@@ -65,7 +75,7 @@ export function loader<T>(
65
75
  sink.loading(false)
66
76
  }
67
77
  return {
68
- deps(deps: DependencyList, load: (() => Promise<T>) | null) {
78
+ deps(deps: DependencyList, load: Load<T> | null) {
69
79
  if (sameDeps(seen, deps)) {
70
80
  return
71
81
  }
@@ -86,7 +96,7 @@ export function loader<T>(
86
96
  })
87
97
  },
88
98
  /** 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> {
99
+ run(load: Load<T> | null): Promise<void> {
90
100
  if (load === null) {
91
101
  skip()
92
102
  return Promise.resolve()
@@ -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: (() => Promise<T>) | null,
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: () => Promise<T>) => runs.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: (() => Promise<T>) | null,
18
+ load: Load<T> | null,
19
19
  deps: DependencyList,
20
20
  initial: T,
21
21
  options?: ((error: unknown) => void) | LoadedOptions,