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 f9729b535e073..31af0363fcf32 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 { @@ -532,7 +534,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, @@ -1641,7 +1643,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 @@ -1812,7 +1814,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 @@ -1828,7 +1835,7 @@ function renderFunctionComponent( function warnForMissingKey( request: Request, - key: null | string, + key: ReactKey, componentDebugInfo: ReactComponentInfo, debugTask: null | ConsoleTask, ): void { @@ -2022,7 +2029,7 @@ function renderClientElement( request: Request, task: Task, type: any, - key: null | string, + key: ReactKey, props: any, validated: number, // DEV-only ): ReactJSONValue { @@ -2032,7 +2039,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; @@ -2159,7 +2171,7 @@ function renderElement( request: Request, task: Task, type: any, - key: null | string, + key: ReactKey, ref: mixed, props: any, validated: number, // DEV only @@ -2650,7 +2662,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, @@ -3504,7 +3516,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 + '"'; } @@ -3530,7 +3542,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);