Skip to content

Commit

Permalink
Add optional initialValue argument to useDeferredValue (facebook#27500)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
acdlite authored and AndyPengc12 committed Apr 15, 2024
1 parent e726663 commit 4e6252b
Show file tree
Hide file tree
Showing 17 changed files with 189 additions and 36 deletions.
2 changes: 1 addition & 1 deletion packages/react-debug-tools/src/ReactDebugHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ function useTransition(): [
return [false, callback => {}];
}

function useDeferredValue<T>(value: T): T {
function useDeferredValue<T>(value: T, initialValue?: T): T {
const hook = nextHook();
hookLog.push({
primitive: 'DeferredValue',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div>{memoizedValue}</div>;
Expand Down
71 changes: 71 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js
Original file line number Diff line number Diff line change
@@ -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(<App />);
await readIntoContainer(stream);
expect(container.textContent).toEqual('Initial');

// After hydration, it's updated to the final value
await act(() => ReactDOMClient.hydrateRoot(container, <App />));
expect(container.textContent).toEqual('Final');
});
});
85 changes: 61 additions & 24 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
debugRenderPhaseSideEffectsForStrictMode,
enableAsyncActions,
enableFormActions,
enableUseDeferredValueInitialArg,
} from 'shared/ReactFeatureFlags';
import {
REACT_CONTEXT_TYPE,
Expand Down Expand Up @@ -2638,33 +2639,69 @@ function updateMemo<T>(
return nextValue;
}

function mountDeferredValue<T>(value: T): T {
function mountDeferredValue<T>(value: T, initialValue?: T): T {
const hook = mountWorkInProgressHook();
hook.memoizedState = value;
return value;
return mountDeferredValueImpl(hook, value, initialValue);
}

function updateDeferredValue<T>(value: T): T {
function updateDeferredValue<T>(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<T>(value: T): T {
function rerenderDeferredValue<T>(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<T>(hook: Hook, prevValue: T, value: T): T {
function mountDeferredValueImpl<T>(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<T>(
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
Expand Down Expand Up @@ -3633,10 +3670,10 @@ if (__DEV__) {
mountHookTypesDev();
return mountDebugValue(value, formatterFn);
},
useDeferredValue<T>(value: T): T {
useDeferredValue<T>(value: T, initialValue?: T): T {
currentHookNameInDev = 'useDeferredValue';
mountHookTypesDev();
return mountDeferredValue(value);
return mountDeferredValue(value, initialValue);
},
useTransition(): [boolean, (() => void) => void] {
currentHookNameInDev = 'useTransition';
Expand Down Expand Up @@ -3802,10 +3839,10 @@ if (__DEV__) {
updateHookTypesDev();
return mountDebugValue(value, formatterFn);
},
useDeferredValue<T>(value: T): T {
useDeferredValue<T>(value: T, initialValue?: T): T {
currentHookNameInDev = 'useDeferredValue';
updateHookTypesDev();
return mountDeferredValue(value);
return mountDeferredValue(value, initialValue);
},
useTransition(): [boolean, (() => void) => void] {
currentHookNameInDev = 'useTransition';
Expand Down Expand Up @@ -3975,10 +4012,10 @@ if (__DEV__) {
updateHookTypesDev();
return updateDebugValue(value, formatterFn);
},
useDeferredValue<T>(value: T): T {
useDeferredValue<T>(value: T, initialValue?: T): T {
currentHookNameInDev = 'useDeferredValue';
updateHookTypesDev();
return updateDeferredValue(value);
return updateDeferredValue(value, initialValue);
},
useTransition(): [boolean, (() => void) => void] {
currentHookNameInDev = 'useTransition';
Expand Down Expand Up @@ -4147,10 +4184,10 @@ if (__DEV__) {
updateHookTypesDev();
return updateDebugValue(value, formatterFn);
},
useDeferredValue<T>(value: T): T {
useDeferredValue<T>(value: T, initialValue?: T): T {
currentHookNameInDev = 'useDeferredValue';
updateHookTypesDev();
return rerenderDeferredValue(value);
return rerenderDeferredValue(value, initialValue);
},
useTransition(): [boolean, (() => void) => void] {
currentHookNameInDev = 'useTransition';
Expand Down Expand Up @@ -4331,11 +4368,11 @@ if (__DEV__) {
mountHookTypesDev();
return mountDebugValue(value, formatterFn);
},
useDeferredValue<T>(value: T): T {
useDeferredValue<T>(value: T, initialValue?: T): T {
currentHookNameInDev = 'useDeferredValue';
warnInvalidHookAccess();
mountHookTypesDev();
return mountDeferredValue(value);
return mountDeferredValue(value, initialValue);
},
useTransition(): [boolean, (() => void) => void] {
currentHookNameInDev = 'useTransition';
Expand Down Expand Up @@ -4529,11 +4566,11 @@ if (__DEV__) {
updateHookTypesDev();
return updateDebugValue(value, formatterFn);
},
useDeferredValue<T>(value: T): T {
useDeferredValue<T>(value: T, initialValue?: T): T {
currentHookNameInDev = 'useDeferredValue';
warnInvalidHookAccess();
updateHookTypesDev();
return updateDeferredValue(value);
return updateDeferredValue(value, initialValue);
},
useTransition(): [boolean, (() => void) => void] {
currentHookNameInDev = 'useTransition';
Expand Down Expand Up @@ -4727,11 +4764,11 @@ if (__DEV__) {
updateHookTypesDev();
return updateDebugValue(value, formatterFn);
},
useDeferredValue<T>(value: T): T {
useDeferredValue<T>(value: T, initialValue?: T): T {
currentHookNameInDev = 'useDeferredValue';
warnInvalidHookAccess();
updateHookTypesDev();
return rerenderDeferredValue(value);
return rerenderDeferredValue(value, initialValue);
},
useTransition(): [boolean, (() => void) => void] {
currentHookNameInDev = 'useTransition';
Expand Down
2 changes: 1 addition & 1 deletion packages/react-reconciler/src/ReactInternalTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ export type Dispatcher = {
deps: Array<mixed> | void | null,
): void,
useDebugValue<T>(value: T, formatterFn: ?(value: T) => mixed): void,
useDeferredValue<T>(value: T): T,
useDeferredValue<T>(value: T, initialValue?: T): T,
useTransition(): [
boolean,
(callback: () => void, options?: StartTransitionOptions) => void,
Expand Down
35 changes: 35 additions & 0 deletions packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,4 +306,39 @@ describe('ReactDeferredValue', () => {
);
});
});

// @gate enableUseDeferredValueInitialArg
it('supports initialValue argument', async () => {
function App() {
const value = useDeferredValue('Final', 'Initial');
return <Text text={value} />;
}

const root = ReactNoop.createRoot();
await act(async () => {
root.render(<App />);
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 <Text text={value} />;
}

const root = ReactNoop.createRoot();
await act(async () => {
// Initial mount is a transition, but it should defer anyway
startTransition(() => root.render(<App />));
await waitForPaint(['Initial']);
expect(root).toMatchRenderedOutput('Initial');
});
assertLog(['Final']);
expect(root).toMatchRenderedOutput('Final');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
Expand Down
9 changes: 7 additions & 2 deletions packages/react-server/src/ReactFizzHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
enableUseMemoCacheHook,
enableAsyncActions,
enableFormActions,
enableUseDeferredValueInitialArg,
} from 'shared/ReactFeatureFlags';
import is from 'shared/objectIs';
import {
Expand Down Expand Up @@ -553,9 +554,13 @@ function useSyncExternalStore<T>(
return getServerSnapshot();
}

function useDeferredValue<T>(value: T): T {
function useDeferredValue<T>(value: T, initialValue?: T): T {
resolveCurrentlyRenderingComponent();
return value;
if (enableUseDeferredValueInitialArg) {
return initialValue !== undefined ? initialValue : value;
} else {
return value;
}
}

function unsupportedStartTransition() {
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/ReactHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,9 @@ export function useTransition(): [
return dispatcher.useTransition();
}

export function useDeferredValue<T>(value: T): T {
export function useDeferredValue<T>(value: T, initialValue?: T): T {
const dispatcher = resolveDispatcher();
return dispatcher.useDeferredValue(value);
return dispatcher.useDeferredValue(value, initialValue);
}

export function useId(): string {
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/ReactFeatureFlags.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ export const useMicrotasksForSchedulingInFabric = false;

export const passChildrenWhenCloningPersistedNodes = false;

export const enableUseDeferredValueInitialArg = __EXPERIMENTAL__;

// -----------------------------------------------------------------------------
// Chopping Block
//
Expand Down
1 change: 1 addition & 0 deletions packages/shared/forks/ReactFeatureFlags.native-fb.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
1 change: 1 addition & 0 deletions packages/shared/forks/ReactFeatureFlags.native-oss.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Loading

0 comments on commit 4e6252b

Please sign in to comment.