From e9252bcdccf7f8f691081e4d48ca47657bc723f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 4 Mar 2025 20:10:08 -0500 Subject: [PATCH] During a Swipe Gesture Render a Clone Offscreen and Animate it Onscreen (#32500) This is really the essence mechanism of the `useSwipeTransition` feature. We don't want to immediately switch to the destination state when starting a gesture. The effects remain mounted on the current state. We want the current state to be "live". This is important to for example allow a video to keeping playing while starting a swipe (think TikTok/Reels) and not stop until you've committed the action. The only thing that can be live is the "new" state. Therefore we treat the destination as the "old" state and perform a reverse animation from there. Ideally we could apply the old state to the DOM tree, take a snapshot and then revert it back in the mutation of `startViewTransition`. Unfortunately, the way `startViewTransition` was designed it always paints one frame of the "old" state which would lead this to cause a flicker. To work around this, we need to create a clone of any View Transition boundary that might be mutated and then render that offscreen. That way we can render the "current" state on screen and the "destination" state offscreen for the screenshots. Being mutated can be either due to React doing a DOM mutation or if a child boundary resizes that causes the parent to relayout. We don't have to do this for insertions or deletions since they only appear on one side. The worst case scenario is that we have to clone the whole root. That's what this first PR implements. We clone the container and if it's not absolutely positioned, we position it on top of the current one. If the container is `document` or `` we instead clone the `` tag since it's the only one we can insert a duplicate of. If the container is deep in the tree we clone just that even though technically we should probably clone the whole document in that case. We just keep the impact smaller. Ideally though we'd never hit this case. In fact, if we clone the document we issue a warning (always for now) since you probably should optimize this. In the future I intend to add optimizations when affected View Transition boundaries are absolutely positioned since they cannot possibly relayout the parent. This would be the ideal way to use this feature most efficiently but it still works without it. Since we render the "old" state outside the viewport, we need to then adjust the animation to put it back into the viewport. This is the trickiest part to get right while still preserving any customization of the View Transitions done using CSS. This current approach reapplies all the animations with adjusted keyframes. In the case of an "exit" the pseudo-element itself is positioned outside the viewport but since we can't programmatically update the style of the pseudo-element itself we instead adjust all the keyframes to put it back into the viewport. If there is no animation on the group we add one. In the case of an "update" the pseudo-element is positioned on the new state which is already inside the viewport. However, the auto-generated animation of the group has a starting keyframe that starts outside the viewport. In this case we need to adjust that keyframe. In the future I might explore a technique that inserts stylesheets instead of mutating the animations. It might be simpler. But whatever hacks work to maximize the compatibility is best. --- .../view-transition/src/components/Page.js | 11 +- packages/react-art/src/ReactFiberConfigART.js | 16 + .../src/client/ReactFiberConfigDOM.js | 498 +++++++++++++++++- .../src/ReactFiberConfigNative.js | 29 + .../src/createReactNoop.js | 22 + .../src/ReactFiberApplyGesture.js | 348 +++++++++++- .../src/ReactFiberCommitWork.js | 3 - .../src/ReactFiberConfigWithNoMutation.js | 4 + .../react-reconciler/src/ReactFiberRoot.js | 1 + .../src/ReactFiberWorkLoop.js | 8 + .../src/ReactInternalTypes.js | 2 + .../src/forks/ReactFiberConfig.custom.js | 6 + .../src/ReactFiberConfigTestHost.js | 49 ++ scripts/error-codes/codes.json | 3 +- 14 files changed, 982 insertions(+), 18 deletions(-) diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index d12ab56203..d7acc93c0c 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -2,6 +2,8 @@ import React, { unstable_ViewTransition as ViewTransition, unstable_Activity as Activity, unstable_useSwipeTransition as useSwipeTransition, + useEffect, + useState, } from 'react'; import SwipeRecognizer from './SwipeRecognizer'; @@ -53,6 +55,13 @@ export default function Page({url, navigate}) { navigate(show ? '/?a' : '/?b'); } + const [counter, setCounter] = useState(0); + + useEffect(() => { + const timer = setInterval(() => setCounter(c => c + 1), 1000); + return () => clearInterval(timer); + }, []); + const exclamation = ( ! @@ -76,7 +85,7 @@ export default function Page({url, navigate}) { 'navigation-back': transitions['slide-right'], 'navigation-forward': transitions['slide-left'], }}> -

{!show ? 'A' : 'B'}

+

{!show ? 'A' + counter : 'B' + counter}

{show ? (
diff --git a/packages/react-art/src/ReactFiberConfigART.js b/packages/react-art/src/ReactFiberConfigART.js index dc746b0e17..1ca506d022 100644 --- a/packages/react-art/src/ReactFiberConfigART.js +++ b/packages/react-art/src/ReactFiberConfigART.js @@ -302,6 +302,10 @@ export function createInstance(type, props, internalInstanceHandle) { return instance; } +export function cloneMutableInstance(instance, keepChildren) { + return instance; +} + export function createTextInstance( text, rootContainerInstance, @@ -310,6 +314,10 @@ export function createTextInstance( return text; } +export function cloneMutableTextInstance(textInstance) { + return textInstance; +} + export function finalizeInitialChildren(domElement, type, props) { return false; } @@ -475,6 +483,14 @@ export function restoreRootViewTransitionName(rootContainer) { // Noop } +export function cloneRootViewTransitionContainer(rootContainer) { + throw new Error('Not implemented.'); +} + +export function removeRootViewTransitionClone(rootContainer, clone) { + throw new Error('Not implemented.'); +} + export type InstanceMeasurement = null; export function measureInstance(instance) { diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 2b288efe46..ba79b08c47 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -544,6 +544,13 @@ export function createInstance( return domElement; } +export function cloneMutableInstance( + instance: Instance, + keepChildren: boolean, +): Instance { + return instance.cloneNode(keepChildren); +} + export function appendInitialChild( parentInstance: Instance, child: Instance | TextInstance, @@ -609,6 +616,12 @@ export function createTextInstance( return textNode; } +export function cloneMutableTextInstance( + textInstance: TextInstance, +): TextInstance { + return textInstance.cloneNode(false); +} + let currentPopstateTransitionEvent: Event | null = null; export function shouldAttemptEagerTransition(): boolean { const event = window.event; @@ -1207,10 +1220,24 @@ export function cancelRootViewTransitionName(rootContainer: Container): void { } export function restoreRootViewTransitionName(rootContainer: Container): void { + let containerInstance: Instance; + if (rootContainer.nodeType === DOCUMENT_NODE) { + containerInstance = (rootContainer: any).body; + } else if (rootContainer.nodeName === 'HTML') { + containerInstance = (rootContainer.ownerDocument.body: any); + } else { + // If the container is not the whole document, then we ideally should probably + // clone the whole document outside of the React too. + containerInstance = (rootContainer: any); + } + // $FlowFixMe[prop-missing] + if (containerInstance.style.viewTransitionName === 'root') { + // If we moved the root view transition name to the container in a gesture + // we need to restore it now. + containerInstance.style.viewTransitionName = ''; + } const documentElement: null | HTMLElement = - rootContainer.nodeType === DOCUMENT_NODE - ? (rootContainer: any).documentElement - : rootContainer.ownerDocument.documentElement; + containerInstance.ownerDocument.documentElement; if ( documentElement !== null && // $FlowFixMe[prop-missing] @@ -1221,6 +1248,226 @@ export function restoreRootViewTransitionName(rootContainer: Container): void { } } +function getComputedTransform(style: CSSStyleDeclaration): string { + // Gets the merged transform of all the short hands. + const computedStyle: any = style; + let transform: string = computedStyle.transform; + if (transform === 'none') { + transform = ''; + } + const scale: string = computedStyle.scale; + if (scale !== 'none' && scale !== '') { + const parts = scale.split(' '); + transform = + (parts.length === 3 ? 'scale3d' : 'scale') + + '(' + + parts.join(', ') + + ') ' + + transform; + } + const rotate: string = computedStyle.rotate; + if (rotate !== 'none' && rotate !== '') { + const parts = rotate.split(' '); + if (parts.length === 1) { + transform = 'rotate(' + parts[0] + ') ' + transform; + } else if (parts.length === 2) { + transform = + 'rotate' + parts[0].toUpperCase() + '(' + parts[1] + ') ' + transform; + } else { + transform = 'rotate3d(' + parts.join(', ') + ') ' + transform; + } + } + const translate: string = computedStyle.translate; + if (translate !== 'none' && translate !== '') { + const parts = translate.split(' '); + transform = + (parts.length === 3 ? 'translate3d' : 'translate') + + '(' + + parts.join(', ') + + ') ' + + transform; + } + return transform; +} + +function moveOutOfViewport( + originalStyle: CSSStyleDeclaration, + element: HTMLElement, +): void { + // Apply a transform that safely puts the whole element outside the viewport + // while still letting it paint its "old" state to a snapshot. + const transform = getComputedTransform(originalStyle); + // Clear the long form properties. + // $FlowFixMe + element.style.translate = 'none'; + // $FlowFixMe + element.style.scale = 'none'; + // $FlowFixMe + element.style.rotate = 'none'; + // Apply a translate to move it way out of the viewport. This is applied first + // so that it is in the coordinate space of the parent and not after applying + // other transforms. That's why we need to merge the long form properties. + // TODO: Ideally we'd adjust for the parent's rotate/scale. Otherwise when + // we move back the ::view-transition-group we might overshoot or undershoot. + element.style.transform = 'translate(-20000px, -20000px) ' + transform; +} + +function moveOldFrameIntoViewport(keyframe: any): void { + // In the resulting View Transition Animation, the first frame will be offset. + const computedTransform: ?string = keyframe.transform; + if (computedTransform != null) { + let transform = computedTransform === 'none' ? '' : computedTransform; + transform = 'translate(20000px, 20000px) ' + transform; + keyframe.transform = transform; + } +} + +export function cloneRootViewTransitionContainer( + rootContainer: Container, +): Instance { + // This implies that we're not going to animate the root document but instead + // the clone so we first clear the name of the root container. + const documentElement: null | HTMLElement = + rootContainer.nodeType === DOCUMENT_NODE + ? (rootContainer: any).documentElement + : rootContainer.ownerDocument.documentElement; + if ( + documentElement !== null && + // $FlowFixMe[prop-missing] + documentElement.style.viewTransitionName === '' + ) { + // $FlowFixMe[prop-missing] + documentElement.style.viewTransitionName = 'none'; + } + + let containerInstance: HTMLElement; + if (rootContainer.nodeType === DOCUMENT_NODE) { + containerInstance = (rootContainer: any).body; + } else if (rootContainer.nodeName === 'HTML') { + containerInstance = (rootContainer.ownerDocument.body: any); + } else { + // If the container is not the whole document, then we ideally should probably + // clone the whole document outside of the React too. + containerInstance = (rootContainer: any); + } + + const containerParent = containerInstance.parentNode; + if (containerParent === null) { + throw new Error('Cannot use a useSwipeTransition() in a detached root.'); + } + + const clone: HTMLElement = containerInstance.cloneNode(false); + + const computedStyle = getComputedStyle(containerInstance); + + if ( + computedStyle.position === 'absolute' || + computedStyle.position === 'fixed' + ) { + // If the style is already absolute, we don't have to do anything because it'll appear + // in the same place. + } else { + // Otherwise we need to absolutely position the clone in the same location as the original. + let positionedAncestor: HTMLElement = containerParent; + while ( + positionedAncestor.parentNode != null && + positionedAncestor.parentNode.nodeType !== DOCUMENT_NODE + ) { + if (getComputedStyle(positionedAncestor).position !== 'static') { + break; + } + // $FlowFixMe: This is refined. + positionedAncestor = positionedAncestor.parentNode; + } + + const positionedAncestorStyle: any = positionedAncestor.style; + const containerInstanceStyle: any = containerInstance.style; + // Clear the transform while we're measuring since it affects the bounding client rect. + const prevAncestorTranslate = positionedAncestorStyle.translate; + const prevAncestorScale = positionedAncestorStyle.scale; + const prevAncestorRotate = positionedAncestorStyle.rotate; + const prevAncestorTransform = positionedAncestorStyle.transform; + const prevTranslate = containerInstanceStyle.translate; + const prevScale = containerInstanceStyle.scale; + const prevRotate = containerInstanceStyle.rotate; + const prevTransform = containerInstanceStyle.transform; + positionedAncestorStyle.translate = 'none'; + positionedAncestorStyle.scale = 'none'; + positionedAncestorStyle.rotate = 'none'; + positionedAncestorStyle.transform = 'none'; + containerInstanceStyle.translate = 'none'; + containerInstanceStyle.scale = 'none'; + containerInstanceStyle.rotate = 'none'; + containerInstanceStyle.transform = 'none'; + + const ancestorRect = positionedAncestor.getBoundingClientRect(); + const rect = containerInstance.getBoundingClientRect(); + + const cloneStyle = clone.style; + cloneStyle.position = 'absolute'; + cloneStyle.top = rect.top - ancestorRect.top + 'px'; + cloneStyle.left = rect.left - ancestorRect.left + 'px'; + cloneStyle.width = rect.width + 'px'; + cloneStyle.height = rect.height + 'px'; + cloneStyle.margin = '0px'; + cloneStyle.boxSizing = 'border-box'; + + positionedAncestorStyle.translate = prevAncestorTranslate; + positionedAncestorStyle.scale = prevAncestorScale; + positionedAncestorStyle.rotate = prevAncestorRotate; + positionedAncestorStyle.transform = prevAncestorTransform; + containerInstanceStyle.translate = prevTranslate; + containerInstanceStyle.scale = prevScale; + containerInstanceStyle.rotate = prevRotate; + containerInstanceStyle.transform = prevTransform; + } + + // For this transition the container will act as the root. Nothing outside of it should + // be affected anyway. This lets us transition from the cloned container to the original. + // $FlowFixMe[prop-missing] + clone.style.viewTransitionName = 'root'; + + // Move out of the viewport so that it's still painted for the snapshot but is not visible + // for the frame where the snapshot happens. + moveOutOfViewport(computedStyle, clone); + + // Insert the clone after the root container as a sibling. This may inject a body + // as the next sibling of an existing body. document.body will still point to the + // first one and any id selectors will still find the first one. That's why it's + // important that it's after the existing node. + containerInstance.parentNode.insertBefore( + clone, + containerInstance.nextSibling, + ); + + return clone; +} + +export function removeRootViewTransitionClone( + rootContainer: Container, + clone: Instance, +): void { + let containerInstance: Instance; + if (rootContainer.nodeType === DOCUMENT_NODE) { + containerInstance = (rootContainer: any).body; + } else if (rootContainer.nodeName === 'HTML') { + containerInstance = (rootContainer.ownerDocument.body: any); + } else { + // If the container is not the whole document, then we ideally should probably + // clone the whole document outside of the React too. + containerInstance = (rootContainer: any); + } + const containerParent = containerInstance.parentNode; + if (containerParent === null) { + throw new Error('Cannot use a useSwipeTransition() in a detached root.'); + } + // We assume that the clone is still within the same parent. + containerParent.removeChild(clone); + + // Now the root is on the containerInstance itself until we call restoreRootViewTransitionName. + containerInstance.style.viewTransitionName = 'root'; +} + export type InstanceMeasurement = { rect: ClientRect | DOMRect, abs: boolean, // is absolutely positioned @@ -1417,8 +1664,127 @@ export type RunningGestureTransition = { ... }; +function mergeTranslate(translateA: ?string, translateB: ?string): string { + if (!translateA || translateA === 'none') { + return translateB || ''; + } + if (!translateB || translateB === 'none') { + return translateA || ''; + } + const partsA = translateA.split(' '); + const partsB = translateB.split(' '); + let i; + let result = ''; + for (i = 0; i < partsA.length && i < partsB.length; i++) { + if (i > 0) { + result += ' '; + } + result += 'calc(' + partsA[i] + ' + ' + partsB[i] + ')'; + } + for (; i < partsA.length; i++) { + result += ' ' + partsA[i]; + } + for (; i < partsB.length; i++) { + result += ' ' + partsB[i]; + } + return result; +} + +function animateGesture( + keyframes: any, + targetElement: Element, + pseudoElement: string, + timeline: AnimationTimeline, + rangeStart: number, + rangeEnd: number, + moveFirstFrameIntoViewport: boolean, + moveAllFramesIntoViewport: boolean, +) { + for (let i = 0; i < keyframes.length; i++) { + const keyframe = keyframes[i]; + // Delete any easing since we always apply linear easing to gestures. + delete keyframe.easing; + delete keyframe.computedOffset; + // Chrome returns "auto" for width/height which is not a valid value to + // animate to. Similarly, transform: "none" is actually lack of transform. + if (keyframe.width === 'auto') { + delete keyframe.width; + } + if (keyframe.height === 'auto') { + delete keyframe.height; + } + if (keyframe.transform === 'none') { + delete keyframe.transform; + } + if (moveAllFramesIntoViewport) { + if (keyframe.transform == null) { + // If a transform is not explicitly specified to override the auto + // generated one on the pseudo element, then we need to adjust it to + // put it back into the viewport. We don't know the offset relative to + // the screen so instead we use the translate prop to do a relative + // adjustment. + // TODO: If the "transform" was manually overridden on the pseudo + // element itself and no longer the auto generated one, then we shouldn't + // adjust it. I'm not sure how to detect this. + if (keyframe.translate == null || keyframe.translate === '') { + // TODO: If there's a CSS rule targeting translate on the pseudo element + // already we need to merge it. + const elementTranslate: ?string = (getComputedStyle( + targetElement, + pseudoElement, + ): any).translate; + keyframe.translate = mergeTranslate( + elementTranslate, + '20000px 20000px', + ); + } else { + keyframe.translate = mergeTranslate( + keyframe.translate, + '20000px 20000px', + ); + } + } + } + } + if (moveFirstFrameIntoViewport) { + // If this is the generated animation that does a FLIP matrix translation + // from the old position, we need to adjust it from the out of viewport + // position. If this is going from old to new it only applies to first + // keyframe. Otherwise it applies to every keyframe. + moveOldFrameIntoViewport(keyframes[0]); + } + const reverse = rangeStart > rangeEnd; + const anim = targetElement.animate(keyframes, { + pseudoElement: pseudoElement, + // Set the timeline to the current gesture timeline to drive the updates. + timeline: timeline, + // We reset all easing functions to linear so that it feels like you + // have direct impact on the transition and to avoid double bouncing + // from scroll bouncing. + easing: 'linear', + // We fill in both direction for overscroll. + fill: 'both', + // Range start needs to be higher than range end. If it goes in reverse + // we reverse the whole animation below. + rangeStart: (reverse ? rangeEnd : rangeStart) + '%', + rangeEnd: (reverse ? rangeStart : rangeEnd) + '%', + }); + if (!reverse) { + // We play all gestures in reverse, except if we're in reverse direction + // in which case we need to play it in reverse of the reverse. + anim.reverse(); + // In Safari, there's a bug where the starting position isn't immediately + // picked up from the ScrollTimeline for one frame. + // $FlowFixMe[cannot-resolve-name] + anim.currentTime = CSS.percent(100); + } +} + export function startGestureTransition( rootContainer: Container, + timeline: GestureTimeline, + rangeStart: number, + rangeEnd: number, transitionTypes: null | TransitionTypes, mutationCallback: () => void, animateCallback: () => void, @@ -1435,26 +1801,136 @@ export function startGestureTransition( }); // $FlowFixMe[prop-missing] ownerDocument.__reactViewTransition = transition; - let blockingAnim = null; - const readyCallback = () => { + const readyCallback = (x: any) => { + const documentElement: Element = (ownerDocument.documentElement: any); + // Loop through all View Transition Animations. + const animations = documentElement.getAnimations({subtree: true}); + // First do a pass to collect all known group and new items so we can look + // up if they exist later. + const foundGroups: Set = new Set(); + const foundNews: Set = new Set(); + for (let i = 0; i < animations.length; i++) { + // $FlowFixMe + const pseudoElement: ?string = animations[i].effect.pseudoElement; + if (pseudoElement == null) { + } else if (pseudoElement.startsWith('::view-transition-group')) { + foundGroups.add(pseudoElement.slice(23)); + } else if (pseudoElement.startsWith('::view-transition-new')) { + // TODO: This is not really a sufficient detection because if the new + // pseudo element might exist but have animations disabled on it. + foundNews.add(pseudoElement.slice(21)); + } + } + for (let i = 0; i < animations.length; i++) { + const anim = animations[i]; + const effect: KeyframeEffect = (anim.effect: any); + // $FlowFixMe + const pseudoElement: ?string = effect.pseudoElement; + if ( + pseudoElement != null && + pseudoElement.startsWith('::view-transition') + ) { + // Ideally we could mutate the existing animation but unfortunately + // the mutable APIs seem less tested and therefore are lacking or buggy. + // Therefore we create a new animation instead. + anim.cancel(); + let isGeneratedGroupAnim = false; + let isExitGroupAnim = false; + if (pseudoElement.startsWith('::view-transition-group')) { + const groupName = pseudoElement.slice(23); + if (foundNews.has(groupName)) { + // If this has both "new" and "old" state we expect this to be an auto-generated + // animation that started outside the viewport. We need to adjust this first frame + // to be inside the viewport. + // $FlowFixMe[prop-missing] + const animationName: ?string = anim.animationName; + isGeneratedGroupAnim = + animationName != null && + // $FlowFixMe[prop-missing] + animationName.startsWith('-ua-view-transition-group-anim-'); + } else { + // If this has only an "old" state then the pseudo element will be outside + // the viewport. If any keyframes don't override "transform" we need to + // adjust them. + isExitGroupAnim = true; + } + // TODO: If this has only an old state and no new state, + } + animateGesture( + effect.getKeyframes(), + // $FlowFixMe: Always documentElement atm. + effect.target, + pseudoElement, + timeline, + rangeStart, + rangeEnd, + isGeneratedGroupAnim, + isExitGroupAnim, + ); + if (pseudoElement.startsWith('::view-transition-old')) { + const groupName = pseudoElement.slice(21); + if (!foundGroups.has(groupName) && !foundNews.has(groupName)) { + foundGroups.add(groupName); + // We haven't seen any group animation with this name. Since the old + // state was outside the viewport we need to put it back. Since we + // can't programmatically target the element itself, we use an + // animation to adjust it. + // This usually happens for exit animations where the element has + // the old position. + // If we also have a "new" state then we skip this because it means + // someone manually disabled the auto-generated animation. We need to + // treat the old state as having the position of the "new" state which + // will happen by default. + const pseudoElementName = '::view-transition-group' + groupName; + animateGesture( + [{}, {}], + // $FlowFixMe: Always documentElement atm. + effect.target, + pseudoElementName, + timeline, + rangeStart, + rangeEnd, + false, + true, // We let the helper apply the translate + ); + } + } + } + } // View Transitions with ScrollTimeline has a quirk where they end if the // ScrollTimeline ever reaches 100% but that doesn't mean we're done because // you can swipe back again. We can prevent this by adding a paused Animation // that never stops. This seems to keep all running Animations alive until // we explicitly abort (or something forces the View Transition to cancel). - const documentElement: Element = (ownerDocument.documentElement: any); - blockingAnim = documentElement.animate([{}, {}], { + const blockingAnim = documentElement.animate([{}, {}], { pseudoElement: '::view-transition', duration: 1, }); blockingAnim.pause(); animateCallback(); }; - transition.ready.then(readyCallback, readyCallback); + // In Chrome, "new" animations are not ready in the ready callback. We have to wait + // until requestAnimationFrame before we can observe them through getAnimations(). + // However, in Safari, that would cause a flicker because we're applying them late. + // TODO: Think of a feature detection for this instead. + const readyForAnimations = + navigator.userAgent.indexOf('Chrome') !== -1 + ? () => requestAnimationFrame(readyCallback) + : readyCallback; + transition.ready.then(readyForAnimations, readyCallback); transition.finished.then(() => { - if (blockingAnim !== null) { - // In Safari, we need to manually clear this or it'll block future transitions. - blockingAnim.cancel(); + // In Safari, we need to manually cancel all manually start animations + // or it'll block future transitions. + const documentElement: Element = (ownerDocument.documentElement: any); + const animations = documentElement.getAnimations({subtree: true}); + for (let i = 0; i < animations.length; i++) { + const anim = animations[i]; + const effect: KeyframeEffect = (anim.effect: any); + // $FlowFixMe + const pseudo: ?string = effect.pseudoElement; + if (pseudo != null && pseudo.startsWith('::view-transition')) { + anim.cancel(); + } } // $FlowFixMe[prop-missing] if (ownerDocument.__reactViewTransition === transition) { diff --git a/packages/react-native-renderer/src/ReactFiberConfigNative.js b/packages/react-native-renderer/src/ReactFiberConfigNative.js index 4a9064c82a..5f1b5583a1 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigNative.js +++ b/packages/react-native-renderer/src/ReactFiberConfigNative.js @@ -165,6 +165,13 @@ export function createInstance( return ((component: any): Instance); } +export function cloneMutableInstance( + instance: Instance, + keepChildren: boolean, +): Instance { + throw new Error('Not yet implemented.'); +} + export function createTextInstance( text: string, rootContainerInstance: Container, @@ -189,6 +196,12 @@ export function createTextInstance( return tag; } +export function cloneMutableTextInstance( + textInstance: TextInstance, +): TextInstance { + throw new Error('Not yet implemented.'); +} + export function finalizeInitialChildren( parentInstance: Instance, type: string, @@ -558,6 +571,19 @@ export function restoreRootViewTransitionName(rootContainer: Container): void { // Not yet implemented } +export function cloneRootViewTransitionContainer( + rootContainer: Container, +): Instance { + throw new Error('Not implemented.'); +} + +export function removeRootViewTransitionClone( + rootContainer: Container, + clone: Instance, +): void { + throw new Error('Not implemented.'); +} + export type InstanceMeasurement = null; export function measureInstance(instance: Instance): InstanceMeasurement { @@ -601,6 +627,9 @@ export type RunningGestureTransition = null; export function startGestureTransition( rootContainer: Container, + timeline: GestureTimeline, + rangeStart: number, + rangeEnd: number, transitionTypes: null | TransitionTypes, mutationCallback: () => void, animateCallback: () => void, diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index e392ad7b0c..4978dccf2a 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -453,6 +453,10 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { return inst; }, + cloneMutableInstance(instance: Instance, keepChildren: boolean): Instance { + throw new Error('Not yet implemented.'); + }, + appendInitialChild( parentInstance: Instance, child: Instance | TextInstance, @@ -504,6 +508,10 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { return inst; }, + cloneMutableTextInstance(textInstance: TextInstance): TextInstance { + throw new Error('Not yet implemented.'); + }, + scheduleTimeout: setTimeout, cancelTimeout: clearTimeout, noTimeout: -1, @@ -761,6 +769,17 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { restoreRootViewTransitionName(rootContainer: Container): void {}, + cloneRootViewTransitionContainer(rootContainer: Container): Instance { + throw new Error('Not yet implemented.'); + }, + + removeRootViewTransitionClone( + rootContainer: Container, + clone: Instance, + ): void { + throw new Error('Not implemented.'); + }, + measureInstance(instance: Instance): InstanceMeasurement { return null; }, @@ -796,6 +815,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { startGestureTransition( rootContainer: Container, + timeline: GestureTimeline, + rangeStart: number, + rangeEnd: number, transitionTypes: null | TransitionTypes, mutationCallback: () => void, animateCallback: () => void, diff --git a/packages/react-reconciler/src/ReactFiberApplyGesture.js b/packages/react-reconciler/src/ReactFiberApplyGesture.js index 46af2ca430..6847a2426c 100644 --- a/packages/react-reconciler/src/ReactFiberApplyGesture.js +++ b/packages/react-reconciler/src/ReactFiberApplyGesture.js @@ -9,10 +9,327 @@ import type {Fiber, FiberRoot} from './ReactInternalTypes'; +import type {Instance, TextInstance} from './ReactFiberConfig'; + +import type {OffscreenState} from './ReactFiberActivityComponent'; + import { + cloneMutableInstance, + cloneMutableTextInstance, + cloneRootViewTransitionContainer, + removeRootViewTransitionClone, cancelRootViewTransitionName, restoreRootViewTransitionName, + appendChild, + commitUpdate, + commitTextUpdate, + resetTextContent, + supportsResources, + supportsSingletons, + unhideInstance, + unhideTextInstance, } from './ReactFiberConfig'; +import { + popMutationContext, + pushMutationContext, + viewTransitionMutationContext, +} from './ReactFiberMutationTracking'; +import { + MutationMask, + Update, + ContentReset, + NoFlags, + Visibility, +} from './ReactFiberFlags'; +import { + HostComponent, + HostHoistable, + HostSingleton, + HostText, + HostPortal, + OffscreenComponent, + ViewTransitionComponent, +} from './ReactWorkTags'; + +let didWarnForRootClone = false; + +function detectMutationOrInsertClones(finishedWork: Fiber): boolean { + return true; +} + +let unhideHostChildren = false; + +function recursivelyInsertClonesFromExistingTree( + parentFiber: Fiber, + hostParentClone: Instance, +): void { + let child = parentFiber.child; + while (child !== null) { + switch (child.tag) { + case HostComponent: { + const instance: Instance = child.stateNode; + // If we have no mutations in this subtree, we just need to make a deep clone. + const clone: Instance = cloneMutableInstance(instance, true); + appendChild(hostParentClone, clone); + // TODO: We may need to transfer some DOM state such as scroll position + // for the deep clones. + // TODO: If there's a manual view-transition-name inside the clone we + // should ideally remove it from the original and then restore it in mutation + // phase. Otherwise it leads to duplicate names. + if (unhideHostChildren) { + unhideInstance(clone, child.memoizedProps); + } + break; + } + case HostText: { + const textInstance: TextInstance = child.stateNode; + if (textInstance === null) { + throw new Error( + 'This should have a text node initialized. This error is likely ' + + 'caused by a bug in React. Please file an issue.', + ); + } + const clone = cloneMutableTextInstance(textInstance); + appendChild(hostParentClone, clone); + if (unhideHostChildren) { + unhideTextInstance(clone, child.memoizedProps); + } + break; + } + case HostPortal: { + // TODO: Consider what should happen to Portals. For now we exclude them. + break; + } + case OffscreenComponent: { + const newState: OffscreenState | null = child.memoizedState; + const isHidden = newState !== null; + if (!isHidden) { + // Only insert clones if this tree is going to be visible. No need to + // clone invisible content. + // TODO: If this is visible but detached it should still be cloned. + // Since there was no mutation to this node, it couldn't have changed + // visibility so we don't need to update unhideHostChildren here. + recursivelyInsertClonesFromExistingTree(child, hostParentClone); + } + break; + } + case ViewTransitionComponent: + const prevMutationContext = pushMutationContext(); + // TODO: If this was already cloned by a previous pass we can reuse those clones. + recursivelyInsertClonesFromExistingTree(child, hostParentClone); + // TODO: Do we need to track whether this should have a name applied? + // child.flags |= Update; + popMutationContext(prevMutationContext); + break; + default: { + recursivelyInsertClonesFromExistingTree(child, hostParentClone); + break; + } + } + child = child.sibling; + } +} + +function recursivelyInsertClones( + parentFiber: Fiber, + hostParentClone: Instance, +) { + const deletions = parentFiber.deletions; + if (deletions !== null) { + for (let i = 0; i < deletions.length; i++) { + // const childToDelete = deletions[i]; + // TODO + } + } + + if ( + parentFiber.alternate === null || + (parentFiber.subtreeFlags & MutationMask) !== NoFlags + ) { + // If we have mutations or if this is a newly inserted tree, clone as we go. + let child = parentFiber.child; + while (child !== null) { + insertDestinationClonesOfFiber(child, hostParentClone); + child = child.sibling; + } + } else { + // Once we reach a subtree with no more mutations we can bail out. + // However, we must still insert deep clones of the HostComponents. + recursivelyInsertClonesFromExistingTree(parentFiber, hostParentClone); + } +} + +function insertDestinationClonesOfFiber( + finishedWork: Fiber, + hostParentClone: Instance, +) { + const current = finishedWork.alternate; + const flags = finishedWork.flags; + // The effect flag should be checked *after* we refine the type of 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 HostHoistable: { + if (supportsResources) { + // TODO: Hoistables should get optimistically inserted and then removed. + recursivelyInsertClones(finishedWork, hostParentClone); + break; + } + // Fall through + } + case HostSingleton: { + if (supportsSingletons) { + recursivelyInsertClones(finishedWork, hostParentClone); + if (__DEV__) { + // We cannot apply mutations to Host Singletons since by definition + // they cannot be cloned. Therefore we warn in DEV if this commit + // had any effect. + if (flags & Update) { + if (current === null) { + console.error( + 'useSwipeTransition() caused something to render a new <%s>. ' + + 'This is not possible in the current implementation. ' + + "Make sure that the swipe doesn't mount any new <%s> elements.", + finishedWork.type, + finishedWork.type, + ); + } else { + const newProps = finishedWork.memoizedProps; + const oldProps = current.memoizedProps; + const instance = finishedWork.stateNode; + const type = finishedWork.type; + const prev = pushMutationContext(); + + try { + // Since we currently don't have a separate diffing algorithm for + // individual properties, the Update flag can be a false positive. + // We have to apply the new props first o detect any mutations and + // then revert them. + commitUpdate(instance, type, oldProps, newProps, finishedWork); + if (viewTransitionMutationContext) { + console.error( + 'useSwipeTransition() caused something to mutate <%s>. ' + + 'This is not possible in the current implementation. ' + + "Make sure that the swipe doesn't update any state which " + + 'causes <%s> to change.', + finishedWork.type, + finishedWork.type, + ); + } + // Revert + commitUpdate(instance, type, newProps, oldProps, finishedWork); + } finally { + popMutationContext(prev); + } + } + } + } + break; + } + // Fall through + } + case HostComponent: { + const instance: Instance = finishedWork.stateNode; + if (current === null) { + // For insertions we don't need to clone. It's already new state node. + // TODO: Do we need to visit it for ViewTransitions though? + appendChild(hostParentClone, instance); + } else { + let clone: Instance; + if (finishedWork.child === null) { + // This node is terminal. We still do a deep clone in case this has user + // inserted content, text content or dangerouslySetInnerHTML. + clone = cloneMutableInstance(instance, true); + if (finishedWork.flags & ContentReset) { + resetTextContent(clone); + } + } else { + // If we have children we'll clone them as we walk the tree so we just + // do a shallow clone here. + clone = cloneMutableInstance(instance, false); + } + + if (flags & Update) { + const newProps = finishedWork.memoizedProps; + const oldProps = current.memoizedProps; + const type = finishedWork.type; + // Apply the delta to the clone. + commitUpdate(clone, type, oldProps, newProps, finishedWork); + } + + if (unhideHostChildren) { + unhideHostChildren = false; + recursivelyInsertClones(finishedWork, clone); + appendChild(hostParentClone, clone); + unhideHostChildren = true; + unhideInstance(clone, finishedWork.memoizedProps); + } else { + recursivelyInsertClones(finishedWork, clone); + appendChild(hostParentClone, clone); + } + } + break; + } + case HostText: { + const textInstance: TextInstance = finishedWork.stateNode; + if (textInstance === null) { + throw new Error( + 'This should have a text node initialized. This error is likely ' + + 'caused by a bug in React. Please file an issue.', + ); + } + if (current === null) { + // For insertions we don't need to clone. It's already new state node. + appendChild(hostParentClone, textInstance); + } else { + const clone = cloneMutableTextInstance(textInstance); + if (flags & Update) { + const newText: string = finishedWork.memoizedProps; + const oldText: string = current.memoizedProps; + commitTextUpdate(clone, newText, oldText); + } + appendChild(hostParentClone, clone); + if (unhideHostChildren) { + unhideTextInstance(clone, finishedWork.memoizedProps); + } + } + break; + } + case HostPortal: { + // TODO: Consider what should happen to Portals. For now we exclude them. + break; + } + case OffscreenComponent: { + const newState: OffscreenState | null = finishedWork.memoizedState; + const isHidden = newState !== null; + if (!isHidden) { + // Only insert clones if this tree is going to be visible. No need to + // clone invisible content. + // TODO: If this is visible but detached it should still be cloned. + const prevUnhide = unhideHostChildren; + unhideHostChildren = prevUnhide || (flags & Visibility) !== NoFlags; + recursivelyInsertClones(finishedWork, hostParentClone); + unhideHostChildren = prevUnhide; + } + break; + } + case ViewTransitionComponent: + const prevMutationContext = pushMutationContext(); + // TODO: If this was already cloned by a previous pass we can reuse those clones. + recursivelyInsertClones(finishedWork, hostParentClone); + if (viewTransitionMutationContext) { + // Track that this boundary had a mutation and therefore needs to animate + // whether it resized or not. + finishedWork.flags |= Update; + } + popMutationContext(prevMutationContext); + break; + default: { + recursivelyInsertClones(finishedWork, hostParentClone); + break; + } + } +} // Clone View Transition boundaries that have any mutations or might have had their // layout affected by child insertions. @@ -20,7 +337,30 @@ export function insertDestinationClones( root: FiberRoot, finishedWork: Fiber, ): void { - // TODO + unhideHostChildren = false; + // We'll either not transition the root, or we'll transition the clone. Regardless + // we cancel the root view transition name. + const needsClone = detectMutationOrInsertClones(finishedWork); + if (needsClone) { + if (__DEV__) { + if (!didWarnForRootClone) { + didWarnForRootClone = true; + console.warn( + 'useSwipeTransition() caused something to mutate or relayout the root. ' + + 'This currently requires a clone of the whole document. Make sure to ' + + 'add a directly around an absolutely positioned DOM node ' + + 'to minimize the impact of any changes caused by the Swipe Transition.', + ); + } + } + // Clone the whole root + const rootClone = cloneRootViewTransitionContainer(root.containerInfo); + root.gestureClone = rootClone; + recursivelyInsertClones(finishedWork, rootClone); + } else { + root.gestureClone = null; + cancelRootViewTransitionName(root.containerInfo); + } } // Revert insertions and apply view transition names to the "new" (current) state. @@ -28,8 +368,12 @@ export function applyDepartureTransitions( root: FiberRoot, finishedWork: Fiber, ): void { + const rootClone = root.gestureClone; + if (rootClone !== null) { + root.gestureClone = null; + removeRootViewTransitionClone(root.containerInfo, rootClone); + } // TODO - cancelRootViewTransitionName(root.containerInfo); } // Revert transition names and start/adjust animations on the started View Transition. diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index f5d0987393..b9ea97bf56 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -247,7 +247,6 @@ import { restoreNestedViewTransitions, measureUpdateViewTransition, measureNestedViewTransitions, - resetShouldStartViewTransition, resetAppearingViewTransitions, trackAppearingViewTransition, viewTransitionCancelableChildren, @@ -290,8 +289,6 @@ export function commitBeforeMutationEffects( focusedInstanceHandle = prepareForCommit(root.containerInfo); shouldFireAfterActiveInstanceBlur = false; - resetShouldStartViewTransition(); - const isViewTransitionEligible = enableViewTransition && includesOnlyViewTransitionEligibleLanes(committedLanes); diff --git a/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js b/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js index 430ad02abc..64b67491aa 100644 --- a/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js +++ b/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js @@ -20,6 +20,8 @@ function shim(...args: any): empty { // Mutation (when unsupported) export const supportsMutation = false; +export const cloneMutableInstance = shim; +export const cloneMutableTextInstance = shim; export const appendChild = shim; export const appendChildToContainer = shim; export const commitTextUpdate = shim; @@ -40,6 +42,8 @@ export const restoreViewTransitionName = shim; export const cancelViewTransitionName = shim; export const cancelRootViewTransitionName = shim; export const restoreRootViewTransitionName = shim; +export const cloneRootViewTransitionContainer = shim; +export const removeRootViewTransitionClone = shim; export type InstanceMeasurement = null; export const measureInstance = shim; export const wasInstanceInViewport = shim; diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index 41401bac2b..1e1ac1cab2 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -101,6 +101,7 @@ function FiberRootNode( if (enableSwipeTransition) { this.pendingGestures = null; this.stoppingGestures = null; + this.gestureClone = null; } this.incompleteTransitions = new Map(); diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index bf611f7327..f8d4fa03a0 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -228,6 +228,7 @@ import { invokePassiveEffectUnmountInDEV, accumulateSuspenseyCommit, } from './ReactFiberCommitWork'; +import {resetShouldStartViewTransition} from './ReactFiberCommitViewTransitions'; import {shouldStartViewTransition} from './ReactFiberCommitViewTransitions'; import { insertDestinationClones, @@ -3449,6 +3450,8 @@ function commitRoot( } } + resetShouldStartViewTransition(); + // The commit phase is broken into several sub-phases. We do a separate pass // of the effect list for each phase: all mutation effects come before all // layout effects, and so on. @@ -3900,6 +3903,11 @@ function commitGestureOnRoot( finishedGesture.running = startGestureTransition( root.containerInfo, + finishedGesture.provider, + finishedGesture.rangeCurrent, + finishedGesture.direction + ? finishedGesture.rangeNext + : finishedGesture.rangePrevious, pendingTransitionTypes, flushGestureMutations, flushGestureAnimations, diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index be5e9a5c0d..1452746089 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -26,6 +26,7 @@ import type {Lane, Lanes, LaneMap} from './ReactFiberLane'; import type {RootTag} from './ReactRootTags'; import type { Container, + Instance, TimeoutHandle, NoTimeout, SuspenseInstance, @@ -286,6 +287,7 @@ type BaseFiberRootProperties = { // enableSwipeTransition only pendingGestures: null | ScheduledGesture, stoppingGestures: null | ScheduledGesture, + gestureClone: null | Instance, }; // The following attributes are only used by DevTools and are only present in DEV builds. diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index 9569b4a896..e978249e18 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -56,10 +56,12 @@ export const getChildHostContext = $$$config.getChildHostContext; export const prepareForCommit = $$$config.prepareForCommit; export const resetAfterCommit = $$$config.resetAfterCommit; export const createInstance = $$$config.createInstance; +export const cloneMutableInstance = $$$config.cloneMutableInstance; export const appendInitialChild = $$$config.appendInitialChild; export const finalizeInitialChildren = $$$config.finalizeInitialChildren; export const shouldSetTextContent = $$$config.shouldSetTextContent; export const createTextInstance = $$$config.createTextInstance; +export const cloneMutableTextInstance = $$$config.cloneMutableTextInstance; export const scheduleTimeout = $$$config.scheduleTimeout; export const cancelTimeout = $$$config.cancelTimeout; export const noTimeout = $$$config.noTimeout; @@ -141,6 +143,10 @@ export const cancelRootViewTransitionName = $$$config.cancelRootViewTransitionName; export const restoreRootViewTransitionName = $$$config.restoreRootViewTransitionName; +export const cloneRootViewTransitionContainer = + $$$config.cloneRootViewTransitionContainer; +export const removeRootViewTransitionClone = + $$$config.removeRootViewTransitionClone; export const measureInstance = $$$config.measureInstance; export const wasInstanceInViewport = $$$config.wasInstanceInViewport; export const hasInstanceChanged = $$$config.hasInstanceChanged; diff --git a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js index 48939e9ff4..bb18e44e55 100644 --- a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js +++ b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js @@ -172,6 +172,21 @@ export function createInstance( }; } +export function cloneMutableInstance( + instance: Instance, + keepChildren: boolean, +): Instance { + return { + type: instance.type, + props: instance.props, + isHidden: instance.isHidden, + children: keepChildren ? instance.children : [], + internalInstanceHandle: null, + rootContainerInstance: instance.rootContainerInstance, + tag: 'INSTANCE', + }; +} + export function appendInitialChild( parentInstance: Instance, child: Instance | TextInstance, @@ -210,6 +225,16 @@ export function createTextInstance( }; } +export function cloneMutableTextInstance( + textInstance: TextInstance, +): TextInstance { + return { + text: textInstance.text, + isHidden: textInstance.isHidden, + tag: 'TEXT', + }; +} + let currentUpdatePriority: EventPriority = NoEventPriority; export function setCurrentUpdatePriority(newPriority: EventPriority): void { currentUpdatePriority = newPriority; @@ -337,6 +362,27 @@ export function restoreRootViewTransitionName(rootContainer: Container): void { // Noop } +export function cloneRootViewTransitionContainer( + rootContainer: Container, +): Instance { + return { + type: 'ROOT', + props: {}, + isHidden: false, + children: [], + internalInstanceHandle: null, + rootContainerInstance: rootContainer, + tag: 'INSTANCE', + }; +} + +export function removeRootViewTransitionClone( + rootContainer: Container, + clone: Instance, +): void { + // Noop since it was never inserted anywhere. +} + export type InstanceMeasurement = null; export function measureInstance(instance: Instance): InstanceMeasurement { @@ -379,6 +425,9 @@ export type RunningGestureTransition = null; export function startGestureTransition( rootContainer: Container, + timeline: GestureTimeline, + rangeStart: number, + rangeEnd: number, transitionTypes: null | TransitionTypes, mutationCallback: () => void, animateCallback: () => void, diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 0722eba56d..9db8a9cb89 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -536,5 +536,6 @@ "548": "Finished rendering the gesture lane but there were no pending gestures. React should not have started a render in this case. This is a bug in React.", "549": "Cannot start a gesture with a disconnected AnimationTimeline.", "550": "useSwipeTransition is not yet supported in react-art.", - "551": "useSwipeTransition is not yet supported in React Native." + "551": "useSwipeTransition is not yet supported in React Native.", + "552": "Cannot use a useSwipeTransition() in a detached root." }