@khanacademy/wonder-blocks-timing 2.0.0 → 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.
@@ -0,0 +1,469 @@
1
+ // @flow
2
+ import {renderHook, act} from "@testing-library/react-hooks";
3
+ import {SchedulePolicy, ClearPolicy} from "../../util/policies.js";
4
+
5
+ import {useScheduledTimeout} from "../use-scheduled-timeout.js";
6
+
7
+ describe("useScheduledTimeout", () => {
8
+ beforeEach(() => {
9
+ jest.useFakeTimers();
10
+ });
11
+
12
+ afterEach(() => {
13
+ jest.restoreAllMocks();
14
+ });
15
+
16
+ it("throws if the action is not a function", () => {
17
+ // Arrange
18
+
19
+ // Act
20
+ const {result} = renderHook(() =>
21
+ useScheduledTimeout((null: any), 1000),
22
+ );
23
+
24
+ // Assert
25
+ expect(result.error).toEqual(Error("Action must be a function"));
26
+ });
27
+
28
+ it("throws if the period is less than 0", () => {
29
+ // Arrange
30
+
31
+ // Act
32
+ const {result} = renderHook(() => useScheduledTimeout(() => {}, -1));
33
+
34
+ // Assert
35
+ expect(result.error).toEqual(Error("Timeout period must be >= 0"));
36
+ });
37
+
38
+ it("should return an ITimeout", () => {
39
+ // Arrange
40
+ const {result} = renderHook(() => useScheduledTimeout(() => {}, 1000));
41
+
42
+ // Act
43
+
44
+ // Assert
45
+ expect(result.current).toEqual(
46
+ expect.objectContaining({
47
+ clear: expect.any(Function),
48
+ set: expect.any(Function),
49
+ isSet: expect.any(Boolean),
50
+ }),
51
+ );
52
+ });
53
+
54
+ it("should default to being immediately set", () => {
55
+ // Arrange
56
+ const {result} = renderHook(() => useScheduledTimeout(() => {}, 1000));
57
+
58
+ // Act
59
+
60
+ // Assert
61
+ expect(result.current.isSet).toBe(true);
62
+ });
63
+
64
+ it("should call the action before unmounting", () => {
65
+ const action = jest.fn();
66
+ const {unmount} = renderHook(() =>
67
+ useScheduledTimeout(action, 1000, {
68
+ clearPolicy: ClearPolicy.Resolve,
69
+ }),
70
+ );
71
+
72
+ act(() => {
73
+ unmount();
74
+ });
75
+
76
+ expect(action).toHaveBeenCalled();
77
+ });
78
+
79
+ it("should call the current action", () => {
80
+ // Arrange
81
+ const action1 = jest.fn();
82
+ const action2 = jest.fn();
83
+ const {rerender} = renderHook(
84
+ ({action}) => useScheduledTimeout(action, 500),
85
+ {
86
+ initialProps: {action: action1},
87
+ },
88
+ );
89
+
90
+ // Act
91
+ rerender({action: action2});
92
+ jest.advanceTimersByTime(501);
93
+
94
+ // Assert
95
+ expect(action2).toHaveBeenCalledTimes(1);
96
+ });
97
+
98
+ it("should only call setTimeout once even if action changes", () => {
99
+ // Arrange
100
+ const timeoutSpy = jest.spyOn(global, "setTimeout");
101
+ const action1 = jest.fn();
102
+ const action2 = jest.fn();
103
+ const {rerender} = renderHook(
104
+ ({action}) => useScheduledTimeout(action, 500),
105
+ {
106
+ initialProps: {action: action1},
107
+ },
108
+ );
109
+ // NOTE: For some reason setTimeout is called twice by the time we get
110
+ // here. I've verified that it only gets called once inside the hook
111
+ // so something else must be calling it.
112
+ const callCount = timeoutSpy.mock.calls.length;
113
+
114
+ // Act
115
+ rerender({action: action2});
116
+
117
+ // Assert
118
+ expect(timeoutSpy).toHaveBeenCalledTimes(callCount);
119
+ });
120
+
121
+ it("should use the new timeout duration after changing it", () => {
122
+ // Arrange
123
+ const action = jest.fn();
124
+ const {rerender} = renderHook(
125
+ ({timeoutMs}) => useScheduledTimeout(action, timeoutMs),
126
+ {
127
+ initialProps: {timeoutMs: 500},
128
+ },
129
+ );
130
+ rerender({timeoutMs: 1000});
131
+
132
+ // Act
133
+ jest.advanceTimersByTime(1501);
134
+
135
+ // Assert
136
+ expect(action).toHaveBeenCalledTimes(1);
137
+ });
138
+
139
+ it("should restart the timeout if intervalMs changes", () => {
140
+ // Arrange
141
+ const timeoutSpy = jest.spyOn(global, "setTimeout");
142
+ const {rerender} = renderHook(
143
+ ({timeoutMs}) => useScheduledTimeout(() => {}, timeoutMs),
144
+ {
145
+ initialProps: {timeoutMs: 500},
146
+ },
147
+ );
148
+
149
+ // Act
150
+ rerender({timeoutMs: 1000});
151
+
152
+ // Assert
153
+ expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 500);
154
+ expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1000);
155
+ });
156
+
157
+ describe("SchedulePolicies.Immediately", () => {
158
+ it("should call the action after the timeout expires", () => {
159
+ // Arrange
160
+ const action = jest.fn();
161
+ renderHook(() => useScheduledTimeout(action, 1000));
162
+
163
+ // Act
164
+ act(() => {
165
+ jest.advanceTimersByTime(1000);
166
+ });
167
+
168
+ // Assert
169
+ expect(action).toHaveBeenCalled();
170
+ });
171
+
172
+ it("should update isSet to false after the timeout expires", () => {
173
+ // Arrange
174
+ const action = jest.fn();
175
+ const {result} = renderHook(() =>
176
+ useScheduledTimeout(action, 1000),
177
+ );
178
+
179
+ // Act
180
+ act(() => {
181
+ jest.advanceTimersByTime(1001);
182
+ });
183
+
184
+ // Assert
185
+ expect(result.current.isSet).toBe(false);
186
+ });
187
+
188
+ it("should call the action again if 'set' is called after the action was called", () => {
189
+ // Arrange
190
+ const action = jest.fn();
191
+ const {result} = renderHook(() =>
192
+ useScheduledTimeout(action, 1000),
193
+ );
194
+
195
+ // Act
196
+ act(() => {
197
+ jest.advanceTimersByTime(1001);
198
+ result.current.set();
199
+ jest.advanceTimersByTime(1001);
200
+ });
201
+
202
+ // Assert
203
+ expect(action).toHaveBeenCalledTimes(2);
204
+ });
205
+
206
+ it("should restart the timeout if timeoutMs gets updated", () => {
207
+ // Arrange
208
+ const action = jest.fn();
209
+ const {rerender} = renderHook(
210
+ ({timeoutMs}) => useScheduledTimeout(action, timeoutMs),
211
+ {
212
+ initialProps: {timeoutMs: 1000},
213
+ },
214
+ );
215
+
216
+ // Act
217
+ act(() => {
218
+ jest.advanceTimersByTime(900);
219
+ });
220
+ rerender({timeoutMs: 500});
221
+ act(() => {
222
+ jest.advanceTimersByTime(100);
223
+ });
224
+
225
+ // Assert
226
+ expect(action).not.toHaveBeenCalled();
227
+ act(() => jest.advanceTimersByTime(500));
228
+ expect(action).toHaveBeenCalled();
229
+ });
230
+
231
+ it("should should timeout after the new timeoutMs if it gets updated", () => {
232
+ // Arrange
233
+ const action = jest.fn();
234
+ const {rerender} = renderHook(
235
+ ({timeoutMs}) => useScheduledTimeout(action, timeoutMs),
236
+ {
237
+ initialProps: {timeoutMs: 1000},
238
+ },
239
+ );
240
+
241
+ // Act
242
+ rerender({timeoutMs: 500});
243
+ act(() => {
244
+ jest.advanceTimersByTime(500);
245
+ });
246
+
247
+ // Assert
248
+ expect(action).toHaveBeenCalled();
249
+ });
250
+
251
+ it("should call the new action after re-rendering with a new action", () => {
252
+ // Arrange
253
+ const action1 = jest.fn();
254
+ const action2 = jest.fn();
255
+ const {rerender} = renderHook(
256
+ ({action}) => useScheduledTimeout(action, 1000),
257
+ {
258
+ initialProps: {action: action1},
259
+ },
260
+ );
261
+
262
+ // Act
263
+ rerender({action: action2});
264
+ act(() => {
265
+ jest.advanceTimersByTime(1000);
266
+ });
267
+
268
+ // Assert
269
+ expect(action2).toHaveBeenCalled();
270
+ });
271
+
272
+ it("should not call the original action after re-rendering with a new action", () => {
273
+ // Arrange
274
+ const action1 = jest.fn();
275
+ const action2 = jest.fn();
276
+ const {rerender} = renderHook(
277
+ ({action}) => useScheduledTimeout(action, 1000),
278
+ {
279
+ initialProps: {action: action1},
280
+ },
281
+ );
282
+
283
+ // Act
284
+ rerender({action: action2});
285
+ act(() => {
286
+ jest.advanceTimersByTime(1000);
287
+ });
288
+
289
+ // Assert
290
+ expect(action1).not.toHaveBeenCalled();
291
+ });
292
+
293
+ it("should not call the action if the timeout is cleared", () => {
294
+ // Arrange
295
+ const action = jest.fn();
296
+ const {result} = renderHook(() =>
297
+ useScheduledTimeout(action, 1000),
298
+ );
299
+
300
+ // Act
301
+ act(() => {
302
+ result.current.clear();
303
+ jest.advanceTimersByTime(1000);
304
+ });
305
+
306
+ // Assert
307
+ expect(action).not.toHaveBeenCalled();
308
+ });
309
+
310
+ it("should call the action when the timeout is cleared when passing ClearPolicies.Resolve to clear()", () => {
311
+ // Arrange
312
+ const action = jest.fn();
313
+ const {result} = renderHook(() =>
314
+ useScheduledTimeout(action, 1000),
315
+ );
316
+
317
+ // Act
318
+ act(() => {
319
+ result.current.clear(ClearPolicy.Resolve);
320
+ });
321
+
322
+ // Assert
323
+ expect(action).toHaveBeenCalled();
324
+ });
325
+
326
+ it("should call the action when the timeout is cleared when using ClearPolicies.Resolve in options", () => {
327
+ // Arrange
328
+ const action = jest.fn();
329
+ const {result} = renderHook(() =>
330
+ useScheduledTimeout(action, 1000, {
331
+ clearPolicy: ClearPolicy.Resolve,
332
+ }),
333
+ );
334
+
335
+ // Act
336
+ act(() => {
337
+ result.current.clear();
338
+ });
339
+
340
+ // Assert
341
+ expect(action).toHaveBeenCalled();
342
+ });
343
+
344
+ it("should call the action on unmount when using ClearPolicies.Resolve in options", () => {
345
+ // Arrange
346
+ const action = jest.fn();
347
+ const {unmount} = renderHook(() =>
348
+ useScheduledTimeout(action, 1000, {
349
+ clearPolicy: ClearPolicy.Resolve,
350
+ }),
351
+ );
352
+
353
+ // Act
354
+ unmount();
355
+
356
+ // Assert
357
+ expect(action).toHaveBeenCalled();
358
+ });
359
+
360
+ it("should not call the action on unmount when using the default options", () => {
361
+ // Arrange
362
+ const action = jest.fn();
363
+ const {unmount} = renderHook(() =>
364
+ useScheduledTimeout(action, 1000),
365
+ );
366
+
367
+ // Act
368
+ unmount();
369
+
370
+ // Assert
371
+ expect(action).not.toHaveBeenCalled();
372
+ });
373
+ });
374
+
375
+ describe("SchedulePolicies.OnDemand", () => {
376
+ it("should not set the timer on creation", () => {
377
+ // Arrange
378
+ const {result} = renderHook(() =>
379
+ useScheduledTimeout(() => {}, 1000, {
380
+ schedulePolicy: SchedulePolicy.OnDemand,
381
+ }),
382
+ );
383
+
384
+ // Act
385
+
386
+ // Assert
387
+ expect(result.current.isSet).toBe(false);
388
+ });
389
+
390
+ it("should not call action after timeoutMs if the timer hasn't been set", () => {
391
+ // Arrange
392
+ const action = jest.fn();
393
+ renderHook(() =>
394
+ useScheduledTimeout(action, 1000, {
395
+ schedulePolicy: SchedulePolicy.OnDemand,
396
+ }),
397
+ );
398
+
399
+ // Act
400
+ act(() => {
401
+ jest.advanceTimersByTime(1000);
402
+ });
403
+
404
+ // Assert
405
+ expect(action).not.toHaveBeenCalled();
406
+ });
407
+
408
+ it("should call action after timeoutMs if the timer has been set", () => {
409
+ // Arrange
410
+ const action = jest.fn();
411
+ const {result} = renderHook(() =>
412
+ useScheduledTimeout(action, 1000, {
413
+ schedulePolicy: SchedulePolicy.OnDemand,
414
+ }),
415
+ );
416
+
417
+ // Act
418
+ act(() => {
419
+ result.current.set();
420
+ jest.advanceTimersByTime(1000);
421
+ });
422
+
423
+ // Assert
424
+ expect(action).toHaveBeenCalled();
425
+ });
426
+
427
+ it("should reset the timer after calling set() again", () => {
428
+ // Arrange
429
+ const action = jest.fn();
430
+ const {result} = renderHook(() =>
431
+ useScheduledTimeout(action, 1000, {
432
+ schedulePolicy: SchedulePolicy.OnDemand,
433
+ }),
434
+ );
435
+
436
+ // Act
437
+ act(() => {
438
+ result.current.set();
439
+ jest.advanceTimersByTime(500);
440
+ result.current.set();
441
+ jest.advanceTimersByTime(500);
442
+ });
443
+
444
+ // Assert
445
+ expect(action).not.toHaveBeenCalled();
446
+ });
447
+
448
+ it("should call the action after calling set() again", () => {
449
+ // Arrange
450
+ const action = jest.fn();
451
+ const {result} = renderHook(() =>
452
+ useScheduledTimeout(action, 1000, {
453
+ schedulePolicy: SchedulePolicy.OnDemand,
454
+ }),
455
+ );
456
+
457
+ // Act
458
+ act(() => {
459
+ result.current.set();
460
+ jest.advanceTimersByTime(500);
461
+ result.current.set();
462
+ jest.advanceTimersByTime(1000);
463
+ });
464
+
465
+ // Assert
466
+ expect(action).toHaveBeenCalled();
467
+ });
468
+ });
469
+ });
@@ -0,0 +1,136 @@
1
+ // @flow
2
+ import {renderHook} from "@testing-library/react-hooks";
3
+
4
+ import {useTimeout} from "../use-timeout.js";
5
+
6
+ describe("useTimeout", () => {
7
+ beforeEach(() => {
8
+ jest.useFakeTimers();
9
+ });
10
+
11
+ it("should not fire if 'active' is false", () => {
12
+ // Arrange
13
+ const action = jest.fn();
14
+
15
+ // Act
16
+ renderHook(() => useTimeout(action, 500, false));
17
+ jest.advanceTimersByTime(501);
18
+
19
+ // Assert
20
+ expect(action).not.toHaveBeenCalled();
21
+ });
22
+
23
+ it("should not fire before the timeout", () => {
24
+ // Arrange
25
+ const action = jest.fn();
26
+
27
+ // Act
28
+ renderHook(() => useTimeout(action, 500, true));
29
+ jest.advanceTimersByTime(499);
30
+
31
+ // Assert
32
+ expect(action).not.toHaveBeenCalled();
33
+ });
34
+
35
+ it("should fire after the timeout", () => {
36
+ // Arrange
37
+ const action = jest.fn();
38
+
39
+ // Act
40
+ renderHook(() => useTimeout(action, 500, true));
41
+ jest.advanceTimersByTime(501);
42
+
43
+ // Assert
44
+ expect(action).toHaveBeenCalledTimes(1);
45
+ });
46
+
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
+ );
54
+
55
+ // Act
56
+ rerender({active: true});
57
+ jest.advanceTimersByTime(501);
58
+
59
+ // Assert
60
+ expect(action).toHaveBeenCalled();
61
+ });
62
+
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
+ );
70
+
71
+ // Act
72
+ rerender({active: false});
73
+ jest.advanceTimersByTime(501);
74
+
75
+ // Assert
76
+ expect(action).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it("should reset the timeout if 'timeoutMs' is changes", () => {
80
+ // Arrange
81
+ const action = jest.fn();
82
+
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);
90
+
91
+ // Assert
92
+ expect(action).not.toHaveBeenCalled();
93
+ jest.advanceTimersByTime(1001);
94
+ expect(action).toHaveBeenCalled();
95
+ });
96
+
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");
102
+
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);
114
+
115
+ // Assert
116
+ expect(timeoutSpy).toHaveBeenCalledTimes(callCount);
117
+ });
118
+
119
+ it("should fire the current action if 'action' changes", () => {
120
+ // Arrange
121
+ const action1 = jest.fn();
122
+ const action2 = jest.fn();
123
+
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);
131
+
132
+ // Assert
133
+ expect(action1).not.toHaveBeenCalledWith();
134
+ expect(action2).toHaveBeenCalledWith();
135
+ });
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
+ }