@khanacademy/wonder-blocks-timing 2.0.2 → 2.0.3

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/dist/es/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import _extends from '@babel/runtime/helpers/extends';
2
- import { Component, forwardRef, createElement } from 'react';
2
+ import { Component, forwardRef, createElement, useState, useRef, useEffect, useCallback } from 'react';
3
3
 
4
4
  const SchedulePolicy = {
5
5
  Immediately: "schedule-immediately",
@@ -430,4 +430,58 @@ function withActionScheduler(WrappedComponent) {
430
430
  }))));
431
431
  }
432
432
 
433
- export { ClearPolicy, SchedulePolicy, withActionScheduler };
433
+ function useTimeout(action, timeoutMs, options) {
434
+ var _options$schedulePoli;
435
+
436
+ const schedulePolicy = (_options$schedulePoli = options == null ? void 0 : options.schedulePolicy) != null ? _options$schedulePoli : SchedulePolicy.Immediately;
437
+ const [isSet, setIsSet] = useState(schedulePolicy === SchedulePolicy.Immediately);
438
+ const actionRef = useRef(action);
439
+ const mountedRef = useRef(false);
440
+ useEffect(() => {
441
+ mountedRef.current = true;
442
+ return () => {
443
+ mountedRef.current = false;
444
+ };
445
+ }, []);
446
+ useEffect(() => {
447
+ actionRef.current = action;
448
+ }, [action]);
449
+ const clear = useCallback(policy => {
450
+ if ((policy != null ? policy : options == null ? void 0 : options.clearPolicy) === ClearPolicy.Resolve) {
451
+ actionRef.current();
452
+ } // This will cause the useEffect below to re-run
453
+
454
+
455
+ setIsSet(false);
456
+ }, [options == null ? void 0 : options.clearPolicy]);
457
+ const set = useCallback(() => {
458
+ if (isSet) {
459
+ clear();
460
+ } // This will cause the useEffect below to re-run
461
+
462
+
463
+ setIsSet(true);
464
+ }, [clear, isSet]);
465
+ useEffect(() => {
466
+ if (isSet && mountedRef.current) {
467
+ const timeout = window.setTimeout(() => {
468
+ actionRef.current();
469
+ setIsSet(false);
470
+ }, timeoutMs);
471
+ return () => {
472
+ window.clearTimeout(timeout);
473
+
474
+ if (!mountedRef.current) {
475
+ clear();
476
+ }
477
+ };
478
+ }
479
+ }, [clear, isSet, timeoutMs]);
480
+ return {
481
+ isSet,
482
+ set,
483
+ clear
484
+ };
485
+ }
486
+
487
+ export { ClearPolicy, SchedulePolicy, useTimeout, withActionScheduler };
package/dist/index.js CHANGED
@@ -82,7 +82,7 @@ module.exports =
82
82
  /******/
83
83
  /******/
84
84
  /******/ // Load entry module and return exports
85
- /******/ return __webpack_require__(__webpack_require__.s = 8);
85
+ /******/ return __webpack_require__(__webpack_require__.s = 9);
86
86
  /******/ })
87
87
  /************************************************************************/
88
88
  /******/ ([
@@ -115,7 +115,7 @@ module.exports = require("react");
115
115
  /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return withActionScheduler; });
116
116
  /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);
117
117
  /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
118
- /* harmony import */ var _action_scheduler_provider_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3);
118
+ /* harmony import */ var _action_scheduler_provider_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(4);
119
119
  function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
120
120
 
121
121
 
