From 3c3696d5548c8a67f2332fd78332b9366abaf2f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 17 Mar 2025 21:38:13 -0400 Subject: [PATCH] Measure Updated ViewTransition Boundaries (#32653) This does the same thing for `measureUpdateViewTransition` that we did for `measureNestedViewTransitions` in https://github.com/facebook/react/pull/32612/commits/e3cbaffef05c7b476c07f7495e06788a9503e636. If a boundary hasn't mutated and didn't change in size, we mark it for cancellation. Otherwise we add names to it. The different from the CommitViewTransition path is that the "old" names are added to the clones so this is the first time the "new" names. Now we also cancel any boundaries that were unchanged. So now the root no longer animates. We still have to clone them. There are other optimizations that can avoid cloning but once we've done all the layouts we can still cancel the running animation and let them just be the regular content if they didn't change. Just like the regular fire-and-forget path. This also fixes the measurement so that we measure clones by adjusting their position back into the viewport. This actually surfaces a bug in Safari that was already in #32612. It turns out that the old names aren't picked up for some reason and so in Safari they looked more like a cross-fade than what #32612 was supposed to fix. However, now that bug is even more apparent because they actually just disappear in Safari. I'm not sure what that bug is but it's unrelated to this PR so will fix that separately. --- .../view-transition/src/components/Page.js | 4 +- packages/react-art/src/ReactFiberConfigART.js | 4 + .../src/client/ReactFiberConfigDOM.js | 30 +++- .../src/ReactFiberConfigNative.js | 4 + .../src/createReactNoop.js | 4 + .../src/ReactFiberApplyGesture.js | 136 ++++++++++++++---- .../src/ReactFiberCommitHostEffects.js | 1 + .../src/ReactFiberCommitViewTransitions.js | 51 +++++-- .../src/ReactFiberCommitWork.js | 18 +-- .../src/ReactFiberConfigWithNoMutation.js | 1 + .../src/forks/ReactFiberConfig.custom.js | 1 + .../src/ReactFiberConfigTestHost.js | 4 + 12 files changed, 197 insertions(+), 61 deletions(-) diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 19313a99e3..48288da252 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -77,9 +77,9 @@ export default function Page({url, navigate}) {
diff --git a/packages/react-art/src/ReactFiberConfigART.js b/packages/react-art/src/ReactFiberConfigART.js index 5b8788453f..a168975221 100644 --- a/packages/react-art/src/ReactFiberConfigART.js +++ b/packages/react-art/src/ReactFiberConfigART.js @@ -518,6 +518,10 @@ export function measureInstance(instance) { return null; } +export function measureClonedInstance(instance) { + return null; +} + export function wasInstanceInViewport(measurement): boolean { return true; } diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 2d34bc9400..3615e06481 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -1477,10 +1477,12 @@ export type InstanceMeasurement = { view: boolean, // is in viewport bounds }; -export function measureInstance(instance: Instance): InstanceMeasurement { - const ownerWindow = instance.ownerDocument.defaultView; - const rect = instance.getBoundingClientRect(); - const computedStyle = getComputedStyle(instance); +function createMeasurement( + rect: ClientRect | DOMRect, + computedStyle: CSSStyleDeclaration, + element: Element, +): InstanceMeasurement { + const ownerWindow = element.ownerDocument.defaultView; return { rect: rect, abs: @@ -1508,6 +1510,26 @@ export function measureInstance(instance: Instance): InstanceMeasurement { }; } +export function measureInstance(instance: Instance): InstanceMeasurement { + const rect = instance.getBoundingClientRect(); + const computedStyle = getComputedStyle(instance); + return createMeasurement(rect, computedStyle, instance); +} + +export function measureClonedInstance(instance: Instance): InstanceMeasurement { + const measuredRect = instance.getBoundingClientRect(); + // Adjust the DOMRect based on the translate that put it outside the viewport. + // TODO: This might not be completely correct if the parent also has a transform. + const rect = new DOMRect( + measuredRect.x + 20000, + measuredRect.y + 20000, + measuredRect.width, + measuredRect.height, + ); + const computedStyle = getComputedStyle(instance); + return createMeasurement(rect, computedStyle, instance); +} + export function wasInstanceInViewport( measurement: InstanceMeasurement, ): boolean { diff --git a/packages/react-native-renderer/src/ReactFiberConfigNative.js b/packages/react-native-renderer/src/ReactFiberConfigNative.js index c08e1f0f82..b15a6f9f76 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigNative.js +++ b/packages/react-native-renderer/src/ReactFiberConfigNative.js @@ -620,6 +620,10 @@ export function measureInstance(instance: Instance): InstanceMeasurement { return null; } +export function measureClonedInstance(instance: Instance): InstanceMeasurement { + return null; +} + export function wasInstanceInViewport( measurement: InstanceMeasurement, ): boolean { diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 2ddbea7928..266f860fe6 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -796,6 +796,10 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { return null; }, + measureClonedInstance(instance: Instance): InstanceMeasurement { + return null; + }, + wasInstanceInViewport(measurement: InstanceMeasurement): boolean { return true; }, diff --git a/packages/react-reconciler/src/ReactFiberApplyGesture.js b/packages/react-reconciler/src/ReactFiberApplyGesture.js index c9b1d7688f..ea4a0ce70a 100644 --- a/packages/react-reconciler/src/ReactFiberApplyGesture.js +++ b/packages/react-reconciler/src/ReactFiberApplyGesture.js @@ -9,7 +9,7 @@ import type {Fiber, FiberRoot} from './ReactInternalTypes'; -import type {Instance, TextInstance} from './ReactFiberConfig'; +import type {Instance, TextInstance, Props} from './ReactFiberConfig'; import type {OffscreenState} from './ReactFiberActivityComponent'; @@ -25,6 +25,7 @@ import { removeRootViewTransitionClone, cancelRootViewTransitionName, restoreRootViewTransitionName, + cancelViewTransitionName, applyViewTransitionName, appendChild, commitUpdate, @@ -39,6 +40,7 @@ import { popMutationContext, pushMutationContext, viewTransitionMutationContext, + trackHostMutation, } from './ReactFiberMutationTracking'; import { MutationMask, @@ -48,6 +50,7 @@ import { Visibility, ViewTransitionNamedStatic, ViewTransitionStatic, + AffectedParentLayout, } from './ReactFiberFlags'; import { HostComponent, @@ -61,9 +64,14 @@ import { import { restoreEnterOrExitViewTransitions, restoreNestedViewTransitions, + restoreUpdateViewTransitionForGesture, appearingViewTransitions, commitEnterViewTransitions, measureNestedViewTransitions, + measureUpdateViewTransition, + viewTransitionCancelableChildren, + pushViewTransitionCancelableScope, + popViewTransitionCancelableScope, } from './ReactFiberCommitViewTransitions'; import { getViewTransitionName, @@ -72,6 +80,10 @@ import { let didWarnForRootClone = false; +// Used during the apply phase to track whether a parent ViewTransition component +// might have been affected by any mutations / relayouts below. +let viewTransitionContextChanged: boolean = false; + function detectMutationOrInsertClones(finishedWork: Fiber): boolean { return true; } @@ -421,6 +433,7 @@ function recursivelyInsertNewFiber( // For insertions we don't need to clone. It's already new state node. if (visitPhase !== INSERT_APPEARING_PAIR) { appendChild(hostParentClone, instance); + trackHostMutation(); recursivelyInsertNew( finishedWork, instance, @@ -450,6 +463,7 @@ function recursivelyInsertNewFiber( // For insertions we don't need to clone. It's already new state node. if (visitPhase !== INSERT_APPEARING_PAIR) { appendChild(hostParentClone, textInstance); + trackHostMutation(); } break; } @@ -575,6 +589,7 @@ function recursivelyInsertClonesFromExistingTree( } if (visitPhase === CLONE_EXIT || visitPhase === CLONE_UNHIDE) { unhideInstance(clone, child.memoizedProps); + trackHostMutation(); } break; } @@ -590,6 +605,7 @@ function recursivelyInsertClonesFromExistingTree( appendChild(hostParentClone, clone); if (visitPhase === CLONE_EXIT || visitPhase === CLONE_UNHIDE) { unhideTextInstance(clone, child.memoizedProps); + trackHostMutation(); } break; } @@ -679,6 +695,10 @@ function recursivelyInsertClones( for (let i = 0; i < deletions.length; i++) { const childToDelete = deletions[i]; trackEnterViewTransitions(childToDelete); + // Normally we would only mark something as triggering a mutation if there was + // actually a HostInstance below here. If this tree didn't contain a HostInstances + // we shouldn't trigger a mutation even though a virtual component was deleted. + trackHostMutation(); } } @@ -801,6 +821,7 @@ function insertDestinationClonesOfFiber( clone = cloneMutableInstance(instance, true); if (finishedWork.flags & ContentReset) { resetTextContent(clone); + trackHostMutation(); } } else { // If we have children we'll clone them as we walk the tree so we just @@ -825,6 +846,7 @@ function insertDestinationClonesOfFiber( ); appendChild(hostParentClone, clone); unhideInstance(clone, finishedWork.memoizedProps); + trackHostMutation(); } else { recursivelyInsertClones(finishedWork, clone, null, visitPhase); appendChild(hostParentClone, clone); @@ -851,10 +873,12 @@ function insertDestinationClonesOfFiber( const newText: string = finishedWork.memoizedProps; const oldText: string = current.memoizedProps; commitTextUpdate(clone, newText, oldText); + trackHostMutation(); } appendChild(hostParentClone, clone); if (visitPhase === CLONE_EXIT || visitPhase === CLONE_UNHIDE) { unhideTextInstance(clone, finishedWork.memoizedProps); + trackHostMutation(); } break; } @@ -885,6 +909,10 @@ function insertDestinationClonesOfFiber( } else if (current !== null && current.memoizedState === null) { // Was previously mounted as visible but is now hidden. trackEnterViewTransitions(current); + // Normally we would only mark something as triggering a mutation if there was + // actually a HostInstance below here. If this tree didn't contain a HostInstances + // we shouldn't trigger a mutation even though a virtual component was hidden. + trackHostMutation(); } break; } @@ -991,13 +1019,6 @@ function measureExitViewTransitions(placement: Fiber): void { } } -function measureUpdateViewTransition( - current: Fiber, - finishedWork: Fiber, -): void { - // TODO -} - function recursivelyApplyViewTransitions(parentFiber: Fiber) { const deletions = parentFiber.deletions; if (deletions !== null) { @@ -1037,15 +1058,6 @@ function applyViewTransitionsOnFiber(finishedWork: Fiber) { // because the fiber tag is more specific. An exception is any flag related // to reconciliation, because those can be set on all fiber types. switch (finishedWork.tag) { - case HostComponent: { - // const instance: Instance = finishedWork.stateNode; - // TODO: Apply name and measure. - recursivelyApplyViewTransitions(finishedWork); - break; - } - case HostText: { - break; - } case HostPortal: { // TODO: Consider what should happen to Portals. For now we exclude them. break; @@ -1063,12 +1075,59 @@ function applyViewTransitionsOnFiber(finishedWork: Fiber) { } break; } - case ViewTransitionComponent: - measureUpdateViewTransition(current, finishedWork); + case ViewTransitionComponent: { + const prevContextChanged = viewTransitionContextChanged; + const prevCancelableChildren = pushViewTransitionCancelableScope(); + viewTransitionContextChanged = false; + recursivelyApplyViewTransitions(finishedWork); + + if (viewTransitionContextChanged) { + finishedWork.flags |= Update; + } + + const inViewport = measureUpdateViewTransition( + current, + finishedWork, + true, + ); + + if ((finishedWork.flags & Update) === NoFlags || !inViewport) { + // If this boundary didn't update, then we may be able to cancel its children. + // We bubble them up to the parent set to be determined later if we can cancel. + // Similarly, if old and new state was outside the viewport, we can skip it + // even if it did update. + if (prevCancelableChildren === null) { + // Bubbling up this whole set to the parent. + } else { + // Merge with parent set. + // $FlowFixMe[method-unbinding] + prevCancelableChildren.push.apply( + prevCancelableChildren, + viewTransitionCancelableChildren, + ); + popViewTransitionCancelableScope(prevCancelableChildren); + } + // TODO: If this doesn't end up canceled, because a parent animates, + // then we should probably issue an event since this instance is part of it. + } else { + // TODO: Schedule gesture events. + // If this boundary did update, we cannot cancel its children so those are dropped. + popViewTransitionCancelableScope(prevCancelableChildren); + } + + if ((finishedWork.flags & AffectedParentLayout) !== NoFlags) { + // This boundary changed size in a way that may have caused its parent to + // relayout. We need to bubble this information up to the parent. + viewTransitionContextChanged = true; + } else { + // Otherwise, we restore it to whatever the parent had found so far. + viewTransitionContextChanged = prevContextChanged; + } + const viewTransitionState: ViewTransitionState = finishedWork.stateNode; viewTransitionState.clones = null; // Reset - recursivelyApplyViewTransitions(finishedWork); break; + } default: { recursivelyApplyViewTransitions(finishedWork); break; @@ -1082,13 +1141,38 @@ export function applyDepartureTransitions( finishedWork: Fiber, ): void { // First measure and apply view-transition-names to the "new" states. + viewTransitionContextChanged = false; + pushViewTransitionCancelableScope(); + recursivelyApplyViewTransitions(finishedWork); + // Then remove the clones. const rootClone = root.gestureClone; if (rootClone !== null) { root.gestureClone = null; removeRootViewTransitionClone(root.containerInfo, rootClone); } + + if (!viewTransitionContextChanged) { + // If we didn't leak any resizing out to the root, we don't have to transition + // the root itself. This means that we can now safely cancel any cancellations + // that bubbled all the way up. + const cancelableChildren = viewTransitionCancelableChildren; + if (cancelableChildren !== null) { + for (let i = 0; i < cancelableChildren.length; i += 3) { + cancelViewTransitionName( + ((cancelableChildren[i]: any): Instance), + ((cancelableChildren[i + 1]: any): string), + ((cancelableChildren[i + 2]: any): Props), + ); + } + } + // We also cancel the root itself. First we restore the name to the documentElement + // and then we cancel it. + restoreRootViewTransitionName(root.containerInfo); + cancelRootViewTransitionName(root.containerInfo); + } + popViewTransitionCancelableScope(null); } function recursivelyRestoreViewTransitions(parentFiber: Fiber) { @@ -1130,15 +1214,6 @@ function restoreViewTransitionsOnFiber(finishedWork: Fiber) { // because the fiber tag is more specific. An exception is any flag related // to reconciliation, because those can be set on all fiber types. switch (finishedWork.tag) { - case HostComponent: { - // const instance: Instance = finishedWork.stateNode; - // TODO: Restore the name. - recursivelyRestoreViewTransitions(finishedWork); - break; - } - case HostText: { - break; - } case HostPortal: { // TODO: Consider what should happen to Portals. For now we exclude them. break; @@ -1157,8 +1232,7 @@ function restoreViewTransitionsOnFiber(finishedWork: Fiber) { break; } case ViewTransitionComponent: - const viewTransitionState: ViewTransitionState = finishedWork.stateNode; - viewTransitionState.clones = null; // Reset + restoreUpdateViewTransitionForGesture(current, finishedWork); recursivelyRestoreViewTransitions(finishedWork); break; default: { diff --git a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js index 7dad8b330d..2ca49f677d 100644 --- a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js +++ b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js @@ -199,6 +199,7 @@ export function commitShowHideHostTextInstance(node: Fiber, isHidden: boolean) { unhideTextInstance(instance, node.memoizedProps); } } + trackHostMutation(); } catch (error) { captureCommitPhaseError(node, node.return, error); } diff --git a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js index 80e1901810..c8ec019da7 100644 --- a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js +++ b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js @@ -31,6 +31,7 @@ import { applyViewTransitionName, restoreViewTransitionName, measureInstance, + measureClonedInstance, hasInstanceChanged, hasInstanceAffectedParent, wasInstanceInViewport, @@ -564,16 +565,23 @@ export function restoreUpdateViewTransition( current: Fiber, finishedWork: Fiber, ): void { - finishedWork.memoizedState = null; restoreViewTransitionOnHostInstances(current.child, true); restoreViewTransitionOnHostInstances(finishedWork.child, true); } +export function restoreUpdateViewTransitionForGesture( + current: Fiber, + finishedWork: Fiber, +): void { + // For gestures we don't need to reset "finishedWork" because those would + // have all been clones that got deleted. + restoreViewTransitionOnHostInstances(current.child, true); +} + export function restoreNestedViewTransitions(changedParent: Fiber): void { let child = changedParent.child; while (child !== null) { if (child.tag === ViewTransitionComponent) { - child.memoizedState = null; restoreViewTransitionOnHostInstances(child.child, false); } else if ((child.subtreeFlags & ViewTransitionStatic) !== NoFlags) { restoreNestedViewTransitions(child); @@ -774,13 +782,16 @@ function measureViewTransitionHostInstancesRecursive( export function measureUpdateViewTransition( current: Fiber, finishedWork: Fiber, + gesture: boolean, ): boolean { - const props: ViewTransitionProps = finishedWork.memoizedProps; - const newName = getViewTransitionName(props, finishedWork.stateNode); - const oldName = getViewTransitionName( - current.memoizedProps, - current.stateNode, - ); + // If this was a gesture then which Fiber was used for the "old" vs "new" state is reversed. + // We still need to treat "finishedWork" as the Fiber that contains the flags for this commmit. + const oldFiber = gesture ? finishedWork : current; + const newFiber = gesture ? current : finishedWork; + const props: ViewTransitionProps = newFiber.memoizedProps; + const state: ViewTransitionState = newFiber.stateNode; + const newName = getViewTransitionName(props, state); + const oldName = getViewTransitionName(oldFiber.memoizedProps, state); const updateClassName: ?string = getViewTransitionClassName( props.className, props.update, @@ -807,7 +818,9 @@ export function measureUpdateViewTransition( if (layoutClassName === 'none') { // If we did not update, then all changes are considered a layout. We'll // attempt to cancel. - cancelViewTransitionHostInstances(finishedWork.child, oldName, true); + // This should use the Fiber that got names applied in the snapshot phase + // since those are the ones we're trying to cancel. + cancelViewTransitionHostInstances(oldFiber.child, oldName, true); return false; } // We didn't update but we might still apply layout so we measure each @@ -816,10 +829,21 @@ export function measureUpdateViewTransition( } // If nothing changed due to a mutation, or children changing size // and the measurements end up unchanged, we should restore it to not animate. - const previousMeasurements = current.memoizedState; + let previousMeasurements: null | Array; + if (gesture) { + const clones = state.clones; + if (clones === null) { + previousMeasurements = null; + } else { + previousMeasurements = clones.map(measureClonedInstance); + } + } else { + previousMeasurements = oldFiber.memoizedState; + oldFiber.memoizedState = null; // Clear it. We won't need it anymore. + } const inViewport = measureViewTransitionHostInstances( - finishedWork, - finishedWork.child, + finishedWork, // This is always finishedWork since it's used to assign flags. + newFiber.child, // This either current or finishedWork depending on if was a gesture. newName, oldName, className, @@ -857,10 +881,11 @@ export function measureNestedViewTransitions( if (clones === null) { previousMeasurements = null; } else { - previousMeasurements = clones.map(measureInstance); + previousMeasurements = clones.map(measureClonedInstance); } } else { previousMeasurements = child.memoizedState; + child.memoizedState = null; // Clear it. We won't need it anymore. } const inViewport = measureViewTransitionHostInstances( child, diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 3e2a5153dd..63bf7b7eb9 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -2474,7 +2474,6 @@ function commitAfterMutationEffectsOnFiber( // the root itself. This means that we can now safely cancel any cancellations // that bubbled all the way up. const cancelableChildren = viewTransitionCancelableChildren; - popViewTransitionCancelableScope(null); if (cancelableChildren !== null) { for (let i = 0; i < cancelableChildren.length; i += 3) { cancelViewTransitionName( @@ -2487,6 +2486,7 @@ function commitAfterMutationEffectsOnFiber( // We also cancel the root itself. cancelRootViewTransitionName(root.containerInfo); } + popViewTransitionCancelableScope(null); break; } case HostComponent: { @@ -2528,7 +2528,11 @@ function commitAfterMutationEffectsOnFiber( finishedWork.flags |= Update; } - const inViewport = measureUpdateViewTransition(current, finishedWork); + const inViewport = measureUpdateViewTransition( + current, + finishedWork, + false, + ); if ((finishedWork.flags & Update) === NoFlags || !inViewport) { // If this boundary didn't update, then we may be able to cancel its children. @@ -3618,15 +3622,7 @@ function commitPassiveMountOnFiber( if (current === null) { // This is a new mount. We should have handled this as part of the // Placement effect or it is deeper inside a entering transition. - } else if ( - (finishedWork.subtreeFlags & - (Placement | - Update | - ChildDeletion | - ContentReset | - Visibility)) !== - NoFlags - ) { + } else { // Something mutated within this subtree. This might have caused // something to cross-fade if we didn't already cancel it. // If not, restore it. diff --git a/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js b/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js index 64b67491aa..74e30da88c 100644 --- a/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js +++ b/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js @@ -46,6 +46,7 @@ export const cloneRootViewTransitionContainer = shim; export const removeRootViewTransitionClone = shim; export type InstanceMeasurement = null; export const measureInstance = shim; +export const measureClonedInstance = shim; export const wasInstanceInViewport = shim; export const hasInstanceChanged = shim; export const hasInstanceAffectedParent = shim; diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index 2be7d18b87..f22b6a580e 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -149,6 +149,7 @@ export const cloneRootViewTransitionContainer = export const removeRootViewTransitionClone = $$$config.removeRootViewTransitionClone; export const measureInstance = $$$config.measureInstance; +export const measureClonedInstance = $$$config.measureClonedInstance; export const wasInstanceInViewport = $$$config.wasInstanceInViewport; export const hasInstanceChanged = $$$config.hasInstanceChanged; export const hasInstanceAffectedParent = $$$config.hasInstanceAffectedParent; diff --git a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js index d9a45550fa..2df5f01571 100644 --- a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js +++ b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js @@ -389,6 +389,10 @@ export function measureInstance(instance: Instance): InstanceMeasurement { return null; } +export function measureClonedInstance(instance: Instance): InstanceMeasurement { + return null; +} + export function wasInstanceInViewport( measurement: InstanceMeasurement, ): boolean {