From 6e33440a44321fd79da45e7fa01e5d06453f6635 Mon Sep 17 00:00:00 2001 From: Rick Hanlon Date: Mon, 4 Nov 2024 16:54:52 -0500 Subject: [PATCH] wip fire --- .../react-reconciler/src/ReactFiberHooks.js | 143 +++ .../src/__tests__/useFire-test.js | 853 ++++++++++++++++++ packages/react/compiler-runtime.js | 1 + packages/react/index.fb.js | 2 + packages/react/src/ReactHooks.js | 6 + packages/shared/ReactFeatureFlags.js | 1 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + ...actFeatureFlags.test-renderer.native-fb.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../forks/ReactFeatureFlags.www-dynamic.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 13 files changed, 1013 insertions(+) create mode 100644 packages/react-reconciler/src/__tests__/useFire-test.js diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 01f3b400ad..84f13582cd 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -48,6 +48,7 @@ import { disableLegacyMode, enableNoCloningMemoCache, enableContextProfiling, + enableFire, } from 'shared/ReactFeatureFlags'; import { REACT_CONTEXT_TYPE, @@ -2693,6 +2694,60 @@ function updateEvent) => Return>( }; } +function useFireImpl) => Return>( + payload: EventFunctionPayload, +) { + currentlyRenderingFiber.flags |= UpdateEffect; + let componentUpdateQueue: null | FunctionComponentUpdateQueue = + (currentlyRenderingFiber.updateQueue: any); + if (componentUpdateQueue === null) { + componentUpdateQueue = createFunctionComponentUpdateQueue(); + currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any); + componentUpdateQueue.events = [payload]; + } else { + const events = componentUpdateQueue.events; + if (events === null) { + componentUpdateQueue.events = [payload]; + } else { + events.push(payload); + } + } +} + +function mountFire) => Return>( + callback: F, +): F { + const hook = mountWorkInProgressHook(); + const ref = {impl: callback}; + hook.memoizedState = ref; + // $FlowIgnore[incompatible-return] + return function eventFn() { + if (isInvalidExecutionContextForEventFunction()) { + throw new Error( + "A function wrapped in useFire can't be called during rendering.", + ); + } + return ref.impl.apply(undefined, arguments); + }; +} + +function updateFire) => Return>( + callback: F, +): F { + const hook = updateWorkInProgressHook(); + const ref = hook.memoizedState; + useFireImpl({ref, nextImpl: callback}); + // $FlowIgnore[incompatible-return] + return function eventFn() { + if (isInvalidExecutionContextForEventFunction()) { + throw new Error( + "A function wrapped in useFire can't be called during rendering.", + ); + } + return ref.impl.apply(undefined, arguments); + }; +} + function mountInsertionEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -3789,6 +3844,9 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (ContextOnlyDispatcher: Dispatcher).useEffectEvent = throwInvalidHookError; } +if (enableFire) { + (ContextOnlyDispatcher: Dispatcher).useFire = throwInvalidHookError; +} if (enableAsyncActions) { (ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus = throwInvalidHookError; @@ -3832,6 +3890,9 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (HooksDispatcherOnMount: Dispatcher).useEffectEvent = mountEvent; } +if (enableFire) { + (HooksDispatcherOnMount: Dispatcher).useFire = mountFire; +} if (enableAsyncActions) { (HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -3875,6 +3936,9 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent; } +if (enableFire) { + (HooksDispatcherOnUpdate: Dispatcher).useFire = updateFire; +} if (enableAsyncActions) { (HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -3918,6 +3982,9 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (HooksDispatcherOnRerender: Dispatcher).useEffectEvent = updateEvent; } +if (enableFire) { + (HooksDispatcherOnRerender: Dispatcher).useFire = updateFire; +} if (enableAsyncActions) { (HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -4108,6 +4175,17 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableFire) { + (HooksDispatcherOnMountInDEV: Dispatcher).useFire = function useFire< + Args, + Return, + F: (...Array) => Return, + >(callback: F): F { + currentHookNameInDev = 'useFire'; + mountHookTypesDev(); + return mountFire(callback); + }; + } if (enableAsyncActions) { (HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -4300,6 +4378,16 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableFire) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useFire = + function useFire) => Return>( + callback: F, + ): F { + currentHookNameInDev = 'useFire'; + updateHookTypesDev(); + return mountFire(callback); + }; + } if (enableAsyncActions) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -4491,6 +4579,17 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableFire) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).useFire = function useFire< + Args, + Return, + F: (...Array) => Return, + >(callback: F): F { + currentHookNameInDev = 'useFire'; + updateHookTypesDev(); + return updateFire(callback); + }; + } if (enableAsyncActions) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -4682,6 +4781,17 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableFire) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).useFire = function useFire< + Args, + Return, + F: (...Array) => Return, + >(callback: F): F { + currentHookNameInDev = 'useFire'; + updateHookTypesDev(); + return updateFire(callback); + }; + } if (enableAsyncActions) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -4897,6 +5007,17 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableFire) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useFire = + function useFire) => Return>( + callback: F, + ): F { + currentHookNameInDev = 'useFire'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountFire(callback); + }; + } if (enableAsyncActions) { (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -5115,6 +5236,17 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableFire) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useFire = + function useFire) => Return>( + callback: F, + ): F { + currentHookNameInDev = 'useFire'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateFire(callback); + }; + } if (enableAsyncActions) { (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -5333,6 +5465,17 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableFire) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useFire = + function useFire) => Return>( + callback: F, + ): F { + currentHookNameInDev = 'useFire'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateFire(callback); + }; + } if (enableAsyncActions) { (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; diff --git a/packages/react-reconciler/src/__tests__/useFire-test.js b/packages/react-reconciler/src/__tests__/useFire-test.js new file mode 100644 index 0000000000..c4fb8f53a9 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/useFire-test.js @@ -0,0 +1,853 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment node + */ + +'use strict'; + +import {useInsertionEffect} from 'react'; + +describe('useFire', () => { + let React; + let ReactNoop; + let Scheduler; + let act; + let createContext; + let useContext; + let useState; + let useFire; + let useEffect; + let useLayoutEffect; + let useMemo; + let waitForAll; + let assertLog; + let waitForThrow; + + beforeEach(() => { + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + + act = require('internal-test-utils').act; + createContext = React.createContext; + useContext = React.useContext; + useState = React.useState; + useFire = require('react/compiler-runtime').f; + useEffect = React.useEffect; + useLayoutEffect = React.useLayoutEffect; + useMemo = React.useMemo; + + const InternalTestUtils = require('internal-test-utils'); + waitForAll = InternalTestUtils.waitForAll; + assertLog = InternalTestUtils.assertLog; + waitForThrow = InternalTestUtils.waitForThrow; + }); + + function Text(props) { + Scheduler.log(props.text); + return ; + } + + // @gate enableUseEffectEventHook + it('memoizes basic case correctly', async () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.onClick(); + }; + render() { + return ; + } + } + + function Counter({incrementBy}) { + const [count, updateCount] = useState(0); + const onClick = useFire(() => updateCount(c => c + incrementBy)); + + return ( + <> + onClick()} ref={button} /> + + + ); + } + + const button = React.createRef(null); + ReactNoop.render(); + await waitForAll(['Increment', 'Count: 0']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + await act(() => button.current.increment()); + assertLog(['Increment', 'Count: 1']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + await act(() => button.current.increment()); + assertLog([ + 'Increment', + // Event should use the updated callback function closed over the new value. + 'Count: 2', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + // Increase the increment prop amount + ReactNoop.render(); + await waitForAll(['Increment', 'Count: 2']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + // Event uses the new prop + await act(() => button.current.increment()); + assertLog(['Increment', 'Count: 12']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + }); + + // @gate enableUseEffectEventHook + it('can be defined more than once', async () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.onClick(); + }; + multiply = () => { + this.props.onMouseEnter(); + }; + render() { + return ; + } + } + + function Counter({incrementBy}) { + const [count, updateCount] = useState(0); + const onClick = useFire(() => updateCount(c => c + incrementBy)); + const onMouseEnter = useFire(() => { + updateCount(c => c * incrementBy); + }); + + return ( + <> + onClick()} + onMouseEnter={() => onMouseEnter()} + ref={button} + /> + + + ); + } + + const button = React.createRef(null); + ReactNoop.render(); + await waitForAll(['Increment', 'Count: 0']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + await act(() => button.current.increment()); + assertLog(['Increment', 'Count: 5']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + await act(() => button.current.multiply()); + assertLog(['Increment', 'Count: 25']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + }); + + // @gate enableUseEffectEventHook + it('does not preserve `this` in event functions', async () => { + class GreetButton extends React.PureComponent { + greet = () => { + this.props.onClick(); + }; + render() { + return ; + } + } + function Greeter({hello}) { + const person = { + toString() { + return 'Jane'; + }, + greet() { + return updateGreeting(this + ' says ' + hello); + }, + }; + const [greeting, updateGreeting] = useState('Seb says ' + hello); + const onClick = useFire(person.greet); + + return ( + <> + onClick()} ref={button} /> + + + ); + } + + const button = React.createRef(null); + ReactNoop.render(); + await waitForAll(['Say hej', 'Greeting: Seb says hej']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + await act(() => button.current.greet()); + assertLog(['Say hej', 'Greeting: undefined says hej']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + }); + + // @gate enableUseEffectEventHook + it('throws when called in render', async () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.onClick(); + }; + + render() { + // Will throw. + this.props.onClick(); + + return ; + } + } + + function Counter({incrementBy}) { + const [count, updateCount] = useState(0); + const onClick = useFire(() => updateCount(c => c + incrementBy)); + + return ( + <> + onClick()} /> + + + ); + } + + ReactNoop.render(); + await waitForThrow( + "A function wrapped in useFire can't be called during rendering.", + ); + assertLog([]); + }); + + // @gate enableUseEffectEventHook + it("useLayoutEffect shouldn't re-fire when event handlers change", async () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.onClick(); + }; + render() { + return ; + } + } + + function Counter({incrementBy}) { + const [count, updateCount] = useState(0); + const increment = useFire(amount => + updateCount(c => c + (amount || incrementBy)), + ); + + useLayoutEffect(() => { + Scheduler.log('Effect: by ' + incrementBy * 2); + increment(incrementBy * 2); + }, [incrementBy]); + + return ( + <> + increment()} ref={button} /> + + + ); + } + + const button = React.createRef(null); + ReactNoop.render(); + assertLog([]); + await waitForAll([ + 'Increment', + 'Count: 0', + 'Effect: by 2', + 'Increment', + 'Count: 2', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + await act(() => button.current.increment()); + assertLog([ + 'Increment', + // Effect should not re-run because the dependency hasn't changed. + 'Count: 3', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + await act(() => button.current.increment()); + assertLog([ + 'Increment', + // Event should use the updated callback function closed over the new value. + 'Count: 4', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + // Increase the increment prop amount + ReactNoop.render(); + await waitForAll([ + 'Increment', + 'Count: 4', + 'Effect: by 20', + 'Increment', + 'Count: 24', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + // Event uses the new prop + await act(() => button.current.increment()); + assertLog(['Increment', 'Count: 34']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + }); + + // @gate enableUseEffectEventHook + it("useEffect shouldn't re-fire when event handlers change", async () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.onClick(); + }; + render() { + return ; + } + } + + function Counter({incrementBy}) { + const [count, updateCount] = useState(0); + const increment = useFire(amount => + updateCount(c => c + (amount || incrementBy)), + ); + + useEffect(() => { + Scheduler.log('Effect: by ' + incrementBy * 2); + increment(incrementBy * 2); + }, [incrementBy]); + + return ( + <> + increment()} ref={button} /> + + + ); + } + + const button = React.createRef(null); + ReactNoop.render(); + await waitForAll([ + 'Increment', + 'Count: 0', + 'Effect: by 2', + 'Increment', + 'Count: 2', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + await act(() => button.current.increment()); + assertLog([ + 'Increment', + // Effect should not re-run because the dependency hasn't changed. + 'Count: 3', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + await act(() => button.current.increment()); + assertLog([ + 'Increment', + // Event should use the updated callback function closed over the new value. + 'Count: 4', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + // Increase the increment prop amount + ReactNoop.render(); + await waitForAll([ + 'Increment', + 'Count: 4', + 'Effect: by 20', + 'Increment', + 'Count: 24', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + // Event uses the new prop + await act(() => button.current.increment()); + assertLog(['Increment', 'Count: 34']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + }); + + // @gate enableUseEffectEventHook + it('is stable in a custom hook', async () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.onClick(); + }; + render() { + return ; + } + } + + function useCount(incrementBy) { + const [count, updateCount] = useState(0); + const increment = useFire(amount => + updateCount(c => c + (amount || incrementBy)), + ); + + return [count, increment]; + } + + function Counter({incrementBy}) { + const [count, increment] = useCount(incrementBy); + + useEffect(() => { + Scheduler.log('Effect: by ' + incrementBy * 2); + increment(incrementBy * 2); + }, [incrementBy]); + + return ( + <> + increment()} ref={button} /> + + + ); + } + + const button = React.createRef(null); + ReactNoop.render(); + await waitForAll([ + 'Increment', + 'Count: 0', + 'Effect: by 2', + 'Increment', + 'Count: 2', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + await act(() => button.current.increment()); + assertLog([ + 'Increment', + // Effect should not re-run because the dependency hasn't changed. + 'Count: 3', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + await act(() => button.current.increment()); + assertLog([ + 'Increment', + // Event should use the updated callback function closed over the new value. + 'Count: 4', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + // Increase the increment prop amount + ReactNoop.render(); + await waitForAll([ + 'Increment', + 'Count: 4', + 'Effect: by 20', + 'Increment', + 'Count: 24', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + // Event uses the new prop + await act(() => button.current.increment()); + assertLog(['Increment', 'Count: 34']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + }); + + // @gate enableUseEffectEventHook + it('is mutated before all other effects', async () => { + function Counter({value}) { + useInsertionEffect(() => { + Scheduler.log('Effect value: ' + value); + increment(); + }, [value]); + + // This is defined after the insertion effect, but it should + // update the event fn _before_ the insertion effect fires. + const increment = useFire(() => { + Scheduler.log('Event value: ' + value); + }); + + return <>; + } + + ReactNoop.render(); + await waitForAll(['Effect value: 1', 'Event value: 1']); + + await act(() => ReactNoop.render()); + assertLog(['Effect value: 2', 'Event value: 2']); + }); + + // @gate enableUseEffectEventHook + it("doesn't provide a stable identity", async () => { + function Counter({shouldRender, value}) { + const onClick = useFire(() => { + Scheduler.log( + 'onClick, shouldRender=' + shouldRender + ', value=' + value, + ); + }); + + // onClick doesn't have a stable function identity so this effect will fire on every render. + // In a real app useFire functions should *not* be passed as a dependency, this is for + // testing purposes only. + useEffect(() => { + onClick(); + }, [onClick]); + + useEffect(() => { + onClick(); + }, [shouldRender]); + + return <>; + } + + ReactNoop.render(); + await waitForAll([ + 'onClick, shouldRender=true, value=0', + 'onClick, shouldRender=true, value=0', + ]); + + ReactNoop.render(); + await waitForAll(['onClick, shouldRender=true, value=1']); + + ReactNoop.render(); + await waitForAll([ + 'onClick, shouldRender=false, value=2', + 'onClick, shouldRender=false, value=2', + ]); + }); + + // @gate enableUseEffectEventHook + it('event handlers always see the latest committed value', async () => { + let committedEventHandler = null; + + function App({value}) { + const event = useFire(() => { + return 'Value seen by useFire: ' + value; + }); + + // Set up an effect that registers the event handler with an external + // event system (e.g. addEventListener). + useEffect( + () => { + // Log when the effect fires. In the test below, we'll assert that this + // only happens during initial render, not during updates. + Scheduler.log('Commit new event handler'); + committedEventHandler = event; + return () => { + committedEventHandler = null; + }; + }, + // Note that we've intentionally omitted the event from the dependency + // array. But it will still be able to see the latest `value`. This is the + // key feature of useFire that makes it different from a regular closure. + [], + ); + return 'Latest rendered value ' + value; + } + + // Initial render + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + assertLog(['Commit new event handler']); + expect(root).toMatchRenderedOutput('Latest rendered value 1'); + expect(committedEventHandler()).toBe('Value seen by useFire: 1'); + + // Update + await act(() => { + root.render(); + }); + // No new event handler should be committed, because it was omitted from + // the dependency array. + assertLog([]); + // But the event handler should still be able to see the latest value. + expect(root).toMatchRenderedOutput('Latest rendered value 2'); + expect(committedEventHandler()).toBe('Value seen by useFire: 2'); + }); + + // @gate enableUseEffectEventHook + it('integration: implements docs chat room example', async () => { + function createConnection() { + let connectedCallback; + let timeout; + return { + connect() { + timeout = setTimeout(() => { + if (connectedCallback) { + connectedCallback(); + } + }, 100); + }, + on(event, callback) { + if (connectedCallback) { + throw Error('Cannot add the handler twice.'); + } + if (event !== 'connected') { + throw Error('Only "connected" event is supported.'); + } + connectedCallback = callback; + }, + disconnect() { + clearTimeout(timeout); + }, + }; + } + + function ChatRoom({roomId, theme}) { + const onConnected = useFire(() => { + Scheduler.log('Connected! theme: ' + theme); + }); + + useEffect(() => { + const connection = createConnection(roomId); + connection.on('connected', () => { + onConnected(); + }); + connection.connect(); + return () => connection.disconnect(); + }, [roomId]); + + return ; + } + + await act(() => + ReactNoop.render(), + ); + + assertLog(['Welcome to the general room!', 'Connected! theme: light']); + expect(ReactNoop).toMatchRenderedOutput( + , + ); + + // change roomId only + await act(() => + ReactNoop.render(), + ); + assertLog([ + 'Welcome to the music room!', + // should trigger a reconnect + 'Connected! theme: light', + ]); + expect(ReactNoop).toMatchRenderedOutput( + , + ); + + // change theme only + await act(() => ReactNoop.render()); + // should not trigger a reconnect + assertLog(['Welcome to the music room!']); + expect(ReactNoop).toMatchRenderedOutput( + , + ); + + // change roomId only + await act(() => + ReactNoop.render(), + ); + assertLog([ + 'Welcome to the travel room!', + // should trigger a reconnect + 'Connected! theme: dark', + ]); + expect(ReactNoop).toMatchRenderedOutput( + , + ); + }); + + // @gate enableUseEffectEventHook + it('integration: implements the docs logVisit example', async () => { + class AddToCartButton extends React.PureComponent { + addToCart = () => { + this.props.onClick(); + }; + render() { + return ; + } + } + const ShoppingCartContext = createContext(null); + + function AppShell({children}) { + const [items, updateItems] = useState([]); + const value = useMemo(() => ({items, updateItems}), [items, updateItems]); + + return ( + + {children} + + ); + } + + function Page({url}) { + const {items, updateItems} = useContext(ShoppingCartContext); + const onClick = useFire(() => updateItems([...items, 1])); + const numberOfItems = items.length; + + const onVisit = useFire(visitedUrl => { + Scheduler.log( + 'url: ' + visitedUrl + ', numberOfItems: ' + numberOfItems, + ); + }); + + useEffect(() => { + onVisit(url); + }, [url]); + + return ( + { + onClick(); + }} + ref={button} + /> + ); + } + + const button = React.createRef(null); + await act(() => + ReactNoop.render( + + + , + ), + ); + assertLog(['Add to cart', 'url: /shop/1, numberOfItems: 0']); + await act(() => button.current.addToCart()); + assertLog(['Add to cart']); + + await act(() => + ReactNoop.render( + + + , + ), + ); + assertLog(['Add to cart', 'url: /shop/2, numberOfItems: 1']); + }); +}); diff --git a/packages/react/compiler-runtime.js b/packages/react/compiler-runtime.js index 7ff68cd6fa..29892e1120 100644 --- a/packages/react/compiler-runtime.js +++ b/packages/react/compiler-runtime.js @@ -8,3 +8,4 @@ */ export {useMemoCache as c} from './src/ReactHooks'; +export {useFire as f} from './src/ReactHooks'; diff --git a/packages/react/index.fb.js b/packages/react/index.fb.js index 7999655f30..ad86c20461 100644 --- a/packages/react/index.fb.js +++ b/packages/react/index.fb.js @@ -67,3 +67,5 @@ export {useMemoCache as unstable_useMemoCache} from './src/ReactHooks'; // export to match the name of the OSS function typically exported from // react/compiler-runtime export {useMemoCache as c} from './src/ReactHooks'; + +export const fire: () => void = () => {}; diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 956a2a96b4..d5be20ef6f 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -226,6 +226,12 @@ export function useEffectEvent) => mixed>( return dispatcher.useEffectEvent(callback); } +export function useFire) => mixed>(callback: F): F { + const dispatcher = resolveDispatcher(); + // $FlowFixMe[not-a-function] This is unstable, thus optional + return dispatcher.useFire(callback); +} + export function useOptimistic( passthrough: S, reducer: ?(S, A) => S, diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index a0722c3138..34833785bf 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -123,6 +123,7 @@ export const enableUseMemoCacheHook = true; export const enableNoCloningMemoCache = false; export const enableUseEffectEventHook = __EXPERIMENTAL__; +export const enableFire = false; // Test in www before enabling in open source. // Enables DOM-server to stream its instruction set as data-attributes diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index b4339c50a5..1be7a96abe 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -94,6 +94,7 @@ export const retryLaneExpirationMs = 5000; export const syncLaneExpirationMs = 250; export const transitionLaneExpirationMs = 5000; export const useModernStrictMode = true; +export const enableFire = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index ea670690a5..d3bde91bdb 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -86,6 +86,7 @@ export const syncLaneExpirationMs = 250; export const transitionLaneExpirationMs = 5000; export const useModernStrictMode = true; export const enableSiblingPrerendering = false; +export const enableFire = false; // Profiling Only export const enableProfilerTimer = __PROFILE__; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 2eb241af6c..e3dee8a59d 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -101,6 +101,7 @@ export const disableDefaultPropsExceptForClasses = true; export const enableObjectFiber = false; export const enableOwnerStacks = false; +export const enableFire = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 1d505aaf56..2ff07c540c 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -81,6 +81,7 @@ export const transitionLaneExpirationMs = 5000; export const useModernStrictMode = true; export const enableFabricCompleteRootInCommitPhase = false; export const enableSiblingPrerendering = false; +export const enableFire = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 5c836b48fe..59ef80fc7f 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -96,6 +96,7 @@ export const enableObjectFiber = false; export const enableOwnerStacks = false; export const enableShallowPropDiffing = false; export const enableSiblingPrerendering = false; +export const enableFire = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index d4102efb06..f300ae5d2c 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -33,6 +33,7 @@ export const renameElementSymbol = __VARIANT__; export const retryLaneExpirationMs = 5000; export const syncLaneExpirationMs = 250; export const transitionLaneExpirationMs = 5000; +export const enableFire = __VARIANT__; // Enable this flag to help with concurrent mode debugging. // It logs information to the console about React scheduling, rendering, and commit phases. diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index b86394ecf7..219cfb8199 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -38,6 +38,7 @@ export const { retryLaneExpirationMs, syncLaneExpirationMs, transitionLaneExpirationMs, + enableFire, } = dynamicFeatureFlags; // On WWW, __EXPERIMENTAL__ is used for a new modern build.