@@ -141,11 +141,76 @@ function withActionScheduler(WrappedComponent) {
141
141
  /* 3 */
142
142
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
143
143
 
144
+ "use strict";
145
+ /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return useTimeout; });
146
+ /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);
147
+ /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
148
+ /* harmony import */ var _util_policies_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(0);
149
+
150
+
151
+ function useTimeout(action, timeoutMs, options) {
152
+ var _options$schedulePoli;
153
+
154
+ const schedulePolicy = (_options$schedulePoli = options == null ? void 0 : options.schedulePolicy) != null ? _options$schedulePoli : _util_policies_js__WEBPACK_IMPORTED_MODULE_1__[/* SchedulePolicy */ "b"].Immediately;
155
+ const [isSet, setIsSet] = Object(react__WEBPACK_IMPORTED_MODULE_0__["useState"])(schedulePolicy === _util_policies_js__WEBPACK_IMPORTED_MODULE_1__[/* SchedulePolicy */ "b"].Immediately);
156
+ const actionRef = Object(react__WEBPACK_IMPORTED_MODULE_0__["useRef"])(action);
157
+ const mountedRef = Object(react__WEBPACK_IMPORTED_MODULE_0__["useRef"])(false);
158
+ Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(() => {
159
+ mountedRef.current = true;
160
+ return () => {
161
+ mountedRef.current = false;
162
+ };
163
+ }, []);
164
+ Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(() => {
165
+ actionRef.current = action;
166
+ }, [action]);
167
+ const clear = Object(react__WEBPACK_IMPORTED_MODULE_0__["useCallback"])(policy => {
168
+ if ((policy != null ? policy : options == null ? void 0 : options.clearPolicy) === _util_policies_js__WEBPACK_IMPORTED_MODULE_1__[/* ClearPolicy */ "a"].Resolve) {
169
+ actionRef.current();
170
+ } // This will cause the useEffect below to re-run
171
+
172
+
173
+ setIsSet(false);
174
+ }, [options == null ? void 0 : options.clearPolicy]);
175
+ const set = Object(react__WEBPACK_IMPORTED_MODULE_0__["useCallback"])(() => {
176
+ if (isSet) {
177
+ clear();
178
+ } // This will cause the useEffect below to re-run
179
+
180
+
181
+ setIsSet(true);
182
+ }, [clear, isSet]);
183
+ Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(() => {
184
+ if (isSet && mountedRef.current) {
185
+ const timeout = window.setTimeout(() => {
186
+ actionRef.current();
187
+ setIsSet(false);
188
+ }, timeoutMs);
189
+ return () => {
190
+ window.clearTimeout(timeout);
191
+
192
+ if (!mountedRef.current) {
193
+ clear();
194
+ }
195
+ };
196
+ }
197
+ }, [clear, isSet, timeoutMs]);
198
+ return {
199
+ isSet,
200
+ set,
201
+ clear
202
+ };
203
+ }
204
+
205
+ /***/ }),
206
+ /* 4 */
207
+ /***/ (function(module, __webpack_exports__, __webpack_require__) {
208
+
144
209
  "use strict";
145
210
  /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return ActionSchedulerProvider; });
146
211
  /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);
147
212
  /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
148
- /* harmony import */ var _util_action_scheduler_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(4);
213
+ /* harmony import */ var _util_action_scheduler_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(5);
149
214
 
150
215
 
151
216
 
@@ -179,14 +244,14 @@ class ActionSchedulerProvider extends react__WEBPACK_IMPORTED_MODULE_0__["Compon
179
244
  }
180
245
 
181
246
  /***/ }),
182
- /* 4 */
247
+ /* 5 */
183
248
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
184
249
 
185
250
  "use strict";
186
251
  /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return ActionScheduler; });
187
- /* harmony import */ var _timeout_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
188
- /* harmony import */ var _interval_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(6);
189
- /* harmony import */ var _animation_frame_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(7);
252
+ /* harmony import */ var _timeout_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(6);
253
+ /* harmony import */ var _interval_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(7);
254
+ /* harmony import */ var _animation_frame_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(8);
190
255
 
191
256
 
192
257
 
@@ -267,7 +332,7 @@ ActionScheduler.NoopAction = {
267
332
  };
268
333
 
269
334
  /***/ }),
270
- /* 5 */
335
+ /* 6 */
271
336
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
272
337
 
273
338
  "use strict";
@@ -378,7 +443,7 @@ class Timeout {
378
443
  }
379
444
 
380
445
  /***/ }),
381
- /* 6 */
446
+ /* 7 */
382
447
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
383
448
 
384
449
  "use strict";
@@ -487,7 +552,7 @@ class Interval {
487
552
  }
488
553
 
489
554
  /***/ }),
490
- /* 7 */
555
+ /* 8 */
491
556
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
492
557
 
493
558
  "use strict";
@@ -594,7 +659,7 @@ class AnimationFrame {
594
659
  }
595
660
 
596
661
  /***/ }),
597
- /* 8 */
662
+ /* 9 */
598
663
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
599
664
 
600
665
  "use strict";
@@ -607,6 +672,10 @@ __webpack_require__.r(__webpack_exports__);
607
672
  /* harmony import */ var _components_with_action_scheduler_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(2);
608
673
  /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "withActionScheduler", function() { return _components_with_action_scheduler_js__WEBPACK_IMPORTED_MODULE_1__["a"]; });
609
674
 
675
+ /* harmony import */ var _hooks_use_timeout_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(3);
676
+ /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "useTimeout", function() { return _hooks_use_timeout_js__WEBPACK_IMPORTED_MODULE_2__["a"]; });
677
+
678
+
610
679
 
611
680
 
612
681
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@khanacademy/wonder-blocks-timing",
3
3
  "private": false,
4
- "version": "2.0.2",
4
+ "version": "2.0.3",
5
5
  "design": "v1",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -17,9 +17,9 @@
17
17
  "react": "16.14.0"
18
18
  },
19
19
  "devDependencies": {
20
- "wb-dev-build-settings": "^0.1.2"
20
+ "wb-dev-build-settings": "^0.2.0"
21
21
  },
22
22
  "author": "",
23
23
  "license": "MIT",
24
- "gitHead": "b6193f70c73e70fbaf76bc688dc69a47fb1d0ef3"
24
+ "gitHead": "9ebea88533e702011165072f090a377e02fa3f0f"
25
25
  }
@@ -0,0 +1,336 @@
1
+ // @flow
2
+ import {renderHook, act} from "@testing-library/react-hooks";
3
+ import {SchedulePolicy, ClearPolicy} from "../../util/policies.js";
4
+
5
+ import {useTimeout} from "../use-timeout.js";
6
+
7
+ describe("useTimeout", () => {
8
+ beforeEach(() => {
9
+ jest.useFakeTimers();
10
+ });
11
+
12
+ it("should return an ITimeout", () => {
13
+ // Arrange
14
+ const {result} = renderHook(() => useTimeout(() => {}, 1000));
15
+
16
+ // Act
17
+
18
+ // 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
+ );
26
+ });
27
+
28
+ it("should default to being immediately set", () => {
29
+ // Arrange
30
+ const {result} = renderHook(() => useTimeout(() => {}, 1000));
31
+
32
+ // Act
33
+
34
+ // Assert
35
+ expect(result.current.isSet).toBe(true);
36
+ });
37
+
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));
233
+
234
+ // Act
235
+ unmount();
236
+
237
+ // Assert
238
+ expect(action).not.toHaveBeenCalled();
239
+ });
240
+ });
241
+
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
+ );
250
+
251
+ // Act
252
+
253
+ // Assert
254
+ expect(result.current.isSet).toBe(false);
255
+ });
256
+
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
+ );
265
+
266
+ // Act
267
+ act(() => {
268
+ jest.advanceTimersByTime(1000);
269
+ });
270
+
271
+ // Assert
272
+ expect(action).not.toHaveBeenCalled();
273
+ });
274
+
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
+ );
283
+
284
+ // Act
285
+ act(() => {
286
+ result.current.set();
287
+ jest.advanceTimersByTime(1000);
288
+ });
289
+
290
+ // Assert
291
+ expect(action).toHaveBeenCalled();
292
+ });
293
+
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
+ );
302
+
303
+ // Act
304
+ act(() => {
305
+ result.current.set();
306
+ jest.advanceTimersByTime(500);
307
+ result.current.set();
308
+ jest.advanceTimersByTime(500);
309
+ });
310
+
311
+ // Assert
312
+ expect(action).not.toHaveBeenCalled();
313
+ });
314
+
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
+ );
323
+
324
+ // Act
325
+ act(() => {
326
+ result.current.set();
327
+ jest.advanceTimersByTime(500);
328
+ result.current.set();
329
+ jest.advanceTimersByTime(1000);
330
+ });
331
+
332
+ // Assert
333
+ expect(action).toHaveBeenCalled();
334
+ });
335
+ });
336
+ });
@@ -0,0 +1,70 @@
1
+ // @flow
2
+ import {useEffect, useState, useCallback, useRef} from "react";
3
+
4
+ import {
5
+ SchedulePolicy as SchedulePolicies,
6
+ ClearPolicy as ClearPolicies,
7
+ } from "../util/policies.js";
8
+ import type {ITimeout, ClearPolicy, Options} from "../util/types.js";
9
+
10
+ export function useTimeout(
11
+ action: () => mixed,
12
+ timeoutMs: number,
13
+ options?: Options,
14
+ ): ITimeout {
15
+ const schedulePolicy =
16
+ options?.schedulePolicy ?? SchedulePolicies.Immediately;
17
+ const [isSet, setIsSet] = useState(
18
+ schedulePolicy === SchedulePolicies.Immediately,
19
+ );
20
+ const actionRef = useRef(action);
21
+ const mountedRef = useRef(false);
22
+
23
+ useEffect(() => {
24
+ mountedRef.current = true;
25
+ return () => {
26
+ mountedRef.current = false;
27
+ };
28
+ }, []);
29
+
30
+ useEffect(() => {
31
+ actionRef.current = action;
32
+ }, [action]);
33
+
34
+ const clear = useCallback(
35
+ (policy?: ClearPolicy) => {
36
+ if ((policy ?? options?.clearPolicy) === ClearPolicies.Resolve) {
37
+ actionRef.current();
38
+ }
39
+ // This will cause the useEffect below to re-run
40
+ setIsSet(false);
41
+ },
42
+ [options?.clearPolicy],
43
+ );
44
+
45
+ const set = useCallback(() => {
46
+ if (isSet) {
47
+ clear();
48
+ }
49
+ // This will cause the useEffect below to re-run
50
+ setIsSet(true);
51
+ }, [clear, isSet]);
52
+
53
+ useEffect(() => {
54
+ if (isSet && mountedRef.current) {
55
+ const timeout = window.setTimeout(() => {
56
+ actionRef.current();
57
+ setIsSet(false);
58
+ }, timeoutMs);
59
+
60
+ return () => {
61
+ window.clearTimeout(timeout);
62
+ if (!mountedRef.current) {
63
+ clear();
64
+ }
65
+ };
66
+ }
67
+ }, [clear, isSet, timeoutMs]);
68
+
69
+ return {isSet, set, clear};
70
+ }
@@ -0,0 +1,152 @@
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 {ClearPolicy, SchedulePolicy} from "../util/policies.js";
8
+ import {useTimeout} from "./use-timeout.js";
9
+
10
+ <Meta
11
+ title="Timing/useTimeout"
12
+ parameters={{
13
+ chromatic: {
14
+ disableSnapshot: true,
15
+ },
16
+ }}
17
+ />
18
+
19
+ # `useTimeout`
20
+
21
+ `useTimeout` is a hook that provides a convenient API for setting and clearing
22
+ a timeout. It is defined as follows:
23
+
24
+ ```ts
25
+ function useTimeout(
26
+ action: () => mixed,
27
+ timeoutMs: number,
28
+ options?: {|
29
+ schedulePolicy?: "schedule-immediately" | "schedule-on-demand",
30
+ clearPolicy?: "resolve-on-clear" | "cancel-on-clear",
31
+ |},
32
+ ): ITimeout;
33
+
34
+ interface ITimeout {
35
+ get isSet(): boolean;
36
+ set(): void;
37
+ clear(policy?: ClearPolicy): void;
38
+ }
39
+ ```
40
+
41
+ By default the timeout will be set immediately up creation. The `options` parameter can
42
+ be used to control when when the timeout is schedule and whether or not `action` should be
43
+ called when the timeout is cleared.
44
+
45
+ Notes:
46
+
47
+ - Because `clear` takes a param, it's import that you don't pass it directly to an event handler,
48
+ e.g. `<Button onClick={clear} />` will not work as expected.
49
+ - Calling `set` after the timeout has expired will restart the timeout.
50
+ - Updating the second paramter, `timeoutMs`, will also restart the timeout.
51
+ - When the component using this hooks is unmounted, the timeout will automatically be cleared.
52
+ - Calling `set` after the timeout is set but before it expires means that the timeout will be
53
+ reset and will call `action`, `timeoutMs` after the most recent call to `set` was made.
54
+
55
+ export const Immediately = () => {
56
+ const [callCount, setCallCount] = React.useState(0);
57
+ const callback = React.useCallback(() => {
58
+ setCallCount((callCount) => callCount + 1);
59
+ }, []);
60
+ const {isSet, set, clear} = useTimeout(callback, 1000);
61
+ return (
62
+ <View>
63
+ <View>isSet = {isSet.toString()}</View>
64
+ <View>callCount = {callCount}</View>
65
+ <View style={{flexDirection: "row"}}>
66
+ <Button onClick={set}>Set timeout</Button>
67
+ <Button onClick={clear}>Clear timeout</Button>
68
+ </View>
69
+ </View>
70
+ );
71
+ };
72
+
73
+ <Canvas>
74
+ <Story name="Immediately">
75
+ <Immediately />
76
+ </Story>
77
+ </Canvas>
78
+
79
+ ```jsx
80
+ const Immediately = () => {
81
+ const [callCount, setCallCount] = React.useState(0);
82
+ const callback = React.useCallback(() => {
83
+ setCallCount((callCount) => callCount + 1);
84
+ }, []);
85
+ const {isSet, set, clear} = useTimeout(callback, 1000);
86
+ return (
87
+ <View>
88
+ <View>isSet = {isSet.toString()}</View>
89
+ <View>callCount = {callCount}</View>
90
+ <View style={{flexDirection: "row"}}>
91
+ <Button onClick={() => set()}>Set timeout</Button>
92
+ <Button onClick={() => clear()}>Clear timeout</Button>
93
+ </View>
94
+ </View>
95
+ );
96
+ };
97
+ ```
98
+
99
+ export const OnDemandAndResolveOnClear = () => {
100
+ const [callCount, setCallCount] = React.useState(0);
101
+ const callback = React.useCallback(() => {
102
+ console.log("action called");
103
+ setCallCount((callCount) => callCount + 1);
104
+ }, []);
105
+ const {isSet, set, clear} = useTimeout(callback, 1000, {
106
+ clearPolicy: ClearPolicy.Resolve,
107
+ schedulePolicy: SchedulePolicy.OnDemand,
108
+ });
109
+ return (
110
+ <View>
111
+ <View>isSet = {isSet.toString()}</View>
112
+ <View>callCount = {callCount}</View>
113
+ <View style={{flexDirection: "row"}}>
114
+ <Button onClick={() => set()}>Set timeout</Button>
115
+ <Button onClick={() => clear()}>Clear timeout</Button>
116
+ </View>
117
+ </View>
118
+ );
119
+ };
120
+
121
+ <Canvas>
122
+ <Story name="OnDemandAndResolveOnClear">
123
+ <OnDemandAndResolveOnClear />
124
+ </Story>
125
+ </Canvas>
126
+
127
+ ```jsx
128
+ const OnDemandAndResolveOnClear = () => {
129
+ const [callCount, setCallCount] = React.useState(0);
130
+ const callback = React.useCallback(() => {
131
+ setCallCount((callCount) => callCount + 1);
132
+ }, []);
133
+ const {isSet, set, clear} = useTimeout(
134
+ callback,
135
+ 1000,
136
+ {
137
+ clearPolicy: ClearPolicy.Resolve,
138
+ schedulePolicy: SchedulePolicy.OnDemand,
139
+ },
140
+ );
141
+ return (
142
+ <View>
143
+ <View>isSet = {isSet.toString()}</View>
144
+ <View>callCount = {callCount}</View>
145
+ <View style={{flexDirection: "row"}}>
146
+ <Button onClick={() => set()}>Set timeout</Button>
147
+ <Button onClick={() => clear()}>Clear timeout</Button>
148
+ </View>
149
+ </View>
150
+ );
151
+ };
152
+ ```
package/src/index.js CHANGED
@@ -21,3 +21,4 @@ export type {
21
21
 
22
22
  export {SchedulePolicy, ClearPolicy} from "./util/policies.js";
23
23
  export {default as withActionScheduler} from "./components/with-action-scheduler.js";
24
+ export {useTimeout} from "./hooks/use-timeout.js";
@@ -9,14 +9,12 @@ describe("AnimationFrame", () => {
9
9
  // Jest doesn't fake out the animation frame API, so we're going to do
10
10
  // it here and map it to timeouts, that way we can use the fake timer
11
11
  // API to test our animation frame things.
12
- jest.spyOn(
13
- global,
14
- "requestAnimationFrame",
15
- ).mockImplementation((fn, ...args) => setTimeout(fn, 0));
16
- jest.spyOn(
17
- global,
18
- "cancelAnimationFrame",
19
- ).mockImplementation((id, ...args) => clearTimeout(id));
12
+ jest.spyOn(global, "requestAnimationFrame").mockImplementation(
13
+ (fn, ...args) => setTimeout(fn, 0),
14
+ );
15
+ jest.spyOn(global, "cancelAnimationFrame").mockImplementation(
16
+ (id, ...args) => clearTimeout(id),
17
+ );
20
18
  });
21
19
 
22
20
  afterEach(() => {
@@ -7,6 +7,10 @@ describe("Interval", () => {
7
7
  jest.useFakeTimers();
8
8
  });
9
9
 
10
+ afterEach(() => {
11
+ jest.restoreAllMocks();
12
+ });
13
+
10
14
  describe("constructor", () => {
11
15
  it("creates instance", () => {
12
16
  // Arrange
@@ -44,13 +48,14 @@ describe("Interval", () => {
44
48
 
45
49
  it("sets an interval when schedule policy is SchedulePolicy.Immediately", () => {
46
50
  // Arrange
51
+ const intervalSpy = jest.spyOn(global, "setInterval");
47
52
 
48
53
  // Act
49
54
  // eslint-disable-next-line no-new
50
55
  new Interval(() => {}, 1000, SchedulePolicy.Immediately);
51
56
 
52
57
  // Assert
53
- expect(setInterval).toHaveBeenCalledTimes(1);
58
+ expect(intervalSpy).toHaveBeenCalledTimes(1);
54
59
  });
55
60
  });
56
61
 
@@ -99,13 +104,18 @@ describe("Interval", () => {
99
104
  describe("#set", () => {
100
105
  it("should call setInterval", () => {
101
106
  // Arrange
102
- const interval = new Interval(() => {}, 500);
107
+ const intervalSpy = jest.spyOn(global, "setInterval");
108
+ const interval = new Interval(
109
+ () => {},
110
+ 500,
111
+ SchedulePolicy.OnDemand,
112
+ );
103
113
 
104
114
  // Act
105
115
  interval.set();
106
116
 
107
117
  // Assert
108
- expect(setInterval).toHaveBeenNthCalledWith(
118
+ expect(intervalSpy).toHaveBeenNthCalledWith(
109
119
  1,
110
120
  expect.any(Function),
111
121
  500,
@@ -114,12 +124,15 @@ describe("Interval", () => {
114
124
 
115
125
  it("should invoke setInterval to call the given action", () => {
116
126
  // Arrange
127
+ const intervalSpy = jest.spyOn(global, "setInterval");
117
128
  const action = jest.fn();
118
- const interval = new Interval(() => action(), 500);
129
+ const interval = new Interval(
130
+ () => action(),
131
+ 500,
132
+ SchedulePolicy.OnDemand,
133
+ );
119
134
  interval.set();
120
- // Flow doesn't know we added jest mocks to this
121
- // $FlowFixMe[prop-missing]
122
- const scheduledAction = setInterval.mock.calls[0][0];
135
+ const scheduledAction = intervalSpy.mock.calls[0][0];
123
136
 
124
137
  // Act
125
138
  scheduledAction();
@@ -131,12 +144,16 @@ describe("Interval", () => {
131
144
  it("should clear the active interval", () => {
132
145
  // Arrange
133
146
  const action = jest.fn();
134
- const interval = new Interval(() => action(), 500);
147
+ const interval = new Interval(
148
+ () => action(),
149
+ 500,
150
+ SchedulePolicy.OnDemand,
151
+ );
135
152
  interval.set();
136
153
 
137
154
  // Act
138
155
  interval.set();
139
- jest.runTimersToTime(501);
156
+ jest.advanceTimersByTime(501);
140
157
 
141
158
  // Assert
142
159
  expect(action).toHaveBeenCalledTimes(1);
@@ -149,7 +166,7 @@ describe("Interval", () => {
149
166
  interval.set();
150
167
 
151
168
  // Act
152
- jest.runTimersToTime(1501);
169
+ jest.advanceTimersByTime(1501);
153
170
 
154
171
  // Assert
155
172
  expect(action).toHaveBeenCalledTimes(3);
@@ -165,7 +182,7 @@ describe("Interval", () => {
165
182
 
166
183
  // Act
167
184
  interval.clear();
168
- jest.runTimersToTime(501);
185
+ jest.advanceTimersByTime(501);
169
186
 
170
187
  // Assert
171
188
  expect(action).not.toHaveBeenCalled();
@@ -179,7 +196,7 @@ describe("Interval", () => {
179
196
 
180
197
  // Act
181
198
  interval.clear(ClearPolicy.Resolve);
182
- jest.runTimersToTime(501);
199
+ jest.advanceTimersByTime(501);
183
200
 
184
201
  // Assert
185
202
  expect(action).toHaveBeenCalledTimes(1);
@@ -196,7 +213,7 @@ describe("Interval", () => {
196
213
 
197
214
  // Act
198
215
  interval.clear(ClearPolicy.Cancel);
199
- jest.runTimersToTime(501);
216
+ jest.advanceTimersByTime(501);
200
217
 
201
218
  // Assert
202
219
  expect(action).not.toHaveBeenCalled();
@@ -209,7 +226,7 @@ describe("Interval", () => {
209
226
 
210
227
  // Act
211
228
  interval.clear(ClearPolicy.Resolve);
212
- jest.runTimersToTime(501);
229
+ jest.advanceTimersByTime(501);
213
230
 
214
231
  // Assert
215
232
  expect(action).not.toHaveBeenCalled();
@@ -7,6 +7,10 @@ describe("Timeout", () => {
7
7
  jest.useFakeTimers();
8
8
  });
9
9
 
10
+ afterEach(() => {
11
+ jest.restoreAllMocks();
12
+ });
13
+
10
14
  describe("constructor", () => {
11
15
  it("creates instance", () => {
12
16
  // Arrange
@@ -44,13 +48,14 @@ describe("Timeout", () => {
44
48
 
45
49
  it("sets a timeout when schedule policy is SchedulePolicy.Immediately", () => {
46
50
  // Arrange
51
+ const timeoutSpy = jest.spyOn(global, "setTimeout");
47
52
 
48
53
  // Act
49
54
  // eslint-disable-next-line no-new
50
55
  new Timeout(() => {}, 0, SchedulePolicy.Immediately);
51
56
 
52
57
  // Assert
53
- expect(setTimeout).toHaveBeenCalledTimes(1);
58
+ expect(timeoutSpy).toHaveBeenCalledTimes(1);
54
59
  });
55
60
  });
56
61
 
@@ -95,13 +100,14 @@ describe("Timeout", () => {
95
100
  describe("#set", () => {
96
101
  it("should call setTimeout", () => {
97
102
  // Arrange
103
+ const timeoutSpy = jest.spyOn(global, "setTimeout");
98
104
  const timeout = new Timeout(() => {}, 500, SchedulePolicy.OnDemand);
99
105
 
100
106
  // Act
101
107
  timeout.set();
102
108
 
103
109
  // Assert
104
- expect(setTimeout).toHaveBeenNthCalledWith(
110
+ expect(timeoutSpy).toHaveBeenNthCalledWith(
105
111
  1,
106
112
  expect.any(Function),
107
113
  500,
@@ -110,12 +116,15 @@ describe("Timeout", () => {
110
116
 
111
117
  it("should invoke setTimeout to call the given action", () => {
112
118
  // Arrange
119
+ const timeoutSpy = jest.spyOn(global, "setTimeout");
113
120
  const action = jest.fn();
114
- const timeout = new Timeout(() => action(), 500);
121
+ const timeout = new Timeout(
122
+ () => action(),
123
+ 500,
124
+ SchedulePolicy.OnDemand,
125
+ );
115
126
  timeout.set();
116
- // Flow doesn't know we added jest mocks to this
117
- // $FlowFixMe[prop-missing]
118
- const scheduledAction = setTimeout.mock.calls[0][0];
127
+ const scheduledAction = timeoutSpy.mock.calls[0][0];
119
128
 
120
129
  // Act
121
130
  scheduledAction();
@@ -140,16 +149,17 @@ describe("Timeout", () => {
140
149
 
141
150
  it("should set the timeout again if it has already executed", () => {
142
151
  // Arrange
152
+ const timeoutSpy = jest.spyOn(global, "setTimeout");
143
153
  const action = jest.fn();
144
154
  const timeout = new Timeout(action, 500, SchedulePolicy.OnDemand);
145
155
  timeout.set();
146
- jest.runOnlyPendingTimers();
156
+ jest.runAllTimers();
147
157
 
148
158
  // Act
149
159
  timeout.set();
150
160
 
151
161
  // Assert
152
- expect(setTimeout).toHaveBeenCalledTimes(2);
162
+ expect(timeoutSpy).toHaveBeenCalledTimes(2);
153
163
  });
154
164
  });
155
165