diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index 00c8bbcd670c0..754af14dcc342 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -459,6 +459,17 @@ export function requestPostPaintCallback(callback: (time: number) => void) { // noop } +export function shouldSuspendCommit(type, props) { + return false; +} + +export function startSuspendingCommit() {} + +export function suspendInstance(type, props) {} + +export function waitForCommitToBeReady() { + return null; +} // eslint-disable-next-line no-undef export function prepareRendererToRender(container: Container): void { // noop diff --git a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js index b63c8054788e9..fdd4484f714e6 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js @@ -1608,6 +1608,19 @@ export function requestPostPaintCallback(callback: (time: number) => void) { localRequestAnimationFrame(time => callback(time)); }); } + +export function shouldSuspendCommit(type: Type, props: Props): boolean { + return false; +} + +export function startSuspendingCommit(): void {} + +export function suspendInstance(type: Type, props: Props): void {} + +export function waitForCommitToBeReady(): null { + return null; +} + // ------------------- // Resources // ------------------- diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 0b2dfd0ffe91f..923860c626be2 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -418,6 +418,18 @@ export function requestPostPaintCallback(callback: (time: number) => void) { // noop } +export function shouldSuspendCommit(type: Type, props: Props): boolean { + return false; +} + +export function startSuspendingCommit(): void {} + +export function suspendInstance(type: Type, props: Props): void {} + +export function waitForCommitToBeReady(): null { + return null; +} + export function prepareRendererToRender(container: Container): void { // noop } diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index 330875debaefd..cddc517aa3e88 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -522,6 +522,18 @@ export function requestPostPaintCallback(callback: (time: number) => void) { // noop } +export function shouldSuspendCommit(type: Type, props: Props): boolean { + return false; +} + +export function startSuspendingCommit(): void {} + +export function suspendInstance(type: Type, props: Props): void {} + +export function waitForCommitToBeReady(): null { + return null; +} + export function prepareRendererToRender(container: Container): void { // noop } diff --git a/packages/react-noop-renderer/src/ReactNoop.js b/packages/react-noop-renderer/src/ReactNoop.js index 72d735692672f..df55810493cc8 100644 --- a/packages/react-noop-renderer/src/ReactNoop.js +++ b/packages/react-noop-renderer/src/ReactNoop.js @@ -28,6 +28,9 @@ export const { createLegacyRoot, getChildrenAsJSX, getPendingChildrenAsJSX, + getSuspenseyThingStatus, + resolveSuspenseyThing, + resetSuspenseyThingCache, createPortal, render, renderLegacySyncRoot, diff --git a/packages/react-noop-renderer/src/ReactNoopPersistent.js b/packages/react-noop-renderer/src/ReactNoopPersistent.js index f5b74a72f653c..040114b86b8a4 100644 --- a/packages/react-noop-renderer/src/ReactNoopPersistent.js +++ b/packages/react-noop-renderer/src/ReactNoopPersistent.js @@ -28,6 +28,9 @@ export const { createLegacyRoot, getChildrenAsJSX, getPendingChildrenAsJSX, + getSuspenseyThingStatus, + resolveSuspenseyThing, + resetSuspenseyThingCache, createPortal, render, renderLegacySyncRoot, diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 67634ab1d58fa..1596eeea95615 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -47,6 +47,7 @@ type Props = { left?: null | number, right?: null | number, top?: null | number, + src?: string, ... }; type Instance = { @@ -72,6 +73,11 @@ type CreateRootOptions = { ... }; +type SuspenseyCommitSubscription = { + pendingCount: number, + commit: null | (() => void), +}; + const NO_CONTEXT = {}; const UPPERCASE_CONTEXT = {}; const UPDATE_SIGNAL = {}; @@ -238,6 +244,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { hidden: !!newProps.hidden, context: instance.context, }; + + if (type === 'suspensey-thing' && typeof newProps.src === 'string') { + clone.src = newProps.src; + } + Object.defineProperty(clone, 'id', { value: clone.id, enumerable: false, @@ -271,6 +282,79 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { return hostContext === UPPERCASE_CONTEXT ? rawText.toUpperCase() : rawText; } + type SuspenseyThingRecord = { + status: 'pending' | 'fulfilled', + subscriptions: Array | null, + }; + + let suspenseyThingCache: Map< + SuspenseyThingRecord, + 'pending' | 'fulfilled', + > | null = null; + + let suspenseyCommitSubscription: SuspenseyCommitSubscription | null = null; + + function startSuspendingCommit(): void { + // This is where we might suspend on things that aren't associated with a + // particular node, like document.fonts.ready. + suspenseyCommitSubscription = null; + } + + function suspendInstance(type: string, props: Props): void { + const src = props.src; + if (type === 'suspensey-thing' && typeof src === 'string') { + // Attach a listener to the suspensey thing and create a subscription + // object that uses reference counting to track when all the suspensey + // things have loaded. + const record = suspenseyThingCache.get(src); + if (record === undefined) { + throw new Error('Could not find record for key.'); + } + if (record.status === 'pending') { + if (suspenseyCommitSubscription === null) { + suspenseyCommitSubscription = { + pendingCount: 1, + commit: null, + }; + } else { + // There's an existing subscription, add to that one. It's OK + // to mutate the commit payload because it's only used for a single + // atomic commit. + suspenseyCommitSubscription.pendingCount++; + } + } + // Stash the subscription on the record. In `resolveSuspenseyThing`, + // we'll use this fire the commit once all the things have loaded. + if (record.subscriptions === null) { + record.subscriptions = []; + } + record.subscriptions.push(suspenseyCommitSubscription); + } else { + throw new Error( + 'Did not expect this host component to be visited when suspending ' + + 'the commit. Did you check the SuspendCommit flag?', + ); + } + return suspenseyCommitSubscription; + } + + function waitForCommitToBeReady(): + | ((commit: () => mixed) => () => void) + | null { + const subscription = suspenseyCommitSubscription; + if (subscription !== null) { + suspenseyCommitSubscription = null; + return (commit: () => void) => { + subscription.commit = commit; + const cancelCommit = () => { + subscription.commit = null; + }; + return cancelCommit; + }; + } + return null; + } + const sharedHostConfig = { supportsSingletons: false, @@ -322,6 +406,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { hidden: !!props.hidden, context: hostContext, }; + + if (type === 'suspensey-thing' && typeof props.src === 'string') { + inst.src = props.src; + } + // Hide from unit tests Object.defineProperty(inst, 'id', {value: inst.id, enumerable: false}); Object.defineProperty(inst, 'parent', { @@ -480,6 +569,45 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { const endTime = Scheduler.unstable_now(); callback(endTime); }, + + shouldSuspendCommit(type: string, props: Props): boolean { + if (type === 'suspensey-thing' && typeof props.src === 'string') { + if (suspenseyThingCache === null) { + suspenseyThingCache = new Map(); + } + const record = suspenseyThingCache.get(props.src); + if (record === undefined) { + const newRecord: SuspenseyThingRecord = { + status: 'pending', + subscriptions: null, + }; + suspenseyThingCache.set(props.src, newRecord); + const onLoadStart = props.onLoadStart; + if (typeof onLoadStart === 'function') { + onLoadStart(); + } + return props.src; + } else { + if (record.status === 'pending') { + // The resource was already requested, but it hasn't finished + // loading yet. + return true; + } else { + // The resource has already loaded. If the renderer is confident that + // the resource will still be cached by the time the render commits, + // then it can return false, like we do here. + return false; + } + } + } + // Don't need to suspend. + return false; + }, + + startSuspendingCommit, + suspendInstance, + waitForCommitToBeReady, + prepareRendererToRender() {}, resetRendererAfterRender() {}, }; @@ -508,6 +636,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { hostUpdateCounter++; instance.prop = newProps.prop; instance.hidden = !!newProps.hidden; + + if (type === 'suspensey-thing' && typeof newProps.src === 'string') { + instance.src = newProps.src; + } + if (shouldSetTextContent(type, newProps)) { if (__DEV__) { checkPropStringCoercion(newProps.children, 'children'); @@ -689,6 +822,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { if (instance.hidden) { props.hidden = true; } + if (instance.src) { + props.src = instance.src; + } if (children !== null) { props.children = children; } @@ -915,6 +1051,50 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { return getPendingChildrenAsJSX(container); }, + getSuspenseyThingStatus(src): string | null { + if (suspenseyThingCache === null) { + return null; + } else { + const record = suspenseyThingCache.get(src); + return record === undefined ? null : record.status; + } + }, + + resolveSuspenseyThing(key: string): void { + if (suspenseyThingCache === null) { + suspenseyThingCache = new Map(); + } + const record = suspenseyThingCache.get(key); + if (record === undefined) { + const newRecord: SuspenseyThingRecord = { + status: 'fulfilled', + subscriptions: null, + }; + suspenseyThingCache.set(key, newRecord); + } else { + if (record.status === 'pending') { + record.status = 'fulfilled'; + const subscriptions = record.subscriptions; + if (subscriptions !== null) { + record.subscriptions = null; + for (let i = 0; i < subscriptions.length; i++) { + const payload = subscriptions[i]; + payload.pendingCount--; + if (payload.pendingCount === 0) { + const commit = payload.commit; + payload.commit = null; + commit(); + } + } + } + } + } + }, + + resetSuspenseyThingCache() { + suspenseyThingCache = null; + }, + createPortal( children: ReactNodeList, container: Container, diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 0a90868fa2e46..dfcc9720125af 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -94,6 +94,7 @@ import { LayoutMask, PassiveMask, Visibility, + SuspenseyCommit, } from './ReactFiberFlags'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; import { @@ -158,6 +159,7 @@ import { mountHoistable, unmountHoistable, prepareToCommitHoistables, + suspendInstance, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -4062,6 +4064,27 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void { resetCurrentDebugFiberInDEV(); } +export function recursivelyAccumulateSuspenseyCommit(parentFiber: Fiber): void { + if (parentFiber.subtreeFlags & SuspenseyCommit) { + let child = parentFiber.child; + while (child !== null) { + recursivelyAccumulateSuspenseyCommit(child); + switch (child.tag) { + case HostComponent: + case HostHoistable: { + if (child.flags & SuspenseyCommit) { + const type = child.type; + const props = child.memoizedProps; + suspendInstance(type, props); + } + break; + } + } + child = child.sibling; + } + } +} + function detachAlternateSiblings(parentFiber: Fiber) { // A fiber was deleted from this parent fiber, but it's still part of the // previous (alternate) parent fiber's list of children. Because children diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 11f00b9aa9b4c..b606632b8e806 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -88,6 +88,8 @@ import { Incomplete, ShouldCapture, ForceClientRender, + SuspenseyCommit, + ScheduleRetry, } from './ReactFiberFlags'; import { @@ -109,6 +111,7 @@ import { finalizeContainerChildren, preparePortalMount, prepareScopeUpdate, + shouldSuspendCommit, } from './ReactFiberHostConfig'; import { getRootHostContainer, @@ -148,6 +151,7 @@ import { renderHasNotSuspendedYet, getRenderTargetTime, getWorkInProgressTransitions, + shouldRemainOnPreviousScreen, } from './ReactFiberWorkLoop'; import { OffscreenLane, @@ -155,6 +159,8 @@ import { NoLanes, includesSomeLane, mergeLanes, + claimNextRetryLane, + includesOnlyNonUrgentLanes, } from './ReactFiberLane'; import {resetChildFibers} from './ReactChildFiber'; import {createScopeInstance} from './ReactFiberScope'; @@ -166,6 +172,7 @@ import { popMarkerInstance, popRootMarkerInstance, } from './ReactFiberTracingMarkerComponent'; +import {suspendCommit} from './ReactFiberThenable'; function markUpdate(workInProgress: Fiber) { // Tag the fiber with an update effect. This turns a Placement into @@ -409,6 +416,7 @@ function updateHostComponent( workInProgress: Fiber, type: Type, newProps: Props, + renderLanes: Lanes, ) { if (supportsMutation) { // If we have an alternate, that means this is an update and we need to @@ -425,6 +433,9 @@ function updateHostComponent( // TODO: Split the update API as separate for the props vs. children. // Even better would be if children weren't special cased at all tho. const instance: Instance = workInProgress.stateNode; + + suspendHostCommitIfNeeded(workInProgress, type, newProps, renderLanes); + const currentHostContext = getHostContext(); // TODO: Experiencing an error where oldProps is null. Suggests a host // component is hitting the resume path. Figure out why. Possibly @@ -483,6 +494,9 @@ function updateHostComponent( childrenUnchanged, recyclableInstance, ); + + suspendHostCommitIfNeeded(workInProgress, type, newProps, renderLanes); + if ( finalizeInitialChildren(newInstance, type, newProps, currentHostContext) ) { @@ -501,6 +515,57 @@ function updateHostComponent( } } +// TODO: This should ideally move to begin phase, but currently the instance is +// not created until the complete phase. For our existing use cases, host nodes +// that suspend don't have children, so it doesn't matter. But that might not +// always be true in the future. +function suspendHostCommitIfNeeded( + workInProgress: Fiber, + type: Type, + props: Props, + renderLanes: Lanes, +) { + // Ask the renderer if this instance should suspend the commit. + if (!shouldSuspendCommit(type, props)) { + // If this flag was set previously, we can remove it. The flag represents + // whether this particular set of props might ever need to suspend. The + // safest thing to do is for shouldSuspendCommit to always return true, but + // if the renderer is reasonably confident that the underlying resource + // won't be evicted, it can return false as a performance optimization. + workInProgress.flags &= ~SuspenseyCommit; + return; + } + + // Mark this fiber with a flag. We use this right before the commit phase to + // find all the fibers that might need to suspend the commit. In the future + // we'll also use it when revealing a hidden tree. It gets set even if we + // don't end up suspending this particular commit, because if this tree ever + // becomes hidden, we might want to suspend before revealing it again. + workInProgress.flags |= SuspenseyCommit; + + // Check if we're rendering at a "non-urgent" priority. This is the same + // check that `useDeferredValue` does to determine whether it needs to + // defer. This is partly for gradual adoption purposes (i.e. shouldn't start + // suspending until you opt in with startTransition or Suspense) but it + // also happens to be the desired behavior for the concrete use cases we've + // thought of so far, like CSS loading, fonts, images, etc. + // TODO: We may decide to expose a way to force a fallback even during a + // sync update. + if (!includesOnlyNonUrgentLanes(renderLanes)) { + // This is an urgent render. Never suspend or trigger a fallback. + } else { + // Need to decide whether to activate the nearest fallback or to continue + // rendering and suspend right before the commit phase. + if (shouldRemainOnPreviousScreen()) { + // It's OK to block the commit. Don't show a fallback. + } else { + // We shouldn't block the commit. Activate a fallback at the nearest + // Suspense boundary. + suspendCommit(); + } + } +} + function scheduleRetryEffect( workInProgress: Fiber, retryQueue: RetryQueue | null, @@ -510,6 +575,25 @@ function scheduleRetryEffect( // Schedule an effect to attach a retry listener to the promise. // TODO: Move to passive phase workInProgress.flags |= Update; + } else { + // This boundary suspended, but no wakeables were added to the retry + // queue. Check if the renderer suspended commit. If so, this means + // that once the fallback is committed, we can immediately retry + // rendering again, because rendering wasn't actually blocked. Only + // the commit phase. + // TODO: Consider a model where we always schedule an immediate retry, even + // for normal Suspense. That way the retry can partially render up to the + // first thing that suspends. + if (workInProgress.flags & ScheduleRetry) { + const retryLane = + // TODO: This check should probably be moved into claimNextRetryLane + // I also suspect that we need some further consolidation of offscreen + // and retry lanes. + workInProgress.tag !== OffscreenComponent + ? claimNextRetryLane() + : OffscreenLane; + workInProgress.lanes = mergeLanes(workInProgress.lanes, retryLane); + } } } @@ -966,6 +1050,7 @@ function completeWork( workInProgress, workInProgress.type, workInProgress.pendingProps, + renderLanes, ); } bubbleProperties(workInProgress); @@ -979,7 +1064,13 @@ function completeWork( const rootContainerInstance = getRootHostContainer(); const type = workInProgress.type; if (current !== null && workInProgress.stateNode != null) { - updateHostComponent(current, workInProgress, type, newProps); + updateHostComponent( + current, + workInProgress, + type, + newProps, + renderLanes, + ); if (current.ref !== workInProgress.ref) { markRef(workInProgress); @@ -1000,19 +1091,22 @@ function completeWork( const currentHostContext = getHostContext(); const wasHydrated = popHydrationState(workInProgress); + let instance: Instance; if (wasHydrated) { // We ignore the boolean indicating there is an updateQueue because // it is used only to set text children and HostSingletons do not // use them. prepareToHydrateHostInstance(workInProgress, currentHostContext); + instance = workInProgress.stateNode; } else { - workInProgress.stateNode = resolveSingletonInstance( + instance = resolveSingletonInstance( type, newProps, rootContainerInstance, currentHostContext, true, ); + workInProgress.stateNode = instance; markUpdate(workInProgress); } @@ -1030,7 +1124,13 @@ function completeWork( popHostContext(workInProgress); const type = workInProgress.type; if (current !== null && workInProgress.stateNode != null) { - updateHostComponent(current, workInProgress, type, newProps); + updateHostComponent( + current, + workInProgress, + type, + newProps, + renderLanes, + ); if (current.ref !== workInProgress.ref) { markRef(workInProgress); @@ -1092,6 +1192,8 @@ function completeWork( } } + suspendHostCommitIfNeeded(workInProgress, type, newProps, renderLanes); + if (workInProgress.ref !== null) { // If there is a ref on a host node we need to schedule a callback markRef(workInProgress); diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js index f8df8f6f5968d..c37a648973ef0 100644 --- a/packages/react-reconciler/src/ReactFiberFlags.js +++ b/packages/react-reconciler/src/ReactFiberFlags.js @@ -12,57 +12,63 @@ import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags'; export type Flags = number; // Don't change these values. They're used by React Dev Tools. -export const NoFlags = /* */ 0b000000000000000000000000000; -export const PerformedWork = /* */ 0b000000000000000000000000001; -export const Placement = /* */ 0b000000000000000000000000010; -export const DidCapture = /* */ 0b000000000000000000010000000; -export const Hydrating = /* */ 0b000000000000001000000000000; +export const NoFlags = /* */ 0b0000000000000000000000000000; +export const PerformedWork = /* */ 0b0000000000000000000000000001; +export const Placement = /* */ 0b0000000000000000000000000010; +export const DidCapture = /* */ 0b0000000000000000000010000000; +export const Hydrating = /* */ 0b0000000000000001000000000000; // You can change the rest (and add more). -export const Update = /* */ 0b000000000000000000000000100; -/* Skipped value: 0b000000000000000000000001000; */ +export const Update = /* */ 0b0000000000000000000000000100; +/* Skipped value: 0b0000000000000000000000001000; */ -export const ChildDeletion = /* */ 0b000000000000000000000010000; -export const ContentReset = /* */ 0b000000000000000000000100000; -export const Callback = /* */ 0b000000000000000000001000000; -/* Used by DidCapture: 0b000000000000000000010000000; */ +export const ChildDeletion = /* */ 0b0000000000000000000000010000; +export const ContentReset = /* */ 0b0000000000000000000000100000; +export const Callback = /* */ 0b0000000000000000000001000000; +/* Used by DidCapture: 0b0000000000000000000010000000; */ -export const ForceClientRender = /* */ 0b000000000000000000100000000; -export const Ref = /* */ 0b000000000000000001000000000; -export const Snapshot = /* */ 0b000000000000000010000000000; -export const Passive = /* */ 0b000000000000000100000000000; -/* Used by Hydrating: 0b000000000000001000000000000; */ +export const ForceClientRender = /* */ 0b0000000000000000000100000000; +export const Ref = /* */ 0b0000000000000000001000000000; +export const Snapshot = /* */ 0b0000000000000000010000000000; +export const Passive = /* */ 0b0000000000000000100000000000; +/* Used by Hydrating: 0b0000000000000001000000000000; */ -export const Visibility = /* */ 0b000000000000010000000000000; -export const StoreConsistency = /* */ 0b000000000000100000000000000; +export const Visibility = /* */ 0b0000000000000010000000000000; +export const StoreConsistency = /* */ 0b0000000000000100000000000000; + +// It's OK to reuse this bit because these flags are mutually exclusive for +// different fiber types. We should really be doing this for as many flags as +// possible, because we're about to run out of bits. +export const ScheduleRetry = StoreConsistency; export const LifecycleEffectMask = Passive | Update | Callback | Ref | Snapshot | StoreConsistency; // Union of all commit flags (flags with the lifetime of a particular commit) -export const HostEffectMask = /* */ 0b00000000000011111111111111; +export const HostEffectMask = /* */ 0b0000000000000111111111111111; // These are not really side effects, but we still reuse this field. -export const Incomplete = /* */ 0b000000000001000000000000000; -export const ShouldCapture = /* */ 0b000000000010000000000000000; -export const ForceUpdateForLegacySuspense = /* */ 0b000000000100000000000000000; -export const DidPropagateContext = /* */ 0b000000001000000000000000000; -export const NeedsPropagation = /* */ 0b000000010000000000000000000; -export const Forked = /* */ 0b000000100000000000000000000; +export const Incomplete = /* */ 0b0000000000001000000000000000; +export const ShouldCapture = /* */ 0b0000000000010000000000000000; +export const ForceUpdateForLegacySuspense = /* */ 0b0000000000100000000000000000; +export const DidPropagateContext = /* */ 0b0000000001000000000000000000; +export const NeedsPropagation = /* */ 0b0000000010000000000000000000; +export const Forked = /* */ 0b0000000100000000000000000000; // Static tags describe aspects of a fiber that are not specific to a render, // e.g. a fiber uses a passive effect (even if there are no updates on this particular render). // This enables us to defer more work in the unmount case, // since we can defer traversing the tree during layout to look for Passive effects, // and instead rely on the static flag as a signal that there may be cleanup work. -export const RefStatic = /* */ 0b000001000000000000000000000; -export const LayoutStatic = /* */ 0b000010000000000000000000000; -export const PassiveStatic = /* */ 0b000100000000000000000000000; +export const RefStatic = /* */ 0b0000001000000000000000000000; +export const LayoutStatic = /* */ 0b0000010000000000000000000000; +export const PassiveStatic = /* */ 0b0000100000000000000000000000; +export const SuspenseyCommit = /* */ 0b0001000000000000000000000000; // Flag used to identify newly inserted fibers. It isn't reset after commit unlike `Placement`. -export const PlacementDEV = /* */ 0b001000000000000000000000000; -export const MountLayoutDev = /* */ 0b010000000000000000000000000; -export const MountPassiveDev = /* */ 0b100000000000000000000000000; +export const PlacementDEV = /* */ 0b0010000000000000000000000000; +export const MountLayoutDev = /* */ 0b0100000000000000000000000000; +export const MountPassiveDev = /* */ 0b1000000000000000000000000000; // Groups of flags that are used in the commit phase to skip over trees that // don't contain effects, by checking subtreeFlags. @@ -96,4 +102,5 @@ export const PassiveMask = Passive | Visibility | ChildDeletion; // Union of tags that don't get reset on clones. // This allows certain concepts to persist without recalculating them, // e.g. whether a subtree contains passive effects or portals. -export const StaticMask = LayoutStatic | PassiveStatic | RefStatic; +export const StaticMask = + LayoutStatic | PassiveStatic | RefStatic | SuspenseyCommit; diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoSingletons.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoSingletons.js index f8cf62243f41f..f592e9518591f 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoSingletons.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoSingletons.js @@ -10,7 +10,7 @@ // Renderers that don't support mutation // can re-export everything from this module. -function shim(...args: any) { +function shim(...args: any): any { throw new Error( 'The current renderer does not support Singletons. ' + 'This error is likely caused by a bug in React. ' + diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index 82f5655250084..c8df0a9e14673 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -60,6 +60,7 @@ function FiberRootNode( this.pingCache = null; this.finishedWork = null; this.timeoutHandle = noTimeout; + this.cancelPendingCommit = null; this.context = null; this.pendingContext = null; this.callbackNode = null; diff --git a/packages/react-reconciler/src/ReactFiberThenable.js b/packages/react-reconciler/src/ReactFiberThenable.js index 81c59baf168d9..5b2501f33fe58 100644 --- a/packages/react-reconciler/src/ReactFiberThenable.js +++ b/packages/react-reconciler/src/ReactFiberThenable.js @@ -31,6 +31,12 @@ export const SuspenseException: mixed = new Error( "call the promise's `.catch` method and pass the result to `use`", ); +// This is a noop thenable that we use to trigger a fallback in throwException. +// TODO: It would be better to refactor throwException into multiple functions +// so we can trigger a fallback directly without having to check the type. But +// for now this will do. +export const noopSuspenseyCommitThenable = {then() {}}; + export function createThenableState(): ThenableState { // The ThenableState is created the first time a component suspends. If it // suspends again, we'll reuse the same state. @@ -140,6 +146,14 @@ export function trackUsedThenable( } } +export function suspendCommit(): void { + // This extra indirection only exists so it can handle passing + // noopSuspenseyCommitThenable through to throwException. + // TODO: Factor the thenable check out of throwException + suspendedThenable = noopSuspenseyCommitThenable; + throw SuspenseException; +} + // This is used to track the actual thenable that suspended so it can be // passed to the rest of the Suspense implementation — which, for historical // reasons, expects to receive a thenable. diff --git a/packages/react-reconciler/src/ReactFiberThrow.js b/packages/react-reconciler/src/ReactFiberThrow.js index b1b4c4afa4aa6..37b211876cdfc 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.js +++ b/packages/react-reconciler/src/ReactFiberThrow.js @@ -34,6 +34,7 @@ import { LifecycleEffectMask, ForceUpdateForLegacySuspense, ForceClientRender, + ScheduleRetry, } from './ReactFiberFlags'; import {NoMode, ConcurrentMode, DebugTracingMode} from './ReactTypeOfMode'; import { @@ -80,6 +81,7 @@ import { queueHydrationError, } from './ReactFiberHydrationContext'; import {ConcurrentRoot} from './ReactRootTags'; +import {noopSuspenseyCommitThenable} from './ReactFiberThenable'; function createRootErrorUpdate( fiber: Fiber, @@ -413,33 +415,52 @@ function throwException( // // When the wakeable resolves, we'll attempt to render the boundary // again ("retry"). - const retryQueue: RetryQueue | null = - (suspenseBoundary.updateQueue: any); - if (retryQueue === null) { - suspenseBoundary.updateQueue = new Set([wakeable]); + + // Check if this is a Suspensey resource. We do not attach retry + // listeners to these, because we don't actually need them for + // rendering. Only for committing. Instead, if a fallback commits + // and the only thing that suspended was a Suspensey resource, we + // retry immediately. + // TODO: Refactor throwException so that we don't have to do this type + // check. The caller already knows what the cause was. + const isSuspenseyResource = wakeable === noopSuspenseyCommitThenable; + if (isSuspenseyResource) { + suspenseBoundary.flags |= ScheduleRetry; } else { - retryQueue.add(wakeable); + const retryQueue: RetryQueue | null = + (suspenseBoundary.updateQueue: any); + if (retryQueue === null) { + suspenseBoundary.updateQueue = new Set([wakeable]); + } else { + retryQueue.add(wakeable); + } } break; } case OffscreenComponent: { if (suspenseBoundary.mode & ConcurrentMode) { suspenseBoundary.flags |= ShouldCapture; - const offscreenQueue: OffscreenQueue | null = - (suspenseBoundary.updateQueue: any); - if (offscreenQueue === null) { - const newOffscreenQueue: OffscreenQueue = { - transitions: null, - markerInstances: null, - retryQueue: new Set([wakeable]), - }; - suspenseBoundary.updateQueue = newOffscreenQueue; + const isSuspenseyResource = + wakeable === noopSuspenseyCommitThenable; + if (isSuspenseyResource) { + suspenseBoundary.flags |= ScheduleRetry; } else { - const retryQueue = offscreenQueue.retryQueue; - if (retryQueue === null) { - offscreenQueue.retryQueue = new Set([wakeable]); + const offscreenQueue: OffscreenQueue | null = + (suspenseBoundary.updateQueue: any); + if (offscreenQueue === null) { + const newOffscreenQueue: OffscreenQueue = { + transitions: null, + markerInstances: null, + retryQueue: new Set([wakeable]), + }; + suspenseBoundary.updateQueue = newOffscreenQueue; } else { - retryQueue.add(wakeable); + const retryQueue = offscreenQueue.retryQueue; + if (retryQueue === null) { + offscreenQueue.retryQueue = new Set([wakeable]); + } else { + retryQueue.add(wakeable); + } } } break; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 14b9300f1d3dd..241c068bb35d1 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -84,6 +84,8 @@ import { scheduleMicrotask, prepareRendererToRender, resetRendererAfterRender, + startSuspendingCommit, + waitForCommitToBeReady, } from './ReactFiberHostConfig'; import { @@ -161,6 +163,7 @@ import { movePendingFibersToMemoized, addTransitionToLanesMap, getTransitionsForLanes, + includesOnlyNonUrgentLanes, } from './ReactFiberLane'; import { DiscreteEventPriority, @@ -201,6 +204,7 @@ import { invokePassiveEffectMountInDEV, invokeLayoutEffectUnmountInDEV, invokePassiveEffectUnmountInDEV, + recursivelyAccumulateSuspenseyCommit, } from './ReactFiberCommitWork'; import {enqueueUpdate} from './ReactFiberClassUpdateQueue'; import {resetContextDependencies} from './ReactFiberNewContext'; @@ -905,6 +909,18 @@ function ensureRootIsScheduled(root: FiberRoot, currentTime: number) { return; } + const cancelPendingCommit = root.cancelPendingCommit; + if (cancelPendingCommit !== null) { + // We should only interrupt a pending commit if the new update + // is urgent. + if (includesOnlyNonUrgentLanes(nextLanes)) { + // The new update is not urgent. Don't interrupt the pending commit. + root.callbackPriority = NoLane; + root.callbackNode = null; + return; + } + } + // We use the highest priority lane to represent the priority of the callback. const newCallbackPriority = getHighestPriorityLane(nextLanes); @@ -1158,7 +1174,7 @@ function performConcurrentWorkOnRoot( // or, if something suspended, wait to commit it after a timeout. root.finishedWork = finishedWork; root.finishedLanes = lanes; - finishConcurrentRender(root, exitStatus, lanes); + finishConcurrentRender(root, exitStatus, finishedWork, lanes); } } @@ -1263,6 +1279,7 @@ export function queueRecoverableErrors(errors: Array>) { function finishConcurrentRender( root: FiberRoot, exitStatus: RootExitStatus, + finishedWork: Fiber, lanes: Lanes, ) { switch (exitStatus) { @@ -1276,10 +1293,12 @@ function finishConcurrentRender( case RootErrored: { // We should have already attempted to retry this tree. If we reached // this point, it errored again. Commit it. - commitRoot( + commitRootWhenReady( root, + finishedWork, workInProgressRootRecoverableErrors, workInProgressTransitions, + lanes, ); break; } @@ -1310,11 +1329,13 @@ function finishConcurrentRender( // lower priority work to do. Instead of committing the fallback // immediately, wait for more data to arrive. root.timeoutHandle = scheduleTimeout( - commitRoot.bind( + commitRootWhenReady.bind( null, root, + finishedWork, workInProgressRootRecoverableErrors, workInProgressTransitions, + lanes, ), msUntilTimeout, ); @@ -1322,10 +1343,12 @@ function finishConcurrentRender( } } // The work expired. Commit immediately. - commitRoot( + commitRootWhenReady( root, + finishedWork, workInProgressRootRecoverableErrors, workInProgressTransitions, + lanes, ); break; } @@ -1357,11 +1380,13 @@ function finishConcurrentRender( // Instead of committing the fallback immediately, wait for more data // to arrive. root.timeoutHandle = scheduleTimeout( - commitRoot.bind( + commitRootWhenReady.bind( null, root, + finishedWork, workInProgressRootRecoverableErrors, workInProgressTransitions, + lanes, ), msUntilTimeout, ); @@ -1370,19 +1395,23 @@ function finishConcurrentRender( } // Commit the placeholder. - commitRoot( + commitRootWhenReady( root, + finishedWork, workInProgressRootRecoverableErrors, workInProgressTransitions, + lanes, ); break; } case RootCompleted: { - // The work completed. Ready to commit. - commitRoot( + // The work completed. + commitRootWhenReady( root, + finishedWork, workInProgressRootRecoverableErrors, workInProgressTransitions, + lanes, ); break; } @@ -1392,6 +1421,53 @@ function finishConcurrentRender( } } +function commitRootWhenReady( + root: FiberRoot, + finishedWork: Fiber, + recoverableErrors: Array> | null, + transitions: Array | null, + lanes: Lanes, +) { + if (includesOnlyNonUrgentLanes(lanes)) { + // Before committing, ask the renderer whether the host tree is ready. + // If it's not, we'll wait until it notifies us. + startSuspendingCommit(); + // This will walk the completed fiber tree and attach listeners to all + // the suspensey resources. The renderer is responsible for accumulating + // all the load events. This all happens in a single synchronous + // transaction, so it track state in its own module scope. + recursivelyAccumulateSuspenseyCommit(finishedWork); + // At the end, ask the renderer if it's ready to commit, or if we should + // suspend. If it's not ready, it will return a callback to subscribe to + // a ready event. + const schedulePendingCommit = waitForCommitToBeReady(); + if (schedulePendingCommit !== null) { + // NOTE: waitForCommitToBeReady returns a subscribe function so that we + // only allocate a function if the commit isn't ready yet. The other + // pattern would be to always pass a callback to waitForCommitToBeReady. + + // Not yet ready to commit. Delay the commit until the renderer notifies + // us that it's ready. This will be canceled if we start work on the + // root again. + root.cancelPendingCommit = schedulePendingCommit( + commitRoot.bind( + null, + root, + workInProgressRootRecoverableErrors, + workInProgressTransitions, + ), + ); + return; + } + } + // Otherwise, commit immediately. + commitRoot( + root, + workInProgressRootRecoverableErrors, + workInProgressTransitions, + ); +} + function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean { // Search the rendered tree for external store reads, and check whether the // stores were mutated in a concurrent event. Intentionally using an iterative @@ -1714,6 +1790,11 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { // $FlowFixMe Complains noTimeout is not a TimeoutID, despite the check above cancelTimeout(timeoutHandle); } + const cancelPendingCommit = root.cancelPendingCommit; + if (cancelPendingCommit !== null) { + root.cancelPendingCommit = null; + cancelPendingCommit(); + } resetWorkInProgressStack(); workInProgressRoot = root; @@ -1868,7 +1949,7 @@ function handleThrow(root: FiberRoot, thrownValue: any): void { } } -function shouldRemainOnPreviousScreen() { +export function shouldRemainOnPreviousScreen(): boolean { // This is asking whether it's better to suspend the transition and remain // on the previous screen, versus showing a fallback as soon as possible. It // takes into account both the priority of render and also whether showing a @@ -2698,6 +2779,7 @@ function commitRootImpl( // So we can clear these now to allow a new callback to be scheduled. root.callbackNode = null; root.callbackPriority = NoLane; + root.cancelPendingCommit = null; // Check which lanes no longer have any work scheduled on them, and mark // those as finished. diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index edc120182e958..7f01daa5240da 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -227,6 +227,10 @@ type BaseFiberRootProperties = { // Timeout handle returned by setTimeout. Used to cancel a pending timeout, if // it's superseded by a new one. timeoutHandle: TimeoutHandle | NoTimeout, + // When a root has a pending commit scheduled, calling this function will + // cancel it. + // TODO: Can this be consolidated with timeoutHandle? + cancelPendingCommit: null | (() => void), // Top context object, used by renderSubtreeIntoContainer context: Object | null, pendingContext: Object | null, diff --git a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js index 9cfd5cea5a9c3..10e764fd1d22b 100644 --- a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js @@ -71,6 +71,14 @@ describe('ReactFiberHostContext', () => { return DefaultEventPriority; }, requestPostPaintCallback: function () {}, + shouldSuspendCommit(type, props) { + return false; + }, + startSuspendingCommit() {}, + suspendInstance(type, props) {}, + waitForCommitToBeReady() { + return null; + }, prepareRendererToRender: function () {}, resetRendererAfterRender: function () {}, supportsMutation: true, diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js new file mode 100644 index 0000000000000..19260ebff93ab --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseyCommitPhase-test.js @@ -0,0 +1,231 @@ +let React; +let startTransition; +let ReactNoop; +let resolveSuspenseyThing; +let getSuspenseyThingStatus; +let Suspense; +let SuspenseList; +let Scheduler; +let act; +let assertLog; + +describe('ReactSuspenseyCommitPhase', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + Suspense = React.Suspense; + SuspenseList = React.SuspenseList; + if (gate(flags => flags.enableSuspenseList)) { + SuspenseList = React.SuspenseList; + } + startTransition = React.startTransition; + resolveSuspenseyThing = ReactNoop.resolveSuspenseyThing; + getSuspenseyThingStatus = ReactNoop.getSuspenseyThingStatus; + + const InternalTestUtils = require('internal-test-utils'); + act = InternalTestUtils.act; + assertLog = InternalTestUtils.assertLog; + }); + + function Text({text}) { + Scheduler.log(text); + return text; + } + + function SuspenseyImage({src}) { + return ( + Scheduler.log(`Image requested [${src}]`)} + /> + ); + } + + test('suspend commit during initial mount', async () => { + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render( + }> + + , + ); + }); + }); + assertLog(['Image requested [A]', 'Loading...']); + expect(getSuspenseyThingStatus('A')).toBe('pending'); + expect(root).toMatchRenderedOutput('Loading...'); + + // This should synchronously commit + resolveSuspenseyThing('A'); + expect(root).toMatchRenderedOutput(); + }); + + test('suspend commit during update', async () => { + const root = ReactNoop.createRoot(); + await act(() => resolveSuspenseyThing('A')); + await act(async () => { + startTransition(() => { + root.render( + }> + + , + ); + }); + }); + expect(root).toMatchRenderedOutput(); + + // Update to a new image src. The transition should suspend because + // the src hasn't loaded yet, and the image is in an already-visible tree. + await act(async () => { + startTransition(() => { + root.render( + }> + + , + ); + }); + }); + assertLog(['Image requested [B]']); + expect(getSuspenseyThingStatus('B')).toBe('pending'); + // Should remain on previous screen + expect(root).toMatchRenderedOutput(); + + // This should synchronously commit + resolveSuspenseyThing('B'); + expect(root).toMatchRenderedOutput(); + }); + + test('does not suspend commit during urgent update', async () => { + const root = ReactNoop.createRoot(); + await act(async () => { + root.render( + }> + + , + ); + }); + // NOTE: `shouldSuspendCommit` is called even during synchronous renders + // because if this node is ever hidden, then revealed again, we want to know + // whether it's capable of suspending the commit. We track this using a + // fiber flag. + assertLog(['Image requested [A]']); + expect(getSuspenseyThingStatus('A')).toBe('pending'); + expect(root).toMatchRenderedOutput(); + }); + + test('an urgent update interrupts a suspended commit', async () => { + const root = ReactNoop.createRoot(); + + // Mount an image. This transition will suspend because it's not inside a + // Suspense boundary. + await act(() => { + startTransition(() => { + root.render(); + }); + }); + assertLog(['Image requested [A]']); + // Nothing showing yet. + expect(root).toMatchRenderedOutput(null); + + // If there's an urgent update, it should interrupt the suspended commit. + await act(() => { + root.render(); + }); + assertLog(['Something else']); + expect(root).toMatchRenderedOutput('Something else'); + }); + + test('a non-urgent update does not interrupt a suspended commit', async () => { + const root = ReactNoop.createRoot(); + + // Mount an image. This transition will suspend because it's not inside a + // Suspense boundary. + await act(() => { + startTransition(() => { + root.render(); + }); + }); + assertLog(['Image requested [A]']); + // Nothing showing yet. + expect(root).toMatchRenderedOutput(null); + + // If there's another transition update, it should not interrupt the + // suspended commit. + await act(() => { + startTransition(() => { + root.render(); + }); + }); + // Still suspended. + expect(root).toMatchRenderedOutput(null); + + await act(() => { + // Resolving the image should result in an immediate, synchronous commit. + resolveSuspenseyThing('A'); + expect(root).toMatchRenderedOutput(); + }); + // Then the second transition is unblocked. + // TODO: Right now the only way to unsuspend a commit early is to proceed + // with the commit even if everything isn't ready. Maybe there should also + // be a way to abort a commit so that it can be interrupted by + // another transition. + assertLog(['Something else']); + expect(root).toMatchRenderedOutput('Something else'); + }); + + // @gate enableSuspenseList + test('demonstrate current behavior when used with SuspenseList (not ideal)', async () => { + function App() { + return ( + + }> + + + }> + + + }> + + + + ); + } + + const root = ReactNoop.createRoot(); + await act(() => { + startTransition(() => { + root.render(); + }); + }); + assertLog([ + 'Image requested [A]', + 'Loading A', + 'Loading B', + 'Loading C', + 'Image requested [B]', + 'Image requested [C]', + ]); + expect(root).toMatchRenderedOutput('Loading ALoading BLoading C'); + + // TODO: Notice that none of these items appear until they've all loaded. + // That's not ideal; we should commit each row as it becomes ready to + // commit. That means we need to prepare both the fallback and the primary + // tree during the render phase. Related to Offscreen, too. + resolveSuspenseyThing('A'); + expect(root).toMatchRenderedOutput('Loading ALoading BLoading C'); + resolveSuspenseyThing('B'); + expect(root).toMatchRenderedOutput('Loading ALoading BLoading C'); + resolveSuspenseyThing('C'); + expect(root).toMatchRenderedOutput( + <> + + + + , + ); + }); +}); diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 3b9b2f688e716..d947045297c86 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -68,6 +68,10 @@ export const getInstanceFromScope = $$$hostConfig.getInstanceFromScope; export const getCurrentEventPriority = $$$hostConfig.getCurrentEventPriority; export const detachDeletedInstance = $$$hostConfig.detachDeletedInstance; export const requestPostPaintCallback = $$$hostConfig.requestPostPaintCallback; +export const shouldSuspendCommit = $$$hostConfig.shouldSuspendCommit; +export const startSuspendingCommit = $$$hostConfig.startSuspendingCommit; +export const suspendInstance = $$$hostConfig.suspendInstance; +export const waitForCommitToBeReady = $$$hostConfig.waitForCommitToBeReady; export const prepareRendererToRender = $$$hostConfig.prepareRendererToRender; export const resetRendererAfterRender = $$$hostConfig.resetRendererAfterRender; diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index 8d08e033faf7d..3c6ada072e7b4 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -324,6 +324,18 @@ export function requestPostPaintCallback(callback: (time: number) => void) { // noop } +export function shouldSuspendCommit(type: Type, props: Props): boolean { + return false; +} + +export function startSuspendingCommit(): void {} + +export function suspendInstance(type: Type, props: Props): void {} + +export function waitForCommitToBeReady(): null { + return null; +} + export function prepareRendererToRender(container: Container): void { // noop }