From b441be7ed4afd8148b9174d3303c7c2e3c5ee4b7 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 13 Apr 2022 14:22:06 -0400 Subject: [PATCH] Add initialValue option to useDeferredValue Currently, useDeferredValue only works for updates. It will never during the initial render because there's no previous value to reuse. This means it can't be used to implement progressive enhancement. This adds an optional initialValue argument to useDeferredValue. When provided, the initial mount will use initialValue if it's during an urgent render. Otherwise it will use the latest, canonical value. During server rendering and hydration, it will always use the initialValue instead of the canonical value, regardless of priority, to avoid a hydration mismatch. The name "initial value" isn't ideal because during a non-urgent client render, it's disregarded entirely. It's more like a "lightweight" value that will later be upgraded to a "heavier" one. Needs some bikeshedding. When initialValue is omitted, the behavior is the same as today. --- .../react-debug-tools/src/ReactDebugHooks.js | 2 +- .../src/__tests__/ReactDOMFizzServer-test.js | 38 ++++++++++ .../ReactDOMServerIntegrationHooks-test.js | 28 ++++++++ .../src/server/ReactPartialRendererHooks.js | 4 +- .../src/ReactFiberHooks.new.js | 69 +++++++++++++------ .../src/ReactFiberHooks.old.js | 69 +++++++++++++------ .../src/ReactInternalTypes.js | 2 +- .../src/__tests__/ReactDeferredValue-test.js | 47 +++++++++++++ .../ReactHooksWithNoopRenderer-test.js | 4 +- packages/react-server/src/ReactFizzHooks.js | 4 +- packages/react/src/ReactHooks.js | 4 +- 11 files changed, 218 insertions(+), 53 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 3657ed2db059a..d5548d74b159c 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -309,7 +309,7 @@ function useTransition(): [ return [false, callback => {}]; } -function useDeferredValue(value: T): T { +function useDeferredValue(value: T, initialValue?: T): T { // useDeferredValue() composes multiple hooks internally. // Advance the current hook index the same number of times // so that subsequent hooks have the right memoized state. diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index bf5388597571e..ebf97d12f089e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -19,6 +19,7 @@ let Suspense; let SuspenseList; let useSyncExternalStore; let useSyncExternalStoreWithSelector; +let useDeferredValue; let PropTypes; let textCache; let window; @@ -61,6 +62,7 @@ describe('ReactDOMFizzServer', () => { useSyncExternalStore = React.useSyncExternalStore; useSyncExternalStoreWithSelector = require('use-sync-external-store/with-selector') .useSyncExternalStoreWithSelector; + useDeferredValue = React.useDeferredValue; textCache = new Map(); @@ -3084,4 +3086,40 @@ describe('ReactDOMFizzServer', () => { expect(Scheduler).toFlushAndYield([]); }); + + // @gate experimental + it('useDeferredValue uses initialValue during hydration even if render is not urgent', async () => { + function Child() { + const value = useDeferredValue('Canonical', 'Initial'); + Scheduler.unstable_yieldValue(value); + return value; + } + + function App() { + // Because the child is wrapped in a Suspense boundary, it hydrates + // at a non-urgent priority. + return ( + + + + ); + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(Scheduler).toHaveYielded(['Initial']); + // The server always renders the initial value, not the canonical one. + expect(getVisibleChildren(container)).toEqual('Initial'); + + // When hydrating, it should use the initial value even if the hydration + // is (per usual) not urgent, to avoid a hydration mismatch. Then it does + // a deferred client render to switch to the canonical value. + // TODO: The deferred render should not be higher than the hydration itself, + // but currently it's always a transition. + ReactDOMClient.hydrateRoot(container, ); + expect(Scheduler).toFlushAndYield(['Initial', 'Canonical']); + expect(getVisibleChildren(container)).toEqual('Canonical'); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js index 114c522313a79..7d4eadb0aafa4 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js @@ -28,6 +28,7 @@ let useImperativeHandle; let useInsertionEffect; let useLayoutEffect; let useDebugValue; +let useDeferredValue; let forwardRef; let yieldedValues; let yieldValue; @@ -52,6 +53,7 @@ function initModules() { useImperativeHandle = React.useImperativeHandle; useInsertionEffect = React.useInsertionEffect; useLayoutEffect = React.useLayoutEffect; + useDeferredValue = React.useDeferredValue; forwardRef = React.forwardRef; yieldedValues = []; @@ -663,6 +665,32 @@ describe('ReactDOMServerHooks', () => { }); }); + describe('useDeferredValue', () => { + it('renders with initialValue, if provided', async () => { + function Counter() { + const value1 = useDeferredValue('Latest', 'Initial'); + const value2 = useDeferredValue('Latest'); + return ( +
+
{value1}
+
{value2}
+
+ ); + } + const domNode = await serverRender(, 1); + expect(domNode).toMatchInlineSnapshot(` +
+
+ Initial +
+
+ Latest +
+
+ `); + }); + }); + describe('useContext', () => { itThrowsWhenRendering( 'if used inside a class component', diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index 2940c47bde46d..319b2c60acc08 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -496,9 +496,9 @@ function useSyncExternalStore( return getServerSnapshot(); } -function useDeferredValue(value: T): T { +function useDeferredValue(value: T, initialValue?: T): T { resolveCurrentlyRenderingComponent(); - return value; + return initialValue !== undefined ? initialValue : value; } function useTransition(): [boolean, (callback: () => void) => void] { diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 1742d00ae1580..4b63bf9226e16 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -1927,25 +1927,23 @@ 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); } -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; @@ -1953,6 +1951,35 @@ function rerenderDeferredValue(value: T): T { } } +function mountDeferredValueImpl(hook: Hook, value: T, initialValue?: T): T { + // During hydration, if an initial value is provided, we always use that one + // regardless of the render priority. This means you can use it for + // progressive enhancement. Otherwise, we only use the initial value if the + // render is urgent — same logic as during an update. + if ( + (getIsHydrating() || !includesOnlyNonUrgentLanes(renderLanes)) && + initialValue !== undefined && + !is(value, initialValue) + ) { + // Spawn 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; + value = initialValue; + } + hook.memoizedState = value; + return value; +} + function updateDeferredValueImpl(hook: Hook, prevValue: T, value: T): T { const shouldDeferValue = !includesOnlyNonUrgentLanes(renderLanes); if (shouldDeferValue) { @@ -2664,10 +2691,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'; @@ -2806,10 +2833,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'; @@ -2948,10 +2975,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'; @@ -3091,10 +3118,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'; @@ -3245,11 +3272,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'; @@ -3404,11 +3431,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'; @@ -3564,11 +3591,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/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 20717bde1cd74..cd7e91bae41f5 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -1927,25 +1927,23 @@ 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); } -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; @@ -1953,6 +1951,35 @@ function rerenderDeferredValue(value: T): T { } } +function mountDeferredValueImpl(hook: Hook, value: T, initialValue?: T): T { + // During hydration, if an initial value is provided, we always use that one + // regardless of the render priority. This means you can use it for + // progressive enhancement. Otherwise, we only use the initial value if the + // render is urgent — same logic as during an update. + if ( + (getIsHydrating() || !includesOnlyNonUrgentLanes(renderLanes)) && + initialValue !== undefined && + !is(value, initialValue) + ) { + // Spawn 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; + value = initialValue; + } + hook.memoizedState = value; + return value; +} + function updateDeferredValueImpl(hook: Hook, prevValue: T, value: T): T { const shouldDeferValue = !includesOnlyNonUrgentLanes(renderLanes); if (shouldDeferValue) { @@ -2664,10 +2691,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'; @@ -2806,10 +2833,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'; @@ -2948,10 +2975,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'; @@ -3091,10 +3118,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'; @@ -3245,11 +3272,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'; @@ -3404,11 +3431,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'; @@ -3564,11 +3591,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 319bbc1c337dd..f5cea6b04770a 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -369,7 +369,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 a799a53c81ca8..f80d89aa3b653 100644 --- a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js +++ b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js @@ -296,4 +296,51 @@ describe('ReactDeferredValue', () => { ); }); }); + + it('defers during mount if initialValue is given and priority is urgent', async () => { + // Note: This does not cover hydration, which always defers regardless of + // priority. That's tested in a different suite. + function App({value}) { + const deferredValue = useDeferredValue(value, '(initial)'); + return ( +
+ + +
+ ); + } + + const root = ReactNoop.createRoot(); + + // Initial render not in a transition. It should defer. + await act(async () => { + root.render(); + // Render with the initial value + expect(Scheduler).toFlushUntilNextPaint([ + 'Not deferred: A', + 'Deferred: (initial)', + ]); + // Then switch to latest + expect(Scheduler).toFlushUntilNextPaint([ + 'Not deferred: A', + 'Deferred: A', + ]); + }); + + await act(async () => { + root.render(null); + }); + + // Initial render during a transition. It should not defer. + await act(async () => { + startTransition(() => { + root.render(); + // Render both in the same batch + expect(Scheduler).toFlushUntilNextPaint([ + 'Not deferred: A', + 'Deferred: A', + ]); + }); + }); + }); }); diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js index bacf9da7029a7..249ff37de9984 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js @@ -3724,9 +3724,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 c3ffa8cd6abd8..328c44ecafc9d 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -497,9 +497,9 @@ function useSyncExternalStore( return getServerSnapshot(); } -function useDeferredValue(value: T): T { +function useDeferredValue(value: T, initialValue?: T): T { resolveCurrentlyRenderingComponent(); - return value; + return initialValue !== undefined ? initialValue : value; } function unsupportedStartTransition() { diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 9dc7a98589e4e..f219749cd6d7b 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -167,9 +167,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 {