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 {