From cb25638a0ec5e639afbd4bf23386783c675003f0 Mon Sep 17 00:00:00 2001 From: Tim Yung Date: Sun, 6 Jun 2021 17:07:43 -0700 Subject: [PATCH] RN: Create `useRefEffect` Utility Summary: Creates `useRefEffect` which will be used by components in React Native. Changelog: [Internal] Reviewed By: lunaleaps Differential Revision: D28862440 fbshipit-source-id: 50e0099c1a3e0a0f506bf82e68984fc5a032f101 --- .../Utilities/__tests__/useRefEffect-test.js | 242 ++++++++++++++++++ Libraries/Utilities/useRefEffect.js | 45 ++++ 2 files changed, 287 insertions(+) create mode 100644 Libraries/Utilities/__tests__/useRefEffect-test.js create mode 100644 Libraries/Utilities/useRefEffect.js diff --git a/Libraries/Utilities/__tests__/useRefEffect-test.js b/Libraries/Utilities/__tests__/useRefEffect-test.js new file mode 100644 index 00000000000000..8204289e71ab86 --- /dev/null +++ b/Libraries/Utilities/__tests__/useRefEffect-test.js @@ -0,0 +1,242 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+react_native + * @flow strict-local + * @format + */ + +import useRefEffect from '../useRefEffect'; +import * as React from 'react'; +import {View} from 'react-native'; +import {act, create} from 'react-test-renderer'; + +/** + * TestView provide a component execution environment to test hooks. + */ +function TestView({childKey = null, effect}) { + const ref = useRefEffect(effect); + return ; +} + +/** + * TestEffect represents an effect invocation. + */ +class TestEffect { + name: string; + key: ?string; + constructor(name: string, key: ?string) { + this.name = name; + this.key = key; + } + static called(name: string, key: ?string) { + // $FlowIssue[prop-missing] - Flow does not support type augmentation. + return expect.effect(name, key); + } +} + +/** + * TestEffectCleanup represents an effect cleanup invocation. + */ +class TestEffectCleanup { + name: string; + key: ?string; + constructor(name: string, key: ?string) { + this.name = name; + this.key = key; + } + static called(name: string, key: ?string) { + // $FlowIssue[prop-missing] - Flow does not support type augmentation. + return expect.effectCleanup(name, key); + } +} + +/** + * extend.effect and expect.extendCleanup make it easier to assert expected + * values. But use TestEffect.called and TestEffectCleanup.called instead of + * extend.effect and expect.extendCleanup because of Flow. + */ +expect.extend({ + effect(received, name, key) { + const pass = + received instanceof TestEffect && + received.name === name && + received.key === key; + return {pass}; + }, + effectCleanup(received, name, key) { + const pass = + received instanceof TestEffectCleanup && + received.name === name && + received.key === key; + return {pass}; + }, +}); + +function mockEffectRegistry(): { + mockEffect: string => () => () => void, + mockEffectWithoutCleanup: string => () => void, + registry: $ReadOnlyArray, +} { + const registry = []; + return { + mockEffect(name: string): () => () => void { + return instance => { + const key = instance?.props?.testID; + registry.push(new TestEffect(name, key)); + return () => { + registry.push(new TestEffectCleanup(name, key)); + }; + }; + }, + mockEffectWithoutCleanup(name: string): () => void { + return instance => { + const key = instance?.props?.testID; + registry.push(new TestEffect(name, key)); + }; + }, + registry, + }; +} + +test('calls effect without cleanup', () => { + let root; + + const {mockEffectWithoutCleanup, registry} = mockEffectRegistry(); + const effectA = mockEffectWithoutCleanup('A'); + + act(() => { + root = create(); + }); + + expect(registry).toEqual([TestEffect.called('A', 'foo')]); + + act(() => { + root.unmount(); + }); + + expect(registry).toEqual([TestEffect.called('A', 'foo')]); +}); + +test('calls effect and cleanup', () => { + let root; + + const {mockEffect, registry} = mockEffectRegistry(); + const effectA = mockEffect('A'); + + act(() => { + root = create(); + }); + + expect(registry).toEqual([TestEffect.called('A', 'foo')]); + + act(() => { + root.unmount(); + }); + + expect(registry).toEqual([ + TestEffect.called('A', 'foo'), + TestEffectCleanup.called('A', 'foo'), + ]); +}); + +test('cleans up old effect before calling new effect', () => { + let root; + + const {mockEffect, registry} = mockEffectRegistry(); + const effectA = mockEffect('A'); + const effectB = mockEffect('B'); + + act(() => { + root = create(); + }); + + act(() => { + root.update(); + }); + + expect(registry).toEqual([ + TestEffect.called('A', 'foo'), + TestEffectCleanup.called('A', 'foo'), + TestEffect.called('B', 'foo'), + ]); + + act(() => { + root.unmount(); + }); + + expect(registry).toEqual([ + TestEffect.called('A', 'foo'), + TestEffectCleanup.called('A', 'foo'), + TestEffect.called('B', 'foo'), + TestEffectCleanup.called('B', 'foo'), + ]); +}); + +test('calls cleanup and effect on new instance', () => { + let root; + + const {mockEffect, registry} = mockEffectRegistry(); + const effectA = mockEffect('A'); + + act(() => { + root = create(); + }); + + act(() => { + root.update(); + }); + + expect(registry).toEqual([ + TestEffect.called('A', 'foo'), + TestEffectCleanup.called('A', 'foo'), + TestEffect.called('A', 'bar'), + ]); + + act(() => { + root.unmount(); + }); + + expect(registry).toEqual([ + TestEffect.called('A', 'foo'), + TestEffectCleanup.called('A', 'foo'), + TestEffect.called('A', 'bar'), + TestEffectCleanup.called('A', 'bar'), + ]); +}); + +test('cleans up old effect before calling new effect with new instance', () => { + let root; + + const {mockEffect, registry} = mockEffectRegistry(); + const effectA = mockEffect('A'); + const effectB = mockEffect('B'); + + act(() => { + root = create(); + }); + + act(() => { + root.update(); + }); + + expect(registry).toEqual([ + TestEffect.called('A', 'foo'), + TestEffectCleanup.called('A', 'foo'), + TestEffect.called('B', 'bar'), + ]); + + act(() => { + root.unmount(); + }); + + expect(registry).toEqual([ + TestEffect.called('A', 'foo'), + TestEffectCleanup.called('A', 'foo'), + TestEffect.called('B', 'bar'), + TestEffectCleanup.called('B', 'bar'), + ]); +}); diff --git a/Libraries/Utilities/useRefEffect.js b/Libraries/Utilities/useRefEffect.js new file mode 100644 index 00000000000000..6362b683fdde7d --- /dev/null +++ b/Libraries/Utilities/useRefEffect.js @@ -0,0 +1,45 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +import {useCallback, useRef} from 'react'; + +type CallbackRef = T => mixed; + +/** + * Constructs a callback ref that provides similar semantics as `useEffect`. The + * supplied `effect` callback will be called with non-null component instances. + * The `effect` callback can also optionally return a cleanup function. + * + * When a component is updated or unmounted, the cleanup function is called. The + * `effect` callback will then be called again, if applicable. + * + * When a new `effect` callback is supplied, the previously returned cleanup + * function will be called before the new `effect` callback is called with the + * same instance. + * + * WARNING: The `effect` callback should be stable (e.g. using `useCallback`). + */ +export default function useRefEffect( + effect: TInstance => (() => void) | void, +): CallbackRef { + const cleanupRef = useRef<(() => void) | void>(undefined); + return useCallback( + instance => { + if (cleanupRef.current) { + cleanupRef.current(); + cleanupRef.current = undefined; + } + if (instance != null) { + cleanupRef.current = effect(instance); + } + }, + [effect], + ); +}