From 210ea60595122afd775014eacdbe58826d4df7d6 Mon Sep 17 00:00:00 2001 From: xobotyi Date: Tue, 20 Aug 2019 10:01:11 +0300 Subject: [PATCH 1/4] useTimeoutFn implementation; --- README.md | 1 + docs/useTimeoutFn.md | 65 ++++++++++++++ src/__stories__/useTimeoutFn.story.tsx | 40 +++++++++ src/__tests__/useTimeoutFn.test.ts | 116 +++++++++++++++++++++++++ src/index.ts | 1 + src/useTimeoutFn.ts | 29 +++++++ 6 files changed, 252 insertions(+) create mode 100644 docs/useTimeoutFn.md create mode 100644 src/__stories__/useTimeoutFn.story.tsx create mode 100644 src/__tests__/useTimeoutFn.test.ts create mode 100644 src/useTimeoutFn.ts diff --git a/README.md b/README.md index d24873e0..a812c062 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ - [`useInterval`](./docs/useInterval.md) — re-renders component on a set interval using `setInterval`. - [`useSpring`](./docs/useSpring.md) — interpolates number over time according to spring dynamics. - [`useTimeout`](./docs/useTimeout.md) — returns true after a timeout. + - [`useTimeoutFn`](./docs/useTimeoutFn.md) — calls given function after a timeout. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/animation-usetimeoutfn--demo) - [`useTween`](./docs/useTween.md) — re-renders component, while tweening a number from 0 to 1. [![][img-demo]](https://codesandbox.io/s/52990wwzyl) - [`useUpdate`](./docs/useUpdate.md) — returns a callback, which re-renders component when called.
diff --git a/docs/useTimeoutFn.md b/docs/useTimeoutFn.md new file mode 100644 index 00000000..52d259be --- /dev/null +++ b/docs/useTimeoutFn.md @@ -0,0 +1,65 @@ +# `useTimeoutFn` + +Calls given function after specified amount of milliseconds. +**Note:** this hook does not re-render component by itself. + +Automatically cancels timeout on component unmount. +Automatically resets timeout on delay change. + +## Usage + +```jsx +import * as React from 'react'; +import { useTimeoutFn } from 'react-use'; + +const Demo = () => { + const [state, setState] = React.useState('Not called yet'); + + function fn() { + setState(`called at ${Date.now()}`); + } + + const [isReady, cancel, reset] = useTimeoutFn(fn, 5000); + const cancelButtonClick = useCallback(() => { + if (isReady() === false) { + cancel(); + setState(`cancelled`); + } else { + reset(); + setState('Not called yet'); + } + }, []); + + const readyState = isReady(); + + return ( +
+
{readyState !== null ? 'Function will be called in 5 seconds' : 'Timer cancelled'}
+ +
+
Function state: {readyState === false ? 'Pending' : readyState ? 'Called' : 'Cancelled'}
+
{state}
+
+ ); +}; +``` + +## Reference + +```ts +const [ + isReady: () => boolean | null, + cancel: () => void, + reset: () => void, +] = useTimeoutFn(fn: Function, ms: number = 0); +``` + +- **`fn`**_`: Function`_ - function that will be called; +- **`ms`**_`: number`_ - delay in milliseconds; +- **`isReady`**_`: ()=>boolean|null`_ - function returning current timeout state: + - `false` - pending + - `true` - called + - `null` - cancelled +- **`cancel`**_`: ()=>void`_ - cancel the timeout +- **`reset`**_`: ()=>void`_ - reset the timeout + diff --git a/src/__stories__/useTimeoutFn.story.tsx b/src/__stories__/useTimeoutFn.story.tsx new file mode 100644 index 00000000..9dcb45f3 --- /dev/null +++ b/src/__stories__/useTimeoutFn.story.tsx @@ -0,0 +1,40 @@ +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; +import { useCallback } from 'react'; +import { useTimeoutFn } from '../index'; +import ShowDocs from './util/ShowDocs'; + +const Demo = () => { + const [state, setState] = React.useState('Not called yet'); + + function fn() { + setState(`called at ${Date.now()}`); + } + + const [isReady, cancel, reset] = useTimeoutFn(fn, 5000); + const cancelButtonClick = useCallback(() => { + if (isReady() === false) { + cancel(); + setState(`cancelled`); + } else { + reset(); + setState('Not called yet'); + } + }, []); + + const readyState = isReady(); + + return ( +
+
{readyState !== null ? 'Function will be called in 5 seconds' : 'Timer cancelled'}
+ +
+
Function state: {readyState === false ? 'Pending' : readyState ? 'Called' : 'Cancelled'}
+
{state}
+
+ ); +}; + +storiesOf('Animation|useTimeoutFn', module) + .add('Docs', () => ) + .add('Demo', () => ); diff --git a/src/__tests__/useTimeoutFn.test.ts b/src/__tests__/useTimeoutFn.test.ts new file mode 100644 index 00000000..8164bcdc --- /dev/null +++ b/src/__tests__/useTimeoutFn.test.ts @@ -0,0 +1,116 @@ +import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import { useTimeoutFn } from '../index'; +import { UseTimeoutFnReturn } from '../useTimeoutFn'; + +describe('useTimeoutFn', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should be defined', () => { + expect(useTimeoutFn).toBeDefined(); + }); + + it('should return three functions', () => { + const hook = renderHook(() => useTimeoutFn(() => {}, 5)); + + expect(hook.result.current.length).toBe(3); + expect(typeof hook.result.current[0]).toBe('function'); + expect(typeof hook.result.current[1]).toBe('function'); + expect(typeof hook.result.current[2]).toBe('function'); + }); + + function getHook(ms: number = 5): [jest.Mock, RenderHookResult<{ delay: number }, UseTimeoutFnReturn>] { + const spy = jest.fn(); + return [spy, renderHook(({ delay = 5 }) => useTimeoutFn(spy, delay), { initialProps: { delay: ms } })]; + } + + it('should call passed function after given amount of time', () => { + const [spy] = getHook(); + + expect(spy).not.toHaveBeenCalled(); + jest.advanceTimersByTime(5); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should cancel function call on unmount', () => { + const [spy, hook] = getHook(); + + expect(spy).not.toHaveBeenCalled(); + hook.unmount(); + jest.advanceTimersByTime(5); + expect(spy).not.toHaveBeenCalled(); + }); + + it('first function should return actual state of timeout', () => { + let [, hook] = getHook(); + let [isReady] = hook.result.current; + + expect(isReady()).toBe(false); + hook.unmount(); + expect(isReady()).toBe(null); + + [, hook] = getHook(); + [isReady] = hook.result.current; + jest.advanceTimersByTime(5); + expect(isReady()).toBe(true); + }); + + it('second function should cancel timeout', () => { + const [spy, hook] = getHook(); + const [isReady, cancel] = hook.result.current; + + expect(spy).not.toHaveBeenCalled(); + expect(isReady()).toBe(false); + + act(() => { + cancel(); + }); + jest.advanceTimersByTime(5); + + expect(spy).not.toHaveBeenCalled(); + expect(isReady()).toBe(null); + }); + + it('third function should reset timeout', () => { + const [spy, hook] = getHook(); + const [isReady, cancel, reset] = hook.result.current; + + expect(isReady()).toBe(false); + + act(() => { + cancel(); + }); + jest.advanceTimersByTime(5); + + expect(isReady()).toBe(null); + + act(() => { + reset(); + }); + expect(isReady()).toBe(false); + + jest.advanceTimersByTime(5); + + expect(spy).toHaveBeenCalledTimes(1); + expect(isReady()).toBe(true); + }); + + it('should reset timeout on delay change', () => { + const [spy, hook] = getHook(50); + + expect(spy).not.toHaveBeenCalled(); + hook.rerender({ delay: 5 }); + + jest.advanceTimersByTime(5); + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/index.ts b/src/index.ts index b4361a8a..644f587e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,7 @@ export { default as useStartTyping } from './useStartTyping'; export { default as useThrottle } from './useThrottle'; export { default as useThrottleFn } from './useThrottleFn'; export { default as useTimeout } from './useTimeout'; +export { default as useTimeoutFn } from './useTimeoutFn'; export { default as useTitle } from './useTitle'; export { default as useToggle } from './useToggle'; export { default as useTween } from './useTween'; diff --git a/src/useTimeoutFn.ts b/src/useTimeoutFn.ts new file mode 100644 index 00000000..16276343 --- /dev/null +++ b/src/useTimeoutFn.ts @@ -0,0 +1,29 @@ +import { useCallback, useEffect, useRef } from 'react'; + +export type UseTimeoutFnReturn = [() => boolean | null, () => void, () => void]; + +export default function useTimeoutFn(fn: Function, ms: number = 0): UseTimeoutFnReturn { + const ready = useRef(false); + const timeout = useRef(0); + + const isReady = useCallback(() => ready.current, []); + const set = useCallback(() => { + ready.current = false; + timeout.current = window.setTimeout(() => { + ready.current = true; + fn(); + }, ms); + }, [ms, fn]); + const clear = useCallback(() => { + ready.current = null; + timeout.current && clearTimeout(timeout.current); + }, []); + + useEffect(() => { + set(); + + return clear; + }, [ms]); + + return [isReady, clear, set]; +} From d4164998e201b3fa895226d2016439a670744adb Mon Sep 17 00:00:00 2001 From: xobotyi Date: Wed, 21 Aug 2019 19:30:46 +0300 Subject: [PATCH 2/4] useTimeout: - `useTimeoutFn` based implementation (as a special case of it); - improved docs; - added tests; --- README.md | 2 +- docs/useTimeout.md | 41 +++++++- src/__stories__/useTimeout.story.tsx | 21 +++- src/__tests__/useTimeout.test.ts | 138 +++++++++++++++++++++++++++ src/useTimeout.ts | 23 ++--- 5 files changed, 201 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/useTimeout.test.ts diff --git a/README.md b/README.md index a812c062..8e36339e 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ - [`useRaf`](./docs/useRaf.md) — re-renders component on each `requestAnimationFrame`. - [`useInterval`](./docs/useInterval.md) — re-renders component on a set interval using `setInterval`. - [`useSpring`](./docs/useSpring.md) — interpolates number over time according to spring dynamics. - - [`useTimeout`](./docs/useTimeout.md) — returns true after a timeout. + - [`useTimeout`](./docs/useTimeout.md) — re-renders component after a timeout. - [`useTimeoutFn`](./docs/useTimeoutFn.md) — calls given function after a timeout. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/animation-usetimeoutfn--demo) - [`useTween`](./docs/useTween.md) — re-renders component, while tweening a number from 0 to 1. [![][img-demo]](https://codesandbox.io/s/52990wwzyl) - [`useUpdate`](./docs/useUpdate.md) — returns a callback, which re-renders component when called. diff --git a/docs/useTimeout.md b/docs/useTimeout.md index 4c4c96e2..6c09e62c 100644 --- a/docs/useTimeout.md +++ b/docs/useTimeout.md @@ -1,15 +1,48 @@ # `useTimeout` -Returns `true` after a specified number of milliseconds. +Re-renders the component after a specified number of milliseconds. +Provides handles to cancel and/or reset the timeout. ## Usage ```jsx import { useTimeout } from 'react-use'; -const Demo = () => { - const ready = useTimeout(2000); +function TestComponent(props: { ms?: number } = {}) { + const ms = props.ms || 5000; + const [isReady, cancel] = useTimeout(ms); - return
Ready: {ready ? 'Yes' : 'No'}
; + return ( +
+ { isReady() ? 'I\'m reloaded after timeout' : `I will be reloaded after ${ ms / 1000 }s` } + { isReady() === false ? : '' } +
+ ); +} + +const Demo = () => { + return ( +
+ + +
+ ); }; ``` + +## Reference + +```ts +const [ + isReady: () => boolean | null, + cancel: () => void, + reset: () => void, +] = useTimeout(ms: number = 0); +``` + +- **`isReady`**_` :()=>boolean|null`_ - function returning current timeout state: + - `false` - pending re-render + - `true` - re-render performed + - `null` - re-render cancelled +- **`cancel`**_` :()=>void`_ - cancel the timeout (component will not be re-rendered) +- **`reset`**_` :()=>void`_ - reset the timeout diff --git a/src/__stories__/useTimeout.story.tsx b/src/__stories__/useTimeout.story.tsx index a393ee21..99fda16a 100644 --- a/src/__stories__/useTimeout.story.tsx +++ b/src/__stories__/useTimeout.story.tsx @@ -3,10 +3,25 @@ import * as React from 'react'; import { useTimeout } from '..'; import ShowDocs from './util/ShowDocs'; -const Demo = () => { - const ready = useTimeout(2e3); +function TestComponent(props: { ms?: number } = {}) { + const ms = props.ms || 5000; + const [isReady, cancel] = useTimeout(ms); - return
Ready: {ready ? 'Yes' : 'No'}
; + return ( +
+ {isReady() ? "I'm reloaded after timeout" : `I will be reloaded after ${ms / 1000}s`} + {isReady() === false ? : ''} +
+ ); +} + +const Demo = () => { + return ( +
+ + +
+ ); }; storiesOf('Animation|useTimeout', module) diff --git a/src/__tests__/useTimeout.test.ts b/src/__tests__/useTimeout.test.ts new file mode 100644 index 00000000..905b4022 --- /dev/null +++ b/src/__tests__/useTimeout.test.ts @@ -0,0 +1,138 @@ +import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import { useTimeout } from '../index'; +import { UseTimeoutReturn } from '../useTimeout'; + +describe('useTimeout', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should be defined', () => { + expect(useTimeout).toBeDefined(); + }); + + it('should return three functions', () => { + const hook = renderHook(() => useTimeout(5)); + + expect(hook.result.current.length).toBe(3); + expect(typeof hook.result.current[0]).toBe('function'); + expect(typeof hook.result.current[1]).toBe('function'); + expect(typeof hook.result.current[2]).toBe('function'); + }); + + function getHook(ms: number = 5): [jest.Mock, RenderHookResult<{ delay: number }, UseTimeoutReturn>] { + const spy = jest.fn(); + return [ + spy, + renderHook( + ({ delay = 5 }) => { + spy(); + return useTimeout(delay); + }, + { initialProps: { delay: ms } } + ), + ]; + } + + it('should re-render component after given amount of time', done => { + const [spy, hook] = getHook(); + expect(spy).toHaveBeenCalledTimes(1); + hook.waitForNextUpdate().then(() => { + expect(spy).toHaveBeenCalledTimes(2); + done(); + }); + jest.advanceTimersByTime(5); + }); + + it('should cancel timeout on unmount', () => { + const [spy, hook] = getHook(); + + expect(spy).toHaveBeenCalledTimes(1); + hook.unmount(); + jest.advanceTimersByTime(5); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('first function should return actual state of timeout', done => { + let [, hook] = getHook(); + let [isReady] = hook.result.current; + + expect(isReady()).toBe(false); + hook.unmount(); + expect(isReady()).toBe(null); + + [, hook] = getHook(); + [isReady] = hook.result.current; + hook.waitForNextUpdate().then(() => { + expect(isReady()).toBe(true); + + done(); + }); + jest.advanceTimersByTime(5); + }); + + it('second function should cancel timeout', () => { + const [spy, hook] = getHook(); + const [isReady, cancel] = hook.result.current; + + expect(spy).toHaveBeenCalledTimes(1); + expect(isReady()).toBe(false); + + act(() => { + cancel(); + }); + jest.advanceTimersByTime(5); + + expect(spy).toHaveBeenCalledTimes(1); + expect(isReady()).toBe(null); + }); + + it('third function should reset timeout', done => { + const [spy, hook] = getHook(); + const [isReady, cancel, reset] = hook.result.current; + + expect(isReady()).toBe(false); + + act(() => { + cancel(); + }); + jest.advanceTimersByTime(5); + + expect(isReady()).toBe(null); + + act(() => { + reset(); + }); + expect(isReady()).toBe(false); + + hook.waitForNextUpdate().then(() => { + expect(spy).toHaveBeenCalledTimes(2); + expect(isReady()).toBe(true); + + done(); + }); + jest.advanceTimersByTime(5); + }); + + it('should reset timeout on delay change', done => { + const [spy, hook] = getHook(15); + + expect(spy).toHaveBeenCalledTimes(1); + hook.rerender({ delay: 5 }); + + hook.waitForNextUpdate().then(() => { + expect(spy).toHaveBeenCalledTimes(3); + + done(); + }); + jest.advanceTimersByTime(15); + }); +}); diff --git a/src/useTimeout.ts b/src/useTimeout.ts index 84c7a143..387a7c76 100644 --- a/src/useTimeout.ts +++ b/src/useTimeout.ts @@ -1,19 +1,10 @@ -import { useEffect, useState } from 'react'; +import useTimeoutFn from './useTimeoutFn'; +import useUpdate from './useUpdate'; -const useTimeout = (ms: number = 0) => { - const [ready, setReady] = useState(false); +export type UseTimeoutReturn = [() => boolean | null, () => void, () => void]; - useEffect(() => { - const timer = setTimeout(() => { - setReady(true); - }, ms); +export default function useTimeout(ms: number = 0): UseTimeoutReturn { + const update = useUpdate(); - return () => { - clearTimeout(timer); - }; - }, [ms]); - - return ready; -}; - -export default useTimeout; + return useTimeoutFn(update, ms); +} From a656c15f5701eff1f2f0da5725db21b54decbcfa Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 22 Aug 2019 08:59:02 +0000 Subject: [PATCH 3/4] chore(release): 11.0.0 [skip ci] # [11.0.0](https://github.com/streamich/react-use/compare/v10.8.0...v11.0.0) (2019-08-22) ### Features * add cancel and reset methods to useTimeout ([283045a](https://github.com/streamich/react-use/commit/283045a)) * add useTimeoutFn ([284e6fd](https://github.com/streamich/react-use/commit/284e6fd)) ### BREAKING CHANGES * useTimeout now returns a tuple --- CHANGELOG.md | 13 +++++++++++++ package.json | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 678d0c69..6f4a3c2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +# [11.0.0](https://github.com/streamich/react-use/compare/v10.8.0...v11.0.0) (2019-08-22) + + +### Features + +* add cancel and reset methods to useTimeout ([283045a](https://github.com/streamich/react-use/commit/283045a)) +* add useTimeoutFn ([284e6fd](https://github.com/streamich/react-use/commit/284e6fd)) + + +### BREAKING CHANGES + +* useTimeout now returns a tuple + # [10.8.0](https://github.com/streamich/react-use/compare/v10.7.1...v10.8.0) (2019-08-20) diff --git a/package.json b/package.json index 6f2f1f5c..6b9a34eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-use", - "version": "10.8.0", + "version": "11.0.0", "description": "Collection of React Hooks", "main": "lib/index.js", "module": "esm/index.js", From 3b6b72385aee706a68b5200cfe73707765c3e32b Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 22 Aug 2019 18:57:05 +0000 Subject: [PATCH 4/4] chore(deps): update dependency semantic-release to v15.13.24 --- package.json | 2 +- yarn.lock | 29 +++++++++++++++++++++++------ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 6b9a34eb..4bb3a3de 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "redux-thunk": "2.3.0", "rimraf": "3.0.0", "rxjs": "6.5.2", - "semantic-release": "15.13.21", + "semantic-release": "15.13.24", "ts-loader": "6.0.4", "ts-node": "8.3.0", "tslint": "5.19.0", diff --git a/yarn.lock b/yarn.lock index 354176c8..aa473406 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10948,10 +10948,10 @@ select@^1.1.2: resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" integrity sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0= -semantic-release@15.13.21: - version "15.13.21" - resolved "https://registry.yarnpkg.com/semantic-release/-/semantic-release-15.13.21.tgz#d021c75f889cff75ae3410736942bee6c4557da7" - integrity sha512-3S9thQas28iv3NeHUqQVsDnxMcBGQICdxabeNnJ8BnbRBvCkgqCg3v9zo/+O5a8GCyxrgjtwJ2iWozL8SiIq1w== +semantic-release@15.13.24: + version "15.13.24" + resolved "https://registry.yarnpkg.com/semantic-release/-/semantic-release-15.13.24.tgz#f0b9544427d059ba5e3c89ac1545234130796be7" + integrity sha512-OPshm6HSp+KmZP9dUv1o3MRILDgOeHYWPI+XSpQRERMri7QkaEiIPkZzoNm2d6KDeFDnp03GphQQS4+Zfo+x/Q== dependencies: "@semantic-release/commit-analyzer" "^6.1.0" "@semantic-release/error" "^2.2.0" @@ -10978,7 +10978,7 @@ semantic-release@15.13.21: resolve-from "^5.0.0" semver "^6.0.0" signale "^1.2.1" - yargs "^13.1.0" + yargs "^14.0.0" semver-compare@^1.0.0: version "1.0.0" @@ -12886,7 +12886,7 @@ yargs@^11.0.0: y18n "^3.2.1" yargs-parser "^9.0.2" -yargs@^13.1.0, yargs@^13.3.0: +yargs@^13.3.0: version "13.3.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.0.tgz#4c657a55e07e5f2cf947f8a366567c04a0dedc83" integrity sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA== @@ -12902,6 +12902,23 @@ yargs@^13.1.0, yargs@^13.3.0: y18n "^4.0.0" yargs-parser "^13.1.1" +yargs@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-14.0.0.tgz#ba4cacc802b3c0b3e36a9e791723763d57a85066" + integrity sha512-ssa5JuRjMeZEUjg7bEL99AwpitxU/zWGAGpdj0di41pOEmJti8NR6kyUIJBkR78DTYNPZOU08luUo0GTHuB+ow== + dependencies: + cliui "^5.0.0" + decamelize "^1.2.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.1" + yn@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/yn/-/yn-3.0.0.tgz#0073c6b56e92aed652fbdfd62431f2d6b9a7a091"