diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/DefaultModuleTypeProvider.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/DefaultModuleTypeProvider.ts index 3b3e120f39076..106fce615c44e 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/DefaultModuleTypeProvider.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/DefaultModuleTypeProvider.ts @@ -86,6 +86,24 @@ export function defaultModuleTypeProvider( }, }; } + case '@tanstack/react-virtual': { + return { + kind: 'object', + properties: { + /* + * Many of the properties of `useVirtualizer()`'s return value are incompatible, so we mark the entire hook + * as incompatible + */ + useVirtualizer: { + kind: 'hook', + positionalParams: [], + restParam: Effect.Read, + returnType: {kind: 'type', name: 'Any'}, + knownIncompatible: `TanStack Virtual's \`useVirtualizer()\` API returns functions that cannot be memoized safely`, + }, + }, + }; + } } return null; } diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 587306fe9578f..658ed686293c7 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -50,7 +50,8 @@ function Component() {

diff --git a/packages/react-art/src/ReactFiberConfigART.js b/packages/react-art/src/ReactFiberConfigART.js index be0f1210974de..77aedb6ae3c7a 100644 --- a/packages/react-art/src/ReactFiberConfigART.js +++ b/packages/react-art/src/ReactFiberConfigART.js @@ -609,13 +609,15 @@ export function preloadInstance(type, props) { return true; } -export function startSuspendingCommit() {} +export function startSuspendingCommit() { + return null; +} -export function suspendInstance(instance, type, props) {} +export function suspendInstance(state, instance, type, props) {} -export function suspendOnActiveViewTransition(container) {} +export function suspendOnActiveViewTransition(state, container) {} -export function waitForCommitToBeReady() { +export function waitForCommitToBeReady(timeoutOffset) { return null; } diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 6b71052d9ae6e..fc80826678e23 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -143,6 +143,7 @@ import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals'; export {default as rendererVersion} from 'shared/ReactVersion'; import noop from 'shared/noop'; +import estimateBandwidth from './estimateBandwidth'; export const rendererPackageName = 'react-dom'; export const extraDevToolsConfig = null; @@ -2009,7 +2010,8 @@ function cancelAllViewTransitionAnimations(scope: Element) { // an issue when it's a new load and slow, yet long enough that you have a chance to load // it. Otherwise we wait for no reason. The assumption here is that you likely have // either cached the font or preloaded it earlier. -const SUSPENSEY_FONT_TIMEOUT = 500; +// This timeout is also used for Suspensey Images when they're blocking a View Transition. +const SUSPENSEY_FONT_AND_IMAGE_TIMEOUT = 500; function customizeViewTransitionError( error: Object, @@ -2079,7 +2081,15 @@ function forceLayout(ownerDocument: Document) { return (ownerDocument.documentElement: any).clientHeight; } +function waitForImageToLoad(this: HTMLImageElement, resolve: () => void) { + // TODO: Use decode() instead of the load event here once the fix in + // https://issues.chromium.org/issues/420748301 has propagated fully. + this.addEventListener('load', resolve); + this.addEventListener('error', resolve); +} + export function startViewTransition( + suspendedState: null | SuspendedState, rootContainer: Container, transitionTypes: null | TransitionTypes, mutationCallback: () => void, @@ -2106,6 +2116,7 @@ export function startViewTransition( // $FlowFixMe[prop-missing] const previousFontLoadingStatus = ownerDocument.fonts.status; mutationCallback(); + const blockingPromises: Array> = []; if (previousFontLoadingStatus === 'loaded') { // Force layout calculation to trigger font loading. forceLayout(ownerDocument); @@ -2117,19 +2128,51 @@ export function startViewTransition( // This avoids waiting for potentially unrelated fonts that were already loading before. // Either in an earlier transition or as part of a sync optimistic state. This doesn't // include preloads that happened earlier. - const fontsReady = Promise.race([ - // $FlowFixMe[prop-missing] - ownerDocument.fonts.ready, - new Promise(resolve => - setTimeout(resolve, SUSPENSEY_FONT_TIMEOUT), - ), - ]).then(layoutCallback, layoutCallback); - const allReady = pendingNavigation - ? Promise.allSettled([pendingNavigation.finished, fontsReady]) - : fontsReady; - return allReady.then(afterMutationCallback, afterMutationCallback); + blockingPromises.push(ownerDocument.fonts.ready); + } + } + if (suspendedState !== null) { + // Suspend on any images that still haven't loaded and are in the viewport. + const suspenseyImages = suspendedState.suspenseyImages; + const blockingIndexSnapshot = blockingPromises.length; + let imgBytes = 0; + for (let i = 0; i < suspenseyImages.length; i++) { + const suspenseyImage = suspenseyImages[i]; + if (!suspenseyImage.complete) { + const rect = suspenseyImage.getBoundingClientRect(); + const inViewport = + rect.bottom > 0 && + rect.right > 0 && + rect.top < ownerWindow.innerHeight && + rect.left < ownerWindow.innerWidth; + if (inViewport) { + imgBytes += estimateImageBytes(suspenseyImage); + if (imgBytes > estimatedBytesWithinLimit) { + // We don't think we'll be able to download all the images within + // the timeout. Give up. Rewind to only block on fonts, if any. + blockingPromises.length = blockingIndexSnapshot; + break; + } + const loadingImage = new Promise( + waitForImageToLoad.bind(suspenseyImage), + ); + blockingPromises.push(loadingImage); + } + } } } + if (blockingPromises.length > 0) { + const blockingReady = Promise.race([ + Promise.all(blockingPromises), + new Promise(resolve => + setTimeout(resolve, SUSPENSEY_FONT_AND_IMAGE_TIMEOUT), + ), + ]).then(layoutCallback, layoutCallback); + const allReady = pendingNavigation + ? Promise.allSettled([pendingNavigation.finished, blockingReady]) + : blockingReady; + return allReady.then(afterMutationCallback, afterMutationCallback); + } layoutCallback(); if (pendingNavigation) { return pendingNavigation.finished.then( @@ -2442,6 +2485,7 @@ function animateGesture( } export function startGestureTransition( + suspendedState: null | SuspendedState, rootContainer: Container, timeline: GestureTimeline, rangeStart: number, @@ -5903,17 +5947,24 @@ export function preloadResource(resource: Resource): boolean { return true; } -type SuspendedState = { +export opaque type SuspendedState = { stylesheets: null | Map, - count: number, + count: number, // suspensey css and active view transitions + imgCount: number, // suspensey images pending to load + imgBytes: number, // number of bytes we estimate needing to download + suspenseyImages: Array, // instances of suspensey images (whether loaded or not) + waitingForImages: boolean, // false when we're no longer blocking on images unsuspend: null | (() => void), }; -let suspendedState: null | SuspendedState = null; -export function startSuspendingCommit(): void { - suspendedState = { +export function startSuspendingCommit(): SuspendedState { + return { stylesheets: null, count: 0, + imgCount: 0, + imgBytes: 0, + suspenseyImages: [], + waitingForImages: true, // We use a noop function when we begin suspending because if possible we want the // waitfor step to finish synchronously. If it doesn't we'll return a function to // provide the actual unsuspend function and that will get completed when the count @@ -5922,9 +5973,18 @@ export function startSuspendingCommit(): void { }; } -const SUSPENSEY_IMAGE_TIMEOUT = 500; +function estimateImageBytes(instance: HTMLImageElement): number { + const width: number = instance.width || 100; + const height: number = instance.height || 100; + const pixelRatio: number = + typeof devicePixelRatio === 'number' ? devicePixelRatio : 1; + const pixelsToDownload = width * height * pixelRatio; + const AVERAGE_BYTE_PER_PIXEL = 0.25; + return pixelsToDownload * AVERAGE_BYTE_PER_PIXEL; +} export function suspendInstance( + state: SuspendedState, instance: Instance, type: Type, props: Props, @@ -5932,41 +5992,33 @@ export function suspendInstance( if (!enableSuspenseyImages && !enableViewTransition) { return; } - if (suspendedState === null) { - throw new Error( - 'Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug.', - ); - } - const state = suspendedState; if ( // $FlowFixMe[prop-missing] - typeof instance.decode === 'function' && - typeof setTimeout === 'function' + typeof instance.decode === 'function' ) { // If this browser supports decode() API, we use it to suspend waiting on the image. // The loading should have already started at this point, so it should be enough to // just call decode() which should also wait for the data to finish loading. - state.count++; - const ping = onUnsuspend.bind(state); - Promise.race([ - // $FlowFixMe[prop-missing] - instance.decode(), - new Promise(resolve => setTimeout(resolve, SUSPENSEY_IMAGE_TIMEOUT)), - ]).then(ping, ping); + state.imgCount++; + // Estimate the byte size that we're about to download based on the width/height + // specified in the props. This is best practice to know ahead of time but if it's + // unspecified we'll fallback to a guess of 100x100 pixels. + if (!(instance: any).complete) { + state.imgBytes += estimateImageBytes((instance: any)); + state.suspenseyImages.push((instance: any)); + } + const ping = onUnsuspendImg.bind(state); + // $FlowFixMe[prop-missing] + instance.decode().then(ping, ping); } } export function suspendResource( + state: SuspendedState, hoistableRoot: HoistableRoot, resource: Resource, props: any, ): void { - if (suspendedState === null) { - throw new Error( - 'Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug.', - ); - } - const state = suspendedState; if (resource.type === 'stylesheet') { if (typeof props.media === 'string') { // If we don't currently match media we avoid suspending on this resource @@ -6046,13 +6098,10 @@ export function suspendResource( } } -export function suspendOnActiveViewTransition(rootContainer: Container): void { - if (suspendedState === null) { - throw new Error( - 'Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug.', - ); - } - const state = suspendedState; +export function suspendOnActiveViewTransition( + state: SuspendedState, + rootContainer: Container, +): void { const ownerDocument = rootContainer.nodeType === DOCUMENT_NODE ? rootContainer @@ -6067,15 +6116,18 @@ export function suspendOnActiveViewTransition(rootContainer: Container): void { activeViewTransition.finished.then(ping, ping); } -export function waitForCommitToBeReady(): null | ((() => void) => () => void) { - if (suspendedState === null) { - throw new Error( - 'Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug.', - ); - } +const SUSPENSEY_STYLESHEET_TIMEOUT = 60000; + +const SUSPENSEY_IMAGE_TIMEOUT = 800; - const state = suspendedState; +const SUSPENSEY_IMAGE_TIME_ESTIMATE = 500; +let estimatedBytesWithinLimit: number = 0; + +export function waitForCommitToBeReady( + state: SuspendedState, + timeoutOffset: number, +): null | ((() => void) => () => void) { if (state.stylesheets && state.count === 0) { // We are not currently blocked but we have not inserted all stylesheets. // If this insertion happens and loads or errors synchronously then we can @@ -6085,7 +6137,7 @@ export function waitForCommitToBeReady(): null | ((() => void) => () => void) { // We need to check the count again because the inserted stylesheets may have led to new // tasks to wait on. - if (state.count > 0) { + if (state.count > 0 || state.imgCount > 0) { return commit => { // We almost never want to show content before its styles have loaded. But // eventually we will give up and allow unstyled content. So this number is @@ -6102,37 +6154,74 @@ export function waitForCommitToBeReady(): null | ((() => void) => () => void) { state.unsuspend = null; unsuspend(); } - }, 60000); // one minute + }, SUSPENSEY_STYLESHEET_TIMEOUT + timeoutOffset); + + if (state.imgBytes > 0 && estimatedBytesWithinLimit === 0) { + // Estimate how many bytes we can download in 500ms. + const mbps = estimateBandwidth(); + estimatedBytesWithinLimit = mbps * 125 * SUSPENSEY_IMAGE_TIME_ESTIMATE; + } + // If we have more images to download than we expect to fit in the timeout, then + // don't wait for images longer than 50ms. The 50ms lets us still do decoding and + // hitting caches if it turns out that they're already in the HTTP cache. + const imgTimeout = + state.imgBytes > estimatedBytesWithinLimit + ? 50 + : SUSPENSEY_IMAGE_TIMEOUT; + const imgTimer = setTimeout(() => { + // We're no longer blocked on images. If CSS resolves after this we can commit. + state.waitingForImages = false; + if (state.count === 0) { + if (state.stylesheets) { + insertSuspendedStylesheets(state, state.stylesheets); + } + if (state.unsuspend) { + const unsuspend = state.unsuspend; + state.unsuspend = null; + unsuspend(); + } + } + }, imgTimeout + timeoutOffset); state.unsuspend = commit; return () => { state.unsuspend = null; clearTimeout(stylesheetTimer); + clearTimeout(imgTimer); }; }; } return null; } -function onUnsuspend(this: SuspendedState) { - this.count--; - if (this.count === 0) { - if (this.stylesheets) { +function checkIfFullyUnsuspended(state: SuspendedState) { + if (state.count === 0 && (state.imgCount === 0 || !state.waitingForImages)) { + if (state.stylesheets) { // If we haven't actually inserted the stylesheets yet we need to do so now before starting the commit. // The reason we do this after everything else has finished is because we want to have all the stylesheets // load synchronously right before mutating. Ideally the new styles will cause a single recalc only on the // new tree. When we filled up stylesheets we only inlcuded stylesheets with matching media attributes so we // wait for them to load before actually continuing. We expect this to increase the count above zero - insertSuspendedStylesheets(this, this.stylesheets); - } else if (this.unsuspend) { - const unsuspend = this.unsuspend; - this.unsuspend = null; + insertSuspendedStylesheets(state, state.stylesheets); + } else if (state.unsuspend) { + const unsuspend = state.unsuspend; + state.unsuspend = null; unsuspend(); } } } +function onUnsuspend(this: SuspendedState) { + this.count--; + checkIfFullyUnsuspended(this); +} + +function onUnsuspendImg(this: SuspendedState) { + this.imgCount--; + checkIfFullyUnsuspended(this); +} + // We use a value that is type distinct from precedence to track which one is last. // This ensures there is no collision with user defined precedences. Normally we would // just track this in module scope but since the precedences are tracked per HoistableRoot diff --git a/packages/react-dom-bindings/src/client/estimateBandwidth.js b/packages/react-dom-bindings/src/client/estimateBandwidth.js new file mode 100644 index 0000000000000..4b143a5b562c3 --- /dev/null +++ b/packages/react-dom-bindings/src/client/estimateBandwidth.js @@ -0,0 +1,112 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +function isLikelyStaticResource(initiatorType: string) { + switch (initiatorType) { + case 'css': + case 'script': + case 'font': + case 'img': + case 'image': + case 'input': + case 'link': + return true; + default: + return false; + } +} + +export default function estimateBandwidth(): number { + // Estimate the current bandwidth for downloading static resources given resources already + // loaded. + // $FlowFixMe[method-unbinding] + if (typeof performance.getEntriesByType === 'function') { + let count = 0; + let bits = 0; + const resourceEntries = performance.getEntriesByType('resource'); + for (let i = 0; i < resourceEntries.length; i++) { + const entry = resourceEntries[i]; + // $FlowFixMe[prop-missing] + const transferSize: number = entry.transferSize; + // $FlowFixMe[prop-missing] + const initiatorType: string = entry.initiatorType; + const duration = entry.duration; + if ( + !transferSize || + !duration || + !isLikelyStaticResource(initiatorType) + ) { + // Skip cached, cross-orgin entries and resources likely to be dynamically generated. + continue; + } + // Find any overlapping entries that were transferring at the same time since the total + // bps at the time will include those bytes. + let overlappingBytes = 0; + // $FlowFixMe[prop-missing] + const parentEndTime: number = entry.responseEnd; + let j; + for (j = i + 1; j < resourceEntries.length; j++) { + const overlapEntry = resourceEntries[j]; + const overlapStartTime = overlapEntry.startTime; + if (overlapStartTime > parentEndTime) { + break; + } + // $FlowFixMe[prop-missing] + const overlapTransferSize: number = overlapEntry.transferSize; + // $FlowFixMe[prop-missing] + const overlapInitiatorType: string = overlapEntry.initiatorType; + if ( + !overlapTransferSize || + !isLikelyStaticResource(overlapInitiatorType) + ) { + // Skip cached, cross-orgin entries and resources likely to be dynamically generated. + continue; + } + // $FlowFixMe[prop-missing] + const overlapEndTime: number = overlapEntry.responseEnd; + const overlapFactor = + overlapEndTime < parentEndTime + ? 1 + : (parentEndTime - overlapStartTime) / + (overlapEndTime - overlapStartTime); + overlappingBytes += overlapTransferSize * overlapFactor; + } + // Skip past any entries we already considered overlapping. Otherwise we'd have to go + // back to consider previous entries when we then handled them. + i = j - 1; + + const bps = + ((transferSize + overlappingBytes) * 8) / (entry.duration / 1000); + bits += bps; + count++; + if (count > 10) { + // We have enough to get an average. + break; + } + } + if (count > 0) { + return bits / count / 1e6; + } + } + + // Fallback to the navigator.connection estimate if available + // $FlowFixMe[prop-missing] + if (navigator.connection) { + // $FlowFixMe + const downlink: ?number = navigator.connection.downlink; + if (typeof downlink === 'number') { + return downlink; + } + } + + // Otherwise, use a default of 5mbps to compute heuristics. + // This can happen commonly in Safari if all static resources and images are loaded + // cross-orgin. + return 5; +} diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js index 91b3b72ad4a0a..1f27c7e0cdb75 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js @@ -297,6 +297,8 @@ export function revealCompletedBoundariesWithViewTransitions( rect.top < window.innerHeight && rect.left < window.innerWidth; if (inViewport) { + // TODO: Use decode() instead of the load event here once the fix in + // https://issues.chromium.org/issues/420748301 has propagated fully. const loadingImage = new Promise(resolve => { suspenseyImage.addEventListener('load', resolve); suspenseyImage.addEventListener('error', resolve); diff --git a/packages/react-native-renderer/src/ReactFiberConfigFabric.js b/packages/react-native-renderer/src/ReactFiberConfigFabric.js index 5ad58533624c1..233c267a1d6a3 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigFabric.js +++ b/packages/react-native-renderer/src/ReactFiberConfigFabric.js @@ -602,17 +602,28 @@ export function preloadInstance( return true; } -export function startSuspendingCommit(): void {} +export opaque type SuspendedState = null; + +export function startSuspendingCommit(): SuspendedState { + return null; +} export function suspendInstance( + state: SuspendedState, instance: Instance, type: Type, props: Props, ): void {} -export function suspendOnActiveViewTransition(container: Container): void {} +export function suspendOnActiveViewTransition( + state: SuspendedState, + container: Container, +): void {} -export function waitForCommitToBeReady(): null { +export function waitForCommitToBeReady( + state: SuspendedState, + timeoutOffset: number, +): null { return null; } diff --git a/packages/react-native-renderer/src/ReactFiberConfigNative.js b/packages/react-native-renderer/src/ReactFiberConfigNative.js index 18a346495f640..c9a5fb591bfd8 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigNative.js +++ b/packages/react-native-renderer/src/ReactFiberConfigNative.js @@ -664,6 +664,7 @@ export function hasInstanceAffectedParent( } export function startViewTransition( + suspendedState: null | SuspendedState, rootContainer: Container, transitionTypes: null | TransitionTypes, mutationCallback: () => void, @@ -684,6 +685,7 @@ export function startViewTransition( export type RunningViewTransition = null; export function startGestureTransition( + suspendedState: null | SuspendedState, rootContainer: Container, timeline: GestureTimeline, rangeStart: number, @@ -778,17 +780,28 @@ export function preloadInstance( return true; } -export function startSuspendingCommit(): void {} +export opaque type SuspendedState = null; + +export function startSuspendingCommit(): SuspendedState { + return null; +} export function suspendInstance( + state: SuspendedState, instance: Instance, type: Type, props: Props, ): void {} -export function suspendOnActiveViewTransition(container: Container): void {} +export function suspendOnActiveViewTransition( + state: SuspendedState, + container: Container, +): void {} -export function waitForCommitToBeReady(): null { +export function waitForCommitToBeReady( + state: SuspendedState, + timeoutOffset: number, +): null { return null; } diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 83cbb24744a9c..7235837cd64df 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -90,6 +90,8 @@ type SuspenseyCommitSubscription = { commit: null | (() => void), }; +export opaque type SuspendedState = SuspenseyCommitSubscription; + export type TransitionStatus = mixed; export type FormInstance = Instance; @@ -311,17 +313,17 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { 'pending' | 'fulfilled', > | null = null; - // Represents a subscription for all the suspensey things that block a - // particular commit. Once they've all loaded, the commit phase can proceed. - 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 startSuspendingCommit(): SuspendedState { + // Represents a subscription for all the suspensey things that block a + // particular commit. Once they've all loaded, the commit phase can proceed. + return { + pendingCount: 0, + commit: null, + }; } function suspendInstance( + state: SuspendedState, instance: Instance, type: string, props: Props, @@ -338,20 +340,13 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { if (record.status === 'fulfilled') { // Already loaded. } else if (record.status === 'pending') { - if (suspenseyCommitSubscription === null) { - suspenseyCommitSubscription = { - pendingCount: 1, - commit: null, - }; - } else { - suspenseyCommitSubscription.pendingCount++; - } + state.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); + record.subscriptions.push(state); } } else { throw new Error( @@ -361,16 +356,15 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { } } - function waitForCommitToBeReady(): - | ((commit: () => mixed) => () => void) - | null { - const subscription = suspenseyCommitSubscription; - if (subscription !== null) { - suspenseyCommitSubscription = null; + function waitForCommitToBeReady( + state: SuspendedState, + timeoutOffset: number, + ): ((commit: () => mixed) => () => void) | null { + if (state.pendingCount > 0) { return (commit: () => void) => { - subscription.commit = commit; + state.commit = commit; const cancelCommit = () => { - subscription.commit = null; + state.commit = null; }; return cancelCommit; }; @@ -693,13 +687,16 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { startSuspendingCommit, suspendInstance, - suspendResource(resource: mixed): void { + suspendResource(state: SuspendedState, resource: mixed): void { throw new Error( 'Resources are not implemented for React Noop yet. This method should not be called', ); }, - suspendOnActiveViewTransition(container: Container): void { + suspendOnActiveViewTransition( + state: SuspendedState, + container: Container, + ): void { // Not implemented }, diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 810ddf5fcbb60..124d17f943db4 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -16,6 +16,7 @@ import type { HoistableRoot, FormInstance, Props, + SuspendedState, } from './ReactFiberConfig'; import type {Fiber, FiberRoot} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane'; @@ -4495,31 +4496,46 @@ let suspenseyCommitFlag: Flags = ShouldSuspendCommit; export function accumulateSuspenseyCommit( finishedWork: Fiber, committedLanes: Lanes, + suspendedState: SuspendedState, ): void { resetAppearingViewTransitions(); - accumulateSuspenseyCommitOnFiber(finishedWork, committedLanes); + accumulateSuspenseyCommitOnFiber( + finishedWork, + committedLanes, + suspendedState, + ); } function recursivelyAccumulateSuspenseyCommit( parentFiber: Fiber, committedLanes: Lanes, + suspendedState: SuspendedState, ): void { if (parentFiber.subtreeFlags & suspenseyCommitFlag) { let child = parentFiber.child; while (child !== null) { - accumulateSuspenseyCommitOnFiber(child, committedLanes); + accumulateSuspenseyCommitOnFiber(child, committedLanes, suspendedState); child = child.sibling; } } } -function accumulateSuspenseyCommitOnFiber(fiber: Fiber, committedLanes: Lanes) { +function accumulateSuspenseyCommitOnFiber( + fiber: Fiber, + committedLanes: Lanes, + suspendedState: SuspendedState, +) { switch (fiber.tag) { case HostHoistable: { - recursivelyAccumulateSuspenseyCommit(fiber, committedLanes); + recursivelyAccumulateSuspenseyCommit( + fiber, + committedLanes, + suspendedState, + ); if (fiber.flags & suspenseyCommitFlag) { if (fiber.memoizedState !== null) { suspendResource( + suspendedState, // This should always be set by visiting HostRoot first (currentHoistableRoot: any), fiber.memoizedState, @@ -4534,14 +4550,18 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber, committedLanes: Lanes) { includesOnlySuspenseyCommitEligibleLanes(committedLanes) || maySuspendCommitInSyncRender(type, props) ) { - suspendInstance(instance, type, props); + suspendInstance(suspendedState, instance, type, props); } } } break; } case HostComponent: { - recursivelyAccumulateSuspenseyCommit(fiber, committedLanes); + recursivelyAccumulateSuspenseyCommit( + fiber, + committedLanes, + suspendedState, + ); if (fiber.flags & suspenseyCommitFlag) { const instance = fiber.stateNode; const type = fiber.type; @@ -4551,7 +4571,7 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber, committedLanes: Lanes) { includesOnlySuspenseyCommitEligibleLanes(committedLanes) || maySuspendCommitInSyncRender(type, props) ) { - suspendInstance(instance, type, props); + suspendInstance(suspendedState, instance, type, props); } } break; @@ -4563,10 +4583,18 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber, committedLanes: Lanes) { const container: Container = fiber.stateNode.containerInfo; currentHoistableRoot = getHoistableRoot(container); - recursivelyAccumulateSuspenseyCommit(fiber, committedLanes); + recursivelyAccumulateSuspenseyCommit( + fiber, + committedLanes, + suspendedState, + ); currentHoistableRoot = previousHoistableRoot; } else { - recursivelyAccumulateSuspenseyCommit(fiber, committedLanes); + recursivelyAccumulateSuspenseyCommit( + fiber, + committedLanes, + suspendedState, + ); } break; } @@ -4584,10 +4612,18 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber, committedLanes: Lanes) { // instances, even if they're in the current tree. const prevFlags = suspenseyCommitFlag; suspenseyCommitFlag = MaySuspendCommit; - recursivelyAccumulateSuspenseyCommit(fiber, committedLanes); + recursivelyAccumulateSuspenseyCommit( + fiber, + committedLanes, + suspendedState, + ); suspenseyCommitFlag = prevFlags; } else { - recursivelyAccumulateSuspenseyCommit(fiber, committedLanes); + recursivelyAccumulateSuspenseyCommit( + fiber, + committedLanes, + suspendedState, + ); } } break; @@ -4607,13 +4643,21 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber, committedLanes: Lanes) { trackAppearingViewTransition(name, state); } } - recursivelyAccumulateSuspenseyCommit(fiber, committedLanes); + recursivelyAccumulateSuspenseyCommit( + fiber, + committedLanes, + suspendedState, + ); break; } // Fallthrough } default: { - recursivelyAccumulateSuspenseyCommit(fiber, committedLanes); + recursivelyAccumulateSuspenseyCommit( + fiber, + committedLanes, + suspendedState, + ); } } } diff --git a/packages/react-reconciler/src/ReactFiberTransition.js b/packages/react-reconciler/src/ReactFiberTransition.js index 6fa0e4359c621..180a09b3659f4 100644 --- a/packages/react-reconciler/src/ReactFiberTransition.js +++ b/packages/react-reconciler/src/ReactFiberTransition.js @@ -28,6 +28,7 @@ import {createCursor, push, pop} from './ReactFiberStack'; import { getWorkInProgressRoot, getWorkInProgressTransitions, + markTransitionStarted, } from './ReactFiberWorkLoop'; import { createCache, @@ -79,6 +80,7 @@ ReactSharedInternals.S = function onStartTransitionFinishForReconciler( transition: Transition, returnValue: mixed, ) { + markTransitionStarted(); if ( typeof returnValue === 'object' && returnValue !== null && diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 00c8ce91c1a75..79909cc25f104 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -26,6 +26,7 @@ import type { Resource, ViewTransitionInstance, RunningViewTransition, + SuspendedState, } from './ReactFiberConfig'; import type {RootState} from './ReactFiberRoot'; import { @@ -475,6 +476,11 @@ let didIncludeCommitPhaseUpdate: boolean = false; // content as it streams in, to minimize jank. // TODO: Think of a better name for this variable? let globalMostRecentFallbackTime: number = 0; +// Track the most recent time we started a new Transition. This lets us apply +// heuristics like the suspensey image timeout based on how long we've waited +// already. +let globalMostRecentTransitionTime: number = 0; + const FALLBACK_THROTTLE_MS: number = 300; // The absolute time for when we should start giving up on rendering @@ -1374,6 +1380,7 @@ function finishConcurrentRender( workInProgressRootInterleavedUpdatedLanes, workInProgressSuspendedRetryLanes, exitStatus, + null, IMMEDIATE_COMMIT, renderStartTime, renderEndTime, @@ -1482,28 +1489,40 @@ function commitRootWhenReady( subtreeFlags & ShouldSuspendCommit || (subtreeFlags & BothVisibilityAndMaySuspendCommit) === BothVisibilityAndMaySuspendCommit; + let suspendedState: null | SuspendedState = null; if (isViewTransitionEligible || maySuspendCommit || isGestureTransition) { // Before committing, ask the renderer whether the host tree is ready. // If it's not, we'll wait until it notifies us. - startSuspendingCommit(); + suspendedState = 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. // This will also track any newly added or appearing ViewTransition // components for the purposes of forming pairs. - accumulateSuspenseyCommit(finishedWork, lanes); + accumulateSuspenseyCommit(finishedWork, lanes, suspendedState); if (isViewTransitionEligible || isGestureTransition) { // If we're stopping gestures we don't have to wait for any pending // view transition. We'll stop it when we commit. if (!enableGestureTransition || root.stoppingGestures === null) { - suspendOnActiveViewTransition(root.containerInfo); + suspendOnActiveViewTransition(suspendedState, root.containerInfo); } } + // For timeouts we use the previous fallback commit for retries and + // the start time of the transition for transitions. This offset + // represents the time already passed. + const timeoutOffset = includesOnlyRetries(lanes) + ? globalMostRecentFallbackTime - now() + : includesOnlyTransitions(lanes) + ? globalMostRecentTransitionTime - now() + : 0; // 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(); + const schedulePendingCommit = waitForCommitToBeReady( + suspendedState, + timeoutOffset, + ); 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 @@ -1525,6 +1544,7 @@ function commitRootWhenReady( updatedLanes, suspendedRetryLanes, exitStatus, + suspendedState, SUSPENDED_COMMIT, completedRenderStartTime, completedRenderEndTime, @@ -1548,6 +1568,7 @@ function commitRootWhenReady( updatedLanes, suspendedRetryLanes, exitStatus, + suspendedState, suspendedCommitReason, completedRenderStartTime, completedRenderEndTime, @@ -2284,6 +2305,10 @@ export function markRenderDerivedCause(fiber: Fiber): void { } } +export function markTransitionStarted() { + globalMostRecentTransitionTime = now(); +} + export function markCommitTimeOfFallback() { globalMostRecentFallbackTime = now(); } @@ -3267,6 +3292,7 @@ function commitRoot( updatedLanes: Lanes, suspendedRetryLanes: Lanes, exitStatus: RootExitStatus, + suspendedState: null | SuspendedState, suspendedCommitReason: SuspendedCommitReason, // Profiling-only completedRenderStartTime: number, // Profiling-only completedRenderEndTime: number, // Profiling-only @@ -3418,6 +3444,7 @@ function commitRoot( root, finishedWork, recoverableErrors, + suspendedState, enableProfilerTimer ? suspendedCommitReason === IMMEDIATE_COMMIT ? completedRenderEndTime @@ -3556,6 +3583,7 @@ function commitRoot( pendingEffectsStatus = PENDING_MUTATION_PHASE; if (enableViewTransition && willStartViewTransition) { pendingViewTransition = startViewTransition( + suspendedState, root.containerInfo, pendingTransitionTypes, flushMutationEffects, @@ -3970,6 +3998,7 @@ function commitGestureOnRoot( root: FiberRoot, finishedWork: Fiber, recoverableErrors: null | Array>, + suspendedState: null | SuspendedState, renderEndTime: number, // Profiling-only ): void { // We assume that the gesture we just rendered was the first one in the queue. @@ -4000,6 +4029,7 @@ function commitGestureOnRoot( pendingEffectsStatus = PENDING_GESTURE_MUTATION_PHASE; pendingViewTransition = finishedGesture.running = startGestureTransition( + suspendedState, root.containerInfo, finishedGesture.provider, finishedGesture.rangeStart, diff --git a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js index 2351f7ed01b8f..33305f80d1750 100644 --- a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js @@ -106,10 +106,12 @@ describe('ReactFiberHostContext', () => { preloadInstance(instance, type, props) { return true; }, - startSuspendingCommit() {}, - suspendInstance(instance, type, props) {}, - suspendOnActiveViewTransition(container) {}, - waitForCommitToBeReady() { + startSuspendingCommit() { + return null; + }, + suspendInstance(state, instance, type, props) {}, + suspendOnActiveViewTransition(state, container) {}, + waitForCommitToBeReady(state, timeoutOffset) { return null; }, supportsMutation: true, diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index 04ad1e13bdc7a..7edea606a94c5 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -41,6 +41,7 @@ export opaque type NoTimeout = mixed; export opaque type RendererInspectionConfig = mixed; export opaque type TransitionStatus = mixed; export opaque type FormInstance = mixed; +export opaque type SuspendedState = mixed; export type RunningViewTransition = mixed; export type ViewTransitionInstance = null | {name: string, ...}; export opaque type InstanceMeasurement = mixed; diff --git a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js index c0fed8ca9bbb0..86621f68480b8 100644 --- a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js +++ b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js @@ -414,6 +414,7 @@ export function hasInstanceAffectedParent( } export function startViewTransition( + suspendedState: null | SuspendedState, rootContainer: Container, transitionTypes: null | TransitionTypes, mutationCallback: () => void, @@ -434,6 +435,7 @@ export function startViewTransition( export type RunningViewTransition = null; export function startGestureTransition( + suspendedState: null | SuspendedState, rootContainer: Container, timeline: GestureTimeline, rangeStart: number, @@ -561,17 +563,28 @@ export function preloadInstance( return true; } -export function startSuspendingCommit(): void {} +export opaque type SuspendedState = null; + +export function startSuspendingCommit(): SuspendedState { + return null; +} export function suspendInstance( + state: SuspendedState, instance: Instance, type: Type, props: Props, ): void {} -export function suspendOnActiveViewTransition(container: Container): void {} +export function suspendOnActiveViewTransition( + state: SuspendedState, + container: Container, +): void {} -export function waitForCommitToBeReady(): null { +export function waitForCommitToBeReady( + state: SuspendedState, + timeoutOffset: number, +): null { return null; }