diff --git a/packages/jest-react/src/internalAct.js b/packages/jest-react/src/internalAct.js index 52b1d6a7c2fc4..4e41f6234c928 100644 --- a/packages/jest-react/src/internalAct.js +++ b/packages/jest-react/src/internalAct.js @@ -23,7 +23,7 @@ import enqueueTask from 'shared/enqueueTask'; let actingUpdatesScopeDepth = 0; export function act(scope: () => Thenable | T): Thenable { - if (Scheduler.unstable_flushAllWithoutAsserting === undefined) { + if (Scheduler.unstable_flushUntilNextPaint === undefined) { throw Error( 'This version of `act` requires a special mock build of Scheduler.', ); @@ -120,19 +120,31 @@ export function act(scope: () => Thenable | T): Thenable { } function flushActWork(resolve, reject) { - // Flush suspended fallbacks - // $FlowFixMe: Flow doesn't know about global Jest object - jest.runOnlyPendingTimers(); - enqueueTask(() => { + if (Scheduler.unstable_hasPendingWork()) { try { - const didFlushWork = Scheduler.unstable_flushAllWithoutAsserting(); - if (didFlushWork) { - flushActWork(resolve, reject); - } else { - resolve(); - } + Scheduler.unstable_flushUntilNextPaint(); } catch (error) { reject(error); } - }); + + // If Scheduler yields while there's still work, it's so that we can + // unblock the main thread (e.g. for paint or for microtasks). Yield to + // the main thread and continue in a new task. + enqueueTask(() => flushActWork(resolve, reject)); + return; + } + + // Once the scheduler queue is empty, run all the timers. The purpose of this + // is to force any pending fallbacks to commit. The public version of act does + // this with dev-only React runtime logic, but since our internal act needs to + // work work production builds of React, we have to cheat. + // $FlowFixMe: Flow doesn't know about global Jest object + jest.runOnlyPendingTimers(); + if (Scheduler.unstable_hasPendingWork()) { + // Committing a fallback scheduled additional work. Continue flushing. + flushActWork(resolve, reject); + return; + } + + resolve(); } diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 074bad71b3bc1..f52aac204c693 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -13,6 +13,8 @@ import type { MutableSourceSubscribeFn, ReactContext, StartTransitionOptions, + Usable, + Thenable, } from 'shared/ReactTypes'; import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.new'; @@ -32,6 +34,7 @@ import { enableLazyContextPropagation, enableUseMutableSource, enableTransitionTracing, + enableUseHook, enableUseMemoCacheHook, } from 'shared/ReactFeatureFlags'; @@ -120,6 +123,10 @@ import { } from './ReactFiberConcurrentUpdates.new'; import {getTreeId} from './ReactFiberTreeContext.new'; import {now} from './Scheduler'; +import { + trackUsedThenable, + getPreviouslyUsedThenableAtIndex, +} from './ReactFiberWakeable.new'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -205,6 +212,9 @@ let didScheduleRenderPhaseUpdate: boolean = false; let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false; // Counts the number of useId hooks in this component. let localIdCounter: number = 0; +// Counts number of `use`-d thenables +let thenableIndexCounter: number = 0; + // Used for ids that are generated completely client-side (i.e. not during // hydration). This counter is global, so client ids are not stable across // render attempts. @@ -403,6 +413,7 @@ export function renderWithHooks( // didScheduleRenderPhaseUpdate = false; // localIdCounter = 0; + // thenableIndexCounter = 0; // TODO Warn if no hooks are used at all during mount, then some are used during update. // Currently we will identify the update render as a mount because memoizedState === null. @@ -441,6 +452,7 @@ export function renderWithHooks( do { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; if (numberOfReRenders >= RE_RENDER_LIMIT) { throw new Error( @@ -524,6 +536,7 @@ export function renderWithHooks( didScheduleRenderPhaseUpdate = false; // This is reset by checkDidRenderIdHook // localIdCounter = 0; + thenableIndexCounter = 0; if (didRenderTooFewHooks) { throw new Error( @@ -631,6 +644,7 @@ export function resetHooksAfterThrow(): void { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; } function mountWorkInProgressHook(): Hook { @@ -722,6 +736,73 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { }; } +function use(usable: Usable): T { + if ( + usable !== null && + typeof usable === 'object' && + typeof usable.then === 'function' + ) { + // This is a thenable. + const thenable: Thenable = (usable: any); + + // Track the position of the thenable within this fiber. + const index = thenableIndexCounter; + thenableIndexCounter += 1; + + switch (thenable.status) { + case 'fulfilled': { + const fulfilledValue: T = thenable.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError = thenable.reason; + throw rejectedError; + } + default: { + const prevThenableAtIndex: Thenable | null = getPreviouslyUsedThenableAtIndex( + index, + ); + if (prevThenableAtIndex !== null) { + switch (prevThenableAtIndex.status) { + case 'fulfilled': { + const fulfilledValue: T = prevThenableAtIndex.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError: mixed = prevThenableAtIndex.reason; + throw rejectedError; + } + default: { + // The thenable still hasn't resolved. Suspend with the same + // thenable as last time to avoid redundant listeners. + throw prevThenableAtIndex; + } + } + } else { + // This is the first time something has been used at this index. + // Stash the thenable at the current index so we can reuse it during + // the next attempt. + trackUsedThenable(thenable, index); + + // Suspend. + // TODO: Throwing here is an implementation detail that allows us to + // unwind the call stack. But we shouldn't allow it to leak into + // userspace. Throw an opaque placeholder value instead of the + // actual thenable. If it doesn't get captured by the work loop, log + // a warning, because that means something in userspace must have + // caught it. + throw thenable; + } + } + } + } + + // TODO: Add support for Context + + // eslint-disable-next-line react-internal/safe-string-coercion + throw new Error('An unsupported type was passed to use(): ' + String(usable)); +} + function useMemoCache(size: number): Array { throw new Error('Not implemented.'); } @@ -2421,6 +2502,9 @@ if (enableCache) { (ContextOnlyDispatcher: Dispatcher).getCacheForType = getCacheForType; (ContextOnlyDispatcher: Dispatcher).useCacheRefresh = throwInvalidHookError; } +if (enableUseHook) { + (ContextOnlyDispatcher: Dispatcher).use = throwInvalidHookError; +} if (enableUseMemoCacheHook) { (ContextOnlyDispatcher: Dispatcher).useMemoCache = throwInvalidHookError; } @@ -2452,6 +2536,9 @@ if (enableCache) { (HooksDispatcherOnMount: Dispatcher).getCacheForType = getCacheForType; (HooksDispatcherOnMount: Dispatcher).useCacheRefresh = mountRefresh; } +if (enableUseHook) { + (HooksDispatcherOnMount: Dispatcher).use = use; +} if (enableUseMemoCacheHook) { (HooksDispatcherOnMount: Dispatcher).useMemoCache = useMemoCache; } @@ -2485,6 +2572,9 @@ if (enableCache) { if (enableUseMemoCacheHook) { (HooksDispatcherOnUpdate: Dispatcher).useMemoCache = useMemoCache; } +if (enableUseHook) { + (HooksDispatcherOnUpdate: Dispatcher).use = use; +} const HooksDispatcherOnRerender: Dispatcher = { readContext, @@ -2513,6 +2603,9 @@ if (enableCache) { (HooksDispatcherOnRerender: Dispatcher).getCacheForType = getCacheForType; (HooksDispatcherOnRerender: Dispatcher).useCacheRefresh = updateRefresh; } +if (enableUseHook) { + (HooksDispatcherOnRerender: Dispatcher).use = use; +} if (enableUseMemoCacheHook) { (HooksDispatcherOnRerender: Dispatcher).useMemoCache = useMemoCache; } @@ -2691,6 +2784,9 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnMountInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -2836,6 +2932,9 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -2981,6 +3080,9 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -3127,6 +3229,9 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -3289,6 +3394,14 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).use = function( + usable: Usable, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } if (enableUseMemoCacheHook) { (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = function( size: number, @@ -3456,6 +3569,14 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).use = function( + usable: Usable, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } if (enableUseMemoCacheHook) { (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = function( size: number, @@ -3624,6 +3745,14 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).use = function( + usable: Usable, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } if (enableUseMemoCacheHook) { (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = function( size: number, diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 9e461e54678b0..473b8aace6ccd 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -13,6 +13,8 @@ import type { MutableSourceSubscribeFn, ReactContext, StartTransitionOptions, + Usable, + Thenable, } from 'shared/ReactTypes'; import type {Fiber, Dispatcher, HookType} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.old'; @@ -32,6 +34,7 @@ import { enableLazyContextPropagation, enableUseMutableSource, enableTransitionTracing, + enableUseHook, enableUseMemoCacheHook, } from 'shared/ReactFeatureFlags'; @@ -120,6 +123,10 @@ import { } from './ReactFiberConcurrentUpdates.old'; import {getTreeId} from './ReactFiberTreeContext.old'; import {now} from './Scheduler'; +import { + trackUsedThenable, + getPreviouslyUsedThenableAtIndex, +} from './ReactFiberWakeable.old'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -205,6 +212,9 @@ let didScheduleRenderPhaseUpdate: boolean = false; let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false; // Counts the number of useId hooks in this component. let localIdCounter: number = 0; +// Counts number of `use`-d thenables +let thenableIndexCounter: number = 0; + // Used for ids that are generated completely client-side (i.e. not during // hydration). This counter is global, so client ids are not stable across // render attempts. @@ -403,6 +413,7 @@ export function renderWithHooks( // didScheduleRenderPhaseUpdate = false; // localIdCounter = 0; + // thenableIndexCounter = 0; // TODO Warn if no hooks are used at all during mount, then some are used during update. // Currently we will identify the update render as a mount because memoizedState === null. @@ -441,6 +452,7 @@ export function renderWithHooks( do { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; if (numberOfReRenders >= RE_RENDER_LIMIT) { throw new Error( @@ -524,6 +536,7 @@ export function renderWithHooks( didScheduleRenderPhaseUpdate = false; // This is reset by checkDidRenderIdHook // localIdCounter = 0; + thenableIndexCounter = 0; if (didRenderTooFewHooks) { throw new Error( @@ -631,6 +644,7 @@ export function resetHooksAfterThrow(): void { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; + thenableIndexCounter = 0; } function mountWorkInProgressHook(): Hook { @@ -722,6 +736,73 @@ function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { }; } +function use(usable: Usable): T { + if ( + usable !== null && + typeof usable === 'object' && + typeof usable.then === 'function' + ) { + // This is a thenable. + const thenable: Thenable = (usable: any); + + // Track the position of the thenable within this fiber. + const index = thenableIndexCounter; + thenableIndexCounter += 1; + + switch (thenable.status) { + case 'fulfilled': { + const fulfilledValue: T = thenable.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError = thenable.reason; + throw rejectedError; + } + default: { + const prevThenableAtIndex: Thenable | null = getPreviouslyUsedThenableAtIndex( + index, + ); + if (prevThenableAtIndex !== null) { + switch (prevThenableAtIndex.status) { + case 'fulfilled': { + const fulfilledValue: T = prevThenableAtIndex.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError: mixed = prevThenableAtIndex.reason; + throw rejectedError; + } + default: { + // The thenable still hasn't resolved. Suspend with the same + // thenable as last time to avoid redundant listeners. + throw prevThenableAtIndex; + } + } + } else { + // This is the first time something has been used at this index. + // Stash the thenable at the current index so we can reuse it during + // the next attempt. + trackUsedThenable(thenable, index); + + // Suspend. + // TODO: Throwing here is an implementation detail that allows us to + // unwind the call stack. But we shouldn't allow it to leak into + // userspace. Throw an opaque placeholder value instead of the + // actual thenable. If it doesn't get captured by the work loop, log + // a warning, because that means something in userspace must have + // caught it. + throw thenable; + } + } + } + } + + // TODO: Add support for Context + + // eslint-disable-next-line react-internal/safe-string-coercion + throw new Error('An unsupported type was passed to use(): ' + String(usable)); +} + function useMemoCache(size: number): Array { throw new Error('Not implemented.'); } @@ -2421,6 +2502,9 @@ if (enableCache) { (ContextOnlyDispatcher: Dispatcher).getCacheForType = getCacheForType; (ContextOnlyDispatcher: Dispatcher).useCacheRefresh = throwInvalidHookError; } +if (enableUseHook) { + (ContextOnlyDispatcher: Dispatcher).use = throwInvalidHookError; +} if (enableUseMemoCacheHook) { (ContextOnlyDispatcher: Dispatcher).useMemoCache = throwInvalidHookError; } @@ -2452,6 +2536,9 @@ if (enableCache) { (HooksDispatcherOnMount: Dispatcher).getCacheForType = getCacheForType; (HooksDispatcherOnMount: Dispatcher).useCacheRefresh = mountRefresh; } +if (enableUseHook) { + (HooksDispatcherOnMount: Dispatcher).use = use; +} if (enableUseMemoCacheHook) { (HooksDispatcherOnMount: Dispatcher).useMemoCache = useMemoCache; } @@ -2485,6 +2572,9 @@ if (enableCache) { if (enableUseMemoCacheHook) { (HooksDispatcherOnUpdate: Dispatcher).useMemoCache = useMemoCache; } +if (enableUseHook) { + (HooksDispatcherOnUpdate: Dispatcher).use = use; +} const HooksDispatcherOnRerender: Dispatcher = { readContext, @@ -2513,6 +2603,9 @@ if (enableCache) { (HooksDispatcherOnRerender: Dispatcher).getCacheForType = getCacheForType; (HooksDispatcherOnRerender: Dispatcher).useCacheRefresh = updateRefresh; } +if (enableUseHook) { + (HooksDispatcherOnRerender: Dispatcher).use = use; +} if (enableUseMemoCacheHook) { (HooksDispatcherOnRerender: Dispatcher).useMemoCache = useMemoCache; } @@ -2691,6 +2784,9 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnMountInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -2836,6 +2932,9 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -2981,6 +3080,9 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -3127,6 +3229,9 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).use = use; + } if (enableUseMemoCacheHook) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = useMemoCache; } @@ -3289,6 +3394,14 @@ if (__DEV__) { return mountRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).use = function( + usable: Usable, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } if (enableUseMemoCacheHook) { (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = function( size: number, @@ -3456,6 +3569,14 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).use = function( + usable: Usable, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } if (enableUseMemoCacheHook) { (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = function( size: number, @@ -3624,6 +3745,14 @@ if (__DEV__) { return updateRefresh(); }; } + if (enableUseHook) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).use = function( + usable: Usable, + ): T { + warnInvalidHookAccess(); + return use(usable); + }; + } if (enableUseMemoCacheHook) { (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = function( size: number, diff --git a/packages/react-reconciler/src/ReactFiberLane.new.js b/packages/react-reconciler/src/ReactFiberLane.new.js index c3f8f5329ac79..4aebeba7a205e 100644 --- a/packages/react-reconciler/src/ReactFiberLane.new.js +++ b/packages/react-reconciler/src/ReactFiberLane.new.js @@ -403,7 +403,11 @@ export function markStarvedLanesAsExpired( // Iterate through the pending lanes and check if we've reached their // expiration time. If so, we'll assume the update is being starved and mark // it as expired to force it to finish. - let lanes = pendingLanes; + // + // We exclude retry lanes because those must always be time sliced, in order + // to unwrap uncached promises. + // TODO: Write a test for this + let lanes = pendingLanes & ~RetryLanes; while (lanes > 0) { const index = pickArbitraryLaneIndex(lanes); const lane = 1 << index; @@ -435,7 +439,15 @@ export function getHighestPriorityPendingLanes(root: FiberRoot) { return getHighestPriorityLanes(root.pendingLanes); } -export function getLanesToRetrySynchronouslyOnError(root: FiberRoot): Lanes { +export function getLanesToRetrySynchronouslyOnError( + root: FiberRoot, + originallyAttemptedLanes: Lanes, +): Lanes { + if (root.errorRecoveryDisabledLanes & originallyAttemptedLanes) { + // The error recovery mechanism is disabled until these lanes are cleared. + return NoLanes; + } + const everythingButOffscreen = root.pendingLanes & ~OffscreenLane; if (everythingButOffscreen !== NoLanes) { return everythingButOffscreen; @@ -646,6 +658,8 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) { root.entangledLanes &= remainingLanes; + root.errorRecoveryDisabledLanes &= remainingLanes; + const entanglements = root.entanglements; const eventTimes = root.eventTimes; const expirationTimes = root.expirationTimes; diff --git a/packages/react-reconciler/src/ReactFiberLane.old.js b/packages/react-reconciler/src/ReactFiberLane.old.js index b3f31ec0ceac7..5861e9d3d3252 100644 --- a/packages/react-reconciler/src/ReactFiberLane.old.js +++ b/packages/react-reconciler/src/ReactFiberLane.old.js @@ -403,7 +403,11 @@ export function markStarvedLanesAsExpired( // Iterate through the pending lanes and check if we've reached their // expiration time. If so, we'll assume the update is being starved and mark // it as expired to force it to finish. - let lanes = pendingLanes; + // + // We exclude retry lanes because those must always be time sliced, in order + // to unwrap uncached promises. + // TODO: Write a test for this + let lanes = pendingLanes & ~RetryLanes; while (lanes > 0) { const index = pickArbitraryLaneIndex(lanes); const lane = 1 << index; @@ -435,7 +439,15 @@ export function getHighestPriorityPendingLanes(root: FiberRoot) { return getHighestPriorityLanes(root.pendingLanes); } -export function getLanesToRetrySynchronouslyOnError(root: FiberRoot): Lanes { +export function getLanesToRetrySynchronouslyOnError( + root: FiberRoot, + originallyAttemptedLanes: Lanes, +): Lanes { + if (root.errorRecoveryDisabledLanes & originallyAttemptedLanes) { + // The error recovery mechanism is disabled until these lanes are cleared. + return NoLanes; + } + const everythingButOffscreen = root.pendingLanes & ~OffscreenLane; if (everythingButOffscreen !== NoLanes) { return everythingButOffscreen; @@ -646,6 +658,8 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) { root.entangledLanes &= remainingLanes; + root.errorRecoveryDisabledLanes &= remainingLanes; + const entanglements = root.entanglements; const eventTimes = root.eventTimes; const expirationTimes = root.expirationTimes; diff --git a/packages/react-reconciler/src/ReactFiberRoot.new.js b/packages/react-reconciler/src/ReactFiberRoot.new.js index f171ca0de3943..892fe78ac1b1e 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.new.js +++ b/packages/react-reconciler/src/ReactFiberRoot.new.js @@ -70,6 +70,7 @@ function FiberRootNode( this.expiredLanes = NoLanes; this.mutableReadLanes = NoLanes; this.finishedLanes = NoLanes; + this.errorRecoveryDisabledLanes = NoLanes; this.entangledLanes = NoLanes; this.entanglements = createLaneMap(NoLanes); diff --git a/packages/react-reconciler/src/ReactFiberRoot.old.js b/packages/react-reconciler/src/ReactFiberRoot.old.js index 9b37cee41edab..f7e16f0bbdcc8 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.old.js +++ b/packages/react-reconciler/src/ReactFiberRoot.old.js @@ -70,6 +70,7 @@ function FiberRootNode( this.expiredLanes = NoLanes; this.mutableReadLanes = NoLanes; this.finishedLanes = NoLanes; + this.errorRecoveryDisabledLanes = NoLanes; this.entangledLanes = NoLanes; this.entanglements = createLaneMap(NoLanes); diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index c17218f83be4e..d4e69b7c66940 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -57,7 +57,7 @@ import { onUncaughtError, markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, - pingSuspendedRoot, + attachPingListener, restorePendingUpdaters, } from './ReactFiberWorkLoop.new'; import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext.new'; @@ -78,8 +78,6 @@ import { queueHydrationError, } from './ReactFiberHydrationContext.new'; -const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; - function createRootErrorUpdate( fiber: Fiber, errorInfo: CapturedValue, @@ -159,46 +157,6 @@ function createClassErrorUpdate( return update; } -function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) { - // Attach a ping listener - // - // The data might resolve before we have a chance to commit the fallback. Or, - // in the case of a refresh, we'll never commit a fallback. So we need to - // attach a listener now. When it resolves ("pings"), we can decide whether to - // try rendering the tree again. - // - // Only attach a listener if one does not already exist for the lanes - // we're currently rendering (which acts like a "thread ID" here). - // - // We only need to do this in concurrent mode. Legacy Suspense always - // commits fallbacks synchronously, so there are no pings. - let pingCache = root.pingCache; - let threadIDs; - if (pingCache === null) { - pingCache = root.pingCache = new PossiblyWeakMap(); - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } else { - threadIDs = pingCache.get(wakeable); - if (threadIDs === undefined) { - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } - } - if (!threadIDs.has(lanes)) { - // Memoize using the thread ID to prevent redundant listeners. - threadIDs.add(lanes); - const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); - if (enableUpdaterTracking) { - if (isDevToolsPresent) { - // If we have pending work still, restore the original updaters - restorePendingUpdaters(root, lanes); - } - } - wakeable.then(ping, ping); - } -} - function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { if (enableLazyContextPropagation) { const currentSourceFiber = sourceFiber.alternate; @@ -357,7 +315,7 @@ function throwException( sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes, -): Wakeable | null { +): void { // The source fiber did not complete. sourceFiber.flags |= Incomplete; @@ -459,7 +417,7 @@ function throwException( if (suspenseBoundary.mode & ConcurrentMode) { attachPingListener(root, wakeable, rootRenderLanes); } - return wakeable; + return; } else { // No boundary was found. Unless this is a sync update, this is OK. // We can suspend and wait for more data to arrive. @@ -474,7 +432,7 @@ function throwException( // This case also applies to initial hydration. attachPingListener(root, wakeable, rootRenderLanes); renderDidSuspendDelayIfPossible(); - return wakeable; + return; } // This is a sync/discrete update. We treat this case like an error @@ -517,7 +475,7 @@ function throwException( // Even though the user may not be affected by this error, we should // still log it so it can be fixed. queueHydrationError(createCapturedValueAtFiber(value, sourceFiber)); - return null; + return; } } else { // Otherwise, fall through to the error path. @@ -540,7 +498,7 @@ function throwException( workInProgress.lanes = mergeLanes(workInProgress.lanes, lane); const update = createRootErrorUpdate(workInProgress, errorInfo, lane); enqueueCapturedUpdate(workInProgress, update); - return null; + return; } case ClassComponent: // Capture and retry @@ -564,7 +522,7 @@ function throwException( lane, ); enqueueCapturedUpdate(workInProgress, update); - return null; + return; } break; default: @@ -572,7 +530,6 @@ function throwException( } workInProgress = workInProgress.return; } while (workInProgress !== null); - return null; } export {throwException, createRootErrorUpdate, createClassErrorUpdate}; diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index d6c5255807f32..cdc7d3c2a79e4 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -57,7 +57,7 @@ import { onUncaughtError, markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, - pingSuspendedRoot, + attachPingListener, restorePendingUpdaters, } from './ReactFiberWorkLoop.old'; import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext.old'; @@ -78,8 +78,6 @@ import { queueHydrationError, } from './ReactFiberHydrationContext.old'; -const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; - function createRootErrorUpdate( fiber: Fiber, errorInfo: CapturedValue, @@ -159,46 +157,6 @@ function createClassErrorUpdate( return update; } -function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) { - // Attach a ping listener - // - // The data might resolve before we have a chance to commit the fallback. Or, - // in the case of a refresh, we'll never commit a fallback. So we need to - // attach a listener now. When it resolves ("pings"), we can decide whether to - // try rendering the tree again. - // - // Only attach a listener if one does not already exist for the lanes - // we're currently rendering (which acts like a "thread ID" here). - // - // We only need to do this in concurrent mode. Legacy Suspense always - // commits fallbacks synchronously, so there are no pings. - let pingCache = root.pingCache; - let threadIDs; - if (pingCache === null) { - pingCache = root.pingCache = new PossiblyWeakMap(); - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } else { - threadIDs = pingCache.get(wakeable); - if (threadIDs === undefined) { - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } - } - if (!threadIDs.has(lanes)) { - // Memoize using the thread ID to prevent redundant listeners. - threadIDs.add(lanes); - const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); - if (enableUpdaterTracking) { - if (isDevToolsPresent) { - // If we have pending work still, restore the original updaters - restorePendingUpdaters(root, lanes); - } - } - wakeable.then(ping, ping); - } -} - function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { if (enableLazyContextPropagation) { const currentSourceFiber = sourceFiber.alternate; @@ -357,7 +315,7 @@ function throwException( sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes, -): Wakeable | null { +): void { // The source fiber did not complete. sourceFiber.flags |= Incomplete; @@ -459,7 +417,7 @@ function throwException( if (suspenseBoundary.mode & ConcurrentMode) { attachPingListener(root, wakeable, rootRenderLanes); } - return wakeable; + return; } else { // No boundary was found. Unless this is a sync update, this is OK. // We can suspend and wait for more data to arrive. @@ -474,7 +432,7 @@ function throwException( // This case also applies to initial hydration. attachPingListener(root, wakeable, rootRenderLanes); renderDidSuspendDelayIfPossible(); - return wakeable; + return; } // This is a sync/discrete update. We treat this case like an error @@ -517,7 +475,7 @@ function throwException( // Even though the user may not be affected by this error, we should // still log it so it can be fixed. queueHydrationError(createCapturedValueAtFiber(value, sourceFiber)); - return null; + return; } } else { // Otherwise, fall through to the error path. @@ -540,7 +498,7 @@ function throwException( workInProgress.lanes = mergeLanes(workInProgress.lanes, lane); const update = createRootErrorUpdate(workInProgress, errorInfo, lane); enqueueCapturedUpdate(workInProgress, update); - return null; + return; } case ClassComponent: // Capture and retry @@ -564,7 +522,7 @@ function throwException( lane, ); enqueueCapturedUpdate(workInProgress, update); - return null; + return; } break; default: @@ -572,7 +530,6 @@ function throwException( } workInProgress = workInProgress.return; } while (workInProgress !== null); - return null; } export {throwException, createRootErrorUpdate, createClassErrorUpdate}; diff --git a/packages/react-reconciler/src/ReactFiberWakeable.new.js b/packages/react-reconciler/src/ReactFiberWakeable.new.js index 589d61eae814a..83bfad32c5cf1 100644 --- a/packages/react-reconciler/src/ReactFiberWakeable.new.js +++ b/packages/react-reconciler/src/ReactFiberWakeable.new.js @@ -7,38 +7,104 @@ * @flow */ -import type {Wakeable} from 'shared/ReactTypes'; +import type { + Wakeable, + Thenable, + PendingThenable, + FulfilledThenable, + RejectedThenable, +} from 'shared/ReactTypes'; -let suspendedWakeable: Wakeable | null = null; -let wasPinged = false; +let suspendedThenable: Thenable | null = null; let adHocSuspendCount: number = 0; +let usedThenables: Array | void> | null = null; +let lastUsedThenable: Thenable | null = null; + const MAX_AD_HOC_SUSPEND_COUNT = 50; -export function suspendedWakeableWasPinged() { - return wasPinged; +export function isTrackingSuspendedThenable() { + return suspendedThenable !== null; } -export function trackSuspendedWakeable(wakeable: Wakeable) { - adHocSuspendCount++; - suspendedWakeable = wakeable; +export function suspendedThenableDidResolve() { + if (suspendedThenable !== null) { + const status = suspendedThenable.status; + return status === 'fulfilled' || status === 'rejected'; + } + return false; } -export function attemptToPingSuspendedWakeable(wakeable: Wakeable) { - if (wakeable === suspendedWakeable) { - // This ping is from the wakeable that just suspended. Mark it as pinged. - // When the work loop resumes, we'll immediately try rendering the fiber - // again instead of unwinding the stack. - wasPinged = true; - return true; +export function trackSuspendedWakeable(wakeable: Wakeable) { + // If this wakeable isn't already a thenable, turn it into one now. Then, + // when we resume the work loop, we can check if its status is + // still pending. + // TODO: Get rid of the Wakeable type? It's superseded by UntrackedThenable. + const thenable: Thenable = (wakeable: any); + + if (thenable !== lastUsedThenable) { + // If this wakeable was not just `use`-d, it must be an ad hoc wakeable + // that was thrown by an older Suspense implementation. Keep a count of + // these so that we can detect an infinite ping loop. + // TODO: Once `use` throws an opaque signal instead of the actual thenable, + // a better way to count ad hoc suspends is whether an actual thenable + // is caught by the work loop. + adHocSuspendCount++; + } + suspendedThenable = thenable; + + // We use an expando to track the status and result of a thenable so that we + // can synchronously unwrap the value. Think of this as an extension of the + // Promise API, or a custom interface that is a superset of Thenable. + // + // If the thenable doesn't have a status, set it to "pending" and attach + // a listener that will update its status and result when it resolves. + switch (thenable.status) { + case 'pending': + // Since the status is already "pending", we can assume it will be updated + // when it resolves, either by React or something in userspace. + break; + case 'fulfilled': + case 'rejected': + // A thenable that already resolved shouldn't have been thrown, so this is + // unexpected. Suggests a mistake in a userspace data library. Don't track + // this thenable, because if we keep trying it will likely infinite loop + // without ever resolving. + // TODO: Log a warning? + suspendedThenable = null; + break; + default: { + const pendingThenable: PendingThenable = (thenable: any); + pendingThenable.status = 'pending'; + pendingThenable.then( + fulfilledValue => { + if (thenable.status === 'pending') { + const fulfilledThenable: FulfilledThenable = (thenable: any); + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = fulfilledValue; + } + }, + (error: mixed) => { + if (thenable.status === 'pending') { + const rejectedThenable: RejectedThenable = (thenable: any); + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = error; + } + }, + ); + break; + } } - return false; } -export function resetWakeableState() { - suspendedWakeable = null; - wasPinged = false; +export function resetWakeableStateAfterEachAttempt() { + suspendedThenable = null; adHocSuspendCount = 0; + lastUsedThenable = null; +} + +export function resetThenableStateOnCompletion() { + usedThenables = null; } export function throwIfInfinitePingLoopDetected() { @@ -48,3 +114,23 @@ export function throwIfInfinitePingLoopDetected() { // the render phase so that it gets the component stack. } } + +export function trackUsedThenable(thenable: Thenable, index: number) { + if (usedThenables === null) { + usedThenables = []; + } + usedThenables[index] = thenable; + lastUsedThenable = thenable; +} + +export function getPreviouslyUsedThenableAtIndex( + index: number, +): Thenable | null { + if (usedThenables !== null) { + const thenable = usedThenables[index]; + if (thenable !== undefined) { + return thenable; + } + } + return null; +} diff --git a/packages/react-reconciler/src/ReactFiberWakeable.old.js b/packages/react-reconciler/src/ReactFiberWakeable.old.js index 589d61eae814a..83bfad32c5cf1 100644 --- a/packages/react-reconciler/src/ReactFiberWakeable.old.js +++ b/packages/react-reconciler/src/ReactFiberWakeable.old.js @@ -7,38 +7,104 @@ * @flow */ -import type {Wakeable} from 'shared/ReactTypes'; +import type { + Wakeable, + Thenable, + PendingThenable, + FulfilledThenable, + RejectedThenable, +} from 'shared/ReactTypes'; -let suspendedWakeable: Wakeable | null = null; -let wasPinged = false; +let suspendedThenable: Thenable | null = null; let adHocSuspendCount: number = 0; +let usedThenables: Array | void> | null = null; +let lastUsedThenable: Thenable | null = null; + const MAX_AD_HOC_SUSPEND_COUNT = 50; -export function suspendedWakeableWasPinged() { - return wasPinged; +export function isTrackingSuspendedThenable() { + return suspendedThenable !== null; } -export function trackSuspendedWakeable(wakeable: Wakeable) { - adHocSuspendCount++; - suspendedWakeable = wakeable; +export function suspendedThenableDidResolve() { + if (suspendedThenable !== null) { + const status = suspendedThenable.status; + return status === 'fulfilled' || status === 'rejected'; + } + return false; } -export function attemptToPingSuspendedWakeable(wakeable: Wakeable) { - if (wakeable === suspendedWakeable) { - // This ping is from the wakeable that just suspended. Mark it as pinged. - // When the work loop resumes, we'll immediately try rendering the fiber - // again instead of unwinding the stack. - wasPinged = true; - return true; +export function trackSuspendedWakeable(wakeable: Wakeable) { + // If this wakeable isn't already a thenable, turn it into one now. Then, + // when we resume the work loop, we can check if its status is + // still pending. + // TODO: Get rid of the Wakeable type? It's superseded by UntrackedThenable. + const thenable: Thenable = (wakeable: any); + + if (thenable !== lastUsedThenable) { + // If this wakeable was not just `use`-d, it must be an ad hoc wakeable + // that was thrown by an older Suspense implementation. Keep a count of + // these so that we can detect an infinite ping loop. + // TODO: Once `use` throws an opaque signal instead of the actual thenable, + // a better way to count ad hoc suspends is whether an actual thenable + // is caught by the work loop. + adHocSuspendCount++; + } + suspendedThenable = thenable; + + // We use an expando to track the status and result of a thenable so that we + // can synchronously unwrap the value. Think of this as an extension of the + // Promise API, or a custom interface that is a superset of Thenable. + // + // If the thenable doesn't have a status, set it to "pending" and attach + // a listener that will update its status and result when it resolves. + switch (thenable.status) { + case 'pending': + // Since the status is already "pending", we can assume it will be updated + // when it resolves, either by React or something in userspace. + break; + case 'fulfilled': + case 'rejected': + // A thenable that already resolved shouldn't have been thrown, so this is + // unexpected. Suggests a mistake in a userspace data library. Don't track + // this thenable, because if we keep trying it will likely infinite loop + // without ever resolving. + // TODO: Log a warning? + suspendedThenable = null; + break; + default: { + const pendingThenable: PendingThenable = (thenable: any); + pendingThenable.status = 'pending'; + pendingThenable.then( + fulfilledValue => { + if (thenable.status === 'pending') { + const fulfilledThenable: FulfilledThenable = (thenable: any); + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = fulfilledValue; + } + }, + (error: mixed) => { + if (thenable.status === 'pending') { + const rejectedThenable: RejectedThenable = (thenable: any); + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = error; + } + }, + ); + break; + } } - return false; } -export function resetWakeableState() { - suspendedWakeable = null; - wasPinged = false; +export function resetWakeableStateAfterEachAttempt() { + suspendedThenable = null; adHocSuspendCount = 0; + lastUsedThenable = null; +} + +export function resetThenableStateOnCompletion() { + usedThenables = null; } export function throwIfInfinitePingLoopDetected() { @@ -48,3 +114,23 @@ export function throwIfInfinitePingLoopDetected() { // the render phase so that it gets the component stack. } } + +export function trackUsedThenable(thenable: Thenable, index: number) { + if (usedThenables === null) { + usedThenables = []; + } + usedThenables[index] = thenable; + lastUsedThenable = thenable; +} + +export function getPreviouslyUsedThenableAtIndex( + index: number, +): Thenable | null { + if (usedThenables !== null) { + const thenable = usedThenables[index]; + if (thenable !== undefined) { + return thenable; + } + } + return null; +} diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 13a673b19aa2a..229756596cc16 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -254,14 +254,17 @@ import { } from './ReactFiberAct.new'; import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.new'; import { - resetWakeableState, + resetWakeableStateAfterEachAttempt, + resetThenableStateOnCompletion, trackSuspendedWakeable, - suspendedWakeableWasPinged, - attemptToPingSuspendedWakeable, + suspendedThenableDidResolve, + isTrackingSuspendedThenable, } from './ReactFiberWakeable.new'; const ceil = Math.ceil; +const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; + const { ReactCurrentDispatcher, ReactCurrentOwner, @@ -299,6 +302,12 @@ let workInProgressRootRenderLanes: Lanes = NoLanes; // after this happens. If the fiber is pinged before we resume, we can retry // immediately instead of unwinding the stack. let workInProgressIsSuspended: boolean = false; +let workInProgressThrownValue: mixed = null; + +// Whether a ping listener was attached during this render. This is slightly +// different that whether something suspended, because we don't add multiple +// listeners to a promise we've already seen (per root and lane). +let workInProgressRootDidAttachPingListener: boolean = false; // A contextual version of workInProgressRootRenderLanes. It is a superset of // the lanes that we started working on at the root. When we enter a subtree @@ -1013,10 +1022,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // render synchronously to block concurrent data mutations, and we'll // includes all pending updates are included. If it still fails after // the second attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } if (exitStatus === RootFatalErrored) { @@ -1056,10 +1073,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // We need to check again if something threw if (exitStatus === RootErrored) { - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); // We assume the tree is now consistent because we didn't yield to any // concurrent events. } @@ -1090,14 +1115,19 @@ function performConcurrentWorkOnRoot(root, didTimeout) { return null; } -function recoverFromConcurrentError(root, errorRetryLanes) { +function recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, +) { // If an error occurred during hydration, discard server response and fall // back to client side render. // Before rendering again, save the errors from the previous attempt. const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; - if (isRootDehydrated(root)) { + const wasRootDehydrated = isRootDehydrated(root); + if (wasRootDehydrated) { // The shell failed to hydrate. Set a flag to force a client rendering // during the next attempt. To do this, we call prepareFreshStack now // to create the root work-in-progress fiber. This is a bit weird in terms @@ -1120,6 +1150,32 @@ function recoverFromConcurrentError(root, errorRetryLanes) { if (exitStatus !== RootErrored) { // Successfully finished rendering on retry + if (workInProgressRootDidAttachPingListener && !wasRootDehydrated) { + // During the synchronous render, we attached additional ping listeners. + // This is highly suggestive of an uncached promise (though it's not the + // only reason this would happen). If it was an uncached promise, then + // it may have masked a downstream error from ocurring without actually + // fixing it. Example: + // + // use(Promise.resolve('uncached')) + // throw new Error('Oops!') + // + // When this happens, there's a conflict between blocking potential + // concurrent data races and unwrapping uncached promise values. We + // have to choose one or the other. Because the data race recovery is + // a last ditch effort, we'll disable it. + root.errorRecoveryDisabledLanes = mergeLanes( + root.errorRecoveryDisabledLanes, + originallyAttemptedLanes, + ); + + // Mark the current render as suspended and force it to restart. Once + // these lanes finish successfully, we'll re-enable the error recovery + // mechanism for subsequent updates. + workInProgressRootInterleavedUpdatedLanes |= originallyAttemptedLanes; + return RootSuspendedWithDelay; + } + // The errors from the failed first attempt have been recovered. Add // them to the collection of recoverable errors. We'll log them in the // commit phase. @@ -1376,10 +1432,18 @@ function performSyncWorkOnRoot(root) { // synchronously to block concurrent data mutations, and we'll includes // all pending updates are included. If it still fails after the second // attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } @@ -1596,13 +1660,16 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { ); interruptedWork = interruptedWork.return; } - resetWakeableState(); + resetWakeableStateAfterEachAttempt(); + resetThenableStateOnCompletion(); } workInProgressRoot = root; const rootWorkInProgress = createWorkInProgress(root.current, null); workInProgress = rootWorkInProgress; workInProgressRootRenderLanes = renderLanes = lanes; workInProgressIsSuspended = false; + workInProgressThrownValue = null; + workInProgressRootDidAttachPingListener = false; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; workInProgressRootSkippedLanes = NoLanes; @@ -1621,94 +1688,65 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { return rootWorkInProgress; } -function handleError(root, thrownValue): Wakeable | null { - do { - let erroredWork = workInProgress; - try { - // Reset module-level state that was set during the render phase. - resetContextDependencies(); - resetHooksAfterThrow(); - resetCurrentDebugFiberInDEV(); - // TODO: I found and added this missing line while investigating a - // separate issue. Write a regression test using string refs. - ReactCurrentOwner.current = null; - - if (erroredWork === null || erroredWork.return === null) { - // Expected to be working on a non-root fiber. This is a fatal error - // because there's no ancestor that can handle it; the root is - // supposed to capture all errors that weren't caught by an error - // boundary. - workInProgressRootExitStatus = RootFatalErrored; - workInProgressRootFatalError = thrownValue; - // Set `workInProgress` to null. This represents advancing to the next - // sibling, or the parent if there are no siblings. But since the root - // has no siblings nor a parent, we set it to null. Usually this is - // handled by `completeUnitOfWork` or `unwindWork`, but since we're - // intentionally not calling those, we need set it here. - // TODO: Consider calling `unwindWork` to pop the contexts. - workInProgress = null; - return null; - } +function handleThrow(root, thrownValue): void { + // Reset module-level state that was set during the render phase. + resetContextDependencies(); + resetHooksAfterThrow(); + resetCurrentDebugFiberInDEV(); + // TODO: I found and added this missing line while investigating a + // separate issue. Write a regression test using string refs. + ReactCurrentOwner.current = null; - if (enableProfilerTimer && erroredWork.mode & ProfileMode) { - // Record the time spent rendering before an error was thrown. This - // avoids inaccurate Profiler durations in the case of a - // suspended render. - stopProfilerTimerIfRunningAndRecordDelta(erroredWork, true); - } + // Setting this to `true` tells the work loop to unwind the stack instead + // of entering the begin phase. It's called "suspended" because it usually + // happens because of Suspense, but it also applies to errors. Think of it + // as suspending the execution of the work loop. + workInProgressIsSuspended = true; + workInProgressThrownValue = thrownValue; + + const erroredWork = workInProgress; + if (erroredWork === null) { + // This is a fatal error + workInProgressRootExitStatus = RootFatalErrored; + workInProgressRootFatalError = thrownValue; + return; + } - if (enableSchedulingProfiler) { - markComponentRenderStopped(); + const isWakeable = + thrownValue !== null && + typeof thrownValue === 'object' && + typeof thrownValue.then === 'function'; - if ( - thrownValue !== null && - typeof thrownValue === 'object' && - typeof thrownValue.then === 'function' - ) { - const wakeable: Wakeable = (thrownValue: any); - markComponentSuspended( - erroredWork, - wakeable, - workInProgressRootRenderLanes, - ); - } else { - markComponentErrored( - erroredWork, - thrownValue, - workInProgressRootRenderLanes, - ); - } - } + if (enableProfilerTimer && erroredWork.mode & ProfileMode) { + // Record the time spent rendering before an error was thrown. This + // avoids inaccurate Profiler durations in the case of a + // suspended render. + stopProfilerTimerIfRunningAndRecordDelta(erroredWork, true); + } - const maybeWakeable = throwException( - root, - erroredWork.return, + if (enableSchedulingProfiler) { + markComponentRenderStopped(); + if (isWakeable) { + const wakeable: Wakeable = (thrownValue: any); + markComponentSuspended( + erroredWork, + wakeable, + workInProgressRootRenderLanes, + ); + } else { + markComponentErrored( erroredWork, thrownValue, workInProgressRootRenderLanes, ); - // Setting this to `true` tells the work loop to unwind the stack instead - // of entering the begin phase. It's called "suspended" because it usually - // happens because of Suspense, but it also applies to errors. Think of it - // as suspending the execution of the work loop. - workInProgressIsSuspended = true; - - // Return to the normal work loop. - return maybeWakeable; - } catch (yetAnotherThrownValue) { - // Something in the return path also threw. - thrownValue = yetAnotherThrownValue; - if (workInProgress === erroredWork && erroredWork !== null) { - // If this boundary has already errored, then we had trouble processing - // the error. Bubble it to the next boundary. - erroredWork = erroredWork.return; - workInProgress = erroredWork; - } else { - erroredWork = workInProgress; - } - continue; } - } while (true); + } + + if (isWakeable) { + const wakeable: Wakeable = (thrownValue: any); + + trackSuspendedWakeable(wakeable); + } } function pushDispatcher() { @@ -1834,7 +1872,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { workLoopSync(); break; } catch (thrownValue) { - handleError(root, thrownValue); + handleThrow(root, thrownValue); } } while (true); resetContextDependencies(); @@ -1872,10 +1910,15 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { function workLoopSync() { // Perform work without checking if we need to yield between fiber. - if (workInProgressIsSuspended && workInProgress !== null) { + if (workInProgressIsSuspended) { // The current work-in-progress was already attempted. We need to unwind // it before we continue the normal work loop. - resumeSuspendedUnitOfWork(workInProgress); + const thrownValue = workInProgressThrownValue; + workInProgressIsSuspended = false; + workInProgressThrownValue = null; + if (workInProgress !== null) { + resumeSuspendedUnitOfWork(workInProgress, thrownValue); + } } while (workInProgress !== null) { @@ -1927,12 +1970,11 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { workLoopConcurrent(); break; } catch (thrownValue) { - const maybeWakeable = handleError(root, thrownValue); - if (maybeWakeable !== null) { + handleThrow(root, thrownValue); + if (isTrackingSuspendedThenable()) { // If this fiber just suspended, it's possible the data is already // cached. Yield to the the main thread to give it a chance to ping. If // it does, we can retry immediately without unwinding the stack. - trackSuspendedWakeable(maybeWakeable); break; } } @@ -1974,10 +2016,15 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { function workLoopConcurrent() { // Perform work until Scheduler asks us to yield - if (workInProgressIsSuspended && workInProgress !== null) { + if (workInProgressIsSuspended) { // The current work-in-progress was already attempted. We need to unwind // it before we continue the normal work loop. - resumeSuspendedUnitOfWork(workInProgress); + const thrownValue = workInProgressThrownValue; + workInProgressIsSuspended = false; + workInProgressThrownValue = null; + if (workInProgress !== null) { + resumeSuspendedUnitOfWork(workInProgress, thrownValue); + } } while (workInProgress !== null && !shouldYield()) { @@ -2013,27 +2060,72 @@ function performUnitOfWork(unitOfWork: Fiber): void { ReactCurrentOwner.current = null; } -function resumeSuspendedUnitOfWork(unitOfWork: Fiber): void { +function resumeSuspendedUnitOfWork( + unitOfWork: Fiber, + thrownValue: mixed, +): void { // This is a fork of performUnitOfWork specifcally for resuming a fiber that // just suspended. In some cases, we may choose to retry the fiber immediately // instead of unwinding the stack. It's a separate function to keep the // additional logic out of the work loop's hot path. - if (!suspendedWakeableWasPinged()) { - // The wakeable wasn't pinged. Return to the normal work loop. This will + const wasPinged = suspendedThenableDidResolve(); + resetWakeableStateAfterEachAttempt(); + + if (!wasPinged) { + // The thenable wasn't pinged. Return to the normal work loop. This will // unwind the stack, and potentially result in showing a fallback. - workInProgressIsSuspended = false; - resetWakeableState(); + resetThenableStateOnCompletion(); + + const returnFiber = unitOfWork.return; + if (returnFiber === null || workInProgressRoot === null) { + // Expected to be working on a non-root fiber. This is a fatal error + // because there's no ancestor that can handle it; the root is + // supposed to capture all errors that weren't caught by an error + // boundary. + workInProgressRootExitStatus = RootFatalErrored; + workInProgressRootFatalError = thrownValue; + // Set `workInProgress` to null. This represents advancing to the next + // sibling, or the parent if there are no siblings. But since the root + // has no siblings nor a parent, we set it to null. Usually this is + // handled by `completeUnitOfWork` or `unwindWork`, but since we're + // intentionally not calling those, we need set it here. + // TODO: Consider calling `unwindWork` to pop the contexts. + workInProgress = null; + return; + } + + try { + // Find and mark the nearest Suspense or error boundary that can handle + // this "exception". + throwException( + workInProgressRoot, + returnFiber, + unitOfWork, + thrownValue, + workInProgressRootRenderLanes, + ); + } catch (error) { + // We had trouble processing the error. An example of this happening is + // when accessing the `componentDidCatch` property of an error boundary + // throws an error. A weird edge case. There's a regression test for this. + // To prevent an infinite loop, bubble the error up to the next parent. + workInProgress = returnFiber; + throw error; + } + + // Return to the normal work loop. completeUnitOfWork(unitOfWork); return; } // The work-in-progress was immediately pinged. Instead of unwinding the - // stack and potentially showing a fallback, reset the fiber and try rendering - // it again. + // stack and potentially showing a fallback, unwind only the last stack frame, + // reset the fiber, and try rendering it again. + const current = unitOfWork.alternate; + unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes); unitOfWork = workInProgress = resetWorkInProgress(unitOfWork, renderLanes); - const current = unitOfWork.alternate; setCurrentDebugFiberInDEV(unitOfWork); let next; @@ -2048,8 +2140,7 @@ function resumeSuspendedUnitOfWork(unitOfWork: Fiber): void { // The begin phase finished successfully without suspending. Reset the state // used to track the fiber while it was suspended. Then return to the normal // work loop. - workInProgressIsSuspended = false; - resetWakeableState(); + resetThenableStateOnCompletion(); resetCurrentDebugFiberInDEV(); unitOfWork.memoizedProps = unitOfWork.pendingProps; @@ -2850,7 +2941,53 @@ export function captureCommitPhaseError( } } -export function pingSuspendedRoot( +export function attachPingListener( + root: FiberRoot, + wakeable: Wakeable, + lanes: Lanes, +) { + // Attach a ping listener + // + // The data might resolve before we have a chance to commit the fallback. Or, + // in the case of a refresh, we'll never commit a fallback. So we need to + // attach a listener now. When it resolves ("pings"), we can decide whether to + // try rendering the tree again. + // + // Only attach a listener if one does not already exist for the lanes + // we're currently rendering (which acts like a "thread ID" here). + // + // We only need to do this in concurrent mode. Legacy Suspense always + // commits fallbacks synchronously, so there are no pings. + let pingCache = root.pingCache; + let threadIDs; + if (pingCache === null) { + pingCache = root.pingCache = new PossiblyWeakMap(); + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } else { + threadIDs = pingCache.get(wakeable); + if (threadIDs === undefined) { + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } + } + if (!threadIDs.has(lanes)) { + workInProgressRootDidAttachPingListener = true; + + // Memoize using the thread ID to prevent redundant listeners. + threadIDs.add(lanes); + const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); + if (enableUpdaterTracking) { + if (isDevToolsPresent) { + // If we have pending work still, restore the original updaters + restorePendingUpdaters(root, lanes); + } + } + wakeable.then(ping, ping); + } +} + +function pingSuspendedRoot( root: FiberRoot, wakeable: Wakeable, pingedLanes: Lanes, @@ -2874,31 +3011,26 @@ export function pingSuspendedRoot( // Received a ping at the same priority level at which we're currently // rendering. We might want to restart this render. This should mirror // the logic of whether or not a root suspends once it completes. - const didPingSuspendedWakeable = attemptToPingSuspendedWakeable(wakeable); - if (didPingSuspendedWakeable) { - // Successfully pinged the in-progress fiber. Don't unwind the stack. - } else { - // TODO: If we're rendering sync either due to Sync, Batched or expired, - // we should probably never restart. + // TODO: If we're rendering sync either due to Sync, Batched or expired, + // we should probably never restart. - // If we're suspended with delay, or if it's a retry, we'll always suspend - // so we can always restart. - if ( - workInProgressRootExitStatus === RootSuspendedWithDelay || - (workInProgressRootExitStatus === RootSuspended && - includesOnlyRetries(workInProgressRootRenderLanes) && - now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS) - ) { - // Restart from the root. - prepareFreshStack(root, NoLanes); - } else { - // Even though we can't restart right now, we might get an - // opportunity later. So we mark this render as having a ping. - workInProgressRootPingedLanes = mergeLanes( - workInProgressRootPingedLanes, - pingedLanes, - ); - } + // If we're suspended with delay, or if it's a retry, we'll always suspend + // so we can always restart. + if ( + workInProgressRootExitStatus === RootSuspendedWithDelay || + (workInProgressRootExitStatus === RootSuspended && + includesOnlyRetries(workInProgressRootRenderLanes) && + now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS) + ) { + // Restart from the root. + prepareFreshStack(root, NoLanes); + } else { + // Even though we can't restart right now, we might get an + // opportunity later. So we mark this render as having a ping. + workInProgressRootPingedLanes = mergeLanes( + workInProgressRootPingedLanes, + pingedLanes, + ); } } @@ -3172,7 +3304,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { throw originalError; } - // Keep this code in sync with handleError; any changes here must have + // Keep this code in sync with handleThrow; any changes here must have // corresponding changes there. resetContextDependencies(); resetHooksAfterThrow(); diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 1d8d868a37a21..c4caf55518a86 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -254,14 +254,17 @@ import { } from './ReactFiberAct.old'; import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.old'; import { - resetWakeableState, + resetWakeableStateAfterEachAttempt, + resetThenableStateOnCompletion, trackSuspendedWakeable, - suspendedWakeableWasPinged, - attemptToPingSuspendedWakeable, + suspendedThenableDidResolve, + isTrackingSuspendedThenable, } from './ReactFiberWakeable.old'; const ceil = Math.ceil; +const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; + const { ReactCurrentDispatcher, ReactCurrentOwner, @@ -299,6 +302,12 @@ let workInProgressRootRenderLanes: Lanes = NoLanes; // after this happens. If the fiber is pinged before we resume, we can retry // immediately instead of unwinding the stack. let workInProgressIsSuspended: boolean = false; +let workInProgressThrownValue: mixed = null; + +// Whether a ping listener was attached during this render. This is slightly +// different that whether something suspended, because we don't add multiple +// listeners to a promise we've already seen (per root and lane). +let workInProgressRootDidAttachPingListener: boolean = false; // A contextual version of workInProgressRootRenderLanes. It is a superset of // the lanes that we started working on at the root. When we enter a subtree @@ -1013,10 +1022,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // render synchronously to block concurrent data mutations, and we'll // includes all pending updates are included. If it still fails after // the second attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } if (exitStatus === RootFatalErrored) { @@ -1056,10 +1073,18 @@ function performConcurrentWorkOnRoot(root, didTimeout) { // We need to check again if something threw if (exitStatus === RootErrored) { - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); // We assume the tree is now consistent because we didn't yield to any // concurrent events. } @@ -1090,14 +1115,19 @@ function performConcurrentWorkOnRoot(root, didTimeout) { return null; } -function recoverFromConcurrentError(root, errorRetryLanes) { +function recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, +) { // If an error occurred during hydration, discard server response and fall // back to client side render. // Before rendering again, save the errors from the previous attempt. const errorsFromFirstAttempt = workInProgressRootConcurrentErrors; - if (isRootDehydrated(root)) { + const wasRootDehydrated = isRootDehydrated(root); + if (wasRootDehydrated) { // The shell failed to hydrate. Set a flag to force a client rendering // during the next attempt. To do this, we call prepareFreshStack now // to create the root work-in-progress fiber. This is a bit weird in terms @@ -1120,6 +1150,32 @@ function recoverFromConcurrentError(root, errorRetryLanes) { if (exitStatus !== RootErrored) { // Successfully finished rendering on retry + if (workInProgressRootDidAttachPingListener && !wasRootDehydrated) { + // During the synchronous render, we attached additional ping listeners. + // This is highly suggestive of an uncached promise (though it's not the + // only reason this would happen). If it was an uncached promise, then + // it may have masked a downstream error from ocurring without actually + // fixing it. Example: + // + // use(Promise.resolve('uncached')) + // throw new Error('Oops!') + // + // When this happens, there's a conflict between blocking potential + // concurrent data races and unwrapping uncached promise values. We + // have to choose one or the other. Because the data race recovery is + // a last ditch effort, we'll disable it. + root.errorRecoveryDisabledLanes = mergeLanes( + root.errorRecoveryDisabledLanes, + originallyAttemptedLanes, + ); + + // Mark the current render as suspended and force it to restart. Once + // these lanes finish successfully, we'll re-enable the error recovery + // mechanism for subsequent updates. + workInProgressRootInterleavedUpdatedLanes |= originallyAttemptedLanes; + return RootSuspendedWithDelay; + } + // The errors from the failed first attempt have been recovered. Add // them to the collection of recoverable errors. We'll log them in the // commit phase. @@ -1376,10 +1432,18 @@ function performSyncWorkOnRoot(root) { // synchronously to block concurrent data mutations, and we'll includes // all pending updates are included. If it still fails after the second // attempt, we'll give up and commit the resulting tree. - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); if (errorRetryLanes !== NoLanes) { lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, errorRetryLanes); + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); } } @@ -1596,13 +1660,16 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { ); interruptedWork = interruptedWork.return; } - resetWakeableState(); + resetWakeableStateAfterEachAttempt(); + resetThenableStateOnCompletion(); } workInProgressRoot = root; const rootWorkInProgress = createWorkInProgress(root.current, null); workInProgress = rootWorkInProgress; workInProgressRootRenderLanes = renderLanes = lanes; workInProgressIsSuspended = false; + workInProgressThrownValue = null; + workInProgressRootDidAttachPingListener = false; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; workInProgressRootSkippedLanes = NoLanes; @@ -1621,94 +1688,65 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { return rootWorkInProgress; } -function handleError(root, thrownValue): Wakeable | null { - do { - let erroredWork = workInProgress; - try { - // Reset module-level state that was set during the render phase. - resetContextDependencies(); - resetHooksAfterThrow(); - resetCurrentDebugFiberInDEV(); - // TODO: I found and added this missing line while investigating a - // separate issue. Write a regression test using string refs. - ReactCurrentOwner.current = null; - - if (erroredWork === null || erroredWork.return === null) { - // Expected to be working on a non-root fiber. This is a fatal error - // because there's no ancestor that can handle it; the root is - // supposed to capture all errors that weren't caught by an error - // boundary. - workInProgressRootExitStatus = RootFatalErrored; - workInProgressRootFatalError = thrownValue; - // Set `workInProgress` to null. This represents advancing to the next - // sibling, or the parent if there are no siblings. But since the root - // has no siblings nor a parent, we set it to null. Usually this is - // handled by `completeUnitOfWork` or `unwindWork`, but since we're - // intentionally not calling those, we need set it here. - // TODO: Consider calling `unwindWork` to pop the contexts. - workInProgress = null; - return null; - } +function handleThrow(root, thrownValue): void { + // Reset module-level state that was set during the render phase. + resetContextDependencies(); + resetHooksAfterThrow(); + resetCurrentDebugFiberInDEV(); + // TODO: I found and added this missing line while investigating a + // separate issue. Write a regression test using string refs. + ReactCurrentOwner.current = null; - if (enableProfilerTimer && erroredWork.mode & ProfileMode) { - // Record the time spent rendering before an error was thrown. This - // avoids inaccurate Profiler durations in the case of a - // suspended render. - stopProfilerTimerIfRunningAndRecordDelta(erroredWork, true); - } + // Setting this to `true` tells the work loop to unwind the stack instead + // of entering the begin phase. It's called "suspended" because it usually + // happens because of Suspense, but it also applies to errors. Think of it + // as suspending the execution of the work loop. + workInProgressIsSuspended = true; + workInProgressThrownValue = thrownValue; + + const erroredWork = workInProgress; + if (erroredWork === null) { + // This is a fatal error + workInProgressRootExitStatus = RootFatalErrored; + workInProgressRootFatalError = thrownValue; + return; + } - if (enableSchedulingProfiler) { - markComponentRenderStopped(); + const isWakeable = + thrownValue !== null && + typeof thrownValue === 'object' && + typeof thrownValue.then === 'function'; - if ( - thrownValue !== null && - typeof thrownValue === 'object' && - typeof thrownValue.then === 'function' - ) { - const wakeable: Wakeable = (thrownValue: any); - markComponentSuspended( - erroredWork, - wakeable, - workInProgressRootRenderLanes, - ); - } else { - markComponentErrored( - erroredWork, - thrownValue, - workInProgressRootRenderLanes, - ); - } - } + if (enableProfilerTimer && erroredWork.mode & ProfileMode) { + // Record the time spent rendering before an error was thrown. This + // avoids inaccurate Profiler durations in the case of a + // suspended render. + stopProfilerTimerIfRunningAndRecordDelta(erroredWork, true); + } - const maybeWakeable = throwException( - root, - erroredWork.return, + if (enableSchedulingProfiler) { + markComponentRenderStopped(); + if (isWakeable) { + const wakeable: Wakeable = (thrownValue: any); + markComponentSuspended( + erroredWork, + wakeable, + workInProgressRootRenderLanes, + ); + } else { + markComponentErrored( erroredWork, thrownValue, workInProgressRootRenderLanes, ); - // Setting this to `true` tells the work loop to unwind the stack instead - // of entering the begin phase. It's called "suspended" because it usually - // happens because of Suspense, but it also applies to errors. Think of it - // as suspending the execution of the work loop. - workInProgressIsSuspended = true; - - // Return to the normal work loop. - return maybeWakeable; - } catch (yetAnotherThrownValue) { - // Something in the return path also threw. - thrownValue = yetAnotherThrownValue; - if (workInProgress === erroredWork && erroredWork !== null) { - // If this boundary has already errored, then we had trouble processing - // the error. Bubble it to the next boundary. - erroredWork = erroredWork.return; - workInProgress = erroredWork; - } else { - erroredWork = workInProgress; - } - continue; } - } while (true); + } + + if (isWakeable) { + const wakeable: Wakeable = (thrownValue: any); + + trackSuspendedWakeable(wakeable); + } } function pushDispatcher() { @@ -1834,7 +1872,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { workLoopSync(); break; } catch (thrownValue) { - handleError(root, thrownValue); + handleThrow(root, thrownValue); } } while (true); resetContextDependencies(); @@ -1872,10 +1910,15 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { function workLoopSync() { // Perform work without checking if we need to yield between fiber. - if (workInProgressIsSuspended && workInProgress !== null) { + if (workInProgressIsSuspended) { // The current work-in-progress was already attempted. We need to unwind // it before we continue the normal work loop. - resumeSuspendedUnitOfWork(workInProgress); + const thrownValue = workInProgressThrownValue; + workInProgressIsSuspended = false; + workInProgressThrownValue = null; + if (workInProgress !== null) { + resumeSuspendedUnitOfWork(workInProgress, thrownValue); + } } while (workInProgress !== null) { @@ -1927,12 +1970,11 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { workLoopConcurrent(); break; } catch (thrownValue) { - const maybeWakeable = handleError(root, thrownValue); - if (maybeWakeable !== null) { + handleThrow(root, thrownValue); + if (isTrackingSuspendedThenable()) { // If this fiber just suspended, it's possible the data is already // cached. Yield to the the main thread to give it a chance to ping. If // it does, we can retry immediately without unwinding the stack. - trackSuspendedWakeable(maybeWakeable); break; } } @@ -1974,10 +2016,15 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { function workLoopConcurrent() { // Perform work until Scheduler asks us to yield - if (workInProgressIsSuspended && workInProgress !== null) { + if (workInProgressIsSuspended) { // The current work-in-progress was already attempted. We need to unwind // it before we continue the normal work loop. - resumeSuspendedUnitOfWork(workInProgress); + const thrownValue = workInProgressThrownValue; + workInProgressIsSuspended = false; + workInProgressThrownValue = null; + if (workInProgress !== null) { + resumeSuspendedUnitOfWork(workInProgress, thrownValue); + } } while (workInProgress !== null && !shouldYield()) { @@ -2013,27 +2060,72 @@ function performUnitOfWork(unitOfWork: Fiber): void { ReactCurrentOwner.current = null; } -function resumeSuspendedUnitOfWork(unitOfWork: Fiber): void { +function resumeSuspendedUnitOfWork( + unitOfWork: Fiber, + thrownValue: mixed, +): void { // This is a fork of performUnitOfWork specifcally for resuming a fiber that // just suspended. In some cases, we may choose to retry the fiber immediately // instead of unwinding the stack. It's a separate function to keep the // additional logic out of the work loop's hot path. - if (!suspendedWakeableWasPinged()) { - // The wakeable wasn't pinged. Return to the normal work loop. This will + const wasPinged = suspendedThenableDidResolve(); + resetWakeableStateAfterEachAttempt(); + + if (!wasPinged) { + // The thenable wasn't pinged. Return to the normal work loop. This will // unwind the stack, and potentially result in showing a fallback. - workInProgressIsSuspended = false; - resetWakeableState(); + resetThenableStateOnCompletion(); + + const returnFiber = unitOfWork.return; + if (returnFiber === null || workInProgressRoot === null) { + // Expected to be working on a non-root fiber. This is a fatal error + // because there's no ancestor that can handle it; the root is + // supposed to capture all errors that weren't caught by an error + // boundary. + workInProgressRootExitStatus = RootFatalErrored; + workInProgressRootFatalError = thrownValue; + // Set `workInProgress` to null. This represents advancing to the next + // sibling, or the parent if there are no siblings. But since the root + // has no siblings nor a parent, we set it to null. Usually this is + // handled by `completeUnitOfWork` or `unwindWork`, but since we're + // intentionally not calling those, we need set it here. + // TODO: Consider calling `unwindWork` to pop the contexts. + workInProgress = null; + return; + } + + try { + // Find and mark the nearest Suspense or error boundary that can handle + // this "exception". + throwException( + workInProgressRoot, + returnFiber, + unitOfWork, + thrownValue, + workInProgressRootRenderLanes, + ); + } catch (error) { + // We had trouble processing the error. An example of this happening is + // when accessing the `componentDidCatch` property of an error boundary + // throws an error. A weird edge case. There's a regression test for this. + // To prevent an infinite loop, bubble the error up to the next parent. + workInProgress = returnFiber; + throw error; + } + + // Return to the normal work loop. completeUnitOfWork(unitOfWork); return; } // The work-in-progress was immediately pinged. Instead of unwinding the - // stack and potentially showing a fallback, reset the fiber and try rendering - // it again. + // stack and potentially showing a fallback, unwind only the last stack frame, + // reset the fiber, and try rendering it again. + const current = unitOfWork.alternate; + unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes); unitOfWork = workInProgress = resetWorkInProgress(unitOfWork, renderLanes); - const current = unitOfWork.alternate; setCurrentDebugFiberInDEV(unitOfWork); let next; @@ -2048,8 +2140,7 @@ function resumeSuspendedUnitOfWork(unitOfWork: Fiber): void { // The begin phase finished successfully without suspending. Reset the state // used to track the fiber while it was suspended. Then return to the normal // work loop. - workInProgressIsSuspended = false; - resetWakeableState(); + resetThenableStateOnCompletion(); resetCurrentDebugFiberInDEV(); unitOfWork.memoizedProps = unitOfWork.pendingProps; @@ -2850,7 +2941,53 @@ export function captureCommitPhaseError( } } -export function pingSuspendedRoot( +export function attachPingListener( + root: FiberRoot, + wakeable: Wakeable, + lanes: Lanes, +) { + // Attach a ping listener + // + // The data might resolve before we have a chance to commit the fallback. Or, + // in the case of a refresh, we'll never commit a fallback. So we need to + // attach a listener now. When it resolves ("pings"), we can decide whether to + // try rendering the tree again. + // + // Only attach a listener if one does not already exist for the lanes + // we're currently rendering (which acts like a "thread ID" here). + // + // We only need to do this in concurrent mode. Legacy Suspense always + // commits fallbacks synchronously, so there are no pings. + let pingCache = root.pingCache; + let threadIDs; + if (pingCache === null) { + pingCache = root.pingCache = new PossiblyWeakMap(); + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } else { + threadIDs = pingCache.get(wakeable); + if (threadIDs === undefined) { + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } + } + if (!threadIDs.has(lanes)) { + workInProgressRootDidAttachPingListener = true; + + // Memoize using the thread ID to prevent redundant listeners. + threadIDs.add(lanes); + const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); + if (enableUpdaterTracking) { + if (isDevToolsPresent) { + // If we have pending work still, restore the original updaters + restorePendingUpdaters(root, lanes); + } + } + wakeable.then(ping, ping); + } +} + +function pingSuspendedRoot( root: FiberRoot, wakeable: Wakeable, pingedLanes: Lanes, @@ -2874,31 +3011,26 @@ export function pingSuspendedRoot( // Received a ping at the same priority level at which we're currently // rendering. We might want to restart this render. This should mirror // the logic of whether or not a root suspends once it completes. - const didPingSuspendedWakeable = attemptToPingSuspendedWakeable(wakeable); - if (didPingSuspendedWakeable) { - // Successfully pinged the in-progress fiber. Don't unwind the stack. - } else { - // TODO: If we're rendering sync either due to Sync, Batched or expired, - // we should probably never restart. + // TODO: If we're rendering sync either due to Sync, Batched or expired, + // we should probably never restart. - // If we're suspended with delay, or if it's a retry, we'll always suspend - // so we can always restart. - if ( - workInProgressRootExitStatus === RootSuspendedWithDelay || - (workInProgressRootExitStatus === RootSuspended && - includesOnlyRetries(workInProgressRootRenderLanes) && - now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS) - ) { - // Restart from the root. - prepareFreshStack(root, NoLanes); - } else { - // Even though we can't restart right now, we might get an - // opportunity later. So we mark this render as having a ping. - workInProgressRootPingedLanes = mergeLanes( - workInProgressRootPingedLanes, - pingedLanes, - ); - } + // If we're suspended with delay, or if it's a retry, we'll always suspend + // so we can always restart. + if ( + workInProgressRootExitStatus === RootSuspendedWithDelay || + (workInProgressRootExitStatus === RootSuspended && + includesOnlyRetries(workInProgressRootRenderLanes) && + now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS) + ) { + // Restart from the root. + prepareFreshStack(root, NoLanes); + } else { + // Even though we can't restart right now, we might get an + // opportunity later. So we mark this render as having a ping. + workInProgressRootPingedLanes = mergeLanes( + workInProgressRootPingedLanes, + pingedLanes, + ); } } @@ -3172,7 +3304,7 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { throw originalError; } - // Keep this code in sync with handleError; any changes here must have + // Keep this code in sync with handleThrow; any changes here must have // corresponding changes there. resetContextDependencies(); resetHooksAfterThrow(); diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index b03a715701368..c8239dc2483e0 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -17,6 +17,7 @@ import type { MutableSource, StartTransitionOptions, Wakeable, + Usable, } from 'shared/ReactTypes'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import type {WorkTag} from './ReactWorkTags'; @@ -238,6 +239,7 @@ type BaseFiberRootProperties = {| pingedLanes: Lanes, expiredLanes: Lanes, mutableReadLanes: Lanes, + errorRecoveryDisabledLanes: Lanes, finishedLanes: Lanes, @@ -353,6 +355,7 @@ type BasicStateAction = (S => S) | S; type Dispatch = A => void; export type Dispatcher = {| + use?: (Usable) => T, getCacheSignal?: () => AbortSignal, getCacheForType?: (resourceType: () => T) => T, readContext(context: ReactContext): T, diff --git a/packages/react-reconciler/src/__tests__/ReactWakeable-test.js b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js index 848962c696c0b..7d946e5cbd03c 100644 --- a/packages/react-reconciler/src/__tests__/ReactWakeable-test.js +++ b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js @@ -4,6 +4,7 @@ let React; let ReactNoop; let Scheduler; let act; +let use; let Suspense; let startTransition; @@ -15,6 +16,7 @@ describe('ReactWakeable', () => { ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); act = require('jest-react').act; + use = React.experimental_use; Suspense = React.Suspense; startTransition = React.startTransition; }); @@ -45,23 +47,272 @@ describe('ReactWakeable', () => { ); } + const root = ReactNoop.createRoot(); await act(async () => { startTransition(() => { - ReactNoop.render(); + root.render(); }); + }); + expect(Scheduler).toHaveYielded([ // React will yield when the async component suspends. - expect(Scheduler).toFlushUntilNextPaint(['Suspend!']); + 'Suspend!', + 'Resolve in microtask', + + // Finished rendering without unwinding the stack or preparing a fallback. + 'Async', + ]); + expect(root).toMatchRenderedOutput('Async'); + }); + + test('if suspended fiber is pinged in a microtask, it does not block a transition from completing', async () => { + let resolved = false; + function Async() { + if (resolved) { + return ; + } + Scheduler.unstable_yieldValue('Suspend!'); + throw Promise.resolve().then(() => { + Scheduler.unstable_yieldValue('Resolve in microtask'); + resolved = true; + }); + } + + function App() { + return ; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded([ + 'Suspend!', + 'Resolve in microtask', + 'Async', + ]); + expect(root).toMatchRenderedOutput('Async'); + }); + + test('does not infinite loop if already resolved thenable is thrown', async () => { + // An already resolved promise should never be thrown. Since it already + // resolved, we shouldn't bother trying to render again — doing so would + // likely lead to an infinite loop. This scenario should only happen if a + // userspace Suspense library makes an implementation mistake. + + // Create an already resolved thenable + const thenable = { + then(ping) {}, + status: 'fulfilled', + value: null, + }; + + let i = 0; + function Async() { + if (i++ > 50) { + throw new Error('Infinite loop detected'); + } + Scheduler.unstable_yieldValue('Suspend!'); + // This thenable should never be thrown because it already resolved. + // But if it is thrown, React should handle it gracefully. + throw thenable; + } + + function App() { + return ( + }> + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Suspend!', 'Loading...']); + expect(root).toMatchRenderedOutput('Loading...'); + }); + + // @gate enableUseHook + test('basic use(promise)', async () => { + const promiseA = Promise.resolve('A'); + const promiseB = Promise.resolve('B'); + const promiseC = Promise.resolve('C'); + + function Async() { + const text = use(promiseA) + use(promiseB) + use(promiseC); + return ; + } + + function App() { + return ( + }> + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded(['ABC']); + expect(root).toMatchRenderedOutput('ABC'); + }); - // Wait for microtasks to resolve - // TODO: The async form of `act` should automatically yield to microtasks - // when a continuation is returned, the way Scheduler does. - await null; + // @gate enableUseHook + test("using a promise that's not cached between attempts", async () => { + function Async() { + const text = + use(Promise.resolve('A')) + + use(Promise.resolve('B')) + + use(Promise.resolve('C')); + return ; + } - expect(Scheduler).toHaveYielded(['Resolve in microtask']); + function App() { + return ( + }> + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); }); + expect(Scheduler).toHaveYielded(['ABC']); + expect(root).toMatchRenderedOutput('ABC'); + }); + + // @gate enableUseHook + test('using a rejected promise will throw', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return ; + } + return this.props.children; + } + } + + const promiseA = Promise.resolve('A'); + const promiseB = Promise.reject(new Error('Oops!')); + const promiseC = Promise.resolve('C'); + + // Jest/Node will raise an unhandled rejected error unless we await this. It + // works fine in the browser, though. + await expect(promiseB).rejects.toThrow('Oops!'); + + function Async() { + const text = use(promiseA) + use(promiseB) + use(promiseC); + return ; + } + + function App() { + return ( + + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded(['Oops!', 'Oops!']); + }); + + // @gate enableUseHook + test('erroring in the same component as an uncached promise does not result in an infinite loop', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return ; + } + return this.props.children; + } + } + + let i = 0; + function Async({ + // Intentionally destrucutring a prop here so that our production error + // stack trick is triggered at the beginning of the function + prop, + }) { + if (i++ > 50) { + throw new Error('Infinite loop detected'); + } + try { + use(Promise.resolve('Async')); + } catch (e) { + Scheduler.unstable_yieldValue('Suspend! [Async]'); + throw e; + } + throw new Error('Oops!'); + } + + function App() { + return ( + }> + + + + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded([ + // First attempt. The uncached promise suspends. + 'Suspend! [Async]', + // Because the promise already resolved, we're able to unwrap the value + // immediately in a microtask. + // + // Then we proceed to the rest of the component, which throws an error. + 'Caught an error: Oops!', + + // During the sync error recovery pass, the component suspends, because + // we were unable to unwrap the value of the promise. + 'Suspend! [Async]', + 'Loading...', + + // Because the error recovery attempt suspended, React can't tell if the + // error was actually fixed, or it was masked by the suspended data. + // In this case, it wasn't actually fixed, so if we were to commit the + // suspended fallback, it would enter an endless error recovery loop. + // + // Instead, we disable error recovery for these lanes and start + // over again. - // Finished rendering without unwinding the stack. - expect(Scheduler).toHaveYielded(['Async']); + // This time, the error is thrown and we commit the result. + 'Suspend! [Async]', + 'Caught an error: Oops!', + ]); + expect(root).toMatchRenderedOutput('Caught an error: Oops!'); }); }); diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index 65cc12a7cfc25..0bc75a3531681 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -27,6 +27,7 @@ export { createMutableSource as unstable_createMutableSource, createRef, createServerContext, + experimental_use, forwardRef, isValidElement, lazy, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 7bfb6bc21f059..d60351e263981 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -24,6 +24,7 @@ export { createFactory, createRef, createServerContext, + experimental_use, forwardRef, isValidElement, lazy, diff --git a/packages/react/index.js b/packages/react/index.js index 77cd739625b9f..d0628ab003a79 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -49,6 +49,7 @@ export { createMutableSource, createRef, createServerContext, + experimental_use, forwardRef, isValidElement, lazy, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index e9e4202d7ae57..24de7511daed5 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -26,6 +26,7 @@ export { createMutableSource as unstable_createMutableSource, createRef, createServerContext, + experimental_use, forwardRef, isValidElement, lazy, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 701c4d1781c1c..fae7ee56b758e 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -55,6 +55,7 @@ import { useDeferredValue, useId, useCacheRefresh, + use, useMemoCache, } from './ReactHooks'; import { @@ -128,6 +129,7 @@ export { getCacheForType as unstable_getCacheForType, useCacheRefresh as unstable_useCacheRefresh, REACT_CACHE_TYPE as unstable_Cache, + use as experimental_use, useMemoCache as unstable_useMemoCache, // enableScopeAPI REACT_SCOPE_TYPE as unstable_Scope, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 197edfaedc559..74699ea673e07 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -14,6 +14,7 @@ import type { MutableSourceSubscribeFn, ReactContext, StartTransitionOptions, + Usable, } from 'shared/ReactTypes'; import ReactCurrentDispatcher from './ReactCurrentDispatcher'; @@ -205,6 +206,12 @@ export function useCacheRefresh(): (?() => T, ?T) => void { return dispatcher.useCacheRefresh(); } +export function use(usable: Usable): T { + const dispatcher = resolveDispatcher(); + // $FlowFixMe This is unstable, thus optional + return dispatcher.use(usable); +} + export function useMemoCache(size: number): Array { const dispatcher = resolveDispatcher(); // $FlowFixMe This is unstable, thus optional diff --git a/packages/scheduler/src/forks/SchedulerMock.js b/packages/scheduler/src/forks/SchedulerMock.js index a1374005ed05c..68dac0a7e6876 100644 --- a/packages/scheduler/src/forks/SchedulerMock.js +++ b/packages/scheduler/src/forks/SchedulerMock.js @@ -517,6 +517,11 @@ function unstable_flushUntilNextPaint(): void { isFlushing = false; } } + return false; +} + +function unstable_hasPendingWork(): boolean { + return scheduledCallback !== null; } function unstable_flushExpired() { @@ -644,6 +649,7 @@ export { unstable_flushExpired, unstable_clearYields, unstable_flushUntilNextPaint, + unstable_hasPendingWork, unstable_flushAll, unstable_yieldValue, unstable_advanceTime, diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 0bd4fc6c04abb..197124fd233e7 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -117,6 +117,7 @@ export const enableCPUSuspense = __EXPERIMENTAL__; export const deletedTreeCleanUpLevel = 3; export const enableFloat = __EXPERIMENTAL__; +export const enableUseHook = __EXPERIMENTAL__; // Enables unstable_useMemoCache hook, intended as a compilation target for // auto-memoization. diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 17aa509e89eb9..7dacd489e4b8a 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -174,13 +174,36 @@ export interface Wakeable { // The subset of a Promise that React APIs rely on. This resolves a value. // This doesn't require a return value neither from the handler nor the // then function. -export interface Thenable<+R> { - then( - onFulfill: (value: R) => void | Thenable | U, - onReject: (error: mixed) => void | Thenable | U, - ): void | Thenable; +interface ThenableImpl { + then( + onFulfill: (value: T) => mixed, + onReject: (error: mixed) => mixed, + ): void | Wakeable; +} +interface UntrackedThenable extends ThenableImpl { + status?: void; +} + +export interface PendingThenable extends ThenableImpl { + status: 'pending'; +} + +export interface FulfilledThenable extends ThenableImpl { + status: 'fulfilled'; + value: T; } +export interface RejectedThenable extends ThenableImpl { + status: 'rejected'; + reason: mixed; +} + +export type Thenable = + | UntrackedThenable + | PendingThenable + | FulfilledThenable + | RejectedThenable; + export type OffscreenMode = | 'hidden' | 'unstable-defer-without-hiding' @@ -189,3 +212,6 @@ export type OffscreenMode = export type StartTransitionOptions = { name?: string, }; + +// TODO: Add Context support +export type Usable = Thenable; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index ec5ae1bf63a75..785aae48b7acd 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -50,6 +50,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = true; +export const enableUseHook = false; export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 2ed90a83b9a59..29e28584ca8f7 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -40,6 +40,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableUseHook = false; export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 81992652fe96b..ac08f57552c4f 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -40,6 +40,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableUseHook = false; export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index 5d6eba8f7fbe4..9408f5d7adb15 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -49,6 +49,7 @@ export const deferRenderPhaseUpdateToNextBatch = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableUseHook = false; export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 5e7caa5fa6cd8..990d1a4a9053e 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -40,6 +40,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = true; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableUseHook = false; export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.js b/packages/shared/forks/ReactFeatureFlags.testing.js index 84a2d4f84ac8b..7698cde8231c6 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.js @@ -40,6 +40,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = false; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; +export const enableUseHook = false; export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.www.js b/packages/shared/forks/ReactFeatureFlags.testing.www.js index 040e2caa2c163..e6768ca0fdb08 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.www.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.www.js @@ -40,6 +40,7 @@ export const warnAboutSpreadingKeyToJSX = false; export const enableSuspenseAvoidThisFallback = true; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = true; +export const enableUseHook = false; export const enableUseMemoCacheHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 90a3f9007cd6c..985c593c03030 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -53,6 +53,7 @@ export const enableSuspenseAvoidThisFallback = true; export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = true; export const enableFloat = false; +export const enableUseHook = true; export const enableUseMemoCacheHook = true; // Logs additional User Timing API marks for use with an experimental profiling tool. diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 15e92133a14d8..423b30de52dd2 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -422,5 +422,6 @@ "434": "`dangerouslySetInnerHTML` does not make sense on .", "435": "Unexpected Suspense handler tag (%s). This is a bug in React.", "436": "Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of \"%s\".", - "437": "the \"precedence\" prop for links to stylesheets expects to receive a string but received something of type \"%s\" instead." + "437": "the \"precedence\" prop for links to stylesheets expects to receive a string but received something of type \"%s\" instead.", + "438": "An unsupported type was passed to use(): %s" }