From 2d9d7e15397a497f724b01c1c1d910de0d84f855 Mon Sep 17 00:00:00 2001 From: Clauderic Demers Date: Thu, 1 Apr 2021 17:09:25 -0400 Subject: [PATCH 1/5] Introduce useIsomorphicLayoutEffect hook --- packages/react-hooks/src/hooks/index.ts | 1 + .../src/hooks/isomorphic-layout-effect.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 packages/react-hooks/src/hooks/isomorphic-layout-effect.ts diff --git a/packages/react-hooks/src/hooks/index.ts b/packages/react-hooks/src/hooks/index.ts index 70862c02e8..a543b43b25 100644 --- a/packages/react-hooks/src/hooks/index.ts +++ b/packages/react-hooks/src/hooks/index.ts @@ -9,3 +9,4 @@ export {useTimeout} from './timeout'; export {useToggle} from './toggle'; export {useForceUpdate} from './force-update'; export {useDelayedCallback} from './delayed-callback'; +export {useIsomorphicLayoutEffect} from './isomorphic-layout-effect'; diff --git a/packages/react-hooks/src/hooks/isomorphic-layout-effect.ts b/packages/react-hooks/src/hooks/isomorphic-layout-effect.ts new file mode 100644 index 0000000000..0c07057404 --- /dev/null +++ b/packages/react-hooks/src/hooks/isomorphic-layout-effect.ts @@ -0,0 +1,14 @@ +import {useEffect, useLayoutEffect} from 'react'; + +// https://github.com/facebook/react/blob/master/packages/shared/ExecutionEnvironment.js +const canUseDOM = + typeof window !== 'undefined' && + typeof window.document !== 'undefined' && + typeof window.document.createElement !== 'undefined'; + +/** + * A hook that resolves to useEffect on the server and useLayoutEffect on the client + */ +export const useIsomorphicLayoutEffect = canUseDOM + ? useLayoutEffect + : useEffect; From 2a47501657ff521485839773e60a1a66ec62cbd1 Mon Sep 17 00:00:00 2001 From: Clauderic Demers Date: Thu, 1 Apr 2021 17:10:23 -0400 Subject: [PATCH 2/5] Use useIsomorphicLayoutEffect to update saved callback synchronously If the saved callback is updated in a normal useEffect instead of useLayoutEffect, the interval or timeout can be invoked with a stale callback function. --- packages/react-hooks/src/hooks/interval.ts | 5 ++++- packages/react-hooks/src/hooks/timeout.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/react-hooks/src/hooks/interval.ts b/packages/react-hooks/src/hooks/interval.ts index ba60b004de..ab1dd689b7 100644 --- a/packages/react-hooks/src/hooks/interval.ts +++ b/packages/react-hooks/src/hooks/interval.ts @@ -1,5 +1,7 @@ import {useEffect, useRef} from 'react'; +import {useIsomorphicLayoutEffect} from './isomorphic-layout-effect'; + type IntervalCallback = () => void; type IntervalDelay = number | null; @@ -11,7 +13,8 @@ type IntervalDelay = number | null; export function useInterval(callback: IntervalCallback, delay: IntervalDelay) { const savedCallback = useRef(callback); - useEffect(() => { + // Need to use a layout effect to force the saved callback to be synchronously updated during a commit + useIsomorphicLayoutEffect(() => { savedCallback.current = callback; }, [callback]); diff --git a/packages/react-hooks/src/hooks/timeout.ts b/packages/react-hooks/src/hooks/timeout.ts index e9959e1db9..6e9279b4ae 100644 --- a/packages/react-hooks/src/hooks/timeout.ts +++ b/packages/react-hooks/src/hooks/timeout.ts @@ -1,12 +1,15 @@ import {useEffect, useRef} from 'react'; +import {useIsomorphicLayoutEffect} from './isomorphic-layout-effect'; + type IntervalCallback = () => void; type IntervalDelay = number | null; export function useTimeout(callback: IntervalCallback, delay: IntervalDelay) { const savedCallback = useRef(callback); - useEffect(() => { + // Need to use a layout effect to force the saved callback to be synchronously updated during a commit + useIsomorphicLayoutEffect(() => { savedCallback.current = callback; }, [callback]); From c782bea9c1fd7ca76becf13145f419685f74bba7 Mon Sep 17 00:00:00 2001 From: Clauderic Demers Date: Thu, 1 Apr 2021 17:10:42 -0400 Subject: [PATCH 3/5] Refactor lazy-ref implementation --- packages/react-hooks/src/hooks/lazy-ref.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/react-hooks/src/hooks/lazy-ref.ts b/packages/react-hooks/src/hooks/lazy-ref.ts index ed63786738..63f0d3a0ad 100644 --- a/packages/react-hooks/src/hooks/lazy-ref.ts +++ b/packages/react-hooks/src/hooks/lazy-ref.ts @@ -1,13 +1,8 @@ -import {useRef, MutableRefObject} from 'react'; - -const UNSET = Symbol('unset'); +import {useRef, useState, MutableRefObject} from 'react'; export function useLazyRef(getValue: () => T): MutableRefObject { - const ref = useRef(UNSET); - - if (ref.current === UNSET) { - ref.current = getValue(); - } + const [value] = useState(getValue); + const ref = useRef(value); return ref as MutableRefObject; } From d87929d3b092f04c8ad09d8f877ff0bd77f4cbfd Mon Sep 17 00:00:00 2001 From: Clauderic Demers Date: Thu, 1 Apr 2021 17:30:52 -0400 Subject: [PATCH 4/5] Add release notes --- packages/react-hooks/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/react-hooks/CHANGELOG.md b/packages/react-hooks/CHANGELOG.md index a55fb55c85..542d692fd0 100644 --- a/packages/react-hooks/CHANGELOG.md +++ b/packages/react-hooks/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [Unreleased] + +- Added `useIsomorphicLayoutEffect` hook [#1813](https://github.com/Shopify/quilt/pull/1813). +- Updated `useLazyRef` hook implementation to avoid mutating refs directly during the render phase, which is unsafe [#1813](https://github.com/Shopify/quilt/pull/1813). +- Updated `useTimeout` and `useInterval` hooks. Both of these hooks use mutable ref to hold on to the latest callback function. Now updating this ref synchronously to avoid stale callbacks being invoked [#1813](https://github.com/Shopify/quilt/pull/1813). + ## [1.12.2] - 2021-03-03 ### Fixed From 41973024e1273d3f9be9e0d1ac5e0a46c562ec3c Mon Sep 17 00:00:00 2001 From: Clauderic Demers Date: Wed, 7 Apr 2021 13:57:15 -0400 Subject: [PATCH 5/5] Update README --- packages/react-hooks/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react-hooks/README.md b/packages/react-hooks/README.md index c0fad33602..70661f6b6d 100644 --- a/packages/react-hooks/README.md +++ b/packages/react-hooks/README.md @@ -18,6 +18,7 @@ $ yarn add @shopify/react-hooks - [useDelayedCallback()](#usedelayedcallback) - [useForceUpdate()](#useforceupdate) - [useInterval()](#useinterval) +- [useIsomorphicLayoutEffect()](#useisomorphiclayouteffect) - [useLazyRef()](#uselazyref) - [useMedia() & useMediaLayout()](#usemedia--usemedialayout) - [useMountedRef()](#usemountedref) @@ -154,6 +155,12 @@ function MyComponent() { This is a TypeScript implementation of @gaeron's `useInterval` hook from the [Overreacted blog post](https://overreacted.io/making-setinterval-declarative-with-react-hooks/#just-show-me-the-code). +### `useIsomorphicLayoutEffect()` + +This hook is a drop-in replacement for `useLayoutEffect` that can be used safely in a server-side rendered app. It resolves to `useEffect` on the server and `useLayoutEffect` on the client (since `useLayoutEffect` cannot be used in a server-side environment). + +Refer to the [`useLayoutEffect` documentation to learn more](https://reactjs.org/docs/hooks-reference.html#uselayouteffect). + ### `useLazyRef()` This hook creates a ref object like React’s `useRef`, but instead of providing it the value directly, you provide a function that returns the value. The first time the hook is run, it will call the function and use the returned value as the initial `ref.current` value. Afterwards, the function is never invoked. You can use this for creating refs to values that are expensive to initialize.