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." }