@rethunk/react-load-hooks 0.1.0 → 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.
@@ -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,7 +1,8 @@
1
+ export { coalescer } from './coalescer.ts';
1
2
  export { type LoadSink, loader } from './loader.ts';
2
3
  export { useDebouncedLoad } from './useDebouncedLoad.ts';
3
4
  export { useDebouncedValue } from './useDebouncedValue.ts';
4
- export { useLoaded } from './useLoaded.ts';
5
+ export { type LoadedOptions, useLoaded } from './useLoaded.ts';
5
6
  export { useNowValue, useSecondValue } from './useNow.ts';
6
7
  export { readStored, useStoredState, writeStored } from './useStoredState.ts';
7
8
  export { useWidth } from './useWidth.ts';
@@ -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;AAC1C,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"}
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 CHANGED
@@ -1,3 +1,4 @@
1
+ export { coalescer } from './coalescer.js';
1
2
  export { loader } from './loader.js';
2
3
  export { useDebouncedLoad } from './useDebouncedLoad.js';
3
4
  export { useDebouncedValue } from './useDebouncedValue.js';
@@ -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, onError?: (error: unknown) => void): {
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;
@@ -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;;;;;GAKG;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,KAAK,EAAE,OAAO,KAAK,IAAI;;;;;;EA+BnC"}
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, onError) {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rethunk/react-load-hooks",
3
- "version": "0.1.0",
3
+ "version": "0.1.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",
@@ -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,8 @@
1
+ export { coalescer } from './coalescer.ts'
1
2
  export { type LoadSink, loader } from './loader.ts'
2
3
  export { useDebouncedLoad } from './useDebouncedLoad.ts'
3
4
  export { useDebouncedValue } from './useDebouncedValue.ts'
4
- export { useLoaded } from './useLoaded.ts'
5
+ export { type LoadedOptions, useLoaded } from './useLoaded.ts'
5
6
  export { useNowValue, useSecondValue } from './useNow.ts'
6
7
  export { readStored, useStoredState, writeStored } from './useStoredState.ts'
7
8
  export { useWidth } from './useWidth.ts'
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
- onError?: (error: unknown) => void,
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
- value: (value) => setData(() => value),
24
- error: (e) => {
25
- setError(e)
26
- latest.current.onError?.(e)
27
- },
28
- loading: (on) => {
29
- setLoading(on)
30
- if (on) {
31
- setError(null)
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
- reset: () => setData(() => latest.current.initial),
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.
@@ -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
- })
@@ -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
- })
@@ -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
- })