@samitouri / QOS-React / commits / e9252bcdcc

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 `<html>` we instead clone the `<body>` 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.

Sebastian Markbåge committed Mar 4, 2025 at 20:10 UTC e9252bcdccf7f8f691081e4d48ca47657bc723f9
14 files changed +981 -17
fixtures/view-transition/src/components/Page.js
+10 -1
@@ -2,6 +2,8 @@ import React, {
2 unstable_ViewTransition as ViewTransition,
3 unstable_Activity as Activity,
4 unstable_useSwipeTransition as useSwipeTransition,
5 + useEffect,
6 + useState,
7 } from 'react';
8
9 import SwipeRecognizer from './SwipeRecognizer';
@@ -53,6 +55,13 @@ export default function Page({url, navigate}) {
55 navigate(show ? '/?a' : '/?b');
56 }
57
58 + const [counter, setCounter] = useState(0);
59 +
60 + useEffect(() => {
61 + const timer = setInterval(() => setCounter(c => c + 1), 1000);
62 + return () => clearInterval(timer);
63 + }, []);
64 +
65 const exclamation = (
66 <ViewTransition name="exclamation" onShare={onTransition}>
67 <span>!</span>
@@ -76,7 +85,7 @@ export default function Page({url, navigate}) {
85 'navigation-back': transitions['slide-right'],
86 'navigation-forward': transitions['slide-left'],
87 }}>
79 - <h1>{!show ? 'A' : 'B'}</h1>
88 + <h1>{!show ? 'A' + counter : 'B' + counter}</h1>
89 </ViewTransition>
90 {show ? (
91 <div>
packages/react-art/src/ReactFiberConfigART.js
+16
@@ -302,6 +302,10 @@ export function createInstance(type, props, internalInstanceHandle) {
302 return instance;
303 }
304
305 +export function cloneMutableInstance(instance, keepChildren) {
306 + return instance;
307 +}
308 +
309 export function createTextInstance(
310 text,
311 rootContainerInstance,
@@ -310,6 +314,10 @@ export function createTextInstance(
314 return text;
315 }
316
317 +export function cloneMutableTextInstance(textInstance) {
318 + return textInstance;
319 +}
320 +
321 export function finalizeInitialChildren(domElement, type, props) {
322 return false;
323 }
@@ -475,6 +483,14 @@ export function restoreRootViewTransitionName(rootContainer) {
483 // Noop
484 }
485
486 +export function cloneRootViewTransitionContainer(rootContainer) {
487 + throw new Error('Not implemented.');
488 +}
489 +
490 +export function removeRootViewTransitionClone(rootContainer, clone) {
491 + throw new Error('Not implemented.');
492 +}
493 +
494 export type InstanceMeasurement = null;
495
496 export function measureInstance(instance) {
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+486 -10
@@ -544,6 +544,13 @@ export function createInstance(
544 return domElement;
545 }
546
547 +export function cloneMutableInstance(
548 + instance: Instance,
549 + keepChildren: boolean,
550 +): Instance {
551 + return instance.cloneNode(keepChildren);
552 +}
553 +
554 export function appendInitialChild(
555 parentInstance: Instance,
556 child: Instance | TextInstance,
@@ -609,6 +616,12 @@ export function createTextInstance(
616 return textNode;
617 }
618
619 +export function cloneMutableTextInstance(
620 + textInstance: TextInstance,
621 +): TextInstance {
622 + return textInstance.cloneNode(false);
623 +}
624 +
625 let currentPopstateTransitionEvent: Event | null = null;
626 export function shouldAttemptEagerTransition(): boolean {
627 const event = window.event;
@@ -1207,6 +1220,113 @@ export function cancelRootViewTransitionName(rootContainer: Container): void {
1220 }
1221
1222 export function restoreRootViewTransitionName(rootContainer: Container): void {
1223 + let containerInstance: Instance;
1224 + if (rootContainer.nodeType === DOCUMENT_NODE) {
1225 + containerInstance = (rootContainer: any).body;
1226 + } else if (rootContainer.nodeName === 'HTML') {
1227 + containerInstance = (rootContainer.ownerDocument.body: any);
1228 + } else {
1229 + // If the container is not the whole document, then we ideally should probably
1230 + // clone the whole document outside of the React too.
1231 + containerInstance = (rootContainer: any);
1232 + }
1233 + // $FlowFixMe[prop-missing]
1234 + if (containerInstance.style.viewTransitionName === 'root') {
1235 + // If we moved the root view transition name to the container in a gesture
1236 + // we need to restore it now.
1237 + containerInstance.style.viewTransitionName = '';
1238 + }
1239 + const documentElement: null | HTMLElement =
1240 + containerInstance.ownerDocument.documentElement;
1241 + if (
1242 + documentElement !== null &&
1243 + // $FlowFixMe[prop-missing]
1244 + documentElement.style.viewTransitionName === 'none'
1245 + ) {
1246 + // $FlowFixMe[prop-missing]
1247 + documentElement.style.viewTransitionName = '';
1248 + }
1249 +}
1250 +
1251 +function getComputedTransform(style: CSSStyleDeclaration): string {
1252 + // Gets the merged transform of all the short hands.
1253 + const computedStyle: any = style;
1254 + let transform: string = computedStyle.transform;
1255 + if (transform === 'none') {
1256 + transform = '';
1257 + }
1258 + const scale: string = computedStyle.scale;
1259 + if (scale !== 'none' && scale !== '') {
1260 + const parts = scale.split(' ');
1261 + transform =
1262 + (parts.length === 3 ? 'scale3d' : 'scale') +
1263 + '(' +
1264 + parts.join(', ') +
1265 + ') ' +
1266 + transform;
1267 + }
1268 + const rotate: string = computedStyle.rotate;
1269 + if (rotate !== 'none' && rotate !== '') {
1270 + const parts = rotate.split(' ');
1271 + if (parts.length === 1) {
1272 + transform = 'rotate(' + parts[0] + ') ' + transform;
1273 + } else if (parts.length === 2) {
1274 + transform =
1275 + 'rotate' + parts[0].toUpperCase() + '(' + parts[1] + ') ' + transform;
1276 + } else {
1277 + transform = 'rotate3d(' + parts.join(', ') + ') ' + transform;
1278 + }
1279 + }
1280 + const translate: string = computedStyle.translate;
1281 + if (translate !== 'none' && translate !== '') {
1282 + const parts = translate.split(' ');
1283 + transform =
1284 + (parts.length === 3 ? 'translate3d' : 'translate') +
1285 + '(' +
1286 + parts.join(', ') +
1287 + ') ' +
1288 + transform;
1289 + }
1290 + return transform;
1291 +}
1292 +
1293 +function moveOutOfViewport(
1294 + originalStyle: CSSStyleDeclaration,
1295 + element: HTMLElement,
1296 +): void {
1297 + // Apply a transform that safely puts the whole element outside the viewport
1298 + // while still letting it paint its "old" state to a snapshot.
1299 + const transform = getComputedTransform(originalStyle);
1300 + // Clear the long form properties.
1301 + // $FlowFixMe
1302 + element.style.translate = 'none';
1303 + // $FlowFixMe
1304 + element.style.scale = 'none';
1305 + // $FlowFixMe
1306 + element.style.rotate = 'none';
1307 + // Apply a translate to move it way out of the viewport. This is applied first
1308 + // so that it is in the coordinate space of the parent and not after applying
1309 + // other transforms. That's why we need to merge the long form properties.
1310 + // TODO: Ideally we'd adjust for the parent's rotate/scale. Otherwise when
1311 + // we move back the ::view-transition-group we might overshoot or undershoot.
1312 + element.style.transform = 'translate(-20000px, -20000px) ' + transform;
1313 +}
1314 +
1315 +function moveOldFrameIntoViewport(keyframe: any): void {
1316 + // In the resulting View Transition Animation, the first frame will be offset.
1317 + const computedTransform: ?string = keyframe.transform;
1318 + if (computedTransform != null) {
1319 + let transform = computedTransform === 'none' ? '' : computedTransform;
1320 + transform = 'translate(20000px, 20000px) ' + transform;
1321 + keyframe.transform = transform;
1322 + }
1323 +}
1324 +
1325 +export function cloneRootViewTransitionContainer(
1326 + rootContainer: Container,
1327 +): Instance {
1328 + // This implies that we're not going to animate the root document but instead
1329 + // the clone so we first clear the name of the root container.
1330 const documentElement: null | HTMLElement =
1331 rootContainer.nodeType === DOCUMENT_NODE
1332 ? (rootContainer: any).documentElement
@@ -1214,11 +1334,138 @@ export function restoreRootViewTransitionName(rootContainer: Container): void {
1334 if (
1335 documentElement !== null &&
1336 // $FlowFixMe[prop-missing]
1217 - documentElement.style.viewTransitionName === 'none'
1337 + documentElement.style.viewTransitionName === ''
1338 ) {
1339 // $FlowFixMe[prop-missing]
1220 - documentElement.style.viewTransitionName = '';
1340 + documentElement.style.viewTransitionName = 'none';
1341 + }
1342 +
1343 + let containerInstance: HTMLElement;
1344 + if (rootContainer.nodeType === DOCUMENT_NODE) {
1345 + containerInstance = (rootContainer: any).body;
1346 + } else if (rootContainer.nodeName === 'HTML') {
1347 + containerInstance = (rootContainer.ownerDocument.body: any);
1348 + } else {
1349 + // If the container is not the whole document, then we ideally should probably
1350 + // clone the whole document outside of the React too.
1351 + containerInstance = (rootContainer: any);
1352 + }
1353 +
1354 + const containerParent = containerInstance.parentNode;
1355 + if (containerParent === null) {
1356 + throw new Error('Cannot use a useSwipeTransition() in a detached root.');
1357 }
1358 +
1359 + const clone: HTMLElement = containerInstance.cloneNode(false);
1360 +
1361 + const computedStyle = getComputedStyle(containerInstance);
1362 +
1363 + if (
1364 + computedStyle.position === 'absolute' ||
1365 + computedStyle.position === 'fixed'
1366 + ) {
1367 + // If the style is already absolute, we don't have to do anything because it'll appear
1368 + // in the same place.
1369 + } else {
1370 + // Otherwise we need to absolutely position the clone in the same location as the original.
1371 + let positionedAncestor: HTMLElement = containerParent;
1372 + while (
1373 + positionedAncestor.parentNode != null &&
1374 + positionedAncestor.parentNode.nodeType !== DOCUMENT_NODE
1375 + ) {
1376 + if (getComputedStyle(positionedAncestor).position !== 'static') {
1377 + break;
1378 + }
1379 + // $FlowFixMe: This is refined.
1380 + positionedAncestor = positionedAncestor.parentNode;
1381 + }
1382 +
1383 + const positionedAncestorStyle: any = positionedAncestor.style;
1384 + const containerInstanceStyle: any = containerInstance.style;
1385 + // Clear the transform while we're measuring since it affects the bounding client rect.
1386 + const prevAncestorTranslate = positionedAncestorStyle.translate;
1387 + const prevAncestorScale = positionedAncestorStyle.scale;
1388 + const prevAncestorRotate = positionedAncestorStyle.rotate;
1389 + const prevAncestorTransform = positionedAncestorStyle.transform;
1390 + const prevTranslate = containerInstanceStyle.translate;
1391 + const prevScale = containerInstanceStyle.scale;
1392 + const prevRotate = containerInstanceStyle.rotate;
1393 + const prevTransform = containerInstanceStyle.transform;
1394 + positionedAncestorStyle.translate = 'none';
1395 + positionedAncestorStyle.scale = 'none';
1396 + positionedAncestorStyle.rotate = 'none';
1397 + positionedAncestorStyle.transform = 'none';
1398 + containerInstanceStyle.translate = 'none';
1399 + containerInstanceStyle.scale = 'none';
1400 + containerInstanceStyle.rotate = 'none';
1401 + containerInstanceStyle.transform = 'none';
1402 +
1403 + const ancestorRect = positionedAncestor.getBoundingClientRect();
1404 + const rect = containerInstance.getBoundingClientRect();
1405 +
1406 + const cloneStyle = clone.style;
1407 + cloneStyle.position = 'absolute';
1408 + cloneStyle.top = rect.top - ancestorRect.top + 'px';
1409 + cloneStyle.left = rect.left - ancestorRect.left + 'px';
1410 + cloneStyle.width = rect.width + 'px';
1411 + cloneStyle.height = rect.height + 'px';
1412 + cloneStyle.margin = '0px';
1413 + cloneStyle.boxSizing = 'border-box';
1414 +
1415 + positionedAncestorStyle.translate = prevAncestorTranslate;
1416 + positionedAncestorStyle.scale = prevAncestorScale;
1417 + positionedAncestorStyle.rotate = prevAncestorRotate;
1418 + positionedAncestorStyle.transform = prevAncestorTransform;
1419 + containerInstanceStyle.translate = prevTranslate;
1420 + containerInstanceStyle.scale = prevScale;
1421 + containerInstanceStyle.rotate = prevRotate;
1422 + containerInstanceStyle.transform = prevTransform;
1423 + }
1424 +
1425 + // For this transition the container will act as the root. Nothing outside of it should
1426 + // be affected anyway. This lets us transition from the cloned container to the original.
1427 + // $FlowFixMe[prop-missing]
1428 + clone.style.viewTransitionName = 'root';
1429 +
1430 + // Move out of the viewport so that it's still painted for the snapshot but is not visible
1431 + // for the frame where the snapshot happens.
1432 + moveOutOfViewport(computedStyle, clone);
1433 +
1434 + // Insert the clone after the root container as a sibling. This may inject a body
1435 + // as the next sibling of an existing body. document.body will still point to the
1436 + // first one and any id selectors will still find the first one. That's why it's
1437 + // important that it's after the existing node.
1438 + containerInstance.parentNode.insertBefore(
1439 + clone,
1440 + containerInstance.nextSibling,
1441 + );
1442 +
1443 + return clone;
1444 +}
1445 +
1446 +export function removeRootViewTransitionClone(
1447 + rootContainer: Container,
1448 + clone: Instance,
1449 +): void {
1450 + let containerInstance: Instance;
1451 + if (rootContainer.nodeType === DOCUMENT_NODE) {
1452 + containerInstance = (rootContainer: any).body;
1453 + } else if (rootContainer.nodeName === 'HTML') {
1454 + containerInstance = (rootContainer.ownerDocument.body: any);
1455 + } else {
1456 + // If the container is not the whole document, then we ideally should probably
1457 + // clone the whole document outside of the React too.
1458 + containerInstance = (rootContainer: any);
1459 + }
1460 + const containerParent = containerInstance.parentNode;
1461 + if (containerParent === null) {
1462 + throw new Error('Cannot use a useSwipeTransition() in a detached root.');
1463 + }
1464 + // We assume that the clone is still within the same parent.
1465 + containerParent.removeChild(clone);
1466 +
1467 + // Now the root is on the containerInstance itself until we call restoreRootViewTransitionName.
1468 + containerInstance.style.viewTransitionName = 'root';
1469 }
1470
1471 export type InstanceMeasurement = {
@@ -1417,8 +1664,127 @@ export type RunningGestureTransition = {
1664 ...
1665 };
1666
1667 +function mergeTranslate(translateA: ?string, translateB: ?string): string {
1668 + if (!translateA || translateA === 'none') {
1669 + return translateB || '';
1670 + }
1671 + if (!translateB || translateB === 'none') {
1672 + return translateA || '';
1673 + }
1674 + const partsA = translateA.split(' ');
1675 + const partsB = translateB.split(' ');
1676 + let i;
1677 + let result = '';
1678 + for (i = 0; i < partsA.length && i < partsB.length; i++) {
1679 + if (i > 0) {
1680 + result += ' ';
1681 + }
1682 + result += 'calc(' + partsA[i] + ' + ' + partsB[i] + ')';
1683 + }
1684 + for (; i < partsA.length; i++) {
1685 + result += ' ' + partsA[i];
1686 + }
1687 + for (; i < partsB.length; i++) {
1688 + result += ' ' + partsB[i];
1689 + }
1690 + return result;
1691 +}
1692 +
1693 +function animateGesture(
1694 + keyframes: any,
1695 + targetElement: Element,
1696 + pseudoElement: string,
1697 + timeline: AnimationTimeline,
1698 + rangeStart: number,
1699 + rangeEnd: number,
1700 + moveFirstFrameIntoViewport: boolean,
1701 + moveAllFramesIntoViewport: boolean,
1702 +) {
1703 + for (let i = 0; i < keyframes.length; i++) {
1704 + const keyframe = keyframes[i];
1705 + // Delete any easing since we always apply linear easing to gestures.
1706 + delete keyframe.easing;
1707 + delete keyframe.computedOffset;
1708 + // Chrome returns "auto" for width/height which is not a valid value to
1709 + // animate to. Similarly, transform: "none" is actually lack of transform.
1710 + if (keyframe.width === 'auto') {
1711 + delete keyframe.width;
1712 + }
1713 + if (keyframe.height === 'auto') {
1714 + delete keyframe.height;
1715 + }
1716 + if (keyframe.transform === 'none') {
1717 + delete keyframe.transform;
1718 + }
1719 + if (moveAllFramesIntoViewport) {
1720 + if (keyframe.transform == null) {
1721 + // If a transform is not explicitly specified to override the auto
1722 + // generated one on the pseudo element, then we need to adjust it to
1723 + // put it back into the viewport. We don't know the offset relative to
1724 + // the screen so instead we use the translate prop to do a relative
1725 + // adjustment.
1726 + // TODO: If the "transform" was manually overridden on the pseudo
1727 + // element itself and no longer the auto generated one, then we shouldn't
1728 + // adjust it. I'm not sure how to detect this.
1729 + if (keyframe.translate == null || keyframe.translate === '') {
1730 + // TODO: If there's a CSS rule targeting translate on the pseudo element
1731 + // already we need to merge it.
1732 + const elementTranslate: ?string = (getComputedStyle(
1733 + targetElement,
1734 + pseudoElement,
1735 + ): any).translate;
1736 + keyframe.translate = mergeTranslate(
1737 + elementTranslate,
1738 + '20000px 20000px',
1739 + );
1740 + } else {
1741 + keyframe.translate = mergeTranslate(
1742 + keyframe.translate,
1743 + '20000px 20000px',
1744 + );
1745 + }
1746 + }
1747 + }
1748 + }
1749 + if (moveFirstFrameIntoViewport) {
1750 + // If this is the generated animation that does a FLIP matrix translation
1751 + // from the old position, we need to adjust it from the out of viewport
1752 + // position. If this is going from old to new it only applies to first
1753 + // keyframe. Otherwise it applies to every keyframe.
1754 + moveOldFrameIntoViewport(keyframes[0]);
1755 + }
1756 + const reverse = rangeStart > rangeEnd;
1757 + const anim = targetElement.animate(keyframes, {
1758 + pseudoElement: pseudoElement,
1759 + // Set the timeline to the current gesture timeline to drive the updates.
1760 + timeline: timeline,
1761 + // We reset all easing functions to linear so that it feels like you
1762 + // have direct impact on the transition and to avoid double bouncing
1763 + // from scroll bouncing.
1764 + easing: 'linear',
1765 + // We fill in both direction for overscroll.
1766 + fill: 'both',
1767 + // Range start needs to be higher than range end. If it goes in reverse
1768 + // we reverse the whole animation below.
1769 + rangeStart: (reverse ? rangeEnd : rangeStart) + '%',
1770 + rangeEnd: (reverse ? rangeStart : rangeEnd) + '%',
1771 + });
1772 + if (!reverse) {
1773 + // We play all gestures in reverse, except if we're in reverse direction
1774 + // in which case we need to play it in reverse of the reverse.
1775 + anim.reverse();
1776 + // In Safari, there's a bug where the starting position isn't immediately
1777 + // picked up from the ScrollTimeline for one frame.
1778 + // $FlowFixMe[cannot-resolve-name]
1779 + anim.currentTime = CSS.percent(100);
1780 + }
1781 +}
1782 +
1783 export function startGestureTransition(
1784 rootContainer: Container,
1785 + timeline: GestureTimeline,
1786 + rangeStart: number,
1787 + rangeEnd: number,
1788 transitionTypes: null | TransitionTypes,
1789 mutationCallback: () => void,
1790 animateCallback: () => void,
@@ -1435,26 +1801,136 @@ export function startGestureTransition(
1801 });
1802 // $FlowFixMe[prop-missing]
1803 ownerDocument.__reactViewTransition = transition;
1438 - let blockingAnim = null;
1439 - const readyCallback = () => {
1804 + const readyCallback = (x: any) => {
1805 + const documentElement: Element = (ownerDocument.documentElement: any);
1806 + // Loop through all View Transition Animations.
1807 + const animations = documentElement.getAnimations({subtree: true});
1808 + // First do a pass to collect all known group and new items so we can look
1809 + // up if they exist later.
1810 + const foundGroups: Set<string> = new Set();
1811 + const foundNews: Set<string> = new Set();
1812 + for (let i = 0; i < animations.length; i++) {
1813 + // $FlowFixMe
1814 + const pseudoElement: ?string = animations[i].effect.pseudoElement;
1815 + if (pseudoElement == null) {
1816 + } else if (pseudoElement.startsWith('::view-transition-group')) {
1817 + foundGroups.add(pseudoElement.slice(23));
1818 + } else if (pseudoElement.startsWith('::view-transition-new')) {
1819 + // TODO: This is not really a sufficient detection because if the new
1820 + // pseudo element might exist but have animations disabled on it.
1821 + foundNews.add(pseudoElement.slice(21));
1822 + }
1823 + }
1824 + for (let i = 0; i < animations.length; i++) {
1825 + const anim = animations[i];
1826 + const effect: KeyframeEffect = (anim.effect: any);
1827 + // $FlowFixMe
1828 + const pseudoElement: ?string = effect.pseudoElement;
1829 + if (
1830 + pseudoElement != null &&
1831 + pseudoElement.startsWith('::view-transition')
1832 + ) {
1833 + // Ideally we could mutate the existing animation but unfortunately
1834 + // the mutable APIs seem less tested and therefore are lacking or buggy.
1835 + // Therefore we create a new animation instead.
1836 + anim.cancel();
1837 + let isGeneratedGroupAnim = false;
1838 + let isExitGroupAnim = false;
1839 + if (pseudoElement.startsWith('::view-transition-group')) {
1840 + const groupName = pseudoElement.slice(23);
1841 + if (foundNews.has(groupName)) {
1842 + // If this has both "new" and "old" state we expect this to be an auto-generated
1843 + // animation that started outside the viewport. We need to adjust this first frame
1844 + // to be inside the viewport.
1845 + // $FlowFixMe[prop-missing]
1846 + const animationName: ?string = anim.animationName;
1847 + isGeneratedGroupAnim =
1848 + animationName != null &&
1849 + // $FlowFixMe[prop-missing]
1850 + animationName.startsWith('-ua-view-transition-group-anim-');
1851 + } else {
1852 + // If this has only an "old" state then the pseudo element will be outside
1853 + // the viewport. If any keyframes don't override "transform" we need to
1854 + // adjust them.
1855 + isExitGroupAnim = true;
1856 + }
1857 + // TODO: If this has only an old state and no new state,
1858 + }
1859 + animateGesture(
1860 + effect.getKeyframes(),
1861 + // $FlowFixMe: Always documentElement atm.
1862 + effect.target,
1863 + pseudoElement,
1864 + timeline,
1865 + rangeStart,
1866 + rangeEnd,
1867 + isGeneratedGroupAnim,
1868 + isExitGroupAnim,
1869 + );
1870 + if (pseudoElement.startsWith('::view-transition-old')) {
1871 + const groupName = pseudoElement.slice(21);
1872 + if (!foundGroups.has(groupName) && !foundNews.has(groupName)) {
1873 + foundGroups.add(groupName);
1874 + // We haven't seen any group animation with this name. Since the old
1875 + // state was outside the viewport we need to put it back. Since we
1876 + // can't programmatically target the element itself, we use an
1877 + // animation to adjust it.
1878 + // This usually happens for exit animations where the element has
1879 + // the old position.
1880 + // If we also have a "new" state then we skip this because it means
1881 + // someone manually disabled the auto-generated animation. We need to
1882 + // treat the old state as having the position of the "new" state which
1883 + // will happen by default.
1884 + const pseudoElementName = '::view-transition-group' + groupName;
1885 + animateGesture(
1886 + [{}, {}],
1887 + // $FlowFixMe: Always documentElement atm.
1888 + effect.target,
1889 + pseudoElementName,
1890 + timeline,
1891 + rangeStart,
1892 + rangeEnd,
1893 + false,
1894 + true, // We let the helper apply the translate
1895 + );
1896 + }
1897 + }
1898 + }
1899 + }
1900 // View Transitions with ScrollTimeline has a quirk where they end if the
1901 // ScrollTimeline ever reaches 100% but that doesn't mean we're done because
1902 // you can swipe back again. We can prevent this by adding a paused Animation
1903 // that never stops. This seems to keep all running Animations alive until
1904 // we explicitly abort (or something forces the View Transition to cancel).
1445 - const documentElement: Element = (ownerDocument.documentElement: any);
1446 - blockingAnim = documentElement.animate([{}, {}], {
1905 + const blockingAnim = documentElement.animate([{}, {}], {
1906 pseudoElement: '::view-transition',
1907 duration: 1,
1908 });
1909 blockingAnim.pause();
1910 animateCallback();
1911 };
1453 - transition.ready.then(readyCallback, readyCallback);
1912 + // In Chrome, "new" animations are not ready in the ready callback. We have to wait
1913 + // until requestAnimationFrame before we can observe them through getAnimations().
1914 + // However, in Safari, that would cause a flicker because we're applying them late.
1915 + // TODO: Think of a feature detection for this instead.
1916 + const readyForAnimations =
1917 + navigator.userAgent.indexOf('Chrome') !== -1
1918 + ? () => requestAnimationFrame(readyCallback)
1919 + : readyCallback;
1920 + transition.ready.then(readyForAnimations, readyCallback);
1921 transition.finished.then(() => {
1455 - if (blockingAnim !== null) {
1456 - // In Safari, we need to manually clear this or it'll block future transitions.
1457 - blockingAnim.cancel();
1922 + // In Safari, we need to manually cancel all manually start animations
1923 + // or it'll block future transitions.
1924 + const documentElement: Element = (ownerDocument.documentElement: any);
1925 + const animations = documentElement.getAnimations({subtree: true});
1926 + for (let i = 0; i < animations.length; i++) {
1927 + const anim = animations[i];
1928 + const effect: KeyframeEffect = (anim.effect: any);
1929 + // $FlowFixMe
1930 + const pseudo: ?string = effect.pseudoElement;
1931 + if (pseudo != null && pseudo.startsWith('::view-transition')) {
1932 + anim.cancel();
1933 + }
1934 }
1935 // $FlowFixMe[prop-missing]
1936 if (ownerDocument.__reactViewTransition === transition) {
packages/react-native-renderer/src/ReactFiberConfigNative.js
+29
@@ -165,6 +165,13 @@ export function createInstance(
165 return ((component: any): Instance);
166 }
167
168 +export function cloneMutableInstance(
169 + instance: Instance,
170 + keepChildren: boolean,
171 +): Instance {
172 + throw new Error('Not yet implemented.');
173 +}
174 +
175 export function createTextInstance(
176 text: string,
177 rootContainerInstance: Container,
@@ -189,6 +196,12 @@ export function createTextInstance(
196 return tag;
197 }
198
199 +export function cloneMutableTextInstance(
200 + textInstance: TextInstance,
201 +): TextInstance {
202 + throw new Error('Not yet implemented.');
203 +}
204 +
205 export function finalizeInitialChildren(
206 parentInstance: Instance,
207 type: string,
@@ -558,6 +571,19 @@ export function restoreRootViewTransitionName(rootContainer: Container): void {
571 // Not yet implemented
572 }
573
574 +export function cloneRootViewTransitionContainer(
575 + rootContainer: Container,
576 +): Instance {
577 + throw new Error('Not implemented.');
578 +}
579 +
580 +export function removeRootViewTransitionClone(
581 + rootContainer: Container,
582 + clone: Instance,
583 +): void {
584 + throw new Error('Not implemented.');
585 +}
586 +
587 export type InstanceMeasurement = null;
588
589 export function measureInstance(instance: Instance): InstanceMeasurement {
@@ -601,6 +627,9 @@ export type RunningGestureTransition = null;
627
628 export function startGestureTransition(
629 rootContainer: Container,
630 + timeline: GestureTimeline,
631 + rangeStart: number,
632 + rangeEnd: number,
633 transitionTypes: null | TransitionTypes,
634 mutationCallback: () => void,
635 animateCallback: () => void,
packages/react-noop-renderer/src/createReactNoop.js
+22
@@ -453,6 +453,10 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
453 return inst;
454 },
455
456 + cloneMutableInstance(instance: Instance, keepChildren: boolean): Instance {
457 + throw new Error('Not yet implemented.');
458 + },
459 +
460 appendInitialChild(
461 parentInstance: Instance,
462 child: Instance | TextInstance,
@@ -504,6 +508,10 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
508 return inst;
509 },
510
511 + cloneMutableTextInstance(textInstance: TextInstance): TextInstance {
512 + throw new Error('Not yet implemented.');
513 + },
514 +
515 scheduleTimeout: setTimeout,
516 cancelTimeout: clearTimeout,
517 noTimeout: -1,
@@ -761,6 +769,17 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
769
770 restoreRootViewTransitionName(rootContainer: Container): void {},
771
772 + cloneRootViewTransitionContainer(rootContainer: Container): Instance {
773 + throw new Error('Not yet implemented.');
774 + },
775 +
776 + removeRootViewTransitionClone(
777 + rootContainer: Container,
778 + clone: Instance,
779 + ): void {
780 + throw new Error('Not implemented.');
781 + },
782 +
783 measureInstance(instance: Instance): InstanceMeasurement {
784 return null;
785 },
@@ -796,6 +815,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
815
816 startGestureTransition(
817 rootContainer: Container,
818 + timeline: GestureTimeline,
819 + rangeStart: number,
820 + rangeEnd: number,
821 transitionTypes: null | TransitionTypes,
822 mutationCallback: () => void,
823 animateCallback: () => void,
packages/react-reconciler/src/ReactFiberApplyGesture.js
+346 -2
@@ -9,10 +9,327 @@
9
10 import type {Fiber, FiberRoot} from './ReactInternalTypes';
11
12 +import type {Instance, TextInstance} from './ReactFiberConfig';
13 +
14 +import type {OffscreenState} from './ReactFiberActivityComponent';
15 +
16 import {
17 + cloneMutableInstance,
18 + cloneMutableTextInstance,
19 + cloneRootViewTransitionContainer,
20 + removeRootViewTransitionClone,
21 cancelRootViewTransitionName,
22 restoreRootViewTransitionName,
23 + appendChild,
24 + commitUpdate,
25 + commitTextUpdate,
26 + resetTextContent,
27 + supportsResources,
28 + supportsSingletons,
29 + unhideInstance,
30 + unhideTextInstance,
31 } from './ReactFiberConfig';
32 +import {
33 + popMutationContext,
34 + pushMutationContext,
35 + viewTransitionMutationContext,
36 +} from './ReactFiberMutationTracking';
37 +import {
38 + MutationMask,
39 + Update,
40 + ContentReset,
41 + NoFlags,
42 + Visibility,
43 +} from './ReactFiberFlags';
44 +import {
45 + HostComponent,
46 + HostHoistable,
47 + HostSingleton,
48 + HostText,
49 + HostPortal,
50 + OffscreenComponent,
51 + ViewTransitionComponent,
52 +} from './ReactWorkTags';
53 +
54 +let didWarnForRootClone = false;
55 +
56 +function detectMutationOrInsertClones(finishedWork: Fiber): boolean {
57 + return true;
58 +}
59 +
60 +let unhideHostChildren = false;
61 +
62 +function recursivelyInsertClonesFromExistingTree(
63 + parentFiber: Fiber,
64 + hostParentClone: Instance,
65 +): void {
66 + let child = parentFiber.child;
67 + while (child !== null) {
68 + switch (child.tag) {
69 + case HostComponent: {
70 + const instance: Instance = child.stateNode;
71 + // If we have no mutations in this subtree, we just need to make a deep clone.
72 + const clone: Instance = cloneMutableInstance(instance, true);
73 + appendChild(hostParentClone, clone);
74 + // TODO: We may need to transfer some DOM state such as scroll position
75 + // for the deep clones.
76 + // TODO: If there's a manual view-transition-name inside the clone we
77 + // should ideally remove it from the original and then restore it in mutation
78 + // phase. Otherwise it leads to duplicate names.
79 + if (unhideHostChildren) {
80 + unhideInstance(clone, child.memoizedProps);
81 + }
82 + break;
83 + }
84 + case HostText: {
85 + const textInstance: TextInstance = child.stateNode;
86 + if (textInstance === null) {
87 + throw new Error(
88 + 'This should have a text node initialized. This error is likely ' +
89 + 'caused by a bug in React. Please file an issue.',
90 + );
91 + }
92 + const clone = cloneMutableTextInstance(textInstance);
93 + appendChild(hostParentClone, clone);
94 + if (unhideHostChildren) {
95 + unhideTextInstance(clone, child.memoizedProps);
96 + }
97 + break;
98 + }
99 + case HostPortal: {
100 + // TODO: Consider what should happen to Portals. For now we exclude them.
101 + break;
102 + }
103 + case OffscreenComponent: {
104 + const newState: OffscreenState | null = child.memoizedState;
105 + const isHidden = newState !== null;
106 + if (!isHidden) {
107 + // Only insert clones if this tree is going to be visible. No need to
108 + // clone invisible content.
109 + // TODO: If this is visible but detached it should still be cloned.
110 + // Since there was no mutation to this node, it couldn't have changed
111 + // visibility so we don't need to update unhideHostChildren here.
112 + recursivelyInsertClonesFromExistingTree(child, hostParentClone);
113 + }
114 + break;
115 + }
116 + case ViewTransitionComponent:
117 + const prevMutationContext = pushMutationContext();
118 + // TODO: If this was already cloned by a previous pass we can reuse those clones.
119 + recursivelyInsertClonesFromExistingTree(child, hostParentClone);
120 + // TODO: Do we need to track whether this should have a name applied?
121 + // child.flags |= Update;
122 + popMutationContext(prevMutationContext);
123 + break;
124 + default: {
125 + recursivelyInsertClonesFromExistingTree(child, hostParentClone);
126 + break;
127 + }
128 + }
129 + child = child.sibling;
130 + }
131 +}
132 +
133 +function recursivelyInsertClones(
134 + parentFiber: Fiber,
135 + hostParentClone: Instance,
136 +) {
137 + const deletions = parentFiber.deletions;
138 + if (deletions !== null) {
139 + for (let i = 0; i < deletions.length; i++) {
140 + // const childToDelete = deletions[i];
141 + // TODO
142 + }
143 + }
144 +
145 + if (
146 + parentFiber.alternate === null ||
147 + (parentFiber.subtreeFlags & MutationMask) !== NoFlags
148 + ) {
149 + // If we have mutations or if this is a newly inserted tree, clone as we go.
150 + let child = parentFiber.child;
151 + while (child !== null) {
152 + insertDestinationClonesOfFiber(child, hostParentClone);
153 + child = child.sibling;
154 + }
155 + } else {
156 + // Once we reach a subtree with no more mutations we can bail out.
157 + // However, we must still insert deep clones of the HostComponents.
158 + recursivelyInsertClonesFromExistingTree(parentFiber, hostParentClone);
159 + }
160 +}
161 +
162 +function insertDestinationClonesOfFiber(
163 + finishedWork: Fiber,
164 + hostParentClone: Instance,
165 +) {
166 + const current = finishedWork.alternate;
167 + const flags = finishedWork.flags;
168 + // The effect flag should be checked *after* we refine the type of fiber,
169 + // because the fiber tag is more specific. An exception is any flag related
170 + // to reconciliation, because those can be set on all fiber types.
171 + switch (finishedWork.tag) {
172 + case HostHoistable: {
173 + if (supportsResources) {
174 + // TODO: Hoistables should get optimistically inserted and then removed.
175 + recursivelyInsertClones(finishedWork, hostParentClone);
176 + break;
177 + }
178 + // Fall through
179 + }
180 + case HostSingleton: {
181 + if (supportsSingletons) {
182 + recursivelyInsertClones(finishedWork, hostParentClone);
183 + if (__DEV__) {
184 + // We cannot apply mutations to Host Singletons since by definition
185 + // they cannot be cloned. Therefore we warn in DEV if this commit
186 + // had any effect.
187 + if (flags & Update) {
188 + if (current === null) {
189 + console.error(
190 + 'useSwipeTransition() caused something to render a new <%s>. ' +
191 + 'This is not possible in the current implementation. ' +
192 + "Make sure that the swipe doesn't mount any new <%s> elements.",
193 + finishedWork.type,
194 + finishedWork.type,
195 + );
196 + } else {
197 + const newProps = finishedWork.memoizedProps;
198 + const oldProps = current.memoizedProps;
199 + const instance = finishedWork.stateNode;
200 + const type = finishedWork.type;
201 + const prev = pushMutationContext();
202 +
203 + try {
204 + // Since we currently don't have a separate diffing algorithm for
205 + // individual properties, the Update flag can be a false positive.
206 + // We have to apply the new props first o detect any mutations and
207 + // then revert them.
208 + commitUpdate(instance, type, oldProps, newProps, finishedWork);
209 + if (viewTransitionMutationContext) {
210 + console.error(
211 + 'useSwipeTransition() caused something to mutate <%s>. ' +
212 + 'This is not possible in the current implementation. ' +
213 + "Make sure that the swipe doesn't update any state which " +
214 + 'causes <%s> to change.',
215 + finishedWork.type,
216 + finishedWork.type,
217 + );
218 + }
219 + // Revert
220 + commitUpdate(instance, type, newProps, oldProps, finishedWork);
221 + } finally {
222 + popMutationContext(prev);
223 + }
224 + }
225 + }
226 + }
227 + break;
228 + }
229 + // Fall through
230 + }
231 + case HostComponent: {
232 + const instance: Instance = finishedWork.stateNode;
233 + if (current === null) {
234 + // For insertions we don't need to clone. It's already new state node.
235 + // TODO: Do we need to visit it for ViewTransitions though?
236 + appendChild(hostParentClone, instance);
237 + } else {
238 + let clone: Instance;
239 + if (finishedWork.child === null) {
240 + // This node is terminal. We still do a deep clone in case this has user
241 + // inserted content, text content or dangerouslySetInnerHTML.
242 + clone = cloneMutableInstance(instance, true);
243 + if (finishedWork.flags & ContentReset) {
244 + resetTextContent(clone);
245 + }
246 + } else {
247 + // If we have children we'll clone them as we walk the tree so we just
248 + // do a shallow clone here.
249 + clone = cloneMutableInstance(instance, false);
250 + }
251 +
252 + if (flags & Update) {
253 + const newProps = finishedWork.memoizedProps;
254 + const oldProps = current.memoizedProps;
255 + const type = finishedWork.type;
256 + // Apply the delta to the clone.
257 + commitUpdate(clone, type, oldProps, newProps, finishedWork);
258 + }
259 +
260 + if (unhideHostChildren) {
261 + unhideHostChildren = false;
262 + recursivelyInsertClones(finishedWork, clone);
263 + appendChild(hostParentClone, clone);
264 + unhideHostChildren = true;
265 + unhideInstance(clone, finishedWork.memoizedProps);
266 + } else {
267 + recursivelyInsertClones(finishedWork, clone);
268 + appendChild(hostParentClone, clone);
269 + }
270 + }
271 + break;
272 + }
273 + case HostText: {
274 + const textInstance: TextInstance = finishedWork.stateNode;
275 + if (textInstance === null) {
276 + throw new Error(
277 + 'This should have a text node initialized. This error is likely ' +
278 + 'caused by a bug in React. Please file an issue.',
279 + );
280 + }
281 + if (current === null) {
282 + // For insertions we don't need to clone. It's already new state node.
283 + appendChild(hostParentClone, textInstance);
284 + } else {
285 + const clone = cloneMutableTextInstance(textInstance);
286 + if (flags & Update) {
287 + const newText: string = finishedWork.memoizedProps;
288 + const oldText: string = current.memoizedProps;
289 + commitTextUpdate(clone, newText, oldText);
290 + }
291 + appendChild(hostParentClone, clone);
292 + if (unhideHostChildren) {
293 + unhideTextInstance(clone, finishedWork.memoizedProps);
294 + }
295 + }
296 + break;
297 + }
298 + case HostPortal: {
299 + // TODO: Consider what should happen to Portals. For now we exclude them.
300 + break;
301 + }
302 + case OffscreenComponent: {
303 + const newState: OffscreenState | null = finishedWork.memoizedState;
304 + const isHidden = newState !== null;
305 + if (!isHidden) {
306 + // Only insert clones if this tree is going to be visible. No need to
307 + // clone invisible content.
308 + // TODO: If this is visible but detached it should still be cloned.
309 + const prevUnhide = unhideHostChildren;
310 + unhideHostChildren = prevUnhide || (flags & Visibility) !== NoFlags;
311 + recursivelyInsertClones(finishedWork, hostParentClone);
312 + unhideHostChildren = prevUnhide;
313 + }
314 + break;
315 + }
316 + case ViewTransitionComponent:
317 + const prevMutationContext = pushMutationContext();
318 + // TODO: If this was already cloned by a previous pass we can reuse those clones.
319 + recursivelyInsertClones(finishedWork, hostParentClone);
320 + if (viewTransitionMutationContext) {
321 + // Track that this boundary had a mutation and therefore needs to animate
322 + // whether it resized or not.
323 + finishedWork.flags |= Update;
324 + }
325 + popMutationContext(prevMutationContext);
326 + break;
327 + default: {
328 + recursivelyInsertClones(finishedWork, hostParentClone);
329 + break;
330 + }
331 + }
332 +}
333
334 // Clone View Transition boundaries that have any mutations or might have had their
335 // layout affected by child insertions.
@@ -20,7 +337,30 @@ export function insertDestinationClones(
337 root: FiberRoot,
338 finishedWork: Fiber,
339 ): void {
23 - // TODO
340 + unhideHostChildren = false;
341 + // We'll either not transition the root, or we'll transition the clone. Regardless
342 + // we cancel the root view transition name.
343 + const needsClone = detectMutationOrInsertClones(finishedWork);
344 + if (needsClone) {
345 + if (__DEV__) {
346 + if (!didWarnForRootClone) {
347 + didWarnForRootClone = true;
348 + console.warn(
349 + 'useSwipeTransition() caused something to mutate or relayout the root. ' +
350 + 'This currently requires a clone of the whole document. Make sure to ' +
351 + 'add a <ViewTransition> directly around an absolutely positioned DOM node ' +
352 + 'to minimize the impact of any changes caused by the Swipe Transition.',
353 + );
354 + }
355 + }
356 + // Clone the whole root
357 + const rootClone = cloneRootViewTransitionContainer(root.containerInfo);
358 + root.gestureClone = rootClone;
359 + recursivelyInsertClones(finishedWork, rootClone);
360 + } else {
361 + root.gestureClone = null;
362 + cancelRootViewTransitionName(root.containerInfo);
363 + }
364 }
365
366 // Revert insertions and apply view transition names to the "new" (current) state.
@@ -28,8 +368,12 @@ export function applyDepartureTransitions(
368 root: FiberRoot,
369 finishedWork: Fiber,
370 ): void {
371 + const rootClone = root.gestureClone;
372 + if (rootClone !== null) {
373 + root.gestureClone = null;
374 + removeRootViewTransitionClone(root.containerInfo, rootClone);
375 + }
376 // TODO
32 - cancelRootViewTransitionName(root.containerInfo);
377 }
378
379 // Revert transition names and start/adjust animations on the started View Transition.
packages/react-reconciler/src/ReactFiberCommitWork.js
-3
@@ -247,7 +247,6 @@ import {
247 restoreNestedViewTransitions,
248 measureUpdateViewTransition,
249 measureNestedViewTransitions,
250 - resetShouldStartViewTransition,
250 resetAppearingViewTransitions,
251 trackAppearingViewTransition,
252 viewTransitionCancelableChildren,
@@ -290,8 +289,6 @@ export function commitBeforeMutationEffects(
289 focusedInstanceHandle = prepareForCommit(root.containerInfo);
290 shouldFireAfterActiveInstanceBlur = false;
291
293 - resetShouldStartViewTransition();
294 -
292 const isViewTransitionEligible =
293 enableViewTransition &&
294 includesOnlyViewTransitionEligibleLanes(committedLanes);
packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js
+4
@@ -20,6 +20,8 @@ function shim(...args: any): empty {
20
21 // Mutation (when unsupported)
22 export const supportsMutation = false;
23 +export const cloneMutableInstance = shim;
24 +export const cloneMutableTextInstance = shim;
25 export const appendChild = shim;
26 export const appendChildToContainer = shim;
27 export const commitTextUpdate = shim;
@@ -40,6 +42,8 @@ export const restoreViewTransitionName = shim;
42 export const cancelViewTransitionName = shim;
43 export const cancelRootViewTransitionName = shim;
44 export const restoreRootViewTransitionName = shim;
45 +export const cloneRootViewTransitionContainer = shim;
46 +export const removeRootViewTransitionClone = shim;
47 export type InstanceMeasurement = null;
48 export const measureInstance = shim;
49 export const wasInstanceInViewport = shim;
packages/react-reconciler/src/ReactFiberRoot.js
+1
@@ -101,6 +101,7 @@ function FiberRootNode(
101 if (enableSwipeTransition) {
102 this.pendingGestures = null;
103 this.stoppingGestures = null;
104 + this.gestureClone = null;
105 }
106
107 this.incompleteTransitions = new Map();
packages/react-reconciler/src/ReactFiberWorkLoop.js
+8
@@ -228,6 +228,7 @@ import {
228 invokePassiveEffectUnmountInDEV,
229 accumulateSuspenseyCommit,
230 } from './ReactFiberCommitWork';
231 +import {resetShouldStartViewTransition} from './ReactFiberCommitViewTransitions';
232 import {shouldStartViewTransition} from './ReactFiberCommitViewTransitions';
233 import {
234 insertDestinationClones,
@@ -3449,6 +3450,8 @@ function commitRoot(
3450 }
3451 }
3452
3453 + resetShouldStartViewTransition();
3454 +
3455 // The commit phase is broken into several sub-phases. We do a separate pass
3456 // of the effect list for each phase: all mutation effects come before all
3457 // layout effects, and so on.
@@ -3900,6 +3903,11 @@ function commitGestureOnRoot(
3903
3904 finishedGesture.running = startGestureTransition(
3905 root.containerInfo,
3906 + finishedGesture.provider,
3907 + finishedGesture.rangeCurrent,
3908 + finishedGesture.direction
3909 + ? finishedGesture.rangeNext
3910 + : finishedGesture.rangePrevious,
3911 pendingTransitionTypes,
3912 flushGestureMutations,
3913 flushGestureAnimations,
packages/react-reconciler/src/ReactInternalTypes.js
+2
@@ -26,6 +26,7 @@ import type {Lane, Lanes, LaneMap} from './ReactFiberLane';
26 import type {RootTag} from './ReactRootTags';
27 import type {
28 Container,
29 + Instance,
30 TimeoutHandle,
31 NoTimeout,
32 SuspenseInstance,
@@ -286,6 +287,7 @@ type BaseFiberRootProperties = {
287 // enableSwipeTransition only
288 pendingGestures: null | ScheduledGesture,
289 stoppingGestures: null | ScheduledGesture,
290 + gestureClone: null | Instance,
291 };
292
293 // The following attributes are only used by DevTools and are only present in DEV builds.
packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
+6
@@ -56,10 +56,12 @@ export const getChildHostContext = $$$config.getChildHostContext;
56 export const prepareForCommit = $$$config.prepareForCommit;
57 export const resetAfterCommit = $$$config.resetAfterCommit;
58 export const createInstance = $$$config.createInstance;
59 +export const cloneMutableInstance = $$$config.cloneMutableInstance;
60 export const appendInitialChild = $$$config.appendInitialChild;
61 export const finalizeInitialChildren = $$$config.finalizeInitialChildren;
62 export const shouldSetTextContent = $$$config.shouldSetTextContent;
63 export const createTextInstance = $$$config.createTextInstance;
64 +export const cloneMutableTextInstance = $$$config.cloneMutableTextInstance;
65 export const scheduleTimeout = $$$config.scheduleTimeout;
66 export const cancelTimeout = $$$config.cancelTimeout;
67 export const noTimeout = $$$config.noTimeout;
@@ -141,6 +143,10 @@ export const cancelRootViewTransitionName =
143 $$$config.cancelRootViewTransitionName;
144 export const restoreRootViewTransitionName =
145 $$$config.restoreRootViewTransitionName;
146 +export const cloneRootViewTransitionContainer =
147 + $$$config.cloneRootViewTransitionContainer;
148 +export const removeRootViewTransitionClone =
149 + $$$config.removeRootViewTransitionClone;
150 export const measureInstance = $$$config.measureInstance;
151 export const wasInstanceInViewport = $$$config.wasInstanceInViewport;
152 export const hasInstanceChanged = $$$config.hasInstanceChanged;
packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+49
@@ -172,6 +172,21 @@ export function createInstance(
172 };
173 }
174
175 +export function cloneMutableInstance(
176 + instance: Instance,
177 + keepChildren: boolean,
178 +): Instance {
179 + return {
180 + type: instance.type,
181 + props: instance.props,
182 + isHidden: instance.isHidden,
183 + children: keepChildren ? instance.children : [],
184 + internalInstanceHandle: null,
185 + rootContainerInstance: instance.rootContainerInstance,
186 + tag: 'INSTANCE',
187 + };
188 +}
189 +
190 export function appendInitialChild(
191 parentInstance: Instance,
192 child: Instance | TextInstance,
@@ -210,6 +225,16 @@ export function createTextInstance(
225 };
226 }
227
228 +export function cloneMutableTextInstance(
229 + textInstance: TextInstance,
230 +): TextInstance {
231 + return {
232 + text: textInstance.text,
233 + isHidden: textInstance.isHidden,
234 + tag: 'TEXT',
235 + };
236 +}
237 +
238 let currentUpdatePriority: EventPriority = NoEventPriority;
239 export function setCurrentUpdatePriority(newPriority: EventPriority): void {
240 currentUpdatePriority = newPriority;
@@ -337,6 +362,27 @@ export function restoreRootViewTransitionName(rootContainer: Container): void {
362 // Noop
363 }
364
365 +export function cloneRootViewTransitionContainer(
366 + rootContainer: Container,
367 +): Instance {
368 + return {
369 + type: 'ROOT',
370 + props: {},
371 + isHidden: false,
372 + children: [],
373 + internalInstanceHandle: null,
374 + rootContainerInstance: rootContainer,
375 + tag: 'INSTANCE',
376 + };
377 +}
378 +
379 +export function removeRootViewTransitionClone(
380 + rootContainer: Container,
381 + clone: Instance,
382 +): void {
383 + // Noop since it was never inserted anywhere.
384 +}
385 +
386 export type InstanceMeasurement = null;
387
388 export function measureInstance(instance: Instance): InstanceMeasurement {
@@ -379,6 +425,9 @@ export type RunningGestureTransition = null;
425
426 export function startGestureTransition(
427 rootContainer: Container,
428 + timeline: GestureTimeline,
429 + rangeStart: number,
430 + rangeEnd: number,
431 transitionTypes: null | TransitionTypes,
432 mutationCallback: () => void,
433 animateCallback: () => void,
scripts/error-codes/codes.json
+2 -1
@@ -536,5 +536,6 @@
536 "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.",
537 "549": "Cannot start a gesture with a disconnected AnimationTimeline.",
538 "550": "useSwipeTransition is not yet supported in react-art.",
539 - "551": "useSwipeTransition is not yet supported in React Native."
539 + "551": "useSwipeTransition is not yet supported in React Native.",
540 + "552": "Cannot use a useSwipeTransition() in a detached root."
541 }