@khanacademy/wonder-blocks-timing 2.0.3 → 2.1.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.
@@ -1,6 +1,5 @@
1
1
  // @flow
2
- import {renderHook, act} from "@testing-library/react-hooks";
3
- import {SchedulePolicy, ClearPolicy} from "../../util/policies.js";
2
+ import {renderHook} from "@testing-library/react-hooks";
4
3
 
5
4
  import {useTimeout} from "../use-timeout.js";
6
5
 
@@ -9,328 +8,129 @@ describe("useTimeout", () => {
9
8
  jest.useFakeTimers();
10
9
  });
11
10
 
12
- it("should return an ITimeout", () => {
11
+ it("should not fire if 'active' is false", () => {
13
12
  // Arrange
14
- const {result} = renderHook(() => useTimeout(() => {}, 1000));
13
+ const action = jest.fn();
15
14
 
16
15
  // Act
16
+ renderHook(() => useTimeout(action, 500, false));
17
+ jest.advanceTimersByTime(501);
17
18
 
18
19
  // Assert
19
- expect(result.current).toEqual(
20
- expect.objectContaining({
21
- clear: expect.any(Function),
22
- set: expect.any(Function),
23
- isSet: expect.any(Boolean),
24
- }),
25
- );
20
+ expect(action).not.toHaveBeenCalled();
26
21
  });
27
22
 
28
- it("should default to being immediately set", () => {
23
+ it("should not fire before the timeout", () => {
29
24
  // Arrange
30
- const {result} = renderHook(() => useTimeout(() => {}, 1000));
25
+ const action = jest.fn();
31
26
 
32
27
  // Act
28
+ renderHook(() => useTimeout(action, 500, true));
29
+ jest.advanceTimersByTime(499);
33
30
 
34
31
  // Assert
35
- expect(result.current.isSet).toBe(true);
32
+ expect(action).not.toHaveBeenCalled();
36
33
  });
37
34
 
38
- describe("SchedulePolicies.Immediately", () => {
39
- it("should call the action after the timeout expires", () => {
40
- // Arrange
41
- const action = jest.fn();
42
- renderHook(() => useTimeout(action, 1000));
43
-
44
- // Act
45
- act(() => {
46
- jest.advanceTimersByTime(1000);
47
- });
48
-
49
- // Assert
50
- expect(action).toHaveBeenCalled();
51
- });
52
-
53
- it("should update isSet to false after the timeout expires", () => {
54
- // Arrange
55
- const action = jest.fn();
56
- const {result} = renderHook(() => useTimeout(action, 1000));
57
-
58
- // Act
59
- act(() => {
60
- jest.advanceTimersByTime(1000);
61
- });
62
-
63
- // Assert
64
- expect(result.current.isSet).toBe(false);
65
- });
66
-
67
- it("should call the action again if 'set' is called after the action was called", () => {
68
- // Arrange
69
- const action = jest.fn();
70
- const {result} = renderHook(() => useTimeout(action, 1000));
71
-
72
- // Act
73
- act(() => {
74
- jest.advanceTimersByTime(1001);
75
- result.current.set();
76
- jest.advanceTimersByTime(1001);
77
- });
78
-
79
- // Assert
80
- expect(action).toHaveBeenCalledTimes(2);
81
- });
82
-
83
- it("should restart the timeout if timeoutMs gets updated", () => {
84
- // Arrange
85
- const action = jest.fn();
86
- const {rerender} = renderHook(
87
- ({timeoutMs}) => useTimeout(action, timeoutMs),
88
- {
89
- initialProps: {timeoutMs: 1000},
90
- },
91
- );
92
-
93
- // Act
94
- act(() => {
95
- jest.advanceTimersByTime(900);
96
- });
97
- rerender({timeoutMs: 500});
98
- act(() => {
99
- jest.advanceTimersByTime(100);
100
- });
101
-
102
- // Assert
103
- expect(action).not.toHaveBeenCalled();
104
- act(() => jest.advanceTimersByTime(500));
105
- expect(action).toHaveBeenCalled();
106
- });
107
-
108
- it("should should timeout after the new timeoutMs if it gets updated", () => {
109
- // Arrange
110
- const action = jest.fn();
111
- const {rerender} = renderHook(
112
- ({timeoutMs}) => useTimeout(action, timeoutMs),
113
- {
114
- initialProps: {timeoutMs: 1000},
115
- },
116
- );
117
-
118
- // Act
119
- rerender({timeoutMs: 500});
120
- act(() => {
121
- jest.advanceTimersByTime(500);
122
- });
123
-
124
- // Assert
125
- expect(action).toHaveBeenCalled();
126
- });
127
-
128
- it("should call the new action after re-rendering with a new action", () => {
129
- // Arrange
130
- const action1 = jest.fn();
131
- const action2 = jest.fn();
132
- const {rerender} = renderHook(
133
- ({action}) => useTimeout(action, 1000),
134
- {
135
- initialProps: {action: action1},
136
- },
137
- );
138
-
139
- // Act
140
- rerender({action: action2});
141
- act(() => {
142
- jest.advanceTimersByTime(1000);
143
- });
144
-
145
- // Assert
146
- expect(action2).toHaveBeenCalled();
147
- });
148
-
149
- it("should not call the original action after re-rendering with a new action", () => {
150
- // Arrange
151
- const action1 = jest.fn();
152
- const action2 = jest.fn();
153
- const {rerender} = renderHook(
154
- ({action}) => useTimeout(action, 1000),
155
- {
156
- initialProps: {action: action1},
157
- },
158
- );
159
-
160
- // Act
161
- rerender({action: action2});
162
- act(() => {
163
- jest.advanceTimersByTime(1000);
164
- });
165
-
166
- // Assert
167
- expect(action1).not.toHaveBeenCalled();
168
- });
169
-
170
- it("should not call the action if the timeout is cleared", () => {
171
- // Arrange
172
- const action = jest.fn();
173
- const {result} = renderHook(() => useTimeout(action, 1000));
174
-
175
- // Act
176
- act(() => {
177
- result.current.clear();
178
- jest.advanceTimersByTime(1000);
179
- });
180
-
181
- // Assert
182
- expect(action).not.toHaveBeenCalled();
183
- });
184
-
185
- it("should call the action when the timeout is cleared when passing ClearPolicies.Resolve to clear()", () => {
186
- // Arrange
187
- const action = jest.fn();
188
- const {result} = renderHook(() => useTimeout(action, 1000));
189
-
190
- // Act
191
- act(() => {
192
- result.current.clear(ClearPolicy.Resolve);
193
- });
194
-
195
- // Assert
196
- expect(action).toHaveBeenCalled();
197
- });
198
-
199
- it("should call the action when the timeout is cleared when using ClearPolicies.Resolve in options", () => {
200
- // Arrange
201
- const action = jest.fn();
202
- const {result} = renderHook(() =>
203
- useTimeout(action, 1000, {clearPolicy: ClearPolicy.Resolve}),
204
- );
205
-
206
- // Act
207
- act(() => {
208
- result.current.clear();
209
- });
210
-
211
- // Assert
212
- expect(action).toHaveBeenCalled();
213
- });
214
-
215
- it("should call the action on unmount when using ClearPolicies.Resolve in options", () => {
216
- // Arrange
217
- const action = jest.fn();
218
- const {unmount} = renderHook(() =>
219
- useTimeout(action, 1000, {clearPolicy: ClearPolicy.Resolve}),
220
- );
221
-
222
- // Act
223
- unmount();
224
-
225
- // Assert
226
- expect(action).toHaveBeenCalled();
227
- });
228
-
229
- it("should not call the action on unmount when using the default options", () => {
230
- // Arrange
231
- const action = jest.fn();
232
- const {unmount} = renderHook(() => useTimeout(action, 1000));
35
+ it("should fire after the timeout", () => {
36
+ // Arrange
37
+ const action = jest.fn();
233
38
 
234
- // Act
235
- unmount();
39
+ // Act
40
+ renderHook(() => useTimeout(action, 500, true));
41
+ jest.advanceTimersByTime(501);
236
42
 
237
- // Assert
238
- expect(action).not.toHaveBeenCalled();
239
- });
43
+ // Assert
44
+ expect(action).toHaveBeenCalledTimes(1);
240
45
  });
241
46
 
242
- describe("SchedulePolicies.OnDemand", () => {
243
- it("should not set the timer on creation", () => {
244
- // Arrange
245
- const {result} = renderHook(() =>
246
- useTimeout(() => {}, 1000, {
247
- schedulePolicy: SchedulePolicy.OnDemand,
248
- }),
249
- );
47
+ it("should fire after time after 'active' changes to true", () => {
48
+ // Arrange
49
+ const action = jest.fn();
50
+ const {rerender} = renderHook(
51
+ ({active}) => useTimeout(action, 500, active),
52
+ {initialProps: {active: false}},
53
+ );
250
54
 
251
- // Act
55
+ // Act
56
+ rerender({active: true});
57
+ jest.advanceTimersByTime(501);
252
58
 
253
- // Assert
254
- expect(result.current.isSet).toBe(false);
255
- });
59
+ // Assert
60
+ expect(action).toHaveBeenCalled();
61
+ });
256
62
 
257
- it("should not call action after timeoutMs if the timer hasn't been set", () => {
258
- // Arrange
259
- const action = jest.fn();
260
- renderHook(() =>
261
- useTimeout(action, 1000, {
262
- schedulePolicy: SchedulePolicy.OnDemand,
263
- }),
264
- );
63
+ it("should not fire after the timeout if 'active' changes to false", () => {
64
+ // Arrange
65
+ const action = jest.fn();
66
+ const {rerender} = renderHook(
67
+ ({active}) => useTimeout(action, 500, active),
68
+ {initialProps: {active: true}},
69
+ );
265
70
 
266
- // Act
267
- act(() => {
268
- jest.advanceTimersByTime(1000);
269
- });
71
+ // Act
72
+ rerender({active: false});
73
+ jest.advanceTimersByTime(501);
270
74
 
271
- // Assert
272
- expect(action).not.toHaveBeenCalled();
273
- });
75
+ // Assert
76
+ expect(action).not.toHaveBeenCalled();
77
+ });
274
78
 
275
- it("should call action after timeoutMs if the timer has been set", () => {
276
- // Arrange
277
- const action = jest.fn();
278
- const {result} = renderHook(() =>
279
- useTimeout(action, 1000, {
280
- schedulePolicy: SchedulePolicy.OnDemand,
281
- }),
282
- );
79
+ it("should reset the timeout if 'timeoutMs' is changes", () => {
80
+ // Arrange
81
+ const action = jest.fn();
283
82
 
284
- // Act
285
- act(() => {
286
- result.current.set();
287
- jest.advanceTimersByTime(1000);
288
- });
83
+ // Act
84
+ const {rerender} = renderHook(
85
+ ({timeoutMs}) => useTimeout(action, timeoutMs, true),
86
+ {initialProps: {timeoutMs: 500}},
87
+ );
88
+ rerender({timeoutMs: 1000});
89
+ jest.advanceTimersByTime(501);
289
90
 
290
- // Assert
291
- expect(action).toHaveBeenCalled();
292
- });
91
+ // Assert
92
+ expect(action).not.toHaveBeenCalled();
93
+ jest.advanceTimersByTime(1001);
94
+ expect(action).toHaveBeenCalled();
95
+ });
293
96
 
294
- it("should reset the timer after calling set() again", () => {
295
- // Arrange
296
- const action = jest.fn();
297
- const {result} = renderHook(() =>
298
- useTimeout(action, 1000, {
299
- schedulePolicy: SchedulePolicy.OnDemand,
300
- }),
301
- );
97
+ it("should not reset the timeout if 'action' changes", () => {
98
+ // Arrange
99
+ const action1 = jest.fn();
100
+ const action2 = jest.fn();
101
+ const timeoutSpy = jest.spyOn(window, "setTimeout");
302
102
 
303
- // Act
304
- act(() => {
305
- result.current.set();
306
- jest.advanceTimersByTime(500);
307
- result.current.set();
308
- jest.advanceTimersByTime(500);
309
- });
103
+ // Act
104
+ const {rerender} = renderHook(
105
+ ({action}) => useTimeout(action, 500, true),
106
+ {initialProps: {action: action1}},
107
+ );
108
+ // NOTE: For some reason setTimeout is called twice by the time we get
109
+ // here. I've verified that it only gets called once inside the hook
110
+ // so something else must be calling it.
111
+ const callCount = timeoutSpy.mock.calls.length;
112
+ rerender({action: action2});
113
+ jest.advanceTimersByTime(501);
310
114
 
311
- // Assert
312
- expect(action).not.toHaveBeenCalled();
313
- });
115
+ // Assert
116
+ expect(timeoutSpy).toHaveBeenCalledTimes(callCount);
117
+ });
314
118
 
315
- it("should call the action after calling set() again", () => {
316
- // Arrange
317
- const action = jest.fn();
318
- const {result} = renderHook(() =>
319
- useTimeout(action, 1000, {
320
- schedulePolicy: SchedulePolicy.OnDemand,
321
- }),
322
- );
119
+ it("should fire the current action if 'action' changes", () => {
120
+ // Arrange
121
+ const action1 = jest.fn();
122
+ const action2 = jest.fn();
323
123
 
324
- // Act
325
- act(() => {
326
- result.current.set();
327
- jest.advanceTimersByTime(500);
328
- result.current.set();
329
- jest.advanceTimersByTime(1000);
330
- });
124
+ // Act
125
+ const {rerender} = renderHook(
126
+ ({action}) => useTimeout(action, 500, true),
127
+ {initialProps: {action: action1}},
128
+ );
129
+ rerender({action: action2});
130
+ jest.advanceTimersByTime(501);
331
131
 
332
- // Assert
333
- expect(action).toHaveBeenCalled();
334
- });
132
+ // Assert
133
+ expect(action1).not.toHaveBeenCalledWith();
134
+ expect(action2).toHaveBeenCalledWith();
335
135
  });
336
136
  });
@@ -0,0 +1,20 @@
1
+ // @flow
2
+ import {useEffect, useRef} from "react";
3
+
4
+ /**
5
+ * Returns a ref whose .current value is updated whenever
6
+ * the `value` passed to this hook changes.
7
+ *
8
+ * this is great for values that you want to reference from
9
+ * within a useCallback or useEffect event listener, without
10
+ * re-triggering the effect when the value changes
11
+ *
12
+ * @returns {{current: T}}
13
+ */
14
+ export const useUpdatingRef = <T>(value: T): {|current: T|} => {
15
+ const ref = useRef<T>(value);
16
+ useEffect(() => {
17
+ ref.current = value;
18
+ }, [value]);
19
+ return ref;
20
+ };
@@ -0,0 +1,37 @@
1
+ // @flow
2
+ import {useEffect} from "react";
3
+
4
+ import {useUpdatingRef} from "./internal/use-updating-ref.js";
5
+
6
+ /**
7
+ * A simple hook for using `setInterval`.
8
+ *
9
+ * @param action called every `intervalMs` when `active` is true
10
+ * @param intervalMs the duration between calls to `action`
11
+ * @param active whether or not the interval is active
12
+ */
13
+ export function useInterval(
14
+ action: () => mixed,
15
+ intervalMs: number,
16
+ active: boolean,
17
+ ) {
18
+ // We using a ref instead of a callback for `action` to avoid resetting
19
+ // the interval whenever the `action` changes.
20
+ const actionRef = useUpdatingRef(action);
21
+
22
+ useEffect(() => {
23
+ if (active) {
24
+ const intervalId = setInterval(() => {
25
+ actionRef.current();
26
+ }, intervalMs);
27
+
28
+ return () => {
29
+ clearInterval(intervalId);
30
+ };
31
+ }
32
+ // actionRef isn't actually required, but react-hooks/exhaustive-deps
33
+ // doesn't recognize it as a ref and thus complains if it isn't in the
34
+ // deps list. It isn't a big deal though since the value ofactionRef
35
+ // never changes (only its contents do).
36
+ }, [intervalMs, active, actionRef]);
37
+ }
@@ -0,0 +1,80 @@
1
+ import {Meta, Story, Source, Canvas} from "@storybook/addon-docs";
2
+
3
+ import {Body, HeadingSmall} from "@khanacademy/wonder-blocks-typography";
4
+ import {View} from "@khanacademy/wonder-blocks-core";
5
+ import Button from "@khanacademy/wonder-blocks-button";
6
+
7
+ import {useInterval} from "./use-interval.js";
8
+
9
+ <Meta
10
+ title="Timing/useInterval"
11
+ parameters={{
12
+ chromatic: {
13
+ disableSnapshot: true,
14
+ },
15
+ }}
16
+ />
17
+
18
+ # `useInterval`
19
+
20
+ `useInterval` is a hook that provides a simple API for using intervals safely.
21
+ It is defined as follows:
22
+
23
+ ```ts
24
+ function useInterval(
25
+ action: () => mixed,
26
+ intervalMs: number,
27
+ active: boolean,
28
+ ): void;
29
+ ```
30
+
31
+ Notes:
32
+
33
+ - Setting `active` to `true` will start the interval and setting it to `false`
34
+ will stop it
35
+ - Changing the value of `timeoutMs` will reset the interval, changing `action`
36
+ will not.
37
+
38
+ export const BasicUsage = () => {
39
+ const [callCount, setCallCount] = React.useState(0);
40
+ const [active, setActive] = React.useState(false);
41
+ const callback = React.useCallback(() => {
42
+ setCallCount((callCount) => callCount + 1);
43
+ }, []);
44
+ useInterval(callback, 1000, active);
45
+ return (
46
+ <View>
47
+ <View>callCount = {callCount}</View>
48
+ <View>active = {active.toString()}</View>
49
+ <Button onClick={() => setActive(!active)} style={{width: 200}}>
50
+ Toggle active
51
+ </Button>
52
+ </View>
53
+ );
54
+ };
55
+
56
+ <Canvas>
57
+ <Story name="BasicUsage">
58
+ <BasicUsage />
59
+ </Story>
60
+ </Canvas>
61
+
62
+ ```jsx
63
+ export const BasicUsage = () => {
64
+ const [callCount, setCallCount] = React.useState(0);
65
+ const [active, setActive] = React.useState(false);
66
+ const callback = React.useCallback(() => {
67
+ setCallCount((callCount) => callCount + 1);
68
+ }, []);
69
+ useInterval(callback, 1000, active);
70
+ return (
71
+ <View>
72
+ <View>callCount = {callCount}</View>
73
+ <View>active = {active.toString()}</View>
74
+ <Button onClick={() => setActive(!active)} style={{width: 200}}>
75
+ Toggle active
76
+ </Button>
77
+ </View>
78
+ );
79
+ };
80
+ ```
@@ -0,0 +1,73 @@
1
+ // @flow
2
+ import {useEffect, useState, useCallback} from "react";
3
+
4
+ import {
5
+ SchedulePolicy as SchedulePolicies,
6
+ ClearPolicy as ClearPolicies,
7
+ } from "../util/policies.js";
8
+ import type {IInterval, ClearPolicy, Options} from "../util/types.js";
9
+
10
+ import {useUpdatingRef} from "./internal/use-updating-ref.js";
11
+ import {useInterval} from "./use-interval.js";
12
+
13
+ export function useScheduledInterval(
14
+ action: () => mixed,
15
+ intervalMs: number,
16
+ options?: Options,
17
+ ): IInterval {
18
+ if (typeof action !== "function") {
19
+ throw new Error("Action must be a function");
20
+ }
21
+
22
+ if (intervalMs < 1) {
23
+ throw new Error("Interval period must be >= 1");
24
+ }
25
+
26
+ const schedulePolicy =
27
+ options?.schedulePolicy ?? SchedulePolicies.Immediately;
28
+
29
+ const [isSet, setIsSet] = useState(
30
+ schedulePolicy === SchedulePolicies.Immediately,
31
+ );
32
+
33
+ const set = useCallback(() => setIsSet(true), []);
34
+
35
+ const actionRef = useUpdatingRef(action);
36
+
37
+ const clear = useCallback(
38
+ (policy?: ClearPolicy) => {
39
+ policy = policy ?? options?.clearPolicy;
40
+ if (isSet && policy === ClearPolicies.Resolve) {
41
+ actionRef.current();
42
+ }
43
+ setIsSet(false);
44
+ },
45
+ // react-hooks/exhaustive-deps doesn't require refs to be
46
+ // listed in the deps array. Unfortunately, in this situation
47
+ // it doesn't recognized actionRef as a ref.
48
+ [actionRef, isSet, options?.clearPolicy],
49
+ );
50
+
51
+ const runOnUnmountRef = useUpdatingRef(
52
+ isSet && options?.clearPolicy === ClearPolicies.Resolve,
53
+ );
54
+
55
+ useEffect(() => {
56
+ return () => {
57
+ // This code will only run with the component using this
58
+ // hook is unmounted.
59
+ // eslint-disable-next-line react-hooks/exhaustive-deps
60
+ if (runOnUnmountRef.current) {
61
+ // eslint-disable-next-line react-hooks/exhaustive-deps
62
+ actionRef.current();
63
+ }
64
+ };
65
+ // This eslint rule doesn't realize actionRef and runOnUnmountRef
66
+ // a both refs and thus do not have to be listed as deps.
67
+ // eslint-disable-next-line react-hooks/exhaustive-deps
68
+ }, []);
69
+
70
+ useInterval(action, intervalMs, isSet);
71
+
72
+ return {isSet, set, clear};
73
+ }