diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackendSuspense-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackendSuspense-itest.js new file mode 100644 index 000000000000..3cb2553d6e57 --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackendSuspense-itest.js @@ -0,0 +1,592 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @fantom_flags useSharedAnimatedBackend:true updateRuntimeShadowNodeReferencesOnCommitThread:* + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import * as Fantom from '@react-native/fantom'; +import {Suspense, startTransition, use} from 'react'; +import {Animated, Easing, View, useAnimatedValue} from 'react-native'; +import {allowStyleProp} from 'react-native/Libraries/Animated/NativeAnimatedAllowlist'; + +// --- Shared test utilities --- + +function Fallback({nativeID}: {nativeID?: string}) { + return ; +} + +/** + * Creates an async data cache backed by manually-resolved promises. + * Call `cache.resolveNext()` to resolve the pending fetch. + */ +function createDataCache(): { + SuspendingChild: React.ComponentType<{dataKey: string}>, + useData: (key: string) => string, + resolveNext: () => void, +} { + let resolvePromise: (() => void) | null = null; + const cache = new Map(); + + async function getData(key: string): Promise { + await new Promise(resolve => { + resolvePromise = resolve; + }); + return `data-${key}`; + } + + async function fetchData(key: string): Promise { + const data = await getData(key); + cache.set(key, data); + return data; + } + + function useData(key: string): string { + let data = cache.get(key); + if (data == null) { + data = use(fetchData(key)); + } + return data; + } + + function SuspendingChild(props: {dataKey: string}) { + return ; + } + + function resolveNext() { + expect(resolvePromise).not.toBeNull(); + Fantom.runTask(() => { + resolvePromise?.(); + resolvePromise = null; + }); + } + + return {SuspendingChild, useData, resolveNext}; +} + +// A promise that never resolves - used to freeze components (react-freeze pattern) +// $FlowFixMe[incompatible-exact] - Promise types are inexact +const freezePromise: Promise & {status?: string} = new Promise(() => {}); +freezePromise.status = 'pending'; + +function Freeze(props: {freeze: boolean, children: React.Node}) { + if (props.freeze) { + throw freezePromise; + } + return props.children; +} + +function AnimatedChild({ + onAnimatedWidth, +}: { + onAnimatedWidth?: (width: Animated.Value) => void, +}) { + const animatedWidth = useAnimatedValue(0); + onAnimatedWidth?.(animatedWidth); + + return ( + + ); +} + +// --- Tests --- + +beforeEach(() => { + allowStyleProp('width'); +}); + +test('animation state is maintained after Suspense', () => { + let _animatedWidth; + let _widthAnimation; + const {SuspendingChild, resolveNext} = createDataCache(); + + function MyApp(props: {dataKey: string}) { + const animatedWidth = useAnimatedValue(0); + _animatedWidth = animatedWidth; + return ( + + }> + + + + ); + } + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual( + + + , + ); + + resolveNext(); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual( + + + , + ); + + Fantom.runTask(() => { + _widthAnimation = Animated.timing(_animatedWidth, { + toValue: 100, + duration: 1000, + easing: Easing.linear, + useNativeDriver: true, + }).start(); + }); + + Fantom.unstable_produceFramesForDuration(500); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual( + + + , + ); + + // Trigger suspense via transition - stale UI should remain visible + Fantom.runTask(() => { + startTransition(() => { + root.render(); + }); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual( + + + , + ); + + // Animation continues while suspended + Fantom.unstable_produceFramesForDuration(250); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual( + + + , + ); + + resolveNext(); + + // Animation state is maintained after suspense resolves + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual( + + + , + ); + + Fantom.unstable_produceFramesForDuration(250); + + Fantom.runTask(() => { + _widthAnimation?.stop(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual( + + + , + ); +}); + +test('animation continues on animated component during suspenseful transition', () => { + let _animatedWidth; + let _widthAnimation; + const {useData, resolveNext} = createDataCache(); + + function AnimatedSuspendingChild(props: {dataKey: string}) { + const animatedWidth = useAnimatedValue(0); + _animatedWidth = animatedWidth; + const data = useData(props.dataKey); + + return ( + + ); + } + + function MyApp(props: {dataKey: string}) { + return ( + }> + + + ); + } + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + resolveNext(); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + Fantom.runTask(() => { + _widthAnimation = Animated.timing(_animatedWidth, { + toValue: 100, + duration: 1000, + easing: Easing.linear, + useNativeDriver: true, + }).start(); + }); + + Fantom.unstable_produceFramesForDuration(500); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + // Trigger suspense via transition - stale UI should remain visible + Fantom.runTask(() => { + startTransition(() => { + root.render(); + }); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + Fantom.unstable_produceFramesForDuration(250); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + resolveNext(); + + // Animation state is maintained after suspense resolves + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + Fantom.unstable_produceFramesForDuration(250); + + Fantom.runTask(() => { + _widthAnimation?.stop(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); +}); + +test('animation continues after component is frozen and unfrozen', () => { + // This test simulates the react-freeze pattern: a Suspense boundary that throws + // a never-resolving promise to "freeze" a component (hiding it but keeping it mounted), + // then stops throwing to "unfreeze" it. The animation should continue seamlessly. + + let _animatedWidth; + let _widthAnimation; + + function MyApp(props: {frozen: boolean}) { + return ( + }> + + { + _animatedWidth = w; + }} + /> + + + ); + } + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + Fantom.runTask(() => { + _widthAnimation = Animated.timing(_animatedWidth, { + toValue: 100, + duration: 1000, + easing: Easing.linear, + useNativeDriver: true, + }).start(); + }); + + Fantom.unstable_produceFramesForDuration(250); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + // Freeze the component - fallback should be shown + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + // Animation continues while frozen + Fantom.unstable_produceFramesForDuration(250); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + // Unfreeze - animation state is preserved + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + Fantom.unstable_produceFramesForDuration(500); + + Fantom.runTask(() => { + _widthAnimation?.stop(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); +}); + +test('animation continues after multiple freeze/unfreeze cycles', () => { + let _animatedWidth; + let _widthAnimation; + + function MyApp(props: {frozen: boolean}) { + return ( + }> + + { + _animatedWidth = w; + }} + /> + + + ); + } + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + Fantom.runTask(() => { + _widthAnimation = Animated.timing(_animatedWidth, { + toValue: 100, + duration: 2000, + easing: Easing.linear, + useNativeDriver: true, + }).start(); + }); + + Fantom.unstable_produceFramesForDuration(200); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + // First freeze + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + Fantom.unstable_produceFramesForDuration(200); + + // First unfreeze + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + Fantom.unstable_produceFramesForDuration(200); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + // Second freeze + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + Fantom.unstable_produceFramesForDuration(400); + + // Second unfreeze + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + // Third freeze (quick freeze/unfreeze) + Fantom.runTask(() => { + root.render(); + }); + + Fantom.unstable_produceFramesForDuration(100); + + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + Fantom.unstable_produceFramesForDuration(900); + + Fantom.runTask(() => { + _widthAnimation?.stop(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); +}); + +test('animation state is maintained when unfrozen after animation completes', () => { + let _animatedWidth; + let _widthAnimation; + + function MyApp(props: {frozen: boolean}) { + return ( + }> + + { + _animatedWidth = w; + }} + /> + + + ); + } + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + Fantom.runTask(() => { + _widthAnimation = Animated.timing(_animatedWidth, { + toValue: 100, + duration: 1000, + easing: Easing.linear, + useNativeDriver: true, + }).start(); + }); + + Fantom.unstable_produceFramesForDuration(200); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + // Freeze the component + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + // Animation completes while frozen + Fantom.unstable_produceFramesForDuration(1000); + + Fantom.runTask(() => { + _widthAnimation?.stop(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); + + // Unfreeze - final animation state is shown + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['width', 'nativeID']}).toJSX(), + ).toEqual(); +});