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;
}