From be67db46b60d94f9fbefccf2523429af25873e5b Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 10 Oct 2023 16:39:02 -0400 Subject: [PATCH] Add optional initialValue argument to useDeferredValue (#27500) Adds a second argument to useDeferredValue called initialValue: ```js const value = useDeferredValue(finalValue, initialValue); ``` During the initial render of a component, useDeferredValue will return initialValue. Once that render finishes, it will spawn an additional render to switch to finalValue. This same sequence should occur whenever the hook is hidden and revealed again, i.e. by a Suspense or Activity, though this part is not yet implemented. When initialValue is not provided, useDeferredValue has no effect during initial render, but during an update, it will remain on the previous value, then spawn an additional render to switch to the new value. (This is the same behavior that exists today.) During SSR, initialValue is always used, if provided. This feature is currently behind an experimental flag. We plan to ship it in a non-breaking release. --- .../react-debug-tools/src/ReactDebugHooks.js | 2 +- .../ReactHooksInspectionIntegration-test.js | 4 +- .../ReactDOMFizzDeferredValue-test.js | 71 ++++++++++++++++ .../react-reconciler/src/ReactFiberHooks.js | 85 +++++++++++++------ .../src/ReactInternalTypes.js | 2 +- .../src/__tests__/ReactDeferredValue-test.js | 35 ++++++++ .../ReactHooksWithNoopRenderer-test.js | 4 +- packages/react-server/src/ReactFizzHooks.js | 9 +- packages/react/src/ReactHooks.js | 4 +- packages/shared/ReactFeatureFlags.js | 2 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + .../ReactFeatureFlags.test-renderer.native.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../forks/ReactFeatureFlags.www-dynamic.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 17 files changed, 189 insertions(+), 36 deletions(-) create mode 100644 packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 6c8acd1e2f..2d75ff127e 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -306,7 +306,7 @@ function useTransition(): [ return [false, callback => {}]; } -function useDeferredValue(value: T): T { +function useDeferredValue(value: T, initialValue?: T): T { const hook = nextHook(); hookLog.push({ primitive: 'DeferredValue', diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js index 7283f3af72..3d5cd38f82 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js @@ -573,9 +573,7 @@ describe('ReactHooksInspectionIntegration', () => { it('should support useDeferredValue hook', () => { function Foo(props) { - React.useDeferredValue('abc', { - timeoutMs: 500, - }); + React.useDeferredValue('abc'); const memoizedValue = React.useMemo(() => 1, []); React.useMemo(() => 2, []); return
{memoizedValue}
; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js new file mode 100644 index 0000000000..4f8a4d98d6 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js @@ -0,0 +1,71 @@ +/** + * 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 + */ + +'use strict'; + +import {insertNodesAndExecuteScripts} from '../test-utils/FizzTestUtils'; + +// Polyfills for test environment +global.ReadableStream = + require('web-streams-polyfill/ponyfill/es6').ReadableStream; +global.TextEncoder = require('util').TextEncoder; + +let act; +let container; +let React; +let ReactDOMServer; +let ReactDOMClient; +let useDeferredValue; + +describe('ReactDOMFizzForm', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOMServer = require('react-dom/server.browser'); + ReactDOMClient = require('react-dom/client'); + useDeferredValue = require('react').useDeferredValue; + act = require('internal-test-utils').act; + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + async function readIntoContainer(stream) { + const reader = stream.getReader(); + let result = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + break; + } + result += Buffer.from(value).toString('utf8'); + } + const temp = document.createElement('div'); + temp.innerHTML = result; + insertNodesAndExecuteScripts(temp, container, null); + } + + // @gate enableUseDeferredValueInitialArg + it('returns initialValue argument, if provided', async () => { + function App() { + return useDeferredValue('Final', 'Initial'); + } + + const stream = await ReactDOMServer.renderToReadableStream(); + await readIntoContainer(stream); + expect(container.textContent).toEqual('Initial'); + + // After hydration, it's updated to the final value + await act(() => ReactDOMClient.hydrateRoot(container, )); + expect(container.textContent).toEqual('Final'); + }); +}); diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index ae4b68b592..e0f5d268bc 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -41,6 +41,7 @@ import { debugRenderPhaseSideEffectsForStrictMode, enableAsyncActions, enableFormActions, + enableUseDeferredValueInitialArg, } from 'shared/ReactFeatureFlags'; import { REACT_CONTEXT_TYPE, @@ -2638,33 +2639,69 @@ function updateMemo( return nextValue; } -function mountDeferredValue(value: T): T { +function mountDeferredValue(value: T, initialValue?: T): T { const hook = mountWorkInProgressHook(); - hook.memoizedState = value; - return value; + return mountDeferredValueImpl(hook, value, initialValue); } -function updateDeferredValue(value: T): T { +function updateDeferredValue(value: T, initialValue?: T): T { const hook = updateWorkInProgressHook(); const resolvedCurrentHook: Hook = (currentHook: any); const prevValue: T = resolvedCurrentHook.memoizedState; - return updateDeferredValueImpl(hook, prevValue, value); + return updateDeferredValueImpl(hook, prevValue, value, initialValue); } -function rerenderDeferredValue(value: T): T { +function rerenderDeferredValue(value: T, initialValue?: T): T { const hook = updateWorkInProgressHook(); if (currentHook === null) { // This is a rerender during a mount. - hook.memoizedState = value; - return value; + return mountDeferredValueImpl(hook, value, initialValue); } else { // This is a rerender during an update. const prevValue: T = currentHook.memoizedState; - return updateDeferredValueImpl(hook, prevValue, value); + return updateDeferredValueImpl(hook, prevValue, value, initialValue); } } -function updateDeferredValueImpl(hook: Hook, prevValue: T, value: T): T { +function mountDeferredValueImpl(hook: Hook, value: T, initialValue?: T): T { + if (enableUseDeferredValueInitialArg && initialValue !== undefined) { + // When `initialValue` is provided, we defer the initial render even if the + // current render is not synchronous. + // TODO: However, to avoid waterfalls, we should not defer if this render + // was itself spawned by an earlier useDeferredValue. Plan is to add a + // Deferred lane to track this. + hook.memoizedState = initialValue; + + // Schedule a deferred render + const deferredLane = claimNextTransitionLane(); + currentlyRenderingFiber.lanes = mergeLanes( + currentlyRenderingFiber.lanes, + deferredLane, + ); + markSkippedUpdateLanes(deferredLane); + + // Set this to true to indicate that the rendered value is inconsistent + // from the latest value. The name "baseState" doesn't really match how we + // use it because we're reusing a state hook field instead of creating a + // new one. + hook.baseState = true; + + return initialValue; + } else { + hook.memoizedState = value; + return value; + } +} + +function updateDeferredValueImpl( + hook: Hook, + prevValue: T, + value: T, + initialValue: ?T, +): T { + // TODO: We should also check if this component is going from + // hidden -> visible. If so, it should use the initialValue arg. + const shouldDeferValue = !includesOnlyNonUrgentLanes(renderLanes); if (shouldDeferValue) { // This is an urgent update. If the value has changed, keep using the @@ -3633,10 +3670,10 @@ if (__DEV__) { mountHookTypesDev(); return mountDebugValue(value, formatterFn); }, - useDeferredValue(value: T): T { + useDeferredValue(value: T, initialValue?: T): T { currentHookNameInDev = 'useDeferredValue'; mountHookTypesDev(); - return mountDeferredValue(value); + return mountDeferredValue(value, initialValue); }, useTransition(): [boolean, (() => void) => void] { currentHookNameInDev = 'useTransition'; @@ -3802,10 +3839,10 @@ if (__DEV__) { updateHookTypesDev(); return mountDebugValue(value, formatterFn); }, - useDeferredValue(value: T): T { + useDeferredValue(value: T, initialValue?: T): T { currentHookNameInDev = 'useDeferredValue'; updateHookTypesDev(); - return mountDeferredValue(value); + return mountDeferredValue(value, initialValue); }, useTransition(): [boolean, (() => void) => void] { currentHookNameInDev = 'useTransition'; @@ -3975,10 +4012,10 @@ if (__DEV__) { updateHookTypesDev(); return updateDebugValue(value, formatterFn); }, - useDeferredValue(value: T): T { + useDeferredValue(value: T, initialValue?: T): T { currentHookNameInDev = 'useDeferredValue'; updateHookTypesDev(); - return updateDeferredValue(value); + return updateDeferredValue(value, initialValue); }, useTransition(): [boolean, (() => void) => void] { currentHookNameInDev = 'useTransition'; @@ -4147,10 +4184,10 @@ if (__DEV__) { updateHookTypesDev(); return updateDebugValue(value, formatterFn); }, - useDeferredValue(value: T): T { + useDeferredValue(value: T, initialValue?: T): T { currentHookNameInDev = 'useDeferredValue'; updateHookTypesDev(); - return rerenderDeferredValue(value); + return rerenderDeferredValue(value, initialValue); }, useTransition(): [boolean, (() => void) => void] { currentHookNameInDev = 'useTransition'; @@ -4331,11 +4368,11 @@ if (__DEV__) { mountHookTypesDev(); return mountDebugValue(value, formatterFn); }, - useDeferredValue(value: T): T { + useDeferredValue(value: T, initialValue?: T): T { currentHookNameInDev = 'useDeferredValue'; warnInvalidHookAccess(); mountHookTypesDev(); - return mountDeferredValue(value); + return mountDeferredValue(value, initialValue); }, useTransition(): [boolean, (() => void) => void] { currentHookNameInDev = 'useTransition'; @@ -4529,11 +4566,11 @@ if (__DEV__) { updateHookTypesDev(); return updateDebugValue(value, formatterFn); }, - useDeferredValue(value: T): T { + useDeferredValue(value: T, initialValue?: T): T { currentHookNameInDev = 'useDeferredValue'; warnInvalidHookAccess(); updateHookTypesDev(); - return updateDeferredValue(value); + return updateDeferredValue(value, initialValue); }, useTransition(): [boolean, (() => void) => void] { currentHookNameInDev = 'useTransition'; @@ -4727,11 +4764,11 @@ if (__DEV__) { updateHookTypesDev(); return updateDebugValue(value, formatterFn); }, - useDeferredValue(value: T): T { + useDeferredValue(value: T, initialValue?: T): T { currentHookNameInDev = 'useDeferredValue'; warnInvalidHookAccess(); updateHookTypesDev(); - return rerenderDeferredValue(value); + return rerenderDeferredValue(value, initialValue); }, useTransition(): [boolean, (() => void) => void] { currentHookNameInDev = 'useTransition'; diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index b603a9fec3..9378a41477 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -399,7 +399,7 @@ export type Dispatcher = { deps: Array | void | null, ): void, useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void, - useDeferredValue(value: T): T, + useDeferredValue(value: T, initialValue?: T): T, useTransition(): [ boolean, (callback: () => void, options?: StartTransitionOptions) => void, diff --git a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js index 54f4ad42f4..c29a9c4287 100644 --- a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js +++ b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js @@ -306,4 +306,39 @@ describe('ReactDeferredValue', () => { ); }); }); + + // @gate enableUseDeferredValueInitialArg + it('supports initialValue argument', async () => { + function App() { + const value = useDeferredValue('Final', 'Initial'); + return ; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + await waitForPaint(['Initial']); + expect(root).toMatchRenderedOutput('Initial'); + }); + assertLog(['Final']); + expect(root).toMatchRenderedOutput('Final'); + }); + + // @gate enableUseDeferredValueInitialArg + it('defers during initial render when initialValue is provided, even if render is not sync', async () => { + function App() { + const value = useDeferredValue('Final', 'Initial'); + return ; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + // Initial mount is a transition, but it should defer anyway + startTransition(() => root.render()); + await waitForPaint(['Initial']); + expect(root).toMatchRenderedOutput('Initial'); + }); + assertLog(['Final']); + expect(root).toMatchRenderedOutput('Final'); + }); }); diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js index db67dde0d7..89d150ed09 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js @@ -3584,9 +3584,7 @@ describe('ReactHooksWithNoopRenderer', () => { let _setText; function App() { const [text, setText] = useState('A'); - const deferredText = useDeferredValue(text, { - timeoutMs: 500, - }); + const deferredText = useDeferredValue(text); _setText = setText; return ( <> diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index c80ac1e3b6..63d1327b85 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -35,6 +35,7 @@ import { enableUseMemoCacheHook, enableAsyncActions, enableFormActions, + enableUseDeferredValueInitialArg, } from 'shared/ReactFeatureFlags'; import is from 'shared/objectIs'; import { @@ -553,9 +554,13 @@ function useSyncExternalStore( return getServerSnapshot(); } -function useDeferredValue(value: T): T { +function useDeferredValue(value: T, initialValue?: T): T { resolveCurrentlyRenderingComponent(); - return value; + if (enableUseDeferredValueInitialArg) { + return initialValue !== undefined ? initialValue : value; + } else { + return value; + } } function unsupportedStartTransition() { diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 112baab1ea..a2141cbc5d 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -181,9 +181,9 @@ export function useTransition(): [ return dispatcher.useTransition(); } -export function useDeferredValue(value: T): T { +export function useDeferredValue(value: T, initialValue?: T): T { const dispatcher = resolveDispatcher(); - return dispatcher.useDeferredValue(value); + return dispatcher.useDeferredValue(value, initialValue); } export function useId(): string { diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 5ca0e6e291..751028ab29 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -126,6 +126,8 @@ export const useMicrotasksForSchedulingInFabric = false; export const passChildrenWhenCloningPersistedNodes = false; +export const enableUseDeferredValueInitialArg = __EXPERIMENTAL__; + // ----------------------------------------------------------------------------- // Chopping Block // diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 99d7392719..c2aa5f9323 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -89,6 +89,7 @@ export const enableDO_NOT_USE_disableStrictPassiveEffect = false; export const enableFizzExternalRuntime = false; export const enableAsyncActions = false; +export const enableUseDeferredValueInitialArg = true; // 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 ee0b2d91d3..912d1b6851 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -80,6 +80,7 @@ export const alwaysThrottleRetries = true; export const useMicrotasksForSchedulingInFabric = false; export const passChildrenWhenCloningPersistedNodes = false; +export const enableUseDeferredValueInitialArg = __EXPERIMENTAL__; // 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.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 5d541fa394..70ea8e5204 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -80,6 +80,7 @@ export const alwaysThrottleRetries = true; export const useMicrotasksForSchedulingInFabric = false; export const passChildrenWhenCloningPersistedNodes = false; +export const enableUseDeferredValueInitialArg = __EXPERIMENTAL__; // 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.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index 23a44293ee..6ec32f3ef5 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -77,6 +77,7 @@ export const alwaysThrottleRetries = true; export const useMicrotasksForSchedulingInFabric = false; export const passChildrenWhenCloningPersistedNodes = false; +export const enableUseDeferredValueInitialArg = __EXPERIMENTAL__; // 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 121442de96..60b2373dcf 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -80,6 +80,7 @@ export const alwaysThrottleRetries = true; export const useMicrotasksForSchedulingInFabric = false; export const passChildrenWhenCloningPersistedNodes = false; +export const enableUseDeferredValueInitialArg = true; // 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 db3adeb56d..1fb76bf4b3 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -28,6 +28,7 @@ export const enableDeferRootSchedulingToMicrotask = __VARIANT__; export const enableAsyncActions = __VARIANT__; export const alwaysThrottleRetries = __VARIANT__; export const enableDO_NOT_USE_disableStrictPassiveEffect = __VARIANT__; +export const enableUseDeferredValueInitialArg = __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 a004364fa4..f3f036911d 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -31,6 +31,7 @@ export const { alwaysThrottleRetries, enableDO_NOT_USE_disableStrictPassiveEffect, disableSchedulerTimeoutInWorkLoop, + enableUseDeferredValueInitialArg, } = dynamicFeatureFlags; // On WWW, __EXPERIMENTAL__ is used for a new modern build.