From eb89912ee5ace8bf8e616cca5a6aeebcd274b521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 18 Nov 2025 16:29:18 -0500 Subject: [PATCH] Add expertimental `optimisticKey` behind a flag (#35162) When dealing with optimistic state, a common problem is not knowing the id of the thing we're waiting on. Items in lists need keys (and single items should often have keys too to reset their state). As a result you have to generate fake keys. It's a pain to manage those and when the real item comes in, you often end up rendering that with a different `key` which resets the state of the component tree. That in turns works against the grain of React and a lot of negatives fall out of it. This adds a special `optimisticKey` symbol that can be used in place of a `string` key. ```js import {optimisticKey} from 'react'; ... const [optimisticItems, setOptimisticItems] = useOptimistic([]); const children = savedItems.concat( optimisticItems.map(item => ) ); return
{children}
; ``` The semantics of this `optimisticKey` is that the assumption is that the newly saved item will be rendered in the same slot as the previous optimistic items. State is transferred into whatever real key ends up in the same slot. This might lead to some incorrect transferring of state in some cases where things don't end up lining up - but it's worth it for simplicity in many cases since dealing with true matching of optimistic state is often very complex for something that only lasts a blink of an eye. If a new item matches a `key` elsewhere in the set, then that's favored over reconciling against the old slot. One quirk with the current algorithm is if the `savedItems` has items removed, then the slots won't line up by index anymore and will be skewed. We might be able to add something where the optimistic set is always reconciled against the end. However, it's probably better to just assume that the set will line up perfectly and otherwise it's just best effort that can lead to weird artifacts. An `optimisticKey` will match itself for updates to the same slot, but it will not match any existing slot that is not an `optimisticKey`. So it's not an `any`, which I originally called it, because it doesn't match existing real keys against new optimistic keys. Only one direction. --- .../src/__tests__/ReactFlight-test.js | 15 +++ .../src/backend/fiber/renderer.js | 24 +++- .../src/backend/shared/ReactSymbols.js | 6 + .../ReactDOMFizzStaticBrowser-test.js | 60 +++++++++ .../react-reconciler/src/ReactChildFiber.js | 123 +++++++++++++++--- packages/react-reconciler/src/ReactFiber.js | 43 ++++-- .../src/ReactInternalTypes.js | 3 +- packages/react-reconciler/src/ReactPortal.js | 24 +++- .../src/__tests__/ReactAsyncActions-test.js | 79 +++++++++++ packages/react-server/src/ReactFizzServer.js | 10 +- .../react-server/src/ReactFlightServer.js | 32 +++-- .../react/index.experimental.development.js | 1 + packages/react/index.experimental.js | 1 + packages/react/src/ReactChildren.js | 9 ++ packages/react/src/ReactClient.js | 3 + .../ReactServer.experimental.development.js | 3 + .../react/src/ReactServer.experimental.js | 3 + packages/react/src/jsx/ReactJSXElement.js | 63 ++++++--- packages/shared/ReactFeatureFlags.js | 2 + packages/shared/ReactSymbols.js | 9 ++ packages/shared/ReactTypes.js | 14 +- .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 2 + .../forks/ReactFeatureFlags.test-renderer.js | 2 + ...actFeatureFlags.test-renderer.native-fb.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 2 + .../shared/forks/ReactFeatureFlags.www.js | 2 + 27 files changed, 454 insertions(+), 83 deletions(-) diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index b0f539bf2572c..d642a02c8c6cf 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -3884,4 +3884,19 @@ describe('ReactFlight', () => { , ); }); + + // @gate enableOptimisticKey + it('collapses optimistic keys to an optimistic key', async () => { + function Bar({text}) { + return
; + } + function Foo() { + return ; + } + const transport = ReactNoopFlightServer.render({ + element: , + }); + const model = await ReactNoopFlightClient.read(transport); + expect(model.element.key).toBe(React.optimisticKey); + }); }); diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index fa802e53a6f26..4f755fda29392 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -120,6 +120,7 @@ import { MEMO_SYMBOL_STRING, SERVER_CONTEXT_SYMBOL_STRING, LAZY_SYMBOL_STRING, + REACT_OPTIMISTIC_KEY, } from '../shared/ReactSymbols'; import {enableStyleXFeatures} from 'react-devtools-feature-flags'; @@ -4849,7 +4850,10 @@ export function attach( } let previousSiblingOfBestMatch = null; let bestMatch = remainingReconcilingChildren; - if (componentInfo.key != null) { + if ( + componentInfo.key != null && + componentInfo.key !== REACT_OPTIMISTIC_KEY + ) { // If there is a key try to find a matching key in the set. bestMatch = remainingReconcilingChildren; while (bestMatch !== null) { @@ -6145,7 +6149,7 @@ export function attach( return { displayName: getDisplayNameForFiber(fiber) || 'Anonymous', id: instance.id, - key: fiber.key, + key: fiber.key === REACT_OPTIMISTIC_KEY ? null : fiber.key, env: null, stack: fiber._debugOwner == null || fiber._debugStack == null @@ -6158,7 +6162,11 @@ export function attach( return { displayName: componentInfo.name || 'Anonymous', id: instance.id, - key: componentInfo.key == null ? null : componentInfo.key, + key: + componentInfo.key == null || + componentInfo.key === REACT_OPTIMISTIC_KEY + ? null + : componentInfo.key, env: componentInfo.env == null ? null : componentInfo.env, stack: componentInfo.owner == null || componentInfo.debugStack == null @@ -7082,7 +7090,7 @@ export function attach( // Does the component have legacy context attached to it. hasLegacyContext, - key: key != null ? key : null, + key: key != null && key !== REACT_OPTIMISTIC_KEY ? key : null, type: elementType, @@ -8641,7 +8649,7 @@ export function attach( } return { displayName, - key, + key: key === REACT_OPTIMISTIC_KEY ? null : key, index, }; } @@ -8649,7 +8657,11 @@ export function attach( function getVirtualPathFrame(virtualInstance: VirtualInstance): PathFrame { return { displayName: virtualInstance.data.name || '', - key: virtualInstance.data.key == null ? null : virtualInstance.data.key, + key: + virtualInstance.data.key == null || + virtualInstance.data.key === REACT_OPTIMISTIC_KEY + ? null + : virtualInstance.data.key, index: -1, // We use -1 to indicate that this is a virtual path frame. }; } diff --git a/packages/react-devtools-shared/src/backend/shared/ReactSymbols.js b/packages/react-devtools-shared/src/backend/shared/ReactSymbols.js index 7a7a9c107e93f..483671b900383 100644 --- a/packages/react-devtools-shared/src/backend/shared/ReactSymbols.js +++ b/packages/react-devtools-shared/src/backend/shared/ReactSymbols.js @@ -72,3 +72,9 @@ export const SERVER_CONTEXT_DEFAULT_VALUE_NOT_LOADED_SYMBOL_STRING = export const REACT_MEMO_CACHE_SENTINEL: symbol = Symbol.for( 'react.memo_cache_sentinel', ); + +import type {ReactOptimisticKey} from 'shared/ReactTypes'; + +export const REACT_OPTIMISTIC_KEY: ReactOptimisticKey = (Symbol.for( + 'react.optimistic_key', +): any); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index beebd3a1165b5..334bb2ddce761 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -1111,4 +1111,64 @@ describe('ReactDOMFizzStaticBrowser', () => {
, ); }); + + // @gate enableHalt && enableOptimisticKey + it('can resume an optimistic keyed slot', async () => { + const errors = []; + + let resolve; + const promise = new Promise(r => (resolve = r)); + + async function Component() { + await promise; + return 'Hi'; + } + + if (React.optimisticKey === undefined) { + throw new Error('optimisticKey missing'); + } + + function App() { + return ( +
+ + + +
+ ); + } + + const controller = new AbortController(); + const pendingResult = serverAct(() => + ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }), + ); + + await serverAct(() => { + controller.abort(); + }); + + const prerendered = await pendingResult; + + const postponedState = JSON.stringify(prerendered.postponed); + + await readIntoContainer(prerendered.prelude); + expect(getVisibleChildren(container)).toEqual(
Loading
); + + expect(prerendered.postponed).not.toBe(null); + + await resolve(); + + const dynamic = await serverAct(() => + ReactDOMFizzServer.resume(, JSON.parse(postponedState)), + ); + + await readIntoContainer(dynamic); + + expect(getVisibleChildren(container)).toEqual(
Hi
); + }); }); diff --git a/packages/react-reconciler/src/ReactChildFiber.js b/packages/react-reconciler/src/ReactChildFiber.js index 8daf7fde4b4e0..2a726447263c7 100644 --- a/packages/react-reconciler/src/ReactChildFiber.js +++ b/packages/react-reconciler/src/ReactChildFiber.js @@ -15,6 +15,8 @@ import type { ReactDebugInfo, ReactComponentInfo, SuspenseListRevealOrder, + ReactKey, + ReactOptimisticKey, } from 'shared/ReactTypes'; import type {Fiber} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane'; @@ -37,6 +39,7 @@ import { REACT_LAZY_TYPE, REACT_CONTEXT_TYPE, REACT_LEGACY_ELEMENT_TYPE, + REACT_OPTIMISTIC_KEY, } from 'shared/ReactSymbols'; import { HostRoot, @@ -50,6 +53,7 @@ import { enableAsyncIterableChildren, disableLegacyMode, enableFragmentRefs, + enableOptimisticKey, } from 'shared/ReactFeatureFlags'; import { @@ -462,18 +466,33 @@ function createChildReconciler( function mapRemainingChildren( currentFirstChild: Fiber, - ): Map { + ): Map { // Add the remaining children to a temporary map so that we can find them by // keys quickly. Implicit (null) keys get added to this set with their index // instead. - const existingChildren: Map = new Map(); + const existingChildren: Map< + | string + | number + // This type is only here for the case when enableOptimisticKey is disabled. + // Remove it after it ships. + | ReactOptimisticKey, + Fiber, + > = new Map(); let existingChild: null | Fiber = currentFirstChild; while (existingChild !== null) { - if (existingChild.key !== null) { - existingChildren.set(existingChild.key, existingChild); - } else { + if (existingChild.key === null) { existingChildren.set(existingChild.index, existingChild); + } else if ( + enableOptimisticKey && + existingChild.key === REACT_OPTIMISTIC_KEY + ) { + // For optimistic keys, we store the negative index (minus one) to differentiate + // them from the regular indices. We'll look this up regardless of what the new + // key is, if there's no other match. + existingChildren.set(-existingChild.index - 1, existingChild); + } else { + existingChildren.set(existingChild.key, existingChild); } existingChild = existingChild.sibling; } @@ -636,6 +655,10 @@ function createChildReconciler( } else { // Update const existing = useFiber(current, portal.children || []); + if (enableOptimisticKey) { + // If the old key was optimistic we need to now save the real one. + existing.key = portal.key; + } existing.return = returnFiber; if (__DEV__) { existing._debugInfo = currentDebugInfo; @@ -649,7 +672,7 @@ function createChildReconciler( current: Fiber | null, fragment: Iterable, lanes: Lanes, - key: null | string, + key: ReactKey, ): Fiber { if (current === null || current.tag !== Fragment) { // Insert @@ -670,6 +693,10 @@ function createChildReconciler( } else { // Update const existing = useFiber(current, fragment); + if (enableOptimisticKey) { + // If the old key was optimistic we need to now save the real one. + existing.key = key; + } existing.return = returnFiber; if (__DEV__) { existing._debugInfo = currentDebugInfo; @@ -840,7 +867,13 @@ function createChildReconciler( if (typeof newChild === 'object' && newChild !== null) { switch (newChild.$$typeof) { case REACT_ELEMENT_TYPE: { - if (newChild.key === key) { + if ( + // If the old child was an optimisticKey, then we'd normally consider that a match, + // but instead, we'll bail to return null from the slot which will bail to slow path. + // That's to ensure that if the new key has a match elsewhere in the list, then that + // takes precedence over assuming the identity of an optimistic slot. + newChild.key === key + ) { const prevDebugInfo = pushDebugInfo(newChild._debugInfo); const updated = updateElement( returnFiber, @@ -855,7 +888,13 @@ function createChildReconciler( } } case REACT_PORTAL_TYPE: { - if (newChild.key === key) { + if ( + // If the old child was an optimisticKey, then we'd normally consider that a match, + // but instead, we'll bail to return null from the slot which will bail to slow path. + // That's to ensure that if the new key has a match elsewhere in the list, then that + // takes precedence over assuming the identity of an optimistic slot. + newChild.key === key + ) { return updatePortal(returnFiber, oldFiber, newChild, lanes); } else { return null; @@ -939,7 +978,7 @@ function createChildReconciler( } function updateFromMap( - existingChildren: Map, + existingChildren: Map, returnFiber: Fiber, newIdx: number, newChild: any, @@ -968,7 +1007,11 @@ function createChildReconciler( const matchedFiber = existingChildren.get( newChild.key === null ? newIdx : newChild.key, - ) || null; + ) || + (enableOptimisticKey && + // If the existing child was an optimistic key, we may still match on the index. + existingChildren.get(-newIdx - 1)) || + null; const prevDebugInfo = pushDebugInfo(newChild._debugInfo); const updated = updateElement( returnFiber, @@ -983,7 +1026,11 @@ function createChildReconciler( const matchedFiber = existingChildren.get( newChild.key === null ? newIdx : newChild.key, - ) || null; + ) || + (enableOptimisticKey && + // If the existing child was an optimistic key, we may still match on the index. + existingChildren.get(-newIdx - 1)) || + null; return updatePortal(returnFiber, matchedFiber, newChild, lanes); } case REACT_LAZY_TYPE: { @@ -1274,14 +1321,22 @@ function createChildReconciler( ); } if (shouldTrackSideEffects) { - if (newFiber.alternate !== null) { + const currentFiber = newFiber.alternate; + if (currentFiber !== null) { // The new fiber is a work in progress, but if there exists a // current, that means that we reused the fiber. We need to delete // it from the child list so that we don't add it to the deletion // list. - existingChildren.delete( - newFiber.key === null ? newIdx : newFiber.key, - ); + if ( + enableOptimisticKey && + currentFiber.key === REACT_OPTIMISTIC_KEY + ) { + existingChildren.delete(-newIdx - 1); + } else { + existingChildren.delete( + currentFiber.key === null ? newIdx : currentFiber.key, + ); + } } } lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); @@ -1568,14 +1623,22 @@ function createChildReconciler( ); } if (shouldTrackSideEffects) { - if (newFiber.alternate !== null) { + const currentFiber = newFiber.alternate; + if (currentFiber !== null) { // The new fiber is a work in progress, but if there exists a // current, that means that we reused the fiber. We need to delete // it from the child list so that we don't add it to the deletion // list. - existingChildren.delete( - newFiber.key === null ? newIdx : newFiber.key, - ); + if ( + enableOptimisticKey && + currentFiber.key === REACT_OPTIMISTIC_KEY + ) { + existingChildren.delete(-newIdx - 1); + } else { + existingChildren.delete( + currentFiber.key === null ? newIdx : currentFiber.key, + ); + } } } lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); @@ -1642,12 +1705,19 @@ function createChildReconciler( while (child !== null) { // TODO: If key === null and child.key === null, then this only applies to // the first item in the list. - if (child.key === key) { + if ( + child.key === key || + (enableOptimisticKey && child.key === REACT_OPTIMISTIC_KEY) + ) { const elementType = element.type; if (elementType === REACT_FRAGMENT_TYPE) { if (child.tag === Fragment) { deleteRemainingChildren(returnFiber, child.sibling); const existing = useFiber(child, element.props.children); + if (enableOptimisticKey) { + // If the old key was optimistic we need to now save the real one. + existing.key = key; + } if (enableFragmentRefs) { coerceRef(existing, element); } @@ -1677,6 +1747,10 @@ function createChildReconciler( ) { deleteRemainingChildren(returnFiber, child.sibling); const existing = useFiber(child, element.props); + if (enableOptimisticKey) { + // If the old key was optimistic we need to now save the real one. + existing.key = key; + } coerceRef(existing, element); existing.return = returnFiber; if (__DEV__) { @@ -1736,7 +1810,10 @@ function createChildReconciler( while (child !== null) { // TODO: If key === null and child.key === null, then this only applies to // the first item in the list. - if (child.key === key) { + if ( + child.key === key || + (enableOptimisticKey && child.key === REACT_OPTIMISTIC_KEY) + ) { if ( child.tag === HostPortal && child.stateNode.containerInfo === portal.containerInfo && @@ -1744,6 +1821,10 @@ function createChildReconciler( ) { deleteRemainingChildren(returnFiber, child.sibling); const existing = useFiber(child, portal.children || []); + if (enableOptimisticKey) { + // If the old key was optimistic we need to now save the real one. + existing.key = key; + } existing.return = returnFiber; return existing; } else { diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index fb2c7347010b6..7ab798ea22bc4 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -14,6 +14,7 @@ import type { ReactScope, ViewTransitionProps, ActivityProps, + ReactKey, } from 'shared/ReactTypes'; import type {Fiber} from './ReactInternalTypes'; import type {RootTag} from './ReactRootTags'; @@ -43,6 +44,7 @@ import { enableObjectFiber, enableViewTransition, enableSuspenseyImages, + enableOptimisticKey, } from 'shared/ReactFeatureFlags'; import {NoFlags, Placement, StaticMask} from './ReactFiberFlags'; import {ConcurrentRoot} from './ReactRootTags'; @@ -137,7 +139,7 @@ function FiberNode( this: $FlowFixMe, tag: WorkTag, pendingProps: mixed, - key: null | string, + key: ReactKey, mode: TypeOfMode, ) { // Instance @@ -224,7 +226,7 @@ function FiberNode( function createFiberImplClass( tag: WorkTag, pendingProps: mixed, - key: null | string, + key: ReactKey, mode: TypeOfMode, ): Fiber { // $FlowFixMe[invalid-constructor]: the shapes are exact here but Flow doesn't like constructors @@ -234,7 +236,7 @@ function createFiberImplClass( function createFiberImplObject( tag: WorkTag, pendingProps: mixed, - key: null | string, + key: ReactKey, mode: TypeOfMode, ): Fiber { const fiber: Fiber = { @@ -364,6 +366,12 @@ export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber { workInProgress.subtreeFlags = NoFlags; workInProgress.deletions = null; + if (enableOptimisticKey) { + // For optimistic keys, the Fibers can have different keys if one is optimistic + // and the other one is filled in. + workInProgress.key = current.key; + } + if (enableProfilerTimer) { // We intentionally reset, rather than copy, actualDuration & actualStartTime. // This prevents time from endlessly accumulating in new commits. @@ -488,8 +496,15 @@ export function resetWorkInProgress( workInProgress.memoizedState = current.memoizedState; workInProgress.updateQueue = current.updateQueue; // Needed because Blocks store data on type. + // TODO: Blocks don't exist anymore. Do we still need this? workInProgress.type = current.type; + if (enableOptimisticKey) { + // For optimistic keys, the Fibers can have different keys if one is optimistic + // and the other one is filled in. + workInProgress.key = current.key; + } + // Clone the dependencies object. This is mutated during the render phase, so // it cannot be shared with the current fiber. const currentDependencies = current.dependencies; @@ -545,7 +560,7 @@ export function createHostRootFiber( // TODO: Get rid of this helper. Only createFiberFromElement should exist. export function createFiberFromTypeAndProps( type: any, // React$ElementType - key: null | string, + key: ReactKey, pendingProps: any, owner: null | ReactComponentInfo | Fiber, mode: TypeOfMode, @@ -747,7 +762,7 @@ export function createFiberFromFragment( elements: ReactFragment, mode: TypeOfMode, lanes: Lanes, - key: null | string, + key: ReactKey, ): Fiber { const fiber = createFiber(Fragment, elements, key, mode); fiber.lanes = lanes; @@ -759,7 +774,7 @@ function createFiberFromScope( pendingProps: any, mode: TypeOfMode, lanes: Lanes, - key: null | string, + key: ReactKey, ) { const fiber = createFiber(ScopeComponent, pendingProps, key, mode); fiber.type = scope; @@ -772,7 +787,7 @@ function createFiberFromProfiler( pendingProps: any, mode: TypeOfMode, lanes: Lanes, - key: null | string, + key: ReactKey, ): Fiber { if (__DEV__) { if (typeof pendingProps.id !== 'string') { @@ -801,7 +816,7 @@ export function createFiberFromSuspense( pendingProps: any, mode: TypeOfMode, lanes: Lanes, - key: null | string, + key: ReactKey, ): Fiber { const fiber = createFiber(SuspenseComponent, pendingProps, key, mode); fiber.elementType = REACT_SUSPENSE_TYPE; @@ -813,7 +828,7 @@ export function createFiberFromSuspenseList( pendingProps: any, mode: TypeOfMode, lanes: Lanes, - key: null | string, + key: ReactKey, ): Fiber { const fiber = createFiber(SuspenseListComponent, pendingProps, key, mode); fiber.elementType = REACT_SUSPENSE_LIST_TYPE; @@ -825,7 +840,7 @@ export function createFiberFromOffscreen( pendingProps: OffscreenProps, mode: TypeOfMode, lanes: Lanes, - key: null | string, + key: ReactKey, ): Fiber { const fiber = createFiber(OffscreenComponent, pendingProps, key, mode); fiber.lanes = lanes; @@ -835,7 +850,7 @@ export function createFiberFromActivity( pendingProps: ActivityProps, mode: TypeOfMode, lanes: Lanes, - key: null | string, + key: ReactKey, ): Fiber { const fiber = createFiber(ActivityComponent, pendingProps, key, mode); fiber.elementType = REACT_ACTIVITY_TYPE; @@ -847,7 +862,7 @@ export function createFiberFromViewTransition( pendingProps: ViewTransitionProps, mode: TypeOfMode, lanes: Lanes, - key: null | string, + key: ReactKey, ): Fiber { if (!enableSuspenseyImages) { // Render a ViewTransition component opts into SuspenseyImages mode even @@ -871,7 +886,7 @@ export function createFiberFromLegacyHidden( pendingProps: LegacyHiddenProps, mode: TypeOfMode, lanes: Lanes, - key: null | string, + key: ReactKey, ): Fiber { const fiber = createFiber(LegacyHiddenComponent, pendingProps, key, mode); fiber.elementType = REACT_LEGACY_HIDDEN_TYPE; @@ -883,7 +898,7 @@ export function createFiberFromTracingMarker( pendingProps: any, mode: TypeOfMode, lanes: Lanes, - key: null | string, + key: ReactKey, ): Fiber { const fiber = createFiber(TracingMarkerComponent, pendingProps, key, mode); fiber.elementType = REACT_TRACING_MARKER_TYPE; diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 95c3b8ca89cb9..775b69d211f76 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -17,6 +17,7 @@ import type { Awaited, ReactComponentInfo, ReactDebugInfo, + ReactKey, } from 'shared/ReactTypes'; import type {TransitionTypes} from 'react/src/ReactTransitionType'; import type {WorkTag} from './ReactWorkTags'; @@ -100,7 +101,7 @@ export type Fiber = { tag: WorkTag, // Unique identifier of this child. - key: null | string, + key: ReactKey, // The value of element.type which is used to preserve the identity during // reconciliation of this child. diff --git a/packages/react-reconciler/src/ReactPortal.js b/packages/react-reconciler/src/ReactPortal.js index 06764c58cc87f..78d9d7f63720f 100644 --- a/packages/react-reconciler/src/ReactPortal.js +++ b/packages/react-reconciler/src/ReactPortal.js @@ -7,25 +7,37 @@ * @flow */ -import {REACT_PORTAL_TYPE} from 'shared/ReactSymbols'; +import {REACT_PORTAL_TYPE, REACT_OPTIMISTIC_KEY} from 'shared/ReactSymbols'; import {checkKeyStringCoercion} from 'shared/CheckStringCoercion'; -import type {ReactNodeList, ReactPortal} from 'shared/ReactTypes'; +import type { + ReactNodeList, + ReactPortal, + ReactOptimisticKey, +} from 'shared/ReactTypes'; export function createPortal( children: ReactNodeList, containerInfo: any, // TODO: figure out the API for cross-renderer implementation. implementation: any, - key: ?string = null, + key: ?string | ReactOptimisticKey = null, ): ReactPortal { - if (__DEV__) { - checkKeyStringCoercion(key); + let resolvedKey; + if (key == null) { + resolvedKey = null; + } else if (key === REACT_OPTIMISTIC_KEY) { + resolvedKey = REACT_OPTIMISTIC_KEY; + } else { + if (__DEV__) { + checkKeyStringCoercion(key); + } + resolvedKey = '' + key; } return { // This tag allow us to uniquely identify this as a React Portal $$typeof: REACT_PORTAL_TYPE, - key: key == null ? null : '' + key, + key: resolvedKey, children, containerInfo, implementation, diff --git a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js index 20480eb287675..45d939fd13dad 100644 --- a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js +++ b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js @@ -1789,4 +1789,83 @@ describe('ReactAsyncActions', () => { }); assertLog(['reportError: Oops']); }); + + // @gate enableOptimisticKey + it('reconciles against new items when optimisticKey is used', async () => { + const startTransition = React.startTransition; + + function Item({text}) { + const [initialText] = React.useState(text); + return {initialText + '-' + text}; + } + + let addOptimisticItem; + function App({items}) { + const [optimisticItems, _addOptimisticItem] = useOptimistic( + items, + (canonicalItems, optimisticText) => + canonicalItems.concat({ + id: React.optimisticKey, + text: optimisticText, + }), + ); + addOptimisticItem = _addOptimisticItem; + return ( +
+ {optimisticItems.map(item => ( + + ))} +
+ ); + } + + const A = { + id: 'a', + text: 'A', + }; + + const B = { + id: 'b', + text: 'B', + }; + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + expect(root).toMatchRenderedOutput( +
+ A-A +
, + ); + + // Start an async action using the non-hook form of startTransition. The + // action includes an optimistic update. + await act(() => { + startTransition(async () => { + addOptimisticItem('b'); + await getText('Yield before updating'); + startTransition(() => root.render()); + }); + }); + // Because the action hasn't finished yet, the optimistic UI is shown. + expect(root).toMatchRenderedOutput( +
+ A-A + b-b +
, + ); + + // Finish the async action. The optimistic state is reverted and replaced by + // the canonical state. The state is transferred to the new row. + await act(() => { + resolveText('Yield before updating'); + }); + expect(root).toMatchRenderedOutput( +
+ A-A + b-B +
, + ); + }); }); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 4f0ece3f98527..4ad48d79fba4f 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -27,6 +27,7 @@ import type { SuspenseProps, SuspenseListProps, SuspenseListRevealOrder, + ReactKey, } from 'shared/ReactTypes'; import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type { @@ -170,6 +171,7 @@ import { REACT_SCOPE_TYPE, REACT_VIEW_TRANSITION_TYPE, REACT_ACTIVITY_TYPE, + REACT_OPTIMISTIC_KEY, } from 'shared/ReactSymbols'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { @@ -3253,7 +3255,7 @@ function retryNode(request: Request, task: Task): void { case REACT_ELEMENT_TYPE: { const element: any = node; const type = element.type; - const key = element.key; + const key: ReactKey = element.key; const props = element.props; // TODO: We should get the ref off the props object right before using @@ -3265,7 +3267,11 @@ function retryNode(request: Request, task: Task): void { const name = getComponentNameFromType(type); const keyOrIndex = - key == null ? (childIndex === -1 ? 0 : childIndex) : key; + key == null || key === REACT_OPTIMISTIC_KEY + ? childIndex === -1 + ? 0 + : childIndex + : key; const keyPath = [task.keyPath, name, keyOrIndex]; if (task.replay !== null) { if (debugTask) { diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 4c9389c3acbb8..d5b534adb497f 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -65,6 +65,7 @@ import type { ReactFunctionLocation, ReactErrorInfo, ReactErrorInfoDev, + ReactKey, } from 'shared/ReactTypes'; import type {ReactElement} from 'shared/ReactElementType'; import type {LazyComponent} from 'react/src/ReactLazy'; @@ -136,6 +137,7 @@ import { REACT_LAZY_TYPE, REACT_MEMO_TYPE, ASYNC_ITERATOR, + REACT_OPTIMISTIC_KEY, } from 'shared/ReactSymbols'; import { @@ -534,7 +536,7 @@ type Task = { model: ReactClientValue, ping: () => void, toJSON: (key: string, value: ReactClientValue) => ReactJSONValue, - keyPath: null | string, // parent server component keys + keyPath: ReactKey, // parent server component keys implicitSlot: boolean, // true if the root server component of this sequence had a null key formatContext: FormatContext, // an approximate parent context from host components thenableState: ThenableState | null, @@ -1643,7 +1645,7 @@ function processServerComponentReturnValue( function renderFunctionComponent( request: Request, task: Task, - key: null | string, + key: ReactKey, Component: (p: Props, arg: void) => any, props: Props, validated: number, // DEV-only @@ -1814,7 +1816,12 @@ function renderFunctionComponent( if (key !== null) { // Append the key to the path. Technically a null key should really add the child // index. We don't do that to hold the payload small and implementation simple. - task.keyPath = prevKeyPath === null ? key : prevKeyPath + ',' + key; + if (key === REACT_OPTIMISTIC_KEY || prevKeyPath === REACT_OPTIMISTIC_KEY) { + // The optimistic key is viral. It turns the whole key into optimistic if any part is. + task.keyPath = REACT_OPTIMISTIC_KEY; + } else { + task.keyPath = prevKeyPath === null ? key : prevKeyPath + ',' + key; + } } else if (prevKeyPath === null) { // This sequence of Server Components has no keys. This means that it was rendered // in a slot that needs to assign an implicit key. Even if children below have @@ -1830,7 +1837,7 @@ function renderFunctionComponent( function warnForMissingKey( request: Request, - key: null | string, + key: ReactKey, componentDebugInfo: ReactComponentInfo, debugTask: null | ConsoleTask, ): void { @@ -2024,7 +2031,7 @@ function renderClientElement( request: Request, task: Task, type: any, - key: null | string, + key: ReactKey, props: any, validated: number, // DEV-only ): ReactJSONValue { @@ -2034,7 +2041,12 @@ function renderClientElement( if (key === null) { key = keyPath; } else if (keyPath !== null) { - key = keyPath + ',' + key; + if (keyPath === REACT_OPTIMISTIC_KEY || key === REACT_OPTIMISTIC_KEY) { + // Optimistic key is viral and turns the whole key optimistic. + key = REACT_OPTIMISTIC_KEY; + } else { + key = keyPath + ',' + key; + } } let debugOwner = null; let debugStack = null; @@ -2161,7 +2173,7 @@ function renderElement( request: Request, task: Task, type: any, - key: null | string, + key: ReactKey, ref: mixed, props: any, validated: number, // DEV only @@ -2667,7 +2679,7 @@ function pingTask(request: Request, task: Task): void { function createTask( request: Request, model: ReactClientValue, - keyPath: null | string, + keyPath: ReactKey, implicitSlot: boolean, formatContext: FormatContext, abortSet: Set, @@ -3521,7 +3533,7 @@ function renderModelDestructive( element._debugTask === undefined ) { let key = ''; - if (element.key !== null) { + if (element.key !== null && element.key !== REACT_OPTIMISTIC_KEY) { key = ' key="' + element.key + '"'; } @@ -3547,7 +3559,7 @@ function renderModelDestructive( request, task, element.type, - // $FlowFixMe[incompatible-call] the key of an element is null | string + // $FlowFixMe[incompatible-call] the key of an element is null | string | ReactOptimisticKey element.key, ref, props, diff --git a/packages/react/index.experimental.development.js b/packages/react/index.experimental.development.js index 9b0e301a8c9be..8ff2e1d257f4c 100644 --- a/packages/react/index.experimental.development.js +++ b/packages/react/index.experimental.development.js @@ -29,6 +29,7 @@ export { cache, cacheSignal, startTransition, + optimisticKey, Activity, unstable_getCacheForType, unstable_SuspenseList, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 0145e9137e9b4..881a71b2501dd 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -29,6 +29,7 @@ export { cache, cacheSignal, startTransition, + optimisticKey, Activity, Activity as unstable_Activity, unstable_getCacheForType, diff --git a/packages/react/src/ReactChildren.js b/packages/react/src/ReactChildren.js index 9b474ac832f2b..d4c41d6669a38 100644 --- a/packages/react/src/ReactChildren.js +++ b/packages/react/src/ReactChildren.js @@ -22,7 +22,9 @@ import { REACT_ELEMENT_TYPE, REACT_LAZY_TYPE, REACT_PORTAL_TYPE, + REACT_OPTIMISTIC_KEY, } from 'shared/ReactSymbols'; +import {enableOptimisticKey} from 'shared/ReactFeatureFlags'; import {checkKeyStringCoercion} from 'shared/CheckStringCoercion'; import {isValidElement, cloneAndReplaceKey} from './jsx/ReactJSXElement'; @@ -73,6 +75,13 @@ function getElementKey(element: any, index: number): string { // Do some typechecking here since we call this blindly. We want to ensure // that we don't block potential future ES APIs. if (typeof element === 'object' && element !== null && element.key != null) { + if (enableOptimisticKey && element.key === REACT_OPTIMISTIC_KEY) { + // For React.Children purposes this is treated as just null. + if (__DEV__) { + console.error("React.Children helpers don't support optimisticKey."); + } + return index.toString(36); + } // Explicit key if (__DEV__) { checkKeyStringCoercion(element.key); diff --git a/packages/react/src/ReactClient.js b/packages/react/src/ReactClient.js index d881030b7d090..5d1c7f3ac05e9 100644 --- a/packages/react/src/ReactClient.js +++ b/packages/react/src/ReactClient.js @@ -19,6 +19,7 @@ import { REACT_SCOPE_TYPE, REACT_TRACING_MARKER_TYPE, REACT_VIEW_TRANSITION_TYPE, + REACT_OPTIMISTIC_KEY, } from 'shared/ReactSymbols'; import {Component, PureComponent} from './ReactBaseClasses'; @@ -127,6 +128,8 @@ export { addTransitionType as addTransitionType, // enableGestureTransition startGestureTransition as unstable_startGestureTransition, + // enableOptimisticKey + REACT_OPTIMISTIC_KEY as optimisticKey, // DEV-only useId, act, diff --git a/packages/react/src/ReactServer.experimental.development.js b/packages/react/src/ReactServer.experimental.development.js index 10d0123843d74..dd92bf9104f4c 100644 --- a/packages/react/src/ReactServer.experimental.development.js +++ b/packages/react/src/ReactServer.experimental.development.js @@ -18,6 +18,7 @@ import { REACT_SUSPENSE_LIST_TYPE, REACT_VIEW_TRANSITION_TYPE, REACT_ACTIVITY_TYPE, + REACT_OPTIMISTIC_KEY, } from 'shared/ReactSymbols'; import { cloneElement, @@ -82,5 +83,7 @@ export { version, // Experimental REACT_SUSPENSE_LIST_TYPE as unstable_SuspenseList, + // enableOptimisticKey + REACT_OPTIMISTIC_KEY as optimisticKey, captureOwnerStack, // DEV-only }; diff --git a/packages/react/src/ReactServer.experimental.js b/packages/react/src/ReactServer.experimental.js index 9fc2634131472..0eb8eafccd439 100644 --- a/packages/react/src/ReactServer.experimental.js +++ b/packages/react/src/ReactServer.experimental.js @@ -18,6 +18,7 @@ import { REACT_SUSPENSE_LIST_TYPE, REACT_VIEW_TRANSITION_TYPE, REACT_ACTIVITY_TYPE, + REACT_OPTIMISTIC_KEY, } from 'shared/ReactSymbols'; import { cloneElement, @@ -81,4 +82,6 @@ export { version, // Experimental REACT_SUSPENSE_LIST_TYPE as unstable_SuspenseList, + // enableOptimisticKey + REACT_OPTIMISTIC_KEY as optimisticKey, }; diff --git a/packages/react/src/jsx/ReactJSXElement.js b/packages/react/src/jsx/ReactJSXElement.js index e23c998da511b..3a5a5d51a9b85 100644 --- a/packages/react/src/jsx/ReactJSXElement.js +++ b/packages/react/src/jsx/ReactJSXElement.js @@ -13,10 +13,11 @@ import { REACT_ELEMENT_TYPE, REACT_FRAGMENT_TYPE, REACT_LAZY_TYPE, + REACT_OPTIMISTIC_KEY, } from 'shared/ReactSymbols'; import {checkKeyStringCoercion} from 'shared/CheckStringCoercion'; import isArray from 'shared/isArray'; -import {ownerStackLimit} from 'shared/ReactFeatureFlags'; +import {ownerStackLimit, enableOptimisticKey} from 'shared/ReactFeatureFlags'; const createTask = // eslint-disable-next-line react-internal/no-production-logging @@ -297,17 +298,25 @@ export function jsxProd(type, config, maybeKey) { //
, because we aren't currently able to tell if // key is explicitly declared to be undefined or not. if (maybeKey !== undefined) { - if (__DEV__) { - checkKeyStringCoercion(maybeKey); + if (enableOptimisticKey && maybeKey === REACT_OPTIMISTIC_KEY) { + key = REACT_OPTIMISTIC_KEY; + } else { + if (__DEV__) { + checkKeyStringCoercion(maybeKey); + } + key = '' + maybeKey; } - key = '' + maybeKey; } if (hasValidKey(config)) { - if (__DEV__) { - checkKeyStringCoercion(config.key); + if (enableOptimisticKey && maybeKey === REACT_OPTIMISTIC_KEY) { + key = REACT_OPTIMISTIC_KEY; + } else { + if (__DEV__) { + checkKeyStringCoercion(config.key); + } + key = '' + config.key; } - key = '' + config.key; } let props; @@ -536,17 +545,25 @@ function jsxDEVImpl( //
, because we aren't currently able to tell if // key is explicitly declared to be undefined or not. if (maybeKey !== undefined) { - if (__DEV__) { - checkKeyStringCoercion(maybeKey); + if (enableOptimisticKey && maybeKey === REACT_OPTIMISTIC_KEY) { + key = REACT_OPTIMISTIC_KEY; + } else { + if (__DEV__) { + checkKeyStringCoercion(maybeKey); + } + key = '' + maybeKey; } - key = '' + maybeKey; } if (hasValidKey(config)) { - if (__DEV__) { - checkKeyStringCoercion(config.key); + if (enableOptimisticKey && config.key === REACT_OPTIMISTIC_KEY) { + key = REACT_OPTIMISTIC_KEY; + } else { + if (__DEV__) { + checkKeyStringCoercion(config.key); + } + key = '' + config.key; } - key = '' + config.key; } let props; @@ -637,10 +654,14 @@ export function createElement(type, config, children) { } if (hasValidKey(config)) { - if (__DEV__) { - checkKeyStringCoercion(config.key); + if (enableOptimisticKey && config.key === REACT_OPTIMISTIC_KEY) { + key = REACT_OPTIMISTIC_KEY; + } else { + if (__DEV__) { + checkKeyStringCoercion(config.key); + } + key = '' + config.key; } - key = '' + config.key; } // Remaining properties are added to a new props object @@ -769,10 +790,14 @@ export function cloneElement(element, config, children) { owner = __DEV__ ? getOwner() : undefined; } if (hasValidKey(config)) { - if (__DEV__) { - checkKeyStringCoercion(config.key); + if (enableOptimisticKey && config.key === REACT_OPTIMISTIC_KEY) { + key = REACT_OPTIMISTIC_KEY; + } else { + if (__DEV__) { + checkKeyStringCoercion(config.key); + } + key = '' + config.key; } - key = '' + config.key; } // Remaining properties override existing props diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index a5befa11a0d5c..ebb287568af8a 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -98,6 +98,8 @@ export const enableHydrationChangeEvent = __EXPERIMENTAL__; export const enableDefaultTransitionIndicator = __EXPERIMENTAL__; +export const enableOptimisticKey = __EXPERIMENTAL__; + /** * Switches Fiber creation to a simple object instead of a constructor. */ diff --git a/packages/shared/ReactSymbols.js b/packages/shared/ReactSymbols.js index f8ebf703f463d..d22d4ba12ee98 100644 --- a/packages/shared/ReactSymbols.js +++ b/packages/shared/ReactSymbols.js @@ -65,3 +65,12 @@ export function getIteratorFn(maybeIterable: ?any): ?() => ?Iterator { } export const ASYNC_ITERATOR = Symbol.asyncIterator; + +export const REACT_OPTIMISTIC_KEY: ReactOptimisticKey = (Symbol.for( + 'react.optimistic_key', +): any); + +// This is actually a symbol but Flow doesn't support comparison of symbols to refine. +// We use a boolean since in our code we often expect string (key) or number (index), +// so by pretending to be a boolean we cover a lot of cases that don't consider this case. +export type ReactOptimisticKey = true; diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index bcdda6da2a7c2..65ed43c063ce9 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -7,6 +7,12 @@ * @flow */ +import type {ReactOptimisticKey} from './ReactSymbols'; + +export type {ReactOptimisticKey}; + +export type ReactKey = null | string | ReactOptimisticKey; + export type ReactNode = | React$Element | ReactPortal @@ -26,7 +32,7 @@ export type ReactText = string | number; export type ReactProvider = { $$typeof: symbol | number, type: ReactContext, - key: null | string, + key: ReactKey, ref: null, props: { value: T, @@ -42,7 +48,7 @@ export type ReactConsumerType = { export type ReactConsumer = { $$typeof: symbol | number, type: ReactConsumerType, - key: null | string, + key: ReactKey, ref: null, props: { children: (value: T) => ReactNodeList, @@ -66,7 +72,7 @@ export type ReactContext = { export type ReactPortal = { $$typeof: symbol | number, - key: null | string, + key: ReactKey, containerInfo: any, children: ReactNodeList, // TODO: figure out the API for cross-renderer implementation. @@ -204,7 +210,7 @@ export type ReactFunctionLocation = [ export type ReactComponentInfo = { +name: string, +env?: string, - +key?: null | string, + +key?: ReactKey, +owner?: null | ReactComponentInfo, +stack?: null | ReactStackTrace, +props?: null | {[name: string]: mixed}, diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 904b2c9837dd2..d9a91f8a808f5 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -85,6 +85,7 @@ export const enableComponentPerformanceTrack: boolean = export const enablePerformanceIssueReporting: boolean = enableComponentPerformanceTrack; export const enableInternalInstanceMap: boolean = false; +export const enableOptimisticKey: boolean = 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 183ae65bc22b4..fa8f336c03f1d 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -78,6 +78,8 @@ export const enableFragmentRefsInstanceHandles: boolean = false; export const enableInternalInstanceMap: boolean = false; +export const enableOptimisticKey: boolean = false; + // Profiling Only export const enableProfilerTimer: boolean = __PROFILE__; export const enableProfilerCommitHooks: boolean = __PROFILE__; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 0747f0e8be433..acf3847bd065a 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -93,5 +93,7 @@ export const enableReactTestRendererWarning: boolean = true; export const enableObjectFiber: boolean = false; +export const enableOptimisticKey: boolean = 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 d9c425319f91a..5d3a551301823 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -69,6 +69,7 @@ export const enableDefaultTransitionIndicator = true; export const enableFragmentRefs = false; export const enableFragmentRefsScrollIntoView = false; export const ownerStackLimit = 1e4; +export const enableOptimisticKey = 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 2b40c1b01c6c0..553be202c45ea 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -87,5 +87,7 @@ export const ownerStackLimit = 1e4; export const enableInternalInstanceMap: boolean = false; +export const enableOptimisticKey: boolean = 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.js b/packages/shared/forks/ReactFeatureFlags.www.js index 029cd0d196e15..87801a9658f68 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -114,5 +114,7 @@ export const ownerStackLimit = 1e4; export const enableFragmentRefsInstanceHandles: boolean = true; +export const enableOptimisticKey: boolean = false; + // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType);