From 54934467faf5d42f6704b720780d454f426033df Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 24 Jul 2026 05:29:18 -0400 Subject: [PATCH 01/12] perf: stabilize screen render subscriptions --- .../src/blank-stack/components/stack-view.tsx | 162 ++++++--- .../src/blank-stack/types.ts | 5 +- .../component-stack/components/stack-view.tsx | 128 +++++-- .../views/NativeStackView.native.tsx | 259 ++++++------- .../__tests__/blank-stack-controller.test.ts | 27 +- .../ownership/gesture-ownership.test.ts | 4 +- .../__tests__/transition-accessor.test.ts | 2 +- .../with-screen-transitions/context.tsx | 5 + .../with-screen-transitions/stack-layout.tsx | 27 +- .../activity/variants/activity-screen.tsx | 57 ++- .../boundary/components/boundary-target.tsx | 4 +- .../components/boundary/hooks/use-measurer.ts | 4 +- .../hooks/use-host-measurement.ts | 4 +- .../providers/boundary-root.provider.tsx | 19 +- .../hooks/use-backdrop-pointer-events.ts | 18 +- .../hooks/use-content-layout.ts | 20 +- .../screen-container/layers/backdrop.tsx | 28 +- .../screen-container/layers/content.tsx | 22 +- .../layers/surface-container.tsx | 8 +- .../hooks/use-close-transition-intent.ts | 28 +- .../hooks/use-open-transition-intent.ts | 6 +- .../components/screen-lifecycle/index.tsx | 5 +- .../navigation/use-navigation-helpers.ts | 17 +- .../hooks/navigation/use-screen-state.ts | 6 +- .../providers/register-bounds.provider.tsx | 35 +- .../screen/animation/animation.provider.tsx | 343 +++++++++--------- .../accessors/use-build-bounds-accessor.tsx | 6 +- .../use-build-transition-accessor.ts | 4 +- .../screen/animation/helpers/pipeline.ts | 10 +- .../providers/screen/animation/index.tsx | 2 +- .../screen/animation/use-screen-animation.tsx | 4 +- .../descriptors/descriptors.provider.tsx | 155 ++++---- .../providers/screen/descriptors/index.tsx | 2 - .../screen/gestures/gestures.provider.tsx | 135 +++---- .../hooks/use-screen-gesture-config.ts | 15 +- .../gestures/hooks/use-screen-gesture.ts | 4 +- .../hooks/use-stable-runtime-config.ts | 6 +- .../providers/screen/gestures/index.tsx | 2 +- .../gestures/ownership/shadowing-claims.ts | 7 +- ...e-walk-up-and-register-shadowing-claims.ts | 24 +- .../pan/activation/use-pan-activation.ts | 9 +- .../gestures/pan/use-build-pan-gesture.ts | 4 +- .../gestures/pinch/use-build-pinch-gesture.ts | 4 +- .../scroll-metadata-owner.tsx | 8 +- .../use-scroll-gesture-coordination.ts | 8 +- .../shared/providers/screen/options/index.ts | 2 +- .../screen/options/options.provider.tsx | 52 ++- .../providers/screen/origin.provider.tsx | 17 +- .../providers/screen/screen-composer.tsx | 11 +- .../hooks/use-interpolated-style-maps.tsx | 8 +- .../hooks/use-maybe-block-visibility.tsx | 6 +- .../shared/providers/screen/styles/index.tsx | 1 - .../providers/screen/styles/slot.provider.tsx | 106 +++--- .../helpers/build-blank-stack-state.ts | 21 +- .../providers/stack/blank-stack.provider.tsx | 141 ++++--- .../shared/providers/stack/core.provider.tsx | 122 +++---- .../providers/stack/direct.provider.tsx | 134 ++++--- .../providers/blank-stack-provider.types.ts | 30 +- .../types/providers/direct-stack.types.ts | 2 + .../src/shared/types/stack.types.ts | 6 +- .../src/shared/utils/create-provider.tsx | 241 ++++++------ 61 files changed, 1370 insertions(+), 1182 deletions(-) diff --git a/packages/react-native-screen-transitions/src/blank-stack/components/stack-view.tsx b/packages/react-native-screen-transitions/src/blank-stack/components/stack-view.tsx index 0e8c66ae..993b8330 100644 --- a/packages/react-native-screen-transitions/src/blank-stack/components/stack-view.tsx +++ b/packages/react-native-screen-transitions/src/blank-stack/components/stack-view.tsx @@ -1,8 +1,10 @@ import { NavigationContext, + type NavigationProp, NavigationRouteContext, + type ParamListBase, } from "@react-navigation/native"; -import { memo } from "react"; +import { memo, type ReactNode } from "react"; import { ActivityContainer, ActivityScreen, @@ -10,72 +12,126 @@ import { import { PortalProvider } from "../../shared/components/boundary/portal"; import { Overlay } from "../../shared/components/overlay"; import { ScreenComposer } from "../../shared/providers/screen/screen-composer"; -import { withBlankStack } from "../../shared/providers/stack/blank-stack.provider"; -import { withStackCore } from "../../shared/providers/stack/core.provider"; -import type { BaseStackScene } from "../../shared/types/stack.types"; +import { + BlankStackProvider, + useBlankStackStore, +} from "../../shared/providers/stack/blank-stack.provider"; +import { + type StackCoreConfig, + StackCoreProvider, +} from "../../shared/providers/stack/core.provider"; +import type { BlankStackProviderProps } from "../../shared/types/providers/blank-stack-provider.types"; import type { BlankStackDescriptor, BlankStackNavigationHelpers, } from "../types"; -interface BlankSceneRowProps { - scene: BaseStackScene; - paintDriverRouteKey?: string; +interface RouteKeyProps { + routeKey: string; } -const BlankSceneRow = memo(function BlankSceneRow({ - scene, - paintDriverRouteKey, -}: BlankSceneRowProps) { - const descriptor = scene.descriptor; - const route = scene.route; +const BlankNavigationProvider = memo(function BlankNavigationProvider({ + children, + routeKey, +}: RouteKeyProps & { children: ReactNode }) { + const navigation = useBlankStackStore( + (store) => store?.scenesByKey[routeKey]?.descriptor.navigation, + ) as NavigationProp | undefined; + const route = useBlankStackStore( + (store) => store?.scenesByKey[routeKey]?.route, + ); + + if (!navigation || !route) { + throw new Error(`Blank stack scene "${routeKey}" was not found.`); + } return ( - + - - - {descriptor.render?.()} - - + {children} ); }); -export const StackView = withStackCore( - { TRANSITIONS_ALWAYS_ON: true, DISABLE_NATIVE_SCREENS: false }, - withBlankStack( - ({ scenes, shouldShowFloatOverlay }) => { - return ( - - {shouldShowFloatOverlay ? : null} +const BlankSceneContent = memo(function BlankSceneContent({ + routeKey, +}: RouteKeyProps) { + const render = useBlankStackStore( + (store) => store?.scenesByKey[routeKey]?.descriptor.render, + ); + + return render?.() ?? null; +}); + +const BlankSceneRow = memo(function BlankSceneRow({ routeKey }: RouteKeyProps) { + return ( + + + + + + + + ); +}); + +const StackViewContent = memo(function StackViewContent() { + const routeKeys = useBlankStackStore( + (store) => store?.routeKeys ?? EMPTY_ROUTE_KEYS, + ); + const shouldShowFloatOverlay = useBlankStackStore( + (store) => store?.shouldShowFloatOverlay ?? false, + ); + + return ( + + {shouldShowFloatOverlay ? : null} + + + {routeKeys.map((routeKey) => ( + + ))} + + + ); +}); + +const EMPTY_ROUTE_KEYS: string[] = []; - - {scenes.map((scene, index) => { - const route = scene.route; - const paintDriverRouteKey = scenes[index + 2]?.route.key; +type StackViewProps = BlankStackProviderProps< + BlankStackDescriptor, + BlankStackNavigationHelpers +> & + StackCoreConfig; - return ( - - ); - })} - - - ); - }, - ), -); +export const StackView = memo(function StackView({ + DISABLE_NATIVE_SCREENS, + DISABLE_NATIVE_SCREEN_CONTAINER, + TRANSITIONS_ALWAYS_ON, + STACK_TYPE, + state, + navigation, + descriptors, + describe, +}: StackViewProps) { + return ( + + + + + + ); +}); diff --git a/packages/react-native-screen-transitions/src/blank-stack/types.ts b/packages/react-native-screen-transitions/src/blank-stack/types.ts index 647022ff..90196663 100644 --- a/packages/react-native-screen-transitions/src/blank-stack/types.ts +++ b/packages/react-native-screen-transitions/src/blank-stack/types.ts @@ -12,7 +12,6 @@ import type { } from "@react-navigation/native"; import type { InactiveBehavior, ScreenTransitionConfig } from "../shared"; import type { OverlayProps } from "../shared/types/overlay.types"; -import type { StackSceneActivity } from "../shared/types/stack.types"; export type { InactiveBehavior } from "../shared"; @@ -143,6 +142,4 @@ export type BlankStackDescriptor = Descriptor< BlankStackNavigationOptions, BlankStackNavigationProp, RouteProp -> & { - activity: StackSceneActivity; -}; +>; diff --git a/packages/react-native-screen-transitions/src/component-stack/components/stack-view.tsx b/packages/react-native-screen-transitions/src/component-stack/components/stack-view.tsx index 743e81d9..062b13a1 100644 --- a/packages/react-native-screen-transitions/src/component-stack/components/stack-view.tsx +++ b/packages/react-native-screen-transitions/src/component-stack/components/stack-view.tsx @@ -1,9 +1,16 @@ -import { Fragment } from "react"; +import { Fragment, memo } from "react"; import { Overlay } from "../../shared/components/overlay"; import { SceneView } from "../../shared/components/scene-view"; import { ScreenComposer } from "../../shared/providers/screen/screen-composer"; -import { withBlankStack } from "../../shared/providers/stack/blank-stack.provider"; -import { withStackCore } from "../../shared/providers/stack/core.provider"; +import { + BlankStackProvider, + useBlankStackStore, +} from "../../shared/providers/stack/blank-stack.provider"; +import { + type StackCoreConfig, + StackCoreProvider, +} from "../../shared/providers/stack/core.provider"; +import type { BlankStackProviderProps } from "../../shared/types/providers/blank-stack-provider.types"; import { StackType } from "../../shared/types/stack.types"; import type { ComponentStackDescriptor, @@ -11,32 +18,89 @@ import type { } from "../types"; import { ComponentScreen } from "./component-screen"; -export const StackView = withStackCore( - { TRANSITIONS_ALWAYS_ON: true, STACK_TYPE: StackType.COMPONENT }, - withBlankStack( - ({ scenes, shouldShowFloatOverlay }) => { - return ( - - {shouldShowFloatOverlay ? : null} - - {scenes.map((scene) => { - const descriptor = scene.descriptor; - const route = scene.route; - - return ( - - - - - - ); - })} - - ); - }, - ), -); +const EMPTY_ROUTE_KEYS: string[] = []; + +const ComponentSceneContent = memo(function ComponentSceneContent({ + routeKey, +}: { + routeKey: string; +}) { + const descriptor = useBlankStackStore( + (store) => store?.scenesByKey[routeKey]?.descriptor, + ) as ComponentStackDescriptor | undefined; + + if (!descriptor) { + throw new Error(`Component stack scene "${routeKey}" was not found.`); + } + + return ; +}); + +const ComponentSceneRow = memo(function ComponentSceneRow({ + routeKey, +}: { + routeKey: string; +}) { + return ( + + + + + + ); +}); + +const StackViewContent = memo(function StackViewContent() { + const routeKeys = useBlankStackStore( + (store) => store?.routeKeys ?? EMPTY_ROUTE_KEYS, + ); + const shouldShowFloatOverlay = useBlankStackStore( + (store) => store?.shouldShowFloatOverlay ?? false, + ); + + return ( + + {shouldShowFloatOverlay ? : null} + + {routeKeys.map((routeKey) => ( + + ))} + + ); +}); + +type StackViewProps = BlankStackProviderProps< + ComponentStackDescriptor, + ComponentStackNavigationHelpers +> & + StackCoreConfig; + +export const StackView = memo(function StackView({ + DISABLE_NATIVE_SCREENS, + DISABLE_NATIVE_SCREEN_CONTAINER, + TRANSITIONS_ALWAYS_ON, + state, + navigation, + descriptors, + describe, +}: StackViewProps) { + return ( + + + + + + ); +}); diff --git a/packages/react-native-screen-transitions/src/native-stack/views/NativeStackView.native.tsx b/packages/react-native-screen-transitions/src/native-stack/views/NativeStackView.native.tsx index 519a3a75..a24b228a 100644 --- a/packages/react-native-screen-transitions/src/native-stack/views/NativeStackView.native.tsx +++ b/packages/react-native-screen-transitions/src/native-stack/views/NativeStackView.native.tsx @@ -30,10 +30,11 @@ import { } from "react-native-screens"; import { Overlay } from "../../shared/components/overlay"; import { ScreenComposer } from "../../shared/providers/screen/screen-composer"; -import { withStackCore } from "../../shared/providers/stack/core.provider"; +import { StackCoreProvider } from "../../shared/providers/stack/core.provider"; import { - type DirectStackContextValue, - withDirectStack, + type DirectStackProps, + DirectStackProvider, + useDirectStackStore, } from "../../shared/providers/stack/direct.provider"; import { StackType } from "../../shared/types/stack.types"; import { isFabric } from "../../shared/utils/platform"; @@ -478,133 +479,143 @@ const SceneView = ({ ); }; -export const NativeStackView = withStackCore( - { TRANSITIONS_ALWAYS_ON: false, STACK_TYPE: StackType.NATIVE }, - withDirectStack(function NativeStackViewContent({ +function NativeStackViewContent() { + const { state, navigation, descriptors, scenes, focusedIndex, shouldShowFloatOverlay, - }: DirectStackContextValue) { - const { setNextDismissedKey } = useDismissedRouteError(state); - - useInvalidPreventRemoveError(descriptors); - - const modalRouteKeys = getModalRouteKeys(state.routes, descriptors); - - return ( - <> - {shouldShowFloatOverlay ? : null} - - {scenes.map((scene, index) => { - const { route, descriptor, isPreloaded } = scene; - const isFocused = focusedIndex === index; - const isBelowFocused = focusedIndex - 1 === index; - - // Get previous/next descriptors from state.routes (not scenes) for proper navigation - const previousKey = state.routes[index - 1]?.key; - const nextKey = state.routes[index + 1]?.key; - const previousDescriptor = previousKey - ? descriptors[previousKey] - : undefined; - const nextDescriptor = nextKey ? descriptors[nextKey] : undefined; - - const isModal = modalRouteKeys.includes(route.key); - - // On Fabric, when screen is frozen, animated and reanimated values are not updated - // due to component being unmounted. To avoid this, we don't freeze the previous screen there - const shouldFreeze = isFabric() - ? !isPreloaded && !isFocused && !isBelowFocused - : !isPreloaded && !isFocused; - - return ( - { - navigation.emit({ - type: "transitionStart", - data: { closing: true }, - target: route.key, - }); - }} - onWillAppear={() => { - navigation.emit({ - type: "transitionStart", - data: { closing: false }, - target: route.key, - }); - }} - onAppear={() => { - navigation.emit({ - type: "transitionEnd", - data: { closing: false }, - target: route.key, - }); - }} - onDisappear={() => { - navigation.emit({ - type: "transitionEnd", - data: { closing: true }, - target: route.key, - }); - }} - onDismissed={(event) => { - navigation.dispatch({ - ...StackActions.pop(event.nativeEvent.dismissCount), - source: route.key, - target: state.key, - }); - - setNextDismissedKey(route.key); - }} - onHeaderBackButtonClicked={() => { - navigation.dispatch({ - ...StackActions.pop(), - source: route.key, - target: state.key, - }); - }} - onNativeDismissCancelled={(event) => { - navigation.dispatch({ - ...StackActions.pop(event.nativeEvent.dismissCount), - source: route.key, - target: state.key, - }); - }} - onGestureCancel={() => { - navigation.emit({ - type: "gestureCancel", - target: route.key, - }); - }} - onSheetDetentChanged={(event) => { - navigation.emit({ - type: "sheetDetentChange", - target: route.key, - data: { - index: event.nativeEvent.index, - stable: event.nativeEvent.isStable, - }, - }); - }} - /> - ); - })} - - - ); - }), -); + } = useDirectStackStore(); + const { setNextDismissedKey } = useDismissedRouteError(state); + + useInvalidPreventRemoveError(descriptors); + + const modalRouteKeys = getModalRouteKeys(state.routes, descriptors); + + return ( + <> + {shouldShowFloatOverlay ? : null} + + {scenes.map((scene, index) => { + const { route, descriptor, isPreloaded } = scene; + const isFocused = focusedIndex === index; + const isBelowFocused = focusedIndex - 1 === index; + + // Get previous/next descriptors from state.routes (not scenes) for proper navigation + const previousKey = state.routes[index - 1]?.key; + const nextKey = state.routes[index + 1]?.key; + const previousDescriptor = previousKey + ? descriptors[previousKey] + : undefined; + const nextDescriptor = nextKey ? descriptors[nextKey] : undefined; + + const isModal = modalRouteKeys.includes(route.key); + + // On Fabric, when screen is frozen, animated and reanimated values are not updated + // due to component being unmounted. To avoid this, we don't freeze the previous screen there + const shouldFreeze = isFabric() + ? !isPreloaded && !isFocused && !isBelowFocused + : !isPreloaded && !isFocused; + + return ( + { + navigation.emit({ + type: "transitionStart", + data: { closing: true }, + target: route.key, + }); + }} + onWillAppear={() => { + navigation.emit({ + type: "transitionStart", + data: { closing: false }, + target: route.key, + }); + }} + onAppear={() => { + navigation.emit({ + type: "transitionEnd", + data: { closing: false }, + target: route.key, + }); + }} + onDisappear={() => { + navigation.emit({ + type: "transitionEnd", + data: { closing: true }, + target: route.key, + }); + }} + onDismissed={(event) => { + navigation.dispatch({ + ...StackActions.pop(event.nativeEvent.dismissCount), + source: route.key, + target: state.key, + }); + + setNextDismissedKey(route.key); + }} + onHeaderBackButtonClicked={() => { + navigation.dispatch({ + ...StackActions.pop(), + source: route.key, + target: state.key, + }); + }} + onNativeDismissCancelled={(event) => { + navigation.dispatch({ + ...StackActions.pop(event.nativeEvent.dismissCount), + source: route.key, + target: state.key, + }); + }} + onGestureCancel={() => { + navigation.emit({ + type: "gestureCancel", + target: route.key, + }); + }} + onSheetDetentChanged={(event) => { + navigation.emit({ + type: "sheetDetentChange", + target: route.key, + data: { + index: event.nativeEvent.index, + stable: event.nativeEvent.isStable, + }, + }); + }} + /> + ); + })} + + + ); +} + +export function NativeStackView(props: DirectStackProps) { + return ( + + + + + + ); +} const styles = StyleSheet.create({ container: { diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/blank-stack-controller.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/blank-stack-controller.test.ts index 8aaa774d..9cea3aba 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/blank-stack-controller.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/blank-stack-controller.test.ts @@ -47,7 +47,6 @@ const createDescriptor = ( route, navigation, options, - activity: "inactive", }); const createProps = ( @@ -98,8 +97,7 @@ describe("createBlankStackController", () => { "c", ]); expect(snapshot.state.descriptors.c?.route).toBe(descriptorC.route); - expect(snapshot.state.descriptors.c?.activity).toBe("closing"); - expect(descriptorC.activity).toBe("inactive"); + expect(snapshot.state.descriptors.c).toBe(descriptorC); expect(snapshot.state.closingRouteKeys.has("c")).toBe(true); expect(snapshot.state.scenes[0]?.previousDescriptor).toBeUndefined(); expect(snapshot.state.scenes[0]?.nextDescriptor?.route).toBe( @@ -111,9 +109,11 @@ describe("createBlankStackController", () => { expect(snapshot.state.scenes[1]?.nextDescriptor?.route).toBe( descriptorC.route, ); - expect(snapshot.state.scenes.map((scene) => scene.descriptor.activity)).toEqual( - ["inactive", "inert", "closing"], - ); + expect(snapshot.state.scenes.map((scene) => scene.activity)).toEqual([ + "inactive", + "inert", + "closing", + ]); }); it("soft-dismisses a focused route before React Navigation removes it", () => { @@ -135,9 +135,10 @@ describe("createBlankStackController", () => { const snapshot = controller.getSnapshot(); expect(snapshot.state.routes.map((route) => route.key)).toEqual(["a", "b"]); expect(snapshot.state.closingRouteKeys.has("b")).toBe(true); - expect(snapshot.state.scenes.map((scene) => scene.descriptor.activity)).toEqual( - ["inert", "closing"], - ); + expect(snapshot.state.scenes.map((scene) => scene.activity)).toEqual([ + "inert", + "closing", + ]); expect(navigation.actions).toEqual([]); }); @@ -180,9 +181,11 @@ describe("createBlankStackController", () => { "dynamic-a", "dynamic-b", ]); - expect(snapshot.state.scenes.map((scene) => scene.descriptor.activity)).toEqual( - ["inert", "closing", "active"], - ); + expect(snapshot.state.scenes.map((scene) => scene.activity)).toEqual([ + "inert", + "closing", + "active", + ]); expect(snapshot.state.scenes[0]?.nextDescriptor?.route).toBe( descriptorB.route, ); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/gestures/ownership/gesture-ownership.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/gestures/ownership/gesture-ownership.test.ts index f1a46a93..585a5db4 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/gestures/ownership/gesture-ownership.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/gestures/ownership/gesture-ownership.test.ts @@ -105,7 +105,7 @@ describe("gesture ownership activation", () => { expect( resolveShadowingClaimDirections({ - currentActivity: "closing", + isCurrentScreenClosing: true, currentClaimedDirections, previousClaimedDirections, }), @@ -121,7 +121,7 @@ describe("gesture ownership activation", () => { expect( resolveShadowingClaimDirections({ - currentActivity: "closing", + isCurrentScreenClosing: true, currentClaimedDirections, previousClaimedDirections, }), diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/transition-accessor.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/transition-accessor.test.ts index b81649c1..c2b7ad13 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/transition-accessor.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/transition-accessor.test.ts @@ -10,7 +10,7 @@ import type { } from "../providers/screen/animation/types"; mock.module("../providers/screen/animation/animation.provider", () => ({ - useScreenAnimationContext: () => ({ + useScreenAnimationStore: () => ({ screenInterpolatorProps: { get: () => ({}) }, screenInterpolatorPropsRevision: { get: () => 0 }, ancestorScreenAnimationSources: [], diff --git a/packages/react-native-screen-transitions/src/shared/adapters/with-screen-transitions/context.tsx b/packages/react-native-screen-transitions/src/shared/adapters/with-screen-transitions/context.tsx index eda501fb..60cca932 100644 --- a/packages/react-native-screen-transitions/src/shared/adapters/with-screen-transitions/context.tsx +++ b/packages/react-native-screen-transitions/src/shared/adapters/with-screen-transitions/context.tsx @@ -10,6 +10,7 @@ export type ScreenTransitionsAdapterScene = export interface ScreenTransitionsAdapterContextValue { routeIndexByKey: ReadonlyMap; scenes: ScreenTransitionsAdapterScene[]; + scenesByKey?: Readonly>; } const ScreenTransitionsAdapterContext = @@ -40,3 +41,7 @@ export function useScreenTransitionsAdapterContext() { } return context; } + +export function useScreenTransitionsAdapterOptionalContext() { + return useContext(ScreenTransitionsAdapterContext); +} diff --git a/packages/react-native-screen-transitions/src/shared/adapters/with-screen-transitions/stack-layout.tsx b/packages/react-native-screen-transitions/src/shared/adapters/with-screen-transitions/stack-layout.tsx index 9a5ed314..696dc5fe 100644 --- a/packages/react-native-screen-transitions/src/shared/adapters/with-screen-transitions/stack-layout.tsx +++ b/packages/react-native-screen-transitions/src/shared/adapters/with-screen-transitions/stack-layout.tsx @@ -7,8 +7,8 @@ import { } from "../../hooks/navigation/use-stack"; import { ScreenComposer } from "../../providers/screen/screen-composer"; import { - useStackCoreContext, - withStackCore, + StackCoreProvider, + useStackCoreStore, } from "../../providers/stack/core.provider"; import type { BaseStackDescriptor, BaseStackRoute } from "../../types"; import { StackType } from "../../types/stack.types"; @@ -102,6 +102,12 @@ function buildTransitionStackState({ } scenes.push({ + activity: + sceneIndex === state.index + ? "active" + : sceneIndex === state.index - 1 + ? "inert" + : "inactive", route, descriptor: normalizedDescriptor, previousDescriptor, @@ -137,7 +143,7 @@ function ScreenTransitionsStackContent({ layout, layoutArgs, }: ScreenTransitionsStackContentProps) { - const { flags } = useStackCoreContext(); + const flags = useStackCoreStore((store) => store.flags); const transitionState = useMemo( () => buildTransitionStackState({ @@ -183,10 +189,17 @@ function ScreenTransitionsStackContent({ ); } -export const ScreenTransitionsStackLayout = withStackCore( - { TRANSITIONS_ALWAYS_ON: false, STACK_TYPE: StackType.NATIVE }, - ScreenTransitionsStackContent, -); +export function ScreenTransitionsStackLayout( + props: ScreenTransitionsStackContentProps, +) { + return ( + + + + ); +} export function ScreenTransitionsScreenLayout({ screenLayout, diff --git a/packages/react-native-screen-transitions/src/shared/components/activity/variants/activity-screen.tsx b/packages/react-native-screen-transitions/src/shared/components/activity/variants/activity-screen.tsx index b8b7feea..6ff1e565 100644 --- a/packages/react-native-screen-transitions/src/shared/components/activity/variants/activity-screen.tsx +++ b/packages/react-native-screen-transitions/src/shared/components/activity/variants/activity-screen.tsx @@ -6,6 +6,7 @@ import { Screen } from "react-native-screens"; import { IS_WEB } from "../../../constants"; import { useStack } from "../../../hooks/navigation/use-stack"; import { useSharedValueState } from "../../../hooks/reanimated/use-shared-value-state"; +import { useBlankStackStore } from "../../../providers/stack/blank-stack.provider"; import { AnimationStore } from "../../../stores/animation.store"; import type { StackSceneActivity } from "../../../types/stack.types"; import { DEFAULT_INACTIVE_BEHAVIOR, type InactiveBehavior } from "../helpers"; @@ -26,11 +27,12 @@ const PointerEventsByActivity = { } satisfies Record; interface ActivityScreenProps { - activity: StackSceneActivity; + activity?: StackSceneActivity; children: React.ReactNode; inactiveBehavior?: InactiveBehavior; paintDriverRouteKey?: string; hasNestedState?: boolean; + routeKey?: string; } export const ActivityScreen = memo(function ActivityScreen({ @@ -39,10 +41,41 @@ export const ActivityScreen = memo(function ActivityScreen({ inactiveBehavior = DEFAULT_INACTIVE_BEHAVIOR, paintDriverRouteKey, hasNestedState, + routeKey, }: ActivityScreenProps) { + const stackActivity = useBlankStackStore((store) => + routeKey ? store?.scenesByKey[routeKey]?.activity : undefined, + ); + const stackInactiveBehavior = useBlankStackStore((store) => + routeKey + ? ( + store?.scenesByKey[routeKey]?.descriptor.options as + | { inactiveBehavior?: InactiveBehavior } + | undefined + )?.inactiveBehavior + : undefined, + ); + const stackRoute = useBlankStackStore((store) => + routeKey ? store?.scenesByKey[routeKey]?.route : undefined, + ); + const stackPaintDriverRouteKey = useBlankStackStore((store) => { + if (!routeKey || !store) { + return undefined; + } + + const routeIndex = store.routeKeys.indexOf(routeKey); + return store.routeKeys[routeIndex + 2]; + }); + const resolvedActivity = activity ?? stackActivity ?? "active"; + const resolvedInactiveBehavior = + inactiveBehavior ?? stackInactiveBehavior ?? DEFAULT_INACTIVE_BEHAVIOR; + const resolvedPaintDriverRouteKey = + paintDriverRouteKey ?? stackPaintDriverRouteKey; + const resolvedHasNestedState = + hasNestedState ?? (stackRoute ? "state" in stackRoute : false); const nativeScreenDisabled = useStack((s) => s.flags.DISABLE_NATIVE_SCREENS); - const paintDriverAnimations = paintDriverRouteKey - ? AnimationStore.getBag(paintDriverRouteKey) + const paintDriverAnimations = resolvedPaintDriverRouteKey + ? AnimationStore.getBag(resolvedPaintDriverRouteKey) : undefined; /** @@ -64,14 +97,14 @@ export const ActivityScreen = memo(function ActivityScreen({ const isPaintDriverSettledOnJS = useSharedValueState(isPaintDriverSettled); - let activityState: ActivityState = ActivityStateByActivity[activity]; + let activityState: ActivityState = ActivityStateByActivity[resolvedActivity]; let shouldFreeze = false; - let visible = activity !== "inactive"; + let visible = resolvedActivity !== "inactive"; const shouldWaitForPaintDriver = !isPaintDriverSettledOnJS; - if (activity === "inactive") { - if (inactiveBehavior === "keep") { + if (resolvedActivity === "inactive") { + if (resolvedInactiveBehavior === "keep") { activityState = 1; visible = true; } else if (shouldWaitForPaintDriver) { @@ -79,7 +112,7 @@ export const ActivityScreen = memo(function ActivityScreen({ // a blank frame during the transition. activityState = 1; visible = true; - } else if (inactiveBehavior === "pause") { + } else if (resolvedInactiveBehavior === "pause") { activityState = 1; shouldFreeze = true; visible = true; @@ -93,12 +126,12 @@ export const ActivityScreen = memo(function ActivityScreen({ } const shouldUnmount = - inactiveBehavior === "unmount" && - activity === "inactive" && - !hasNestedState && + resolvedInactiveBehavior === "unmount" && + resolvedActivity === "inactive" && + !resolvedHasNestedState && isPaintDriverSettledOnJS; - const pointerEvents = PointerEventsByActivity[activity]; + const pointerEvents = PointerEventsByActivity[resolvedActivity]; if (shouldUnmount) { return null; diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/components/boundary-target.tsx b/packages/react-native-screen-transitions/src/shared/components/boundary/components/boundary-target.tsx index 534f4faa..9a065909 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/components/boundary-target.tsx +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/components/boundary-target.tsx @@ -13,7 +13,7 @@ import { import { BoundaryPortal } from "../portal/components/boundary-portal"; import { TARGET_OUTSIDE_ROOT_WARNING, - useBoundaryRootContext, + useBoundaryRootStore, } from "../providers/boundary-root.provider"; type BoundaryTargetProps = Omit< @@ -36,7 +36,7 @@ const BoundaryTargetInner = (props: InternalBoundaryTargetProps) => { style, ...rest } = props; - const rootContext = useBoundaryRootContext(); + const rootContext = useBoundaryRootStore(); const boundaryId = rootContext?.boundTag.tag; const isActiveTarget = active === true && rootContext !== null; const portalRuntime = rootContext?.portalRuntime; diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/use-measurer.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/use-measurer.ts index e073f0d0..f9fbef70 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/use-measurer.ts +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/use-measurer.ts @@ -7,7 +7,7 @@ import { type StyleProps, } from "react-native-reanimated"; import { applyMeasuredBoundsWrites } from "../../../providers/helpers/measured-bounds-writes"; -import { useOriginContext } from "../../../providers/screen/origin.provider"; +import { useOriginStore } from "../../../providers/screen/origin.provider"; import { useScreenSlots } from "../../../providers/screen/styles"; import type { BoundTag } from "../../../stores/bounds/types"; import { ScrollStore } from "../../../stores/scroll.store"; @@ -49,7 +49,7 @@ export const useMeasurer = ({ currentScreenKey, "pendingLifecycleStartBlockCount", ); - const { originRef } = useOriginContext(); + const originRef = useOriginStore((store) => store.originRef); const { visibilityBlocked } = useScreenSlots(); return useCallback( diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/hooks/use-host-measurement.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/hooks/use-host-measurement.ts index fbb6cef8..39f77b3e 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/hooks/use-host-measurement.ts +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/hooks/use-host-measurement.ts @@ -12,7 +12,7 @@ import { withDelay, withTiming, } from "react-native-reanimated"; -import { useOriginContext } from "../../../../../../providers/screen/origin.provider"; +import { useOriginStore } from "../../../../../../providers/screen/origin.provider"; import { ScrollStore } from "../../../../../../stores/scroll.store"; import { getVisibilityBlockOffset } from "../../../../../../utils/visibility-block-offset"; import { @@ -49,7 +49,7 @@ export const useHostMeasurement = ({ const hostRef = useAnimatedRef(); const scrollMetadata = ScrollStore.getValue(screenKey, "metadata"); const [canRenderHosts, setCanRenderHosts] = useState(false); - const { originRef } = useOriginContext(); + const originRef = useOriginStore((store) => store.originRef); const hasMeasuredHost = useSharedValue(false); const retryToken = useSharedValue(0); diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/providers/boundary-root.provider.tsx b/packages/react-native-screen-transitions/src/shared/components/boundary/providers/boundary-root.provider.tsx index c83aec0e..278fb253 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/providers/boundary-root.provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/providers/boundary-root.provider.tsx @@ -14,6 +14,7 @@ import { useComposedSlotStyles, useSlotStackingStyles, } from "../../../providers/screen/styles"; +import { useBlankStackStore } from "../../../providers/stack/blank-stack.provider"; import { createBoundTag } from "../../../stores/bounds/helpers/link-pairs.helpers"; import type { BoundTag } from "../../../stores/bounds/types"; import createProvider from "../../../utils/create-provider"; @@ -64,14 +65,10 @@ type BoundaryRootProviderProps = Pick< targetStyle?: unknown; }; -export const { - BoundaryRootProvider, - useBoundaryRootContext, - useBoundaryRootStore, -} = createProvider("BoundaryRoot", { guarded: false })< - BoundaryRootProviderProps, - BoundaryRootContextValue ->( +export const { BoundaryRootProvider, useBoundaryRootStore } = createProvider( + "BoundaryRoot", + { guarded: false }, +)( ({ children, config, @@ -98,12 +95,12 @@ export const { const currentScreenKey = useDescriptorsStore( (s) => s.derivations.currentScreenKey, ); - const currentActivity = useDescriptorsStore( - (s) => s.descriptors.current.activity, + const isCurrentScreenClosing = useBlankStackStore( + (store) => store?.scenesByKey[currentScreenKey]?.activity === "closing", ); const retainedBoundTagRef = useRef(requestedBoundTag); const shouldRetainClosingBoundTag = - portalRuntime.handoff && currentActivity === "closing"; + portalRuntime.handoff && isCurrentScreenClosing; // Navigation can update a retained closing route's params before that // route leaves the stack. Keep its handoff identity stable so the payload diff --git a/packages/react-native-screen-transitions/src/shared/components/screen-container/hooks/use-backdrop-pointer-events.ts b/packages/react-native-screen-transitions/src/shared/components/screen-container/hooks/use-backdrop-pointer-events.ts index b66e04d6..ccc88de7 100644 --- a/packages/react-native-screen-transitions/src/shared/components/screen-container/hooks/use-backdrop-pointer-events.ts +++ b/packages/react-native-screen-transitions/src/shared/components/screen-container/hooks/use-backdrop-pointer-events.ts @@ -1,8 +1,8 @@ import { useState } from "react"; import { runOnJS, useAnimatedReaction } from "react-native-reanimated"; -import { useDescriptors } from "../../../providers/screen/descriptors"; -import { useScreenOptionsContext } from "../../../providers/screen/options"; -import { useStackCoreContext } from "../../../providers/stack/core.provider"; +import { useDescriptorsStore } from "../../../providers/screen/descriptors"; +import { useScreenOptionsStore } from "../../../providers/screen/options"; +import { useStackCoreStore } from "../../../providers/stack/core.provider"; import type { BackdropBehavior } from "../../../types/screen.types"; import { StackType } from "../../../types/stack.types"; @@ -21,9 +21,11 @@ interface BackdropPointerEventsResult { * - Other stacks default to 'block' (undefined = normal touch handling) */ export function useBackdropPointerEvents(): BackdropPointerEventsResult { - const { current } = useDescriptors(); - const screenOptions = useScreenOptionsContext(); - const { flags } = useStackCoreContext(); + const screenOptions = useScreenOptionsStore(); + const descriptorBackdropBehavior = useDescriptorsStore( + (store) => store.options.backdropBehavior, + ); + const stackType = useStackCoreStore((store) => store.flags.STACK_TYPE); const [runtimeBackdropBehavior, setRuntimeBackdropBehavior] = useState< BackdropBehavior | undefined >(undefined); @@ -39,10 +41,10 @@ export function useBackdropPointerEvents(): BackdropPointerEventsResult { [screenOptions], ); - const isComponentStack = flags.STACK_TYPE === StackType.COMPONENT; + const isComponentStack = stackType === StackType.COMPONENT; const backdropBehavior: BackdropBehavior = runtimeBackdropBehavior ?? - current.options.backdropBehavior ?? + descriptorBackdropBehavior ?? (isComponentStack ? "passthrough" : "block"); const pointerEvents = diff --git a/packages/react-native-screen-transitions/src/shared/components/screen-container/hooks/use-content-layout.ts b/packages/react-native-screen-transitions/src/shared/components/screen-container/hooks/use-content-layout.ts index 90903f23..06069541 100644 --- a/packages/react-native-screen-transitions/src/shared/components/screen-container/hooks/use-content-layout.ts +++ b/packages/react-native-screen-transitions/src/shared/components/screen-container/hooks/use-content-layout.ts @@ -1,10 +1,7 @@ import { useCallback } from "react"; import { type LayoutChangeEvent, useWindowDimensions } from "react-native"; import { runOnUI } from "react-native-reanimated"; -import { - useDescriptorDerivations, - useDescriptors, -} from "../../../providers/screen/descriptors"; +import { useDescriptorsStore } from "../../../providers/screen/descriptors"; import { AnimationStore } from "../../../stores/animation.store"; import { LifecycleTransitionRequestKind, @@ -12,10 +9,16 @@ import { } from "../../../stores/system.store"; export function useContentLayout() { - const { current } = useDescriptors(); - const { isFirstKey } = useDescriptorDerivations(); + const routeKey = useDescriptorsStore( + (store) => store.derivations.currentScreenKey, + ); + const isFirstKey = useDescriptorsStore( + (store) => store.derivations.isFirstKey, + ); + const experimental_animateOnInitialMount = useDescriptorsStore( + (store) => store.options.experimental_animateOnInitialMount, + ); const { height: screenHeight } = useWindowDimensions(); - const routeKey = current.route.key; const animations = AnimationStore.getBag(routeKey); const system = SystemStore.getBag(routeKey); @@ -23,9 +26,6 @@ export function useContentLayout() { system; const { requestLifecycleTransition } = system.actions; - const experimental_animateOnInitialMount = - current.options.experimental_animateOnInitialMount; - return useCallback( (event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; diff --git a/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/backdrop.tsx b/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/backdrop.tsx index cc3ff8e5..fb79dc01 100644 --- a/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/backdrop.tsx +++ b/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/backdrop.tsx @@ -3,7 +3,7 @@ import { Pressable, StyleSheet } from "react-native"; import Animated, { runOnUI } from "react-native-reanimated"; import { DefaultSnapSpec } from "../../../configs/specs"; import { useNavigationHelpers } from "../../../hooks/navigation/use-navigation-helpers"; -import { useDescriptors } from "../../../providers/screen/descriptors"; +import { useDescriptorsStore } from "../../../providers/screen/descriptors"; import { useSlotProps, useSlotStyles } from "../../../providers/screen/styles"; import { AnimationStore } from "../../../stores/animation.store"; import { GestureStore } from "../../../stores/gesture.store"; @@ -23,11 +23,23 @@ export const BackdropLayer = memo(function BackdropLayer({ backdropBehavior: BackdropBehavior; isBackdropActive: boolean; }) { - const { current } = useDescriptors(); const { dismissScreen } = useNavigationHelpers(); - const BackdropComponent = current.options.backdropComponent; - const routeKey = current.route.key; + const routeKey = useDescriptorsStore( + (store) => store.derivations.currentScreenKey, + ); + const BackdropComponent = useDescriptorsStore( + (store) => store.options.backdropComponent, + ); + const rawSnapPoints = useDescriptorsStore( + (store) => store.options.snapPoints, + ); + const canDismiss = useDescriptorsStore( + (store) => store.options.gestureEnabled !== false, + ); + const transitionSpec = useDescriptorsStore( + (store) => store.options.transitionSpec, + ); const animations = AnimationStore.getBag(routeKey); const { targetProgress, animationProgress, resolvedAutoSnapPoint } = SystemStore.getBag(routeKey); @@ -49,9 +61,6 @@ export const BackdropLayer = memo(function BackdropLayer({ } if (backdropBehavior === "collapse") { - const rawSnapPoints = current.options.snapPoints; - const canDismiss = current.options.gestureEnabled !== false; - // No snap points → fallback to dismiss if (!rawSnapPoints || rawSnapPoints.length === 0) { dismissScreen(); @@ -59,7 +68,6 @@ export const BackdropLayer = memo(function BackdropLayer({ } const gestures = GestureStore.getBag(routeKey); - const transitionSpec = current.options.transitionSpec; runOnUI(() => { "worklet"; @@ -109,7 +117,9 @@ export const BackdropLayer = memo(function BackdropLayer({ animationProgress, resolvedAutoSnapPoint, backdropBehavior, - current, + rawSnapPoints, + canDismiss, + transitionSpec, dismissScreen, routeKey, ]); diff --git a/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/content.tsx b/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/content.tsx index 4dcdcb4e..daf5a4b5 100644 --- a/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/content.tsx +++ b/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/content.tsx @@ -3,8 +3,8 @@ import { type ComponentType, memo, useMemo } from "react"; import { StyleSheet, View } from "react-native"; import { GestureDetector } from "react-native-gesture-handler"; import Animated from "react-native-reanimated"; -import { useDescriptors } from "../../../providers/screen/descriptors"; -import { useGestureContext } from "../../../providers/screen/gestures"; +import { useDescriptorsStore } from "../../../providers/screen/descriptors"; +import { useGestureStore } from "../../../providers/screen/gestures"; import { OriginProvider } from "../../../providers/screen/origin.provider"; import { useSlotProps, useSlotStyles } from "../../../providers/screen/styles"; import type { ScreenContentComponentProps } from "../../../types"; @@ -22,11 +22,16 @@ type Props = { export const ContentLayer = memo( ({ children, pointerEvents, isBackdropActive }: Props) => { - const { current } = useDescriptors(); - - const gestureContext = useGestureContext(); - const ContentComponent = current.options.contentComponent; - const isNavigationMaskEnabled = !!current.options.navigationMaskEnabled; + const gestureContext = useGestureStore(); + const ContentComponent = useDescriptorsStore( + (store) => store.options.contentComponent, + ); + const isNavigationMaskEnabled = useDescriptorsStore( + (store) => !!store.options.navigationMaskEnabled, + ); + const hasAutoSnapPoint = useDescriptorsStore( + (store) => store.options.snapPoints?.includes("auto") ?? false, + ); const contentPointerEvents = isBackdropActive ? "box-none" : pointerEvents; const AnimatedContentComponent = useMemo(() => { @@ -37,9 +42,6 @@ export const ContentLayer = memo( : null; }, [ContentComponent]); - const hasAutoSnapPoint = - current.options.snapPoints?.includes("auto") ?? false; - const handleContentLayout = useContentLayout(); const animatedContentStyle = useSlotStyles("content"); diff --git a/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/surface-container.tsx b/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/surface-container.tsx index c5b1d4f5..f8d30149 100644 --- a/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/surface-container.tsx +++ b/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/surface-container.tsx @@ -1,7 +1,7 @@ import { type ComponentType, memo, useMemo } from "react"; import { StyleSheet, type ViewProps } from "react-native"; import Animated from "react-native-reanimated"; -import { useDescriptors } from "../../../providers/screen/descriptors"; +import { useDescriptorsStore } from "../../../providers/screen/descriptors"; import { useSlotProps, useSlotStyles } from "../../../providers/screen/styles"; type Props = { @@ -10,10 +10,10 @@ type Props = { }; export const SurfaceContainer = memo(({ children, pointerEvents }: Props) => { - const { current } = useDescriptors(); - /** @deprecated Use `contentComponent` instead. */ - const DeprecatedSurfaceComponent = current.options.surfaceComponent; + const DeprecatedSurfaceComponent = useDescriptorsStore( + (store) => store.options.surfaceComponent, + ); const AnimatedSurfaceComponent = useMemo | null>(() => { return DeprecatedSurfaceComponent diff --git a/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-close-transition-intent.ts b/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-close-transition-intent.ts index c7945bd1..c1ca5962 100644 --- a/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-close-transition-intent.ts +++ b/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-close-transition-intent.ts @@ -3,10 +3,10 @@ import { useLayoutEffect, useMemo, useRef } from "react"; import useStableCallback from "../../../hooks/use-stable-callback"; import { type BaseDescriptor, - useDescriptorDerivations, + useDescriptorsStore, } from "../../../providers/screen/descriptors"; -import { useBlankStackContext } from "../../../providers/stack/blank-stack.provider"; -import { useStackCoreContext } from "../../../providers/stack/core.provider"; +import { useBlankStackStore } from "../../../providers/stack/blank-stack.provider"; +import { useStackCoreStore } from "../../../providers/stack/core.provider"; import { GestureStore } from "../../../stores/gesture.store"; import { LifecycleTransitionRequestKind, @@ -27,10 +27,15 @@ const useBlankStackClose = ({ requestLifecycleTransition, resetStores, }: CloseHookParams) => { - const { handleCloseRoute } = useBlankStackContext(); + const handleCloseRoute = useBlankStackStore( + (store) => store?.handleCloseRoute, + ); + const isClosing = useBlankStackStore( + (store) => store?.scenesByKey[current.route.key]?.activity === "closing", + ); useLayoutEffect(() => { - if (current.activity !== "closing") { + if (!isClosing) { return; } @@ -38,10 +43,13 @@ const useBlankStackClose = ({ LifecycleTransitionRequestKind.BlankStackClose, 0, ); - }, [current.activity, requestLifecycleTransition]); + }, [isClosing, requestLifecycleTransition]); const handleBlankStackCloseEnd = useStableCallback((finished: boolean) => { if (!finished) return; + if (!handleCloseRoute) { + throw new Error("Blank stack close handler was not found."); + } handleCloseRoute({ route: current.route }); requestAnimationFrame(() => { resetStores(); @@ -59,7 +67,9 @@ const useNativeStackClose = ({ requestLifecycleTransition, resetStores, }: CloseHookParams) => { - const { parentScreenKey } = useDescriptorDerivations(); + const parentScreenKey = useDescriptorsStore( + (store) => store.derivations.parentScreenKey, + ); const pendingActionRef = useRef(null); const nearestAncestorDismissing = useMemo(() => { @@ -121,8 +131,8 @@ export function useCloseTransitionIntent( handleNativeCloseEnd?: (finished: boolean) => void; } { const routeKey = current.route.key; - const { flags } = useStackCoreContext(); - const isNativeStack = flags.STACK_TYPE === StackType.NATIVE; + const stackType = useStackCoreStore((store) => store.flags.STACK_TYPE); + const isNativeStack = stackType === StackType.NATIVE; const { requestLifecycleTransition } = system.actions; const resetStores = useStableCallback(() => { diff --git a/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-open-transition-intent.ts b/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-open-transition-intent.ts index 8b7d1078..e092e129 100644 --- a/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-open-transition-intent.ts +++ b/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-open-transition-intent.ts @@ -1,7 +1,7 @@ import { useLayoutEffect } from "react"; import { type BaseDescriptor, - useDescriptorDerivations, + useDescriptorsStore, } from "../../../providers/screen/descriptors"; import type { AnimationStoreMap } from "../../../stores/animation.store"; import { @@ -41,7 +41,9 @@ export function useOpenTransitionIntent( animations: AnimationStoreMap, system: SystemStoreMap, ) { - const { isFirstKey } = useDescriptorDerivations(); + const isFirstKey = useDescriptorsStore( + (store) => store.derivations.isFirstKey, + ); const { requestLifecycleTransition } = system.actions; // biome-ignore lint/correctness/useExhaustiveDependencies: Must only run once on mount diff --git a/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/index.tsx b/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/index.tsx index f1ebdf88..4a7691df 100644 --- a/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/index.tsx +++ b/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/index.tsx @@ -1,4 +1,4 @@ -import { useDescriptors } from "../../providers/screen/descriptors"; +import { useDescriptorsStore } from "../../providers/screen/descriptors"; import { AnimationStore } from "../../stores/animation.store"; import { SystemStore } from "../../stores/system.store"; import { useScreenHistory } from "./hooks/history/use-screen-history"; @@ -15,7 +15,8 @@ interface Props { * Reads current/previous descriptors from DescriptorsProvider context. */ export const ScreenLifecycle = ({ children }: Props) => { - const { current, previous } = useDescriptors(); + const current = useDescriptorsStore((store) => store.current); + const previous = useDescriptorsStore((store) => store.previous); const animations = AnimationStore.getBag(current.route.key); const system = SystemStore.getBag(current.route.key); diff --git a/packages/react-native-screen-transitions/src/shared/hooks/navigation/use-navigation-helpers.ts b/packages/react-native-screen-transitions/src/shared/hooks/navigation/use-navigation-helpers.ts index 509287b9..5a04ba64 100644 --- a/packages/react-native-screen-transitions/src/shared/hooks/navigation/use-navigation-helpers.ts +++ b/packages/react-native-screen-transitions/src/shared/hooks/navigation/use-navigation-helpers.ts @@ -4,28 +4,29 @@ import { useDescriptorsStore } from "../../providers/screen/descriptors"; import { useStack } from "./use-stack"; export function useNavigationHelpers() { - const current = useDescriptorsStore((store) => store.descriptors.current); + const route = useDescriptorsStore((store) => store.current.route); + const navigation = useDescriptorsStore((store) => store.current.navigation); const requestStackDismiss = useStack((stack) => stack.requestDismiss); const requestDismiss = useCallback((): boolean => { - return requestStackDismiss?.({ route: current.route }) ?? false; - }, [current.route, requestStackDismiss]); + return requestStackDismiss?.({ route }) ?? false; + }, [route, requestStackDismiss]); const dismissScreen = useCallback((): boolean => { - const state = current.navigation.getState(); + const state = navigation.getState(); const routeIndex = state.routes.findIndex( - (route) => route.key === current.route.key, + (stateRoute) => stateRoute.key === route.key, ); const routeStillPresent = routeIndex !== -1; if (!routeStillPresent || routeIndex === 0) return false; - current.navigation.dispatch({ + navigation.dispatch({ ...StackActions.pop(), - source: current.route.key, + source: route.key, target: state.key, }); return true; - }, [current]); + }, [navigation, route.key]); return { dismissScreen, requestDismiss }; } diff --git a/packages/react-native-screen-transitions/src/shared/hooks/navigation/use-screen-state.ts b/packages/react-native-screen-transitions/src/shared/hooks/navigation/use-screen-state.ts index 58c91f9c..d3197ead 100644 --- a/packages/react-native-screen-transitions/src/shared/hooks/navigation/use-screen-state.ts +++ b/packages/react-native-screen-transitions/src/shared/hooks/navigation/use-screen-state.ts @@ -3,7 +3,7 @@ import { useCallback, useMemo } from "react"; import { snapDescriptorToIndex } from "../../animation/snap-to"; import { type BaseDescriptor, - useDescriptors, + useDescriptorsStore, } from "../../providers/screen/descriptors"; import type { ScreenTransitionConfig } from "../../types/screen.types"; import type { BaseStackNavigation } from "../../types/stack.types"; @@ -65,7 +65,9 @@ export function useScreenState< >(): ScreenState { const { routes, scenes, routeKeys, focusedIndex } = useStack(); - const { current } = useDescriptors(); + const current = useDescriptorsStore( + (store) => store.current, + ) as BaseDescriptor; const index = useMemo( () => routeKeys.indexOf(current.route.key), diff --git a/packages/react-native-screen-transitions/src/shared/providers/register-bounds.provider.tsx b/packages/react-native-screen-transitions/src/shared/providers/register-bounds.provider.tsx index 69532c45..31038539 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/register-bounds.provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/register-bounds.provider.tsx @@ -23,7 +23,7 @@ import { getDestination, getSource } from "../stores/bounds/internals/links"; import { prepareStyleForBounds } from "../utils/bounds/helpers/styles/styles"; import createProvider from "../utils/create-provider"; import { applyMeasuredBoundsWrites } from "./helpers/measured-bounds-writes"; -import { useDescriptorDerivations, useDescriptors } from "./screen/descriptors"; +import { useDescriptorsStore } from "./screen/descriptors"; /** * @deprecated Internal legacy provider for the old shared-bound-tag registration @@ -144,7 +144,9 @@ const useBlurMeasurement = (params: { ancestorKeys: string[]; maybeMeasureAndStore: (options: MaybeMeasureAndStoreParams) => void; }) => { - const { current } = useDescriptors(); + const currentScreenKey = useDescriptorsStore( + (store) => store.derivations.currentScreenKey, + ); const { enabled, sharedBoundTag, @@ -154,7 +156,7 @@ const useBlurMeasurement = (params: { } = params; const hasCapturedSource = useRef(false); - const ancestorClosing = [current.route.key, ...ancestorKeys].map((key) => + const ancestorClosing = [currentScreenKey, ...ancestorKeys].map((key) => AnimationStore.getValue(key, "closing"), ); @@ -212,19 +214,27 @@ const useParentSyncReaction = (params: { ); }; -let useRegisterBoundsContext: () => RegisterBoundsContextValue | null; - const registerBoundsBundle = createProvider("RegisterBounds", { guarded: false, })( - ({ style, onPress, sharedBoundTag, animatedRef, children }) => { - const { current, next } = useDescriptors(); - const { ancestorKeys, previousScreenKey } = useDescriptorDerivations(); - const currentScreenKey = current.route.key; - const selectedNextRouteId = getRouteParamId(next?.route); - + ( + { style, onPress, sharedBoundTag, animatedRef, children }, + { useParentStore }, + ) => { + const currentScreenKey = useDescriptorsStore( + (store) => store.derivations.currentScreenKey, + ); + const selectedNextRouteId = useDescriptorsStore((store) => + getRouteParamId(store.next?.route), + ); + const ancestorKeys = useDescriptorsStore( + (store) => store.derivations.ancestorKeys, + ); + const previousScreenKey = useDescriptorsStore( + (store) => store.derivations.previousScreenKey, + ); // Context & signals - const parentContext = useRegisterBoundsContext(); + const parentContext = useParentStore(); const ownSignal = useSharedValue(0); const updateSignal: SharedValue = @@ -397,6 +407,5 @@ const registerBoundsBundle = createProvider("RegisterBounds", { * for backwards compatibility with the older shared-bound-tag registration path. */ const RegisterBoundsProvider = registerBoundsBundle.RegisterBoundsProvider; -useRegisterBoundsContext = registerBoundsBundle.useRegisterBoundsContext; export { RegisterBoundsProvider }; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/animation.provider.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/animation.provider.tsx index 21c7e3f9..2e94d7d2 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/animation.provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/animation.provider.tsx @@ -1,10 +1,4 @@ -import { - type ReactNode, - useCallback, - useContext, - useLayoutEffect, - useMemo, -} from "react"; +import { type ReactNode, useCallback, useLayoutEffect, useMemo } from "react"; import { useSharedValue } from "react-native-reanimated"; import { createBoundsAccessor } from "../../../utils/bounds"; import createProvider from "../../../utils/create-provider"; @@ -34,178 +28,181 @@ export type ScreenAnimationContextResult = { value: ScreenAnimationContextValue; }; -export const { - ScreenAnimationProvider, - ScreenAnimationContext, - useScreenAnimationContext, -} = createProvider("ScreenAnimation", { - guarded: true, -})((): ScreenAnimationContextResult => { - const parentContext = useContext(ScreenAnimationContext); - const parentScreenInterpolatorProps = parentContext?.screenInterpolatorProps; - const parentScreenInterpolatorPropsRevision = - parentContext?.screenInterpolatorPropsRevision; - const parentAncestorScreenAnimationSources = - parentContext?.ancestorScreenAnimationSources; - const parentRegisterDescendantScreenAnimationSource = - parentContext?.registerDescendantScreenAnimationSource; - const parentAncestorDescendantScreenAnimationRegistrars = - parentContext?.ancestorDescendantScreenAnimationRegistrars; - - const { - screenInterpolatorProps, - screenInterpolatorPropsRevision, - selectedInterpolatorOptions, - nextInterpolator, - currentInterpolator, - } = useScreenAnimationPipeline(); - - const selfScreenAnimationSource = useMemo( - () => ({ - screenInterpolatorProps, - screenInterpolatorPropsRevision, - }), - [screenInterpolatorProps, screenInterpolatorPropsRevision], - ); - - const selfScreenAnimationTransitionSource = - useMemo( - () => ({ - ...selfScreenAnimationSource, - boundsAccessor: createBoundsAccessor(() => { - "worklet"; - return selfScreenAnimationSource.screenInterpolatorProps.get(); +export const { ScreenAnimationProvider, useScreenAnimationStore } = + createProvider("ScreenAnimation", { + guarded: true, + })( + (_props, { useParentStore }): ScreenAnimationContextResult => { + const parentContext = useParentStore(); + const parentScreenInterpolatorProps = + parentContext?.screenInterpolatorProps; + const parentScreenInterpolatorPropsRevision = + parentContext?.screenInterpolatorPropsRevision; + const parentAncestorScreenAnimationSources = + parentContext?.ancestorScreenAnimationSources; + const parentRegisterDescendantScreenAnimationSource = + parentContext?.registerDescendantScreenAnimationSource; + const parentAncestorDescendantScreenAnimationRegistrars = + parentContext?.ancestorDescendantScreenAnimationRegistrars; + + const { + screenInterpolatorProps, + screenInterpolatorPropsRevision, + selectedInterpolatorOptions, + nextInterpolator, + currentInterpolator, + } = useScreenAnimationPipeline(); + + const selfScreenAnimationSource = useMemo( + () => ({ + screenInterpolatorProps, + screenInterpolatorPropsRevision, }), - }), - [selfScreenAnimationSource], - ); - - const descendantScreenAnimationSources = useSharedValue< - ScreenAnimationDescendantSources["value"] - >([]); - - const registerDescendantScreenAnimationSource = - useCallback( - (source, depth) => { - descendantScreenAnimationSources.modify( - ( - currentSources: T, - ): T => { - "worklet"; - const existingIndex = currentSources.findIndex( - (currentSource) => currentSource.source === source, + [screenInterpolatorProps, screenInterpolatorPropsRevision], + ); + + const selfScreenAnimationTransitionSource = + useMemo( + () => ({ + ...selfScreenAnimationSource, + boundsAccessor: createBoundsAccessor(() => { + "worklet"; + return selfScreenAnimationSource.screenInterpolatorProps.get(); + }), + }), + [selfScreenAnimationSource], + ); + + const descendantScreenAnimationSources = useSharedValue< + ScreenAnimationDescendantSources["value"] + >([]); + + const registerDescendantScreenAnimationSource = + useCallback( + (source, depth) => { + descendantScreenAnimationSources.modify( + ( + currentSources: T, + ): T => { + "worklet"; + const existingIndex = currentSources.findIndex( + (currentSource) => currentSource.source === source, + ); + + if ( + existingIndex !== -1 && + currentSources[existingIndex]?.depth === depth + ) { + return currentSources; + } + + const nextSources = + existingIndex === -1 + ? [...currentSources, { source, depth }] + : currentSources.map((currentSource, index) => + index === existingIndex + ? { source, depth } + : currentSource, + ); + + return nextSources.sort((a, b) => a.depth - b.depth) as T; + }, ); - if ( - existingIndex !== -1 && - currentSources[existingIndex]?.depth === depth - ) { - return currentSources; - } - - const nextSources = - existingIndex === -1 - ? [...currentSources, { source, depth }] - : currentSources.map((currentSource, index) => - index === existingIndex ? { source, depth } : currentSource, - ); - - return nextSources.sort((a, b) => a.depth - b.depth) as T; + return () => { + descendantScreenAnimationSources.modify( + ( + currentSources: T, + ): T => { + "worklet"; + return currentSources.filter( + (currentSource) => currentSource.source !== source, + ) as T; + }, + ); + }; + }, + [descendantScreenAnimationSources], + ); + + const ancestorScreenAnimationSources = useMemo(() => { + if ( + !parentScreenInterpolatorProps || + !parentScreenInterpolatorPropsRevision || + !parentAncestorScreenAnimationSources + ) { + return []; + } + + return [ + { + screenInterpolatorProps: parentScreenInterpolatorProps, + screenInterpolatorPropsRevision: + parentScreenInterpolatorPropsRevision, + }, + ...parentAncestorScreenAnimationSources, + ]; + }, [ + parentScreenInterpolatorProps, + parentScreenInterpolatorPropsRevision, + parentAncestorScreenAnimationSources, + ]); + + const ancestorDescendantScreenAnimationRegistrars = useMemo(() => { + if (!parentRegisterDescendantScreenAnimationSource) { + return []; + } + + // Each provider exposes its own descendant registrar and forwards ancestor + // registrars, letting a mounted child register with every ancestor scope. + return [ + { + register: parentRegisterDescendantScreenAnimationSource, + depth: 1, }, + ...(parentAncestorDescendantScreenAnimationRegistrars ?? []).map( + (registrar) => ({ + register: registrar.register, + depth: registrar.depth + 1, + }), + ), + ]; + }, [ + parentRegisterDescendantScreenAnimationSource, + parentAncestorDescendantScreenAnimationRegistrars, + ]); + + useLayoutEffect(() => { + const cleanups = ancestorDescendantScreenAnimationRegistrars.map( + (registrar) => + registrar.register( + selfScreenAnimationTransitionSource, + registrar.depth, + ), ); return () => { - descendantScreenAnimationSources.modify( - ( - currentSources: T, - ): T => { - "worklet"; - return currentSources.filter( - (currentSource) => currentSource.source !== source, - ) as T; - }, - ); + for (const cleanup of cleanups) { + cleanup(); + } }; - }, - [descendantScreenAnimationSources], - ); - - const ancestorScreenAnimationSources = useMemo(() => { - if ( - !parentScreenInterpolatorProps || - !parentScreenInterpolatorPropsRevision || - !parentAncestorScreenAnimationSources - ) { - return []; - } - - return [ - { - screenInterpolatorProps: parentScreenInterpolatorProps, - screenInterpolatorPropsRevision: parentScreenInterpolatorPropsRevision, - }, - ...parentAncestorScreenAnimationSources, - ]; - }, [ - parentScreenInterpolatorProps, - parentScreenInterpolatorPropsRevision, - parentAncestorScreenAnimationSources, - ]); - - const ancestorDescendantScreenAnimationRegistrars = useMemo(() => { - if (!parentRegisterDescendantScreenAnimationSource) { - return []; - } - - // Each provider exposes its own descendant registrar and forwards ancestor - // registrars, letting a mounted child register with every ancestor scope. - return [ - { - register: parentRegisterDescendantScreenAnimationSource, - depth: 1, - }, - ...(parentAncestorDescendantScreenAnimationRegistrars ?? []).map( - (registrar) => ({ - register: registrar.register, - depth: registrar.depth + 1, - }), - ), - ]; - }, [ - parentRegisterDescendantScreenAnimationSource, - parentAncestorDescendantScreenAnimationRegistrars, - ]); - - useLayoutEffect(() => { - const cleanups = ancestorDescendantScreenAnimationRegistrars.map( - (registrar) => - registrar.register( - selfScreenAnimationTransitionSource, - registrar.depth, - ), - ); - - return () => { - for (const cleanup of cleanups) { - cleanup(); - } - }; - }, [ - ancestorDescendantScreenAnimationRegistrars, - selfScreenAnimationTransitionSource, - ]); - - return { - value: { - screenInterpolatorProps, - screenInterpolatorPropsRevision, - selectedInterpolatorOptions, - nextInterpolator, - currentInterpolator, - ancestorScreenAnimationSources, - descendantScreenAnimationSources, - registerDescendantScreenAnimationSource, - ancestorDescendantScreenAnimationRegistrars, + }, [ + ancestorDescendantScreenAnimationRegistrars, + selfScreenAnimationTransitionSource, + ]); + + return { + value: { + screenInterpolatorProps, + screenInterpolatorPropsRevision, + selectedInterpolatorOptions, + nextInterpolator, + currentInterpolator, + ancestorScreenAnimationSources, + descendantScreenAnimationSources, + registerDescendantScreenAnimationSource, + ancestorDescendantScreenAnimationRegistrars, + }, + }; }, - }; -}); + ); diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/accessors/use-build-bounds-accessor.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/accessors/use-build-bounds-accessor.tsx index 6578c573..e15d4914 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/accessors/use-build-bounds-accessor.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/accessors/use-build-bounds-accessor.tsx @@ -1,9 +1,11 @@ import { useMemo } from "react"; import { createBoundsAccessor } from "../../../../../utils/bounds"; -import { useScreenAnimationContext } from "../../animation.provider"; +import { useScreenAnimationStore } from "../../animation.provider"; export const useBuildBoundsAccessor = () => { - const { screenInterpolatorProps } = useScreenAnimationContext(); + const screenInterpolatorProps = useScreenAnimationStore( + (store) => store.screenInterpolatorProps, + ); return useMemo(() => { return createBoundsAccessor(() => { "worklet"; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/accessors/use-build-transition-accessor.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/accessors/use-build-transition-accessor.ts index 8ba91e32..33f5574f 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/accessors/use-build-transition-accessor.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/accessors/use-build-transition-accessor.ts @@ -4,7 +4,7 @@ import type { ScreenTransitionTarget, } from "../../../../../types/animation.types"; import { createBoundsAccessor } from "../../../../../utils/bounds"; -import { useScreenAnimationContext } from "../../animation.provider"; +import { useScreenAnimationStore } from "../../animation.provider"; import type { ScreenAnimationDescendantSources, ScreenAnimationSource, @@ -124,7 +124,7 @@ export const useBuildTransitionAccessor = () => { screenInterpolatorPropsRevision, ancestorScreenAnimationSources, descendantScreenAnimationSources, - } = useScreenAnimationContext(); + } = useScreenAnimationStore(); return useMemo(() => { const selfSource = { diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/pipeline.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/pipeline.ts index a37e418d..43349a14 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/pipeline.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/pipeline.ts @@ -18,7 +18,7 @@ import type { ScreenTransitionState, } from "../../../../types/animation.types"; -import { type BaseDescriptor, useDescriptors } from "../../descriptors"; +import { type BaseDescriptor, useDescriptorsStore } from "../../descriptors"; import { buildScreenTransitionOptions } from "./build-screen-transition-options"; import { updateDerivations } from "./derivations"; import { hasTransitionsEnabled } from "./has-transitions-enabled"; @@ -280,11 +280,9 @@ export function useScreenAnimationPipeline(): ScreenAnimationPipeline { const dimensions = useWindowDimensions(); const insets = useSafeAreaInsets(); - const { - current: currDescriptor, - next: nextDescriptor, - previous: prevDescriptor, - } = useDescriptors(); + const currDescriptor = useDescriptorsStore((store) => store.current); + const nextDescriptor = useDescriptorsStore((store) => store.next); + const prevDescriptor = useDescriptorsStore((store) => store.previous); const currentAnimation = useBuildTransitionState(currDescriptor); const nextAnimation = useBuildTransitionState(nextDescriptor); diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/index.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/index.tsx index b1269154..2f74dff3 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/index.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/index.tsx @@ -1,6 +1,6 @@ export { ScreenAnimationProvider, - useScreenAnimationContext, + useScreenAnimationStore, } from "./animation.provider"; export { type ScreenAnimationTarget, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/use-screen-animation.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/use-screen-animation.tsx index 146ebe68..c00feb97 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/use-screen-animation.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/use-screen-animation.tsx @@ -3,7 +3,7 @@ import type { ScreenInterpolationProps, ScreenTransitionTarget, } from "../../../types/animation.types"; -import { useScreenAnimationContext } from "./animation.provider"; +import { useScreenAnimationStore } from "./animation.provider"; import { useBuildTransitionAccessor } from "./helpers/accessors/use-build-transition-accessor"; import { readScreenAnimationRevisions } from "./helpers/read-screen-animation-revisions"; import type { @@ -37,7 +37,7 @@ export function useScreenAnimation( screenInterpolatorPropsRevision, ancestorScreenAnimationSources, descendantScreenAnimationSources, - } = useScreenAnimationContext(); + } = useScreenAnimationStore(); const transition = useBuildTransitionAccessor(); const transitionTarget = normalizeScreenAnimationTarget( target, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/descriptors/descriptors.provider.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/descriptors/descriptors.provider.tsx index d1da6e1e..bc2a118f 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/descriptors/descriptors.provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/descriptors/descriptors.provider.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from "react"; import { useMemo } from "react"; +import { useScreenTransitionsAdapterOptionalContext } from "../../../adapters/with-screen-transitions/context"; +import { useBlankStackStore } from "../../../providers/stack/blank-stack.provider"; import type { BaseStackDescriptor } from "../../../types/stack.types"; import createProvider from "../../../utils/create-provider"; import type { DescriptorDerivations } from "./helpers/derive-descriptor-derivations"; @@ -25,87 +27,96 @@ export type DescriptorDerivationsContextValue = DescriptorDerivations; interface DescriptorStoreValue< TDescriptor extends BaseDescriptor = BaseDescriptor, -> { +> extends DescriptorsContextValue { descriptors: DescriptorsContextValue; derivations: DescriptorDerivationsContextValue; + options: TDescriptor["options"]; } -interface DescriptorsProviderProps { +type DescriptorsProviderProps = { children: ReactNode; - previous?: TDescriptor; - current: TDescriptor; - next?: TDescriptor; -} - -const { - DescriptorsProvider: InternalDescriptorsProvider, - useDescriptorsStore, -} = createProvider("Descriptors", { guarded: true })< - DescriptorsProviderProps, - DescriptorStoreValue ->(({ previous, current, next, children }) => { - const descriptors = useMemo( - () => ({ previous, current, next }), - [previous, current, next], - ); - - const { ancestorKeys, ancestorDestinationPairKey } = useMemo( - () => getAncestorKeyState(current), - [current], - ); - - const derivations = useMemo( - () => - deriveDescriptorDerivations({ - previous, - current, - next, - ancestorKeys, - ancestorDestinationPairKey, - }), - [previous, current, next, ancestorKeys, ancestorDestinationPairKey], - ); + previous?: BaseDescriptor; + current?: BaseDescriptor; + next?: BaseDescriptor; + routeKey?: string; +}; - const value = useMemo( - () => ({ - descriptors, - derivations, - }), - [descriptors, derivations], - ); +export const { DescriptorsProvider, useDescriptorsStore } = createProvider( + "Descriptors", + { guarded: true }, +)>( + ({ previous, current, next, routeKey, children }) => { + const blankStackCurrent = useBlankStackStore((store) => + routeKey ? store?.scenesByKey[routeKey]?.descriptor : undefined, + ); + const blankStackPrevious = useBlankStackStore((store) => + routeKey ? store?.scenesByKey[routeKey]?.previousDescriptor : undefined, + ); + const blankStackNext = useBlankStackStore((store) => + routeKey ? store?.scenesByKey[routeKey]?.nextDescriptor : undefined, + ); + const adapterContext = useScreenTransitionsAdapterOptionalContext(); + const adapterScene = routeKey + ? (adapterContext?.scenesByKey?.[routeKey] ?? + adapterContext?.scenes[ + adapterContext.routeIndexByKey.get(routeKey) ?? -1 + ]) + : undefined; - return { - value, - children, - }; -}); + const resolvedCurrent = + current ?? blankStackCurrent ?? adapterScene?.descriptor; + const resolvedPrevious = + previous ?? blankStackPrevious ?? adapterScene?.previousDescriptor; + const resolvedNext = next ?? blankStackNext ?? adapterScene?.nextDescriptor; -export function DescriptorsProvider({ - children, - previous, - current, - next, -}: DescriptorsProviderProps) { - const providerProps = { - previous, - current, - next, - children, - }; + if (!resolvedCurrent) { + throw new Error( + `Descriptors scene "${routeKey ?? "unknown"}" was not found.`, + ); + } - return ; -} + const descriptors = useMemo( + () => ({ + previous: resolvedPrevious, + current: resolvedCurrent, + next: resolvedNext, + }), + [resolvedPrevious, resolvedCurrent, resolvedNext], + ); -export function useDescriptors< - TDescriptor extends BaseDescriptor = BaseDescriptor, ->() { - return useDescriptorsStore( - (store) => store.descriptors, - ) as DescriptorsContextValue; -} + const { ancestorKeys, ancestorDestinationPairKey } = useMemo( + () => getAncestorKeyState(resolvedCurrent), + [resolvedCurrent], + ); -export function useDescriptorDerivations(): DescriptorDerivationsContextValue { - return useDescriptorsStore((store) => store.derivations); -} + const derivations = useMemo( + () => + deriveDescriptorDerivations({ + previous: resolvedPrevious, + current: resolvedCurrent, + next: resolvedNext, + ancestorKeys, + ancestorDestinationPairKey, + }), + [ + resolvedPrevious, + resolvedCurrent, + resolvedNext, + ancestorKeys, + ancestorDestinationPairKey, + ], + ); -export { useDescriptorsStore }; + return { + value: { + previous: resolvedPrevious, + current: resolvedCurrent, + next: resolvedNext, + descriptors, + derivations, + options: resolvedCurrent.options, + }, + children, + }; + }, +); diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/descriptors/index.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/descriptors/index.tsx index d3517b64..b6fa87f2 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/descriptors/index.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/descriptors/index.tsx @@ -1,7 +1,5 @@ export { type BaseDescriptor, DescriptorsProvider, - useDescriptorDerivations, - useDescriptors, useDescriptorsStore, } from "./descriptors.provider"; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/gestures.provider.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/gestures.provider.tsx index 45382796..4f207aa2 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/gestures.provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/gestures.provider.tsx @@ -3,7 +3,7 @@ import { Gesture } from "react-native-gesture-handler"; import { useSharedValue } from "react-native-reanimated"; import { ScrollStore } from "../../../stores/scroll.store"; import createProvider from "../../../utils/create-provider"; -import { useDescriptorDerivations } from "../descriptors"; +import { useDescriptorsStore } from "../descriptors"; import { useScreenGestureConfig } from "./hooks/use-screen-gesture-config"; import { useWalkUpAndRegisterShadowingClaims } from "./ownership/use-walk-up-and-register-shadowing-claims"; import { useBuildPanGesture } from "./pan/use-build-pan-gesture"; @@ -19,74 +19,83 @@ interface ScreenGestureProviderProps { children: React.ReactNode; } -export const { - ScreenGestureProvider, - useScreenGestureContext: useGestureContext, -} = createProvider("ScreenGesture", { guarded: false })< - ScreenGestureProviderProps, - GestureContextType ->(({ children }): { value: GestureContextType; children: React.ReactNode } => { - const gestureContext = useGestureContext(); - const gestureConfig = useScreenGestureConfig(); - const { currentScreenKey } = useDescriptorDerivations(); +export const { ScreenGestureProvider, useScreenGestureStore: useGestureStore } = + createProvider("ScreenGesture", { guarded: false })< + ScreenGestureProviderProps, + GestureContextType + >( + ( + { children }, + { useParentStore }, + ): { value: GestureContextType; children: React.ReactNode } => { + const gestureContext = useParentStore(); + const gestureConfig = useScreenGestureConfig(); + const currentScreenKey = useDescriptorsStore( + (store) => store.derivations.currentScreenKey, + ); - const scrollState = ScrollStore.getValue(currentScreenKey, "coordination"); + const scrollState = ScrollStore.getValue( + currentScreenKey, + "coordination", + ); - // Ancestors read this before activating. If a nested screen claims the same - // direction, it writes here so the ancestor can fail and let it take priority. - const childDirectionClaims = - useSharedValue(NO_DIRECTION_CLAIMS); + // Ancestors read this before activating. If a nested screen claims the same + // direction, it writes here so the ancestor can fail and let it take priority. + const childDirectionClaims = + useSharedValue(NO_DIRECTION_CLAIMS); - // The first gesture to activate owns navigation release. Other gestures may - // still join as companion trackers during the same simultaneous composition. - const gestureCompositionOwner = useSharedValue(null); + // The first gesture to activate owns navigation release. Other gestures may + // still join as companion trackers during the same simultaneous composition. + const gestureCompositionOwner = + useSharedValue(null); - const panGesture = useBuildPanGesture({ - scrollState, - gestureConfig, - childDirectionClaims, - gestureCompositionOwner, - }); + const panGesture = useBuildPanGesture({ + scrollState, + gestureConfig, + childDirectionClaims, + gestureCompositionOwner, + }); - const pinchGesture = useBuildPinchGesture({ - gestureConfig, - gestureCompositionOwner, - }); + const pinchGesture = useBuildPinchGesture({ + gestureConfig, + gestureCompositionOwner, + }); - const detectorGesture = useMemo( - () => Gesture.Simultaneous(panGesture, pinchGesture), - [panGesture, pinchGesture], - ); + const detectorGesture = useMemo( + () => Gesture.Simultaneous(panGesture, pinchGesture), + [panGesture, pinchGesture], + ); - const value = useMemo( - () => ({ - routeKey: currentScreenKey, - detectorGesture, - panGesture, - pinchGesture, - scrollState, - gestureContext, - claimedDirections: gestureConfig.participation.claimedDirections, - childDirectionClaims, - }), - [ - currentScreenKey, - detectorGesture, - panGesture, - pinchGesture, - scrollState, - gestureContext, - gestureConfig.participation.claimedDirections, - childDirectionClaims, - ], - ); + const value = useMemo( + () => ({ + routeKey: currentScreenKey, + detectorGesture, + panGesture, + pinchGesture, + scrollState, + gestureContext, + claimedDirections: gestureConfig.participation.claimedDirections, + childDirectionClaims, + }), + [ + currentScreenKey, + detectorGesture, + panGesture, + pinchGesture, + scrollState, + gestureContext, + gestureConfig.participation.claimedDirections, + childDirectionClaims, + ], + ); - useWalkUpAndRegisterShadowingClaims( - gestureConfig.participation.claimedDirections, - ); + useWalkUpAndRegisterShadowingClaims( + gestureConfig.participation.claimedDirections, + ); - return { - value, - children, - }; -}); + return { + value, + children, + }; + }, + ); diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-screen-gesture-config.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-screen-gesture-config.ts index 0111d7f3..216d87db 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-screen-gesture-config.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-screen-gesture-config.ts @@ -1,16 +1,15 @@ import { useMemo } from "react"; -import { useDescriptorDerivations, useDescriptors } from "../../descriptors"; -import { useGestureContext } from "../gestures.provider"; +import { useDescriptorsStore } from "../../descriptors"; +import { useGestureStore } from "../gestures.provider"; import { resolveScreenGestureConfig } from "../shared/policy"; import type { ScreenGestureConfig } from "../types"; export function useScreenGestureConfig(): ScreenGestureConfig { - const gestureContext = useGestureContext(); - const { - current: { options }, - } = useDescriptors(); - - const { isFirstKey } = useDescriptorDerivations(); + const gestureContext = useGestureStore(); + const options = useDescriptorsStore((store) => store.options); + const isFirstKey = useDescriptorsStore( + (store) => store.derivations.isFirstKey, + ); return useMemo( () => diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-screen-gesture.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-screen-gesture.ts index 535db459..ce959d4b 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-screen-gesture.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-screen-gesture.ts @@ -2,7 +2,7 @@ import { type ChainTarget, resolveChainTarget, } from "../../../../utils/resolve-chain-target"; -import { useGestureContext } from "../gestures.provider"; +import { useGestureStore } from "../gestures.provider"; import { walkGestureAncestors } from "../shared/ancestors"; export type ScreenGestureTarget = ChainTarget; @@ -21,7 +21,7 @@ export type ScreenGestureTarget = ChainTarget; * ``` */ export const useScreenGesture = (target?: ScreenGestureTarget) => { - const ctx = useGestureContext(); + const ctx = useGestureStore(); return ( resolveChainTarget({ diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-stable-runtime-config.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-stable-runtime-config.ts index a63d7c0b..d64f6e47 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-stable-runtime-config.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-stable-runtime-config.ts @@ -3,7 +3,7 @@ import { type SharedValue, useSharedValue } from "react-native-reanimated"; import { AnimationStore } from "../../../../stores/animation.store"; import { GestureStore } from "../../../../stores/gesture.store"; import { SystemStore } from "../../../../stores/system.store"; -import { useDescriptorDerivations } from "../../descriptors"; +import { useDescriptorsStore } from "../../descriptors"; import type { GesturePolicy, GestureRuntime } from "../types"; type RuntimeConfigInput = Omit< @@ -14,7 +14,9 @@ type RuntimeConfigInput = Omit< export function useStableRuntimeConfig( runtimeConfigInput: RuntimeConfigInput, ): SharedValue> { - const { currentScreenKey } = useDescriptorDerivations(); + const currentScreenKey = useDescriptorsStore( + (store) => store.derivations.currentScreenKey, + ); const { participation, policy } = runtimeConfigInput; const stores = useMemo(() => { diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/index.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/index.tsx index 424466ab..7b388403 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/index.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/index.tsx @@ -1,6 +1,6 @@ export { ScreenGestureProvider, - useGestureContext, + useGestureStore, } from "./gestures.provider"; export type { DirectionClaim, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/shadowing-claims.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/shadowing-claims.ts index e9cbf2f9..c31d7a28 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/shadowing-claims.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/shadowing-claims.ts @@ -1,16 +1,15 @@ import type { ClaimedDirections } from "../../../../types/ownership.types"; -import type { StackSceneActivity } from "../../../../types/stack.types"; export const resolveShadowingClaimDirections = ({ - currentActivity, + isCurrentScreenClosing, currentClaimedDirections, previousClaimedDirections, }: { - currentActivity: StackSceneActivity | undefined; + isCurrentScreenClosing: boolean; currentClaimedDirections: ClaimedDirections; previousClaimedDirections: ClaimedDirections; }): ClaimedDirections => { - if (currentActivity === "closing") { + if (isCurrentScreenClosing) { return previousClaimedDirections; } diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/use-walk-up-and-register-shadowing-claims.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/use-walk-up-and-register-shadowing-claims.ts index 41cc1e75..7af2dab0 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/use-walk-up-and-register-shadowing-claims.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/use-walk-up-and-register-shadowing-claims.ts @@ -8,10 +8,10 @@ import { } from "../../../../types/ownership.types"; import { type BaseDescriptor, - useDescriptorDerivations, - useDescriptors, + useDescriptorsStore, } from "../../../screen/descriptors"; -import { useGestureContext } from "../gestures.provider"; +import { useBlankStackStore } from "../../../stack/blank-stack.provider"; +import { useGestureStore } from "../gestures.provider"; import { walkGestureAncestors } from "../shared/ancestors"; import { resolveScreenGestureConfig } from "../shared/policy"; import type { GestureContextType } from "../types"; @@ -135,9 +135,17 @@ const getDescriptorClaimedDirections = ( export function useWalkUpAndRegisterShadowingClaims( claimedDirections: ClaimedDirections, ): void { - const parentContext = useGestureContext(); - const { current, previous } = useDescriptors(); - const { isTopMostScreen, currentScreenKey } = useDescriptorDerivations(); + const parentContext = useGestureStore(); + const previous = useDescriptorsStore((store) => store.previous); + const isTopMostScreen = useDescriptorsStore( + (store) => store.derivations.isTopMostScreen, + ); + const currentScreenKey = useDescriptorsStore( + (store) => store.derivations.currentScreenKey, + ); + const isCurrentScreenClosing = useBlankStackStore( + (store) => store?.scenesByKey[currentScreenKey]?.activity === "closing", + ); /* * We want to calculate effective claimed directions as claimedDirections is not enough for us. @@ -161,14 +169,14 @@ export function useWalkUpAndRegisterShadowingClaims( const effectiveClaimedDirections = useMemo( () => resolveShadowingClaimDirections({ - currentActivity: current.activity, + isCurrentScreenClosing, currentClaimedDirections: claimedDirections, previousClaimedDirections: getDescriptorClaimedDirections( previous, parentContext, ), }), - [current.activity, claimedDirections, previous, parentContext], + [isCurrentScreenClosing, claimedDirections, previous, parentContext], ); const shadowedAncestors = useMemo( diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/activation/use-pan-activation.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/activation/use-pan-activation.ts index 4d2623a5..47e4c89d 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/activation/use-pan-activation.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/activation/use-pan-activation.ts @@ -7,7 +7,7 @@ import { type SharedValue, useSharedValue } from "react-native-reanimated"; import { GestureStore } from "../../../../../stores/gesture.store"; import { GestureActivationState } from "../../../../../types/gesture.types"; import type { Direction } from "../../../../../types/ownership.types"; -import { useDescriptorDerivations } from "../../../descriptors"; +import { useDescriptorsStore } from "../../../descriptors"; import type { ScreenOptionsContextValue } from "../../../options"; import { resolvePanRuntime } from "../../shared/runtime"; import type { @@ -36,7 +36,12 @@ export const usePanActivation = ({ dimensions, gestureCompositionOwner, }: UsePanActivationProps) => { - const { currentScreenKey, parentScreenKey } = useDescriptorDerivations(); + const currentScreenKey = useDescriptorsStore( + (store) => store.derivations.currentScreenKey, + ); + const parentScreenKey = useDescriptorsStore( + (store) => store.derivations.parentScreenKey, + ); const ancestorDismissing = useMemo(() => { if (!parentScreenKey) return null; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/use-build-pan-gesture.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/use-build-pan-gesture.ts index fc2ded00..7bca0c75 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/use-build-pan-gesture.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/use-build-pan-gesture.ts @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { useWindowDimensions } from "react-native"; import { Gesture } from "react-native-gesture-handler"; import type { SharedValue } from "react-native-reanimated"; -import { useScreenOptionsContext } from "../../options"; +import { useScreenOptionsStore } from "../../options"; import { useStableRuntimeConfig } from "../hooks/use-stable-runtime-config"; import type { DirectionClaimMap, @@ -29,7 +29,7 @@ export const useBuildPanGesture = ({ }: UseBuildPanGestureProps): PanGesture => { const dimensions = useWindowDimensions(); const { participation, pan: policy } = gestureConfig; - const screenOptions = useScreenOptionsContext(); + const screenOptions = useScreenOptionsStore(); const runtime = useStableRuntimeConfig({ participation, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pinch/use-build-pinch-gesture.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pinch/use-build-pinch-gesture.ts index 073eb541..31a0343c 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pinch/use-build-pinch-gesture.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pinch/use-build-pinch-gesture.ts @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { Gesture } from "react-native-gesture-handler"; import type { SharedValue } from "react-native-reanimated"; -import { useScreenOptionsContext } from "../../options"; +import { useScreenOptionsStore } from "../../options"; import { useStableRuntimeConfig } from "../hooks/use-stable-runtime-config"; import type { GestureCompositionOwner, @@ -21,7 +21,7 @@ export const useBuildPinchGesture = ({ gestureCompositionOwner, }: UseBuildPinchGestureProps): PinchGesture => { const { participation, pinch: policy } = gestureConfig; - const screenOptions = useScreenOptionsContext(); + const screenOptions = useScreenOptionsStore(); const runtime = useStableRuntimeConfig({ participation, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/scroll-coordination/scroll-metadata-owner.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/scroll-coordination/scroll-metadata-owner.tsx index b7959b0f..ced491f3 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/scroll-coordination/scroll-metadata-owner.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/scroll-coordination/scroll-metadata-owner.tsx @@ -16,19 +16,19 @@ const DEFAULT_SCROLL_METADATA_OWNER_CONTEXT: ScrollMetadataOwnerContextValue = { const { ScrollMetadataOwnerProvider, - useScrollMetadataOwnerContext: useMaybeScrollMetadataOwnerContext, + useScrollMetadataOwnerStore: useMaybeScrollMetadataOwnerStore, } = createProvider("ScrollMetadataOwner", { guarded: false })< ScrollMetadataOwnerProviderProps, ScrollMetadataOwnerContextValue >(({ children, value }) => ({ children, value })); -export const useScrollMetadataOwnerContext = () => - useMaybeScrollMetadataOwnerContext() ?? DEFAULT_SCROLL_METADATA_OWNER_CONTEXT; +export const useScrollMetadataOwnerStore = () => + useMaybeScrollMetadataOwnerStore() ?? DEFAULT_SCROLL_METADATA_OWNER_CONTEXT; export const useScrollMetadataOwnerProviderValue = ( axis: ScrollGestureAxis, ) => { - const parent = useScrollMetadataOwnerContext(); + const parent = useScrollMetadataOwnerStore(); return useMemo(() => { if (parent[axis]) return parent; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/scroll-coordination/use-scroll-gesture-coordination.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/scroll-coordination/use-scroll-gesture-coordination.ts index ab7ce6b7..906cfe64 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/scroll-coordination/use-scroll-gesture-coordination.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/scroll-coordination/use-scroll-gesture-coordination.ts @@ -10,7 +10,7 @@ import { useSharedValueState } from "../../../../hooks/reanimated/use-shared-val import useStableCallback from "../../../../hooks/use-stable-callback"; import { AnimationStore } from "../../../../stores/animation.store"; import { ScrollStore } from "../../../../stores/scroll.store"; -import { useGestureContext } from "../gestures.provider"; +import { useGestureStore } from "../gestures.provider"; import type { ScrollGestureAxis, ScrollGestureAxisState, @@ -18,8 +18,8 @@ import type { ScrollMetadataState, } from "../types"; import { - useScrollMetadataOwnerContext, useScrollMetadataOwnerProviderValue, + useScrollMetadataOwnerStore, } from "./scroll-metadata-owner"; import { clearScrollMetadataAxisState, @@ -88,10 +88,10 @@ const clearScrollMetadataAxis = ( export const useScrollGestureCoordination = ( props: ScrollGestureCoordinationProps, ) => { - const context = useGestureContext(); + const context = useGestureStore(); const scrollDirection = props.direction ?? "vertical"; - const metadataOwnerContext = useScrollMetadataOwnerContext(); + const metadataOwnerContext = useScrollMetadataOwnerStore(); const metadataOwnerProviderValue = useScrollMetadataOwnerProviderValue(scrollDirection); const isFirstMetadataWriterInTree = !metadataOwnerContext[scrollDirection]; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/options/index.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/options/index.ts index 58d0b850..b4ca91c7 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/options/index.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/options/index.ts @@ -1,6 +1,6 @@ export { syncScreenOptionsOverrides } from "./helpers"; export { ScreenOptionsProvider, - useScreenOptionsContext, + useScreenOptionsStore, } from "./options.provider"; export type { ScreenOptionsContextValue, ScreenOptionsSnapshot } from "./types"; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/options/options.provider.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/options/options.provider.tsx index 76236071..6306fd20 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/options/options.provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/options/options.provider.tsx @@ -1,7 +1,7 @@ import { useLayoutEffect, useMemo } from "react"; import { useSharedValue } from "react-native-reanimated"; import createProvider from "../../../utils/create-provider"; -import { useDescriptors } from "../descriptors"; +import { useDescriptorsStore } from "../descriptors"; import { resolveBaseScreenOptions, syncScreenOptionsBase } from "./helpers"; import type { ScreenOptionsContextValue, @@ -9,35 +9,31 @@ import type { ScreenOptionsState, } from "./types"; -export const { ScreenOptionsProvider, useScreenOptionsContext } = - createProvider("ScreenOptions")< - ScreenOptionsProviderProps, - ScreenOptionsContextValue - >(() => { - const { - current: { options }, - } = useDescriptors(); +export const { ScreenOptionsProvider, useScreenOptionsStore } = createProvider( + "ScreenOptions", +)(() => { + const options = useDescriptorsStore((store) => store.options); - const baseScreenOptions = useMemo( - () => resolveBaseScreenOptions(options), - [options], - ); + const baseScreenOptions = useMemo( + () => resolveBaseScreenOptions(options), + [options], + ); - const initialScreenOptions = useMemo( - () => ({ - ...baseScreenOptions, - baseOptions: baseScreenOptions, - }), - [baseScreenOptions], - ); + const initialScreenOptions = useMemo( + () => ({ + ...baseScreenOptions, + baseOptions: baseScreenOptions, + }), + [baseScreenOptions], + ); - const value = useSharedValue(initialScreenOptions); + const value = useSharedValue(initialScreenOptions); - useLayoutEffect(() => { - syncScreenOptionsBase(value, baseScreenOptions); - }, [value, baseScreenOptions]); + useLayoutEffect(() => { + syncScreenOptionsBase(value, baseScreenOptions); + }, [value, baseScreenOptions]); - return { - value, - }; - }); + return { + value, + }; +}); diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/origin.provider.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/origin.provider.tsx index 677fbea8..54a50e49 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/origin.provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/origin.provider.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { StyleSheet, type View } from "react-native"; import Animated, { type AnimatedRef, @@ -12,16 +13,13 @@ interface ContextValue { originRef: AnimatedRef; } -export const { OriginProvider, useOriginContext } = createProvider("Origin", { +export const { OriginProvider, useOriginStore } = createProvider("Origin", { guarded: true, })(({ children }) => { const originRef = useAnimatedRef(); - return { - value: { - originRef, - }, - children: ( + const content = useMemo( + () => ( ), + [originRef, children], + ); + return { + value: { + originRef, + }, + children: content, }; }); diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/screen-composer.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/screen-composer.tsx index a6ac01fd..790cccd4 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/screen-composer.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/screen-composer.tsx @@ -9,8 +9,9 @@ import { ScreenSlotProvider } from "./styles"; type Props = { previous?: TDescriptor; - current: TDescriptor; + current?: TDescriptor; next?: TDescriptor; + routeKey?: string; children: React.ReactNode; }; @@ -18,10 +19,16 @@ export function ScreenComposer({ previous, current, next, + routeKey, children, }: Props) { return ( - + diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/use-interpolated-style-maps.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/use-interpolated-style-maps.tsx index 826e0750..6927e52d 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/use-interpolated-style-maps.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/use-interpolated-style-maps.tsx @@ -12,7 +12,7 @@ import type { TransitionInterpolatedStyle, } from "../../../../types/animation.types"; import { logger } from "../../../../utils/logger"; -import { useScreenAnimationContext } from "../../animation"; +import { useScreenAnimationStore } from "../../animation"; import { useBuildBoundsAccessor } from "../../animation/helpers/accessors/use-build-bounds-accessor"; import { useBuildTransitionAccessor } from "../../animation/helpers/accessors/use-build-transition-accessor"; import type { ScreenInterpolatorFrame } from "../../animation/helpers/pipeline"; @@ -21,7 +21,7 @@ import { syncSelectedInterpolatorOptions } from "../../animation/helpers/selecte import { useDescriptorsStore } from "../../descriptors"; import { syncScreenOptionsOverrides, - useScreenOptionsContext, + useScreenOptionsStore, } from "../../options"; import { collectInterpolatorSharedValues } from "../helpers/collect-interpolator-shared-values"; import { normalizeSlots } from "../helpers/normalize-slots"; @@ -137,7 +137,7 @@ export const useInterpolatedStylesMap = () => { (s) => s.derivations.currentScreenKey, ); const nextScreenKey = useDescriptorsStore((s) => s.derivations.nextScreenKey); - const screenOptions = useScreenOptionsContext(); + const screenOptions = useScreenOptionsStore(); const { screenInterpolatorProps, screenInterpolatorPropsRevision, @@ -146,7 +146,7 @@ export const useInterpolatedStylesMap = () => { currentInterpolator, ancestorScreenAnimationSources, descendantScreenAnimationSources, - } = useScreenAnimationContext(); + } = useScreenAnimationStore(); const boundsAccessor = useBuildBoundsAccessor(); const transition = useBuildTransitionAccessor(); const nextInterpolatorReady = useSharedValue(0); diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/use-maybe-block-visibility.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/use-maybe-block-visibility.tsx index 6be81fa9..c911c06f 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/use-maybe-block-visibility.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/use-maybe-block-visibility.tsx @@ -8,13 +8,15 @@ import { import { AnimationStore } from "../../../../stores/animation.store"; import { SystemStore } from "../../../../stores/system.store"; import { getVisibilityBlockOffset } from "../../../../utils/visibility-block-offset"; -import { useDescriptorDerivations } from "../../descriptors"; +import { useDescriptorsStore } from "../../descriptors"; import { hasCloseTransitionFinished } from "../helpers/transition-visual-state"; import { resolveScreenVisibilityGate } from "../helpers/visibility-gate"; export const useMaybeBlockVisibility = (isFloatingOverlay?: boolean) => { const { height } = useWindowDimensions(); - const { currentScreenKey } = useDescriptorDerivations(); + const currentScreenKey = useDescriptorsStore( + (store) => store.derivations.currentScreenKey, + ); const { closing, entering } = AnimationStore.getBag(currentScreenKey); const { animationProgress, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/index.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/index.tsx index dd532bbe..728115fa 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/index.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/index.tsx @@ -7,7 +7,6 @@ export { } from "./hooks/slot-resolvers"; export { - ScreenSlotContext, type ScreenSlotName, ScreenSlotProvider, useScreenSlots, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/slot.provider.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/slot.provider.tsx index 4f0285c1..9c4282ec 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/slot.provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/slot.provider.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useContext, useLayoutEffect, useMemo } from "react"; +import { type ReactNode, useLayoutEffect, useMemo } from "react"; import { StyleSheet } from "react-native"; import Animated, { type SharedValue } from "react-native-reanimated"; import type { @@ -37,58 +37,68 @@ export type ScreenSlotContextValue = { visibilityBlocked: SharedValue; }; -export const { - ScreenSlotProvider, - ScreenSlotContext, - useScreenSlotContext: useScreenSlots, -} = createProvider("ScreenSlot", { - guarded: true, -})(({ children, isFloatingOverlay }) => { - const parentContext = useContext(ScreenSlotContext); - const currentScreenKey = useDescriptorsStore( - (s) => s.derivations.currentScreenKey, - ); +export const { ScreenSlotProvider, useScreenSlotStore: useScreenSlots } = + createProvider("ScreenSlot", { + guarded: true, + })( + ({ children, isFloatingOverlay }, { useParentStore }) => { + const parentContext = useParentStore(); + const currentScreenKey = useDescriptorsStore( + (s) => s.derivations.currentScreenKey, + ); - const { localStylesMaps, nextInterpolatorReady } = useInterpolatedStylesMap(); + const { localStylesMaps, nextInterpolatorReady } = + useInterpolatedStylesMap(); - const slotsMap = useResolvedStylesMap({ - localStylesMaps, - ancestorStylesMap: parentContext?.slotsMap, - }); - const { animatedStyle, animatedProps, shouldBlockVisibility } = - useMaybeBlockVisibility(isFloatingOverlay); - const value = useMemo( - () => ({ - localStylesMaps, - nextInterpolatorReady, - slotsMap, - visibilityBlocked: shouldBlockVisibility, - }), - [localStylesMaps, nextInterpolatorReady, shouldBlockVisibility, slotsMap], - ); + const slotsMap = useResolvedStylesMap({ + localStylesMaps, + ancestorStylesMap: parentContext?.slotsMap, + }); + const { animatedStyle, animatedProps, shouldBlockVisibility } = + useMaybeBlockVisibility(isFloatingOverlay); + const value = useMemo( + () => ({ + localStylesMaps, + nextInterpolatorReady, + slotsMap, + visibilityBlocked: shouldBlockVisibility, + }), + [ + localStylesMaps, + nextInterpolatorReady, + shouldBlockVisibility, + slotsMap, + ], + ); - useLayoutEffect(() => { - registerScreenSlots(currentScreenKey, value); + useLayoutEffect(() => { + registerScreenSlots(currentScreenKey, value); - return () => { - unregisterScreenSlots(currentScreenKey, value); - }; - }, [currentScreenKey, value]); + return () => { + unregisterScreenSlots(currentScreenKey, value); + }; + }, [currentScreenKey, value]); - return { - value, - children: ( - - - {children} - - - ), - }; -}); + const content = useMemo( + () => ( + + + {children} + + + ), + [children, animatedStyle, isFloatingOverlay, animatedProps], + ); + + return { + value, + children: content, + }; + }, + ); const styles = StyleSheet.create({ container: { flex: 1 }, diff --git a/packages/react-native-screen-transitions/src/shared/providers/stack/blank-stack-state/helpers/build-blank-stack-state.ts b/packages/react-native-screen-transitions/src/shared/providers/stack/blank-stack-state/helpers/build-blank-stack-state.ts index 4cc7cdb9..090e2f10 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/stack/blank-stack-state/helpers/build-blank-stack-state.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/stack/blank-stack-state/helpers/build-blank-stack-state.ts @@ -138,20 +138,6 @@ const getSceneActivityWindow = < }; }; -const withDescriptorActivityState = ( - descriptor: StackDescriptorSource, - activity: StackSceneActivity, -): TDescriptor => { - if (descriptor.activity === activity) { - return descriptor as TDescriptor; - } - - return { - ...descriptor, - activity, - } as TDescriptor; -}; - const buildBaseScenes = < TDescriptor extends BaseStackDescriptor, TNavigation extends BaseStackNavigation, @@ -207,10 +193,9 @@ const buildBaseScenes = < childStateUnchanged && previousDescriptor.route === sourceDescriptor.route && previousDescriptor.navigation === sourceDescriptor.navigation && - previousDescriptor.options === sourceDescriptor.options && - previousDescriptor.activity === activity + previousDescriptor.options === sourceDescriptor.options ? previousDescriptor - : withDescriptorActivityState(sourceDescriptor, activity); + : (sourceDescriptor as TDescriptor); routeKeys.push(route.key); routeChildStates[route.key] = routeChildState; @@ -222,6 +207,7 @@ const buildBaseScenes = < } scenes.push({ + activity, route: descriptor.route, descriptor, }); @@ -289,6 +275,7 @@ const withSceneRelationships = ({ if ( previousScene && + previousScene.activity === nextScene.activity && previousScene.route === nextScene.route && previousScene.descriptor === nextScene.descriptor && previousScene.previousDescriptor === nextScene.previousDescriptor && diff --git a/packages/react-native-screen-transitions/src/shared/providers/stack/blank-stack.provider.tsx b/packages/react-native-screen-transitions/src/shared/providers/stack/blank-stack.provider.tsx index 65c9fa1f..df726839 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/stack/blank-stack.provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/stack/blank-stack.provider.tsx @@ -1,51 +1,70 @@ import type { Route } from "@react-navigation/native"; -import * as React from "react"; -import { useMemo } from "react"; +import { type ReactNode, useMemo } from "react"; import { type StackContextValue, StackProvider, } from "../../hooks/navigation/use-stack"; import type { - BlankStackProviderContextValue, BlankStackProviderProps, - BlankStackProviderRenderProps, - BlankStackProviderResult, + BlankStackStoreValue, } from "../../types/providers/blank-stack-provider.types"; import type { BaseStackDescriptor, BaseStackNavigation, BaseStackScene, } from "../../types/stack.types"; +import createProvider from "../../utils/create-provider"; import { useBlankStackState } from "./blank-stack-state"; -import { useStackCoreContext } from "./core.provider"; +import { useStackCoreStore } from "./core.provider"; -const BlankStackProviderContext = - React.createContext(null); -BlankStackProviderContext.displayName = "BlankStackProvider"; +type InternalBlankStackProviderProps = BlankStackProviderProps< + BaseStackDescriptor, + BaseStackNavigation +> & { + children: ReactNode; +}; + +const createScenesByKey = ( + scenes: BaseStackScene[], +) => { + const scenesByKey: Record> = {}; -function useBlankStackContext(): BlankStackProviderContextValue { - const context = React.useContext(BlankStackProviderContext); - if (!context) { - throw new Error( - "useBlankStackContext must be used within BlankStackProvider", - ); + for (const scene of scenes) { + scenesByKey[scene.route.key] = scene; } - return context; -} -function useBlankStackProviderValue< - TDescriptor extends BaseStackDescriptor, - TNavigation extends BaseStackNavigation, ->( - props: BlankStackProviderProps, -): BlankStackProviderResult { - const { flags } = useStackCoreContext(); - const { state, handleCloseRoute, requestDismiss } = useBlankStackState(props); - const navigatorKey = props.state.key; + return scenesByKey; +}; - const focusedIndex = props.state.index; +type BlankStackStoreProviderProps = { + children: ReactNode; + value: BlankStackStoreValue; +}; - const stackContextValue = useMemo( +const { BlankStackProvider: BlankStackStoreProvider, useBlankStackStore } = + createProvider("BlankStack", { guarded: false })< + BlankStackStoreProviderProps, + BlankStackStoreValue + >(({ children, value }) => ({ children, value })); + +function BlankStackProvider({ + state: stackState, + children, + ...props +}: InternalBlankStackProviderProps) { + const flags = useStackCoreStore((store) => store.flags); + const { state, handleCloseRoute, requestDismiss } = useBlankStackState({ + ...props, + state: stackState, + }); + const navigatorKey = stackState.key; + const focusedIndex = stackState.index; + const scenesByKey = useMemo( + () => createScenesByKey(state.scenes), + [state.scenes], + ); + + const stackValue = useMemo( () => ({ flags, navigatorKey, @@ -65,54 +84,26 @@ function useBlankStackProviderValue< requestDismiss, ], ); + const blankStackValue = { + navigatorKey, + routeKeys: state.routeKeys, + routes: state.routes, + scenes: state.scenes, + scenesByKey, + focusedIndex, + requestDismiss, + shouldShowFloatOverlay: state.shouldShowFloatOverlay, + handleCloseRoute, + }; - const blankStackProviderContextValue = - useMemo( - () => ({ - handleCloseRoute, - }), - [handleCloseRoute], - ); - - const renderProps = useMemo>( - () => ({ - scenes: state.scenes, - focusedIndex, - shouldShowFloatOverlay: state.shouldShowFloatOverlay, - }), - [state.scenes, focusedIndex, state.shouldShowFloatOverlay], + return ( + + + {children} + + ); - - return { stackContextValue, blankStackProviderContextValue, renderProps }; -} - -function withBlankStack< - TDescriptor extends BaseStackDescriptor = BaseStackDescriptor, - TNavigation extends BaseStackNavigation = BaseStackNavigation, ->( - Component: React.ComponentType>, -): React.FC> { - return function BlankStackProvider( - props: BlankStackProviderProps, - ) { - const { stackContextValue, blankStackProviderContextValue, renderProps } = - useBlankStackProviderValue(props); - - return ( - - - - - - ); - }; } -export type { - BlankStackProviderContextValue, - BlankStackProviderProps, - BlankStackProviderRenderProps, -}; -export { useBlankStackContext, withBlankStack }; +export type { BlankStackProviderProps, BlankStackStoreValue }; +export { BlankStackProvider, useBlankStackStore }; diff --git a/packages/react-native-screen-transitions/src/shared/providers/stack/core.provider.tsx b/packages/react-native-screen-transitions/src/shared/providers/stack/core.provider.tsx index 52081ad6..9c8e01fb 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/stack/core.provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/stack/core.provider.tsx @@ -1,6 +1,5 @@ import { SafeAreaProviderCompat } from "@react-navigation/elements"; -import type * as React from "react"; -import { useMemo } from "react"; +import { memo, type ReactNode, useMemo } from "react"; import { StyleSheet } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { StackType } from "../../types/stack.types"; @@ -15,7 +14,7 @@ export interface StackCoreConfig { interface StackCoreProviderProps { config: StackCoreConfig; - children: React.ReactNode; + children: ReactNode; } export interface StackCoreContextValue { @@ -27,84 +26,55 @@ export interface StackCoreContextValue { }; } -const { StackCoreProvider: InternalStackCoreProvider, useStackCoreContext } = - createProvider("StackCore", { guarded: true })< - StackCoreProviderProps, - StackCoreContextValue - >(({ config, children }) => { - const { - TRANSITIONS_ALWAYS_ON = false, - DISABLE_NATIVE_SCREENS = false, - DISABLE_NATIVE_SCREEN_CONTAINER = false, - STACK_TYPE = StackType.BLANK, - } = config; +const StackCoreRoot = memo(function StackCoreRoot({ + children, + stackType, +}: { + children: ReactNode; + stackType: StackType; +}) { + return ( + + {children} + + ); +}); - const flags = useMemo( - () => ({ - TRANSITIONS_ALWAYS_ON, - STACK_TYPE, - DISABLE_NATIVE_SCREENS, - DISABLE_NATIVE_SCREEN_CONTAINER, - }), - [ - TRANSITIONS_ALWAYS_ON, - STACK_TYPE, - DISABLE_NATIVE_SCREENS, - DISABLE_NATIVE_SCREEN_CONTAINER, - ], - ); +export const { StackCoreProvider, useStackCoreStore } = createProvider( + "StackCore", + { guarded: true }, +)(({ config, children }) => { + const { + TRANSITIONS_ALWAYS_ON = false, + DISABLE_NATIVE_SCREENS = false, + DISABLE_NATIVE_SCREEN_CONTAINER = false, + STACK_TYPE = StackType.BLANK, + } = config; - return { - value: { flags }, - children: ( - - {children} - - ), - }; - }); + const flags = useMemo( + () => ({ + TRANSITIONS_ALWAYS_ON, + STACK_TYPE, + DISABLE_NATIVE_SCREENS, + DISABLE_NATIVE_SCREEN_CONTAINER, + }), + [ + TRANSITIONS_ALWAYS_ON, + STACK_TYPE, + DISABLE_NATIVE_SCREENS, + DISABLE_NATIVE_SCREEN_CONTAINER, + ], + ); -/** - * HOC that wraps a component with the StackCore provider. - * Just a simple open gate - */ -export function withStackCore( - defaultConfig: StackCoreConfig, - Component: React.ComponentType, -): React.FC { - return function StackCoreWrapper({ - DISABLE_NATIVE_SCREENS, - DISABLE_NATIVE_SCREEN_CONTAINER, - TRANSITIONS_ALWAYS_ON, - STACK_TYPE, - ...props - }: TProps & StackCoreConfig) { - // Start from defaults, then apply explicit overrides from the caller. - const config: StackCoreConfig = { - TRANSITIONS_ALWAYS_ON: - TRANSITIONS_ALWAYS_ON ?? defaultConfig.TRANSITIONS_ALWAYS_ON, - STACK_TYPE: STACK_TYPE ?? defaultConfig.STACK_TYPE, - DISABLE_NATIVE_SCREENS: - DISABLE_NATIVE_SCREENS ?? defaultConfig.DISABLE_NATIVE_SCREENS, - DISABLE_NATIVE_SCREEN_CONTAINER: - DISABLE_NATIVE_SCREEN_CONTAINER ?? - defaultConfig.DISABLE_NATIVE_SCREEN_CONTAINER, - }; - return ( - - - - ); + return { + value: { flags }, + children: {children}, }; -} +}); const styles = StyleSheet.create({ container: { flex: 1 }, }); - -export { useStackCoreContext }; diff --git a/packages/react-native-screen-transitions/src/shared/providers/stack/direct.provider.tsx b/packages/react-native-screen-transitions/src/shared/providers/stack/direct.provider.tsx index 5c77b5c3..f92c4a28 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/stack/direct.provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/stack/direct.provider.tsx @@ -1,5 +1,4 @@ -import type * as React from "react"; -import { useMemo } from "react"; +import { type ReactNode, useMemo } from "react"; import type { NativeStackDescriptorMap } from "../../../native-stack/types"; import { type StackContextValue, @@ -10,16 +9,34 @@ import type { DirectStackProps, DirectStackScene, } from "../../types/providers/direct-stack.types"; +import createProvider from "../../utils/create-provider"; import { isOverlayVisible } from "../../utils/overlay/visibility"; -import { useStackCoreContext } from "./core.provider"; +import { useStackCoreStore } from "./core.provider"; -function useDirectStackValue( - props: DirectStackProps, -): DirectStackContextValue & { stackContextValue: StackContextValue } { - const { state, navigation, descriptors, describe } = props; - const { flags } = useStackCoreContext(); - const navigatorKey = state.key; +type DirectStackProviderProps = DirectStackProps & { + children: ReactNode; +}; + +type DirectStackStoreProviderProps = { + children: ReactNode; + value: DirectStackContextValue; +}; +const { DirectStackProvider: DirectStackStoreProvider, useDirectStackStore } = + createProvider("DirectStack", { guarded: true })< + DirectStackStoreProviderProps, + DirectStackContextValue + >(({ children, value }) => ({ children, value })); + +function DirectStackProvider({ + state, + navigation, + descriptors, + describe, + children, +}: DirectStackProviderProps) { + const flags = useStackCoreStore((store) => store.flags); + const navigatorKey = state.key; const preloadedDescriptors = useMemo(() => { return state.preloadedRoutes.reduce( (acc, route) => { @@ -41,23 +58,41 @@ function useDirectStackValue( }; let shouldShowFloatOverlay = false; - for (const route of allRoutes) { + for (let index = 0; index < allRoutes.length; index++) { + const route = allRoutes[index]; + if (!route) { + throw new Error(`Missing native stack route at index ${index}.`); + } const descriptor = allDescriptors[route.key]; const isPreloaded = preloadedDescriptors[route.key] !== undefined && descriptors[route.key] === undefined; - scenes.push({ route, descriptor, isPreloaded }); + if (!descriptor) { + throw new Error( + `Missing native descriptor for route "${route.key}".`, + ); + } + + scenes.push({ + activity: + index === state.index + ? "active" + : index === state.index - 1 + ? "inert" + : "inactive", + route, + descriptor, + isPreloaded, + }); routeKeys.push(route.key); - if (!shouldShowFloatOverlay && descriptor) { - const options = descriptor.options; - if ( - options?.enableTransitions === true && - isOverlayVisible(options) - ) { - shouldShowFloatOverlay = true; - } + if ( + !shouldShowFloatOverlay && + descriptor.options?.enableTransitions === true && + isOverlayVisible(descriptor.options) + ) { + shouldShowFloatOverlay = true; } } @@ -70,13 +105,13 @@ function useDirectStackValue( }, [ state.routes, state.preloadedRoutes, + state.index, preloadedDescriptors, descriptors, ]); const focusedIndex = state.index; - - const stackContextValue = useMemo( + const stackValue = useMemo( () => ({ flags, navigatorKey, @@ -87,43 +122,28 @@ function useDirectStackValue( }), [flags, navigatorKey, routeKeys, allRoutes, scenes, focusedIndex], ); + const directStackValue = { + state, + navigation, + descriptors, + scenes, + focusedIndex, + shouldShowFloatOverlay, + }; - // DirectStack context value - const lifecycleValue = useMemo( - () => ({ - state, - navigation, - descriptors, - scenes, - focusedIndex, - shouldShowFloatOverlay, - }), - [ - state, - navigation, - descriptors, - scenes, - focusedIndex, - shouldShowFloatOverlay, - ], + return ( + + + {children} + + ); - - return { ...lifecycleValue, stackContextValue }; } -function withDirectStack( - Component: React.ComponentType, -): React.FC { - return function DirectStackProvider(props: TProps) { - const { stackContextValue, ...lifecycleValue } = useDirectStackValue(props); - - return ( - - - - ); - }; -} - -export type { DirectStackContextValue, DirectStackProps, DirectStackScene }; -export { withDirectStack }; +export type { + DirectStackContextValue, + DirectStackProps, + DirectStackProviderProps, + DirectStackScene, +}; +export { DirectStackProvider, useDirectStackStore }; diff --git a/packages/react-native-screen-transitions/src/shared/types/providers/blank-stack-provider.types.ts b/packages/react-native-screen-transitions/src/shared/types/providers/blank-stack-provider.types.ts index 394ddb94..7f250e2a 100644 --- a/packages/react-native-screen-transitions/src/shared/types/providers/blank-stack-provider.types.ts +++ b/packages/react-native-screen-transitions/src/shared/types/providers/blank-stack-provider.types.ts @@ -1,4 +1,3 @@ -import type { StackContextValue } from "../../hooks/navigation/use-stack"; import type { BaseStackDescriptor, BaseStackNavigation, @@ -10,7 +9,7 @@ import type { /** * Props for blank stack - generic over descriptor and navigation types. - * Defaults to base types for backward compatibility. + * Defaults to the shared base types. */ export interface BlankStackProviderProps< TDescriptor extends BaseStackDescriptor = BaseStackDescriptor, @@ -29,29 +28,16 @@ export interface BlankStackProviderProps< * Context value for blank stack — only fields unique to blank stack lifecycle. * Shared fields (routes, scenes, etc.) live in StackContext. */ -export interface BlankStackProviderContextValue { - handleCloseRoute: (payload: { route: BaseStackRoute }) => void; -} - -/** - * Props passed to the render child of `withBlankStack`. - * Only the fields that stack-view components actually consume. - */ -export interface BlankStackProviderRenderProps< +export interface BlankStackStoreValue< TDescriptor extends BaseStackDescriptor = BaseStackDescriptor, > { + navigatorKey: string; + routeKeys: string[]; + routes: TDescriptor["route"][]; scenes: BaseStackScene[]; + scenesByKey: Record>; focusedIndex: number; + requestDismiss: (payload: { route: BaseStackRoute }) => boolean; shouldShowFloatOverlay: boolean; -} - -/** - * Internal result shape returned by useBlankStackProviderValue. - */ -export interface BlankStackProviderResult< - TDescriptor extends BaseStackDescriptor = BaseStackDescriptor, -> { - stackContextValue: StackContextValue; - blankStackProviderContextValue: BlankStackProviderContextValue; - renderProps: BlankStackProviderRenderProps; + handleCloseRoute: (payload: { route: BaseStackRoute }) => void; } diff --git a/packages/react-native-screen-transitions/src/shared/types/providers/direct-stack.types.ts b/packages/react-native-screen-transitions/src/shared/types/providers/direct-stack.types.ts index 5af26207..3740866c 100644 --- a/packages/react-native-screen-transitions/src/shared/types/providers/direct-stack.types.ts +++ b/packages/react-native-screen-transitions/src/shared/types/providers/direct-stack.types.ts @@ -8,8 +8,10 @@ import type { NativeStackDescriptorMap, NativeStackNavigationHelpers, } from "../../../native-stack/types"; +import type { StackSceneActivity } from "../stack.types"; export interface DirectStackScene { + activity: StackSceneActivity; route: StackNavigationState["routes"][number]; descriptor: NativeStackDescriptor; isPreloaded: boolean; diff --git a/packages/react-native-screen-transitions/src/shared/types/stack.types.ts b/packages/react-native-screen-transitions/src/shared/types/stack.types.ts index ac194855..b3475581 100644 --- a/packages/react-native-screen-transitions/src/shared/types/stack.types.ts +++ b/packages/react-native-screen-transitions/src/shared/types/stack.types.ts @@ -47,15 +47,12 @@ export interface BaseStackDescriptor< route: TRoute; navigation: TNavigation; options: TOptions; - activity?: StackSceneActivity; render?: () => React.JSX.Element | null; } export type StackDescriptorSource< TDescriptor extends BaseStackDescriptor = BaseStackDescriptor, -> = Omit & { - activity?: StackSceneActivity; -}; +> = TDescriptor; /** * Base scene interface - route + descriptor pair. @@ -64,6 +61,7 @@ export type StackDescriptorSource< export interface BaseStackScene< TDescriptor extends BaseStackDescriptor = BaseStackDescriptor, > { + activity: StackSceneActivity; route: TDescriptor["route"]; descriptor: TDescriptor; previousDescriptor?: TDescriptor; diff --git a/packages/react-native-screen-transitions/src/shared/utils/create-provider.tsx b/packages/react-native-screen-transitions/src/shared/utils/create-provider.tsx index 5cdee20b..c01b68cb 100644 --- a/packages/react-native-screen-transitions/src/shared/utils/create-provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/utils/create-provider.tsx @@ -2,6 +2,18 @@ * THANK YOU @MatiPl01 * https://github.com/MatiPl01/react-native-sortables/blob/main/packages/react-native-sortables/src/providers/utils/createProvider.tsx * SUPER COOL AMAZING UTILITY + * + * Store-only provider: values propagate exclusively through a subscription + * store read with `use${Name}Store(selector)`. There is intentionally no raw + * context channel, so consumers subscribe only to the values they render. + * + * Factories do not memoize what they return: + * - `value`: the store shallow-compares snapshots and keeps the previous + * object when the contents are unchanged. Derived object and array fields + * must still have stable identities. + * - `children`: passed-through children keep their identity across + * provider-local renders. Factories that wrap children should use a + * module-level memoized component for the wrapper. */ import { createContext, @@ -9,13 +21,10 @@ import { useCallback, useContext, useLayoutEffect, - useMemo, useRef, useSyncExternalStore, } from "react"; -type InnerProviderComponent = React.FC<{ children: ReactNode }>; - type ProviderSnapshot< ContextValue, Guarded extends boolean, @@ -32,9 +41,8 @@ type ProviderStoreHook = { ): Selected; }; -type ProviderOptionalStoreHook = { - (): ContextValue | null; - (selector: (value: ContextValue | null) => Selected): Selected; +export type ProviderFactoryInternals = { + useParentStore: ProviderStoreHook; }; export interface ProviderStoreApi { @@ -53,6 +61,42 @@ const NullProviderStore: ProviderStoreApi = { subscribe: () => () => {}, }; +const shallowEqual = (a: unknown, b: unknown): boolean => { + if (Object.is(a, b)) { + return true; + } + + if ( + typeof a !== "object" || + a === null || + typeof b !== "object" || + b === null + ) { + return false; + } + + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + + if (aKeys.length !== bKeys.length) { + return false; + } + + for (const key of aKeys) { + if ( + !(key in b) || + !Object.is( + (a as Record)[key], + (b as Record)[key], + ) + ) { + return false; + } + } + + return true; +}; + const createProviderStore = ( initialSnapshot: ContextValue | null, ): MutableProviderStoreApi => { @@ -67,7 +111,7 @@ const createProviderStore = ( } }, setSnapshot: (nextSnapshot) => { - if (Object.is(snapshot, nextSnapshot)) { + if (shallowEqual(snapshot, nextSnapshot)) { return false; } @@ -88,36 +132,73 @@ export default function createProvider< Guarded extends boolean = true, >(name: ProviderName, options?: { guarded?: Guarded }) { return ( - factory: (props: ProviderProps) => { + factory: ( + props: ProviderProps, + internals: ProviderFactoryInternals, + ) => { value?: ContextValue; enabled?: boolean; - children?: - | ReactNode - | (( - innerProvider: { - [K in ProviderName as `${K}Provider`]: InnerProviderComponent; - }, - ) => ReactNode); + children?: ReactNode; }, ) => { const { guarded = true } = options ?? {}; const providerDisplayName = `${name}Provider`; - const innerProviderDisplayName = `${name}InnerProvider`; - - const Context = createContext(null); - Context.displayName = name; const StoreContext = createContext | null>( null, ); StoreContext.displayName = `${name}Store`; + const createStoreHook = (strict: boolean) => { + return ( + selector?: (value: ContextValue | null) => Selected, + ): Selected | ContextValue | null => { + const store = useContext(StoreContext); + const resolvedStore = + store ?? (NullProviderStore as ProviderStoreApi); + const selectorRef = useRef(selector); + selectorRef.current = selector; + + const getSelectedSnapshot = useCallback(() => { + if (strict && store === null) { + throw new Error( + `${name} store must be used within a ${name}Provider`, + ); + } + + const snapshot = resolvedStore.getSnapshot(); + + if (strict && snapshot === null) { + throw new Error( + `${name} store must be used within an enabled ${name}Provider`, + ); + } + + return selectorRef.current ? selectorRef.current(snapshot) : snapshot; + }, [resolvedStore, store]); + + return useSyncExternalStore( + resolvedStore.subscribe, + getSelectedSnapshot, + getSelectedSnapshot, + ); + }; + }; + + const useStoreSelector = createStoreHook(guarded); + const factoryInternals: ProviderFactoryInternals = { + useParentStore: createStoreHook(false) as ProviderStoreHook< + ContextValue, + false + >, + }; + const Provider: React.FC = (props) => { const { children = (props as { children?: ReactNode }).children, enabled = true, value, - } = factory(props); + } = factory(props, factoryInternals); if (!value) { throw new Error( @@ -125,23 +206,19 @@ export default function createProvider< ); } - const memoValue = useMemo( - () => (enabled ? value : null), - [enabled, value], - ); - + const snapshotValue = enabled ? value : null; const storeRef = useRef | null>( null, ); const pendingNotifyRef = useRef(false); if (storeRef.current === null) { - storeRef.current = createProviderStore(memoValue); + storeRef.current = createProviderStore(snapshotValue); } const store = storeRef.current; pendingNotifyRef.current = - store.setSnapshot(memoValue) || pendingNotifyRef.current; + store.setSnapshot(snapshotValue) || pendingNotifyRef.current; useLayoutEffect(() => { if (!pendingNotifyRef.current) { @@ -152,130 +229,22 @@ export default function createProvider< store.notify(); }); - // Per-instance ref ensures InnerProvider reads latest value while keeping - // a stable component reference. - const valueRef = useRef(memoValue); - valueRef.current = memoValue; - - const InnerProvider = useMemo((): InnerProviderComponent => { - const NamedInnerProvider: InnerProviderComponent = ({ children }) => ( - - - {children} - - - ); - - NamedInnerProvider.displayName = innerProviderDisplayName; - - return NamedInnerProvider; - }, [store]); - - if (typeof children === "function") { - return children({ - [`${name}Provider`]: InnerProvider, - } as { [K in ProviderName as `${K}Provider`]: InnerProviderComponent }); - } - return ( - - {children} - + {children} ); }; Provider.displayName = providerDisplayName; - const useEnhancedContext = (): ContextValue | null => { - const context = useContext(Context); - - if (guarded && context === null) { - throw new Error( - `${name} context must be used within a ${name}Provider`, - ); - } - - return context; - }; - - const useStoreSelector = ( - selector?: (value: ContextValue | null) => Selected, - ): Selected | ContextValue | null => { - const store = useContext(StoreContext); - const resolvedStore = - store ?? (NullProviderStore as ProviderStoreApi); - const selectorRef = useRef(selector); - selectorRef.current = selector; - - const getSelectedSnapshot = useCallback(() => { - if (guarded && store === null) { - throw new Error( - `${name} store must be used within a ${name}Provider`, - ); - } - - const snapshot = resolvedStore.getSnapshot(); - - if (guarded && snapshot === null) { - throw new Error( - `${name} store must be used within an enabled ${name}Provider`, - ); - } - - return selectorRef.current ? selectorRef.current(snapshot) : snapshot; - }, [resolvedStore, store]); - - return useSyncExternalStore( - resolvedStore.subscribe, - getSelectedSnapshot, - getSelectedSnapshot, - ); - }; - - const useOptionalStoreSelector = ( - selector?: (value: ContextValue | null) => Selected, - ): Selected | ContextValue | null => { - const store = useContext(StoreContext); - const resolvedStore = - store ?? (NullProviderStore as ProviderStoreApi); - const selectorRef = useRef(selector); - selectorRef.current = selector; - - const getSelectedSnapshot = useCallback(() => { - const snapshot = resolvedStore.getSnapshot(); - return selectorRef.current ? selectorRef.current(snapshot) : snapshot; - }, [resolvedStore]); - - return useSyncExternalStore( - resolvedStore.subscribe, - getSelectedSnapshot, - getSelectedSnapshot, - ); - }; - return { - [`${name}Context`]: Context, - [`${name}StoreContext`]: StoreContext, [`${name}Provider`]: Provider, - [`use${name}Context`]: useEnhancedContext, [`use${name}Store`]: useStoreSelector, - [`use${name}OptionalStore`]: useOptionalStoreSelector, } as { - [P in ProviderName as `${P}Context`]: React.Context; - } & { - [P in ProviderName as `${P}StoreContext`]: React.Context | null>; - } & { [P in ProviderName as `${P}Provider`]: React.FC; - } & { - [P in ProviderName as `use${P}Context`]: () => Guarded extends true - ? ContextValue - : ContextValue | null; } & { [P in ProviderName as `use${P}Store`]: ProviderStoreHook< ContextValue, Guarded >; - } & { - [P in ProviderName as `use${P}OptionalStore`]: ProviderOptionalStoreHook; }; }; } From e124c6051107b4c991610ae84f31c21892a06b3f Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 24 Jul 2026 05:40:03 -0400 Subject: [PATCH 02/12] refactor!: remove legacy bounds registration --- .../app/[stackType]/shared-x-image/[id].tsx | 8 +- .../[stackType]/shared-x-image/_layout.tsx | 2 +- .../app/[stackType]/shared-x-image/index.tsx | 21 +- .../src/content/docs/migrating-to-3-4.mdx | 4 +- .../docs/src/content/docs/surface-slots.mdx | 6 +- .../content/docs/transition-components.mdx | 5 +- .../create-transition-aware-component.tsx | 47 +- .../src/shared/configs/presets.ts | 26 +- .../providers/register-bounds.provider.tsx | 411 ------------------ .../src/shared/types/screen.types.ts | 32 -- 10 files changed, 45 insertions(+), 517 deletions(-) delete mode 100644 packages/react-native-screen-transitions/src/shared/providers/register-bounds.provider.tsx diff --git a/apps/e2e/app/[stackType]/shared-x-image/[id].tsx b/apps/e2e/app/[stackType]/shared-x-image/[id].tsx index 416f4d95..63ee7898 100644 --- a/apps/e2e/app/[stackType]/shared-x-image/[id].tsx +++ b/apps/e2e/app/[stackType]/shared-x-image/[id].tsx @@ -21,9 +21,11 @@ export default function SharedXImageDetail() { return ( - - - + + + + + ); } diff --git a/apps/e2e/app/[stackType]/shared-x-image/_layout.tsx b/apps/e2e/app/[stackType]/shared-x-image/_layout.tsx index 912d3a1f..248f12bf 100644 --- a/apps/e2e/app/[stackType]/shared-x-image/_layout.tsx +++ b/apps/e2e/app/[stackType]/shared-x-image/_layout.tsx @@ -25,7 +25,7 @@ export default function SharedXImageLayout() { options={{ headerShown: false, ...Transition.Presets.SharedXImage({ - sharedBoundTag: boundId, + id: boundId, }), }} /> diff --git a/apps/e2e/app/[stackType]/shared-x-image/index.tsx b/apps/e2e/app/[stackType]/shared-x-image/index.tsx index a66bfe8d..8b06dd19 100644 --- a/apps/e2e/app/[stackType]/shared-x-image/index.tsx +++ b/apps/e2e/app/[stackType]/shared-x-image/index.tsx @@ -70,9 +70,8 @@ function Post({ 12h - { router.push({ pathname: buildStackPath( @@ -86,13 +85,15 @@ function Post({ }); }} > - - + + + + diff --git a/packages/docs/src/content/docs/migrating-to-3-4.mdx b/packages/docs/src/content/docs/migrating-to-3-4.mdx index 423e3073..1cde0ec5 100644 --- a/packages/docs/src/content/docs/migrating-to-3-4.mdx +++ b/packages/docs/src/content/docs/migrating-to-3-4.mdx @@ -26,9 +26,7 @@ If you are already on v3.4 or v3.5 and want the current release changes, read [U ## Move New Bounds Flows To Transition.Boundary.* -`sharedBoundTag` on transition-aware components still exists, but it is the legacy path. - -For new work, use explicit boundary ownership: +Use explicit boundary ownership: ```tsx diff --git a/packages/docs/src/content/docs/surface-slots.mdx b/packages/docs/src/content/docs/surface-slots.mdx index f574de6b..4e6e577f 100644 --- a/packages/docs/src/content/docs/surface-slots.mdx +++ b/packages/docs/src/content/docs/surface-slots.mdx @@ -21,7 +21,7 @@ The layer keys are: - `NAVIGATION_MASK_CONTAINER_STYLE_ID` - `NAVIGATION_MASK_ELEMENT_STYLE_ID` -Any other key is treated as an element target and matched against a `styleId` or `sharedBoundTag` on a transition-aware component such as `Transition.View` or `Transition.Pressable`. +Any other key is treated as an element target and matched against a `styleId` on a transition-aware component such as `Transition.View` or `Transition.Pressable`. ## Layer Map @@ -59,7 +59,7 @@ Inside `screen-container`, layers are resolved in this order: 2. `content` 3. optional navigation mask wrapper when `navigationMaskEnabled` is enabled 4. deprecated optional surface wrapper when `surfaceComponent` is provided -5. screen children and any matching `styleId` or `sharedBoundTag` targets +5. screen children and any matching `styleId` targets That gives you two levels of control: @@ -193,8 +193,6 @@ return { These keys apply only to transition-aware components created by the library such as `Transition.View`, `Transition.Pressable`, `Transition.ScrollView`, and `Transition.FlatList`. -The same lookup path also works for `sharedBoundTag`, because the screen style system routes element styles by the component's associated id. - ## Navigation Mask Layers When `navigationMaskEnabled` is enabled and `@react-native-masked-view/masked-view` is installed, the content layer is wrapped in a masked container. diff --git a/packages/docs/src/content/docs/transition-components.mdx b/packages/docs/src/content/docs/transition-components.mdx index e80ebbb5..c10b71b9 100644 --- a/packages/docs/src/content/docs/transition-components.mdx +++ b/packages/docs/src/content/docs/transition-components.mdx @@ -107,7 +107,6 @@ Use transition-aware scrollables inside sheets and other gesture-driven screens ### Common Primitive Props - `styleId` for element targeting from `screenStyleInterpolator` -- `sharedBoundTag` for legacy shared-bound flows ## Boundary Components @@ -271,9 +270,7 @@ See [API](/api) for `useScreenAnimation()`, `useScreenGesture()`, `useHistory()` `Transition.MaskedView` is still exported for legacy masked-view flows. -`sharedBoundTag` still exists for older shared-bound presets. - -New work should prefer: +Prefer: - screen options on one of the stack creators - `Transition.Boundary` diff --git a/packages/react-native-screen-transitions/src/shared/components/create-transition-aware-component.tsx b/packages/react-native-screen-transitions/src/shared/components/create-transition-aware-component.tsx index ed790ad1..fa142ae7 100644 --- a/packages/react-native-screen-transitions/src/shared/components/create-transition-aware-component.tsx +++ b/packages/react-native-screen-transitions/src/shared/components/create-transition-aware-component.tsx @@ -1,14 +1,8 @@ /** biome-ignore-all lint/style/noNonNullAssertion: */ import type React from "react"; import { type ComponentType, forwardRef, memo } from "react"; -import type { View } from "react-native"; import { GestureDetector } from "react-native-gesture-handler"; -import Animated, { - runOnUI, - useAnimatedRef, - useComposedEventHandler, -} from "react-native-reanimated"; -import { RegisterBoundsProvider } from "../providers/register-bounds.provider"; +import Animated, { useComposedEventHandler } from "react-native-reanimated"; import { ScrollMetadataOwnerProvider, useScrollGestureCoordination, @@ -36,7 +30,6 @@ export function createTransitionAwareComponent

( TransitionAwareProps

>((props: any, ref) => { const { - remeasureOnFocus: _remeasureOnFocus, onScroll: userOnScroll, onMomentumScrollEnd: userOnMomentumScrollEnd, onScrollEndDrag: userOnScrollEndDrag, @@ -112,45 +105,27 @@ export function createTransitionAwareComponent

( const Inner = forwardRef< React.ComponentRef, TransitionAwareProps

- >((props, _) => { + >((props, ref) => { const { children, style, - sharedBoundTag, styleId, - onPress, - remeasureOnFocus, animatedProps: userAnimatedProps, ...rest } = props as any; - const animatedRef = useAnimatedRef(); - const associatedId = sharedBoundTag || styleId; - const associatedStyles = useSlotStyles(associatedId); - const associatedProps = useSlotProps(associatedId); + const associatedStyles = useSlotStyles(styleId); + const associatedProps = useSlotProps(styleId); return ( - - {({ captureActiveOnPress, handleInitialLayout }) => ( - - {children} - - )} - + {children} + ); }); diff --git a/packages/react-native-screen-transitions/src/shared/configs/presets.ts b/packages/react-native-screen-transitions/src/shared/configs/presets.ts index 2f449484..6e1ae32f 100644 --- a/packages/react-native-screen-transitions/src/shared/configs/presets.ts +++ b/packages/react-native-screen-transitions/src/shared/configs/presets.ts @@ -237,10 +237,10 @@ export const ElasticCard = ( }; export const SharedIGImage = ({ - sharedBoundTag, + id, ...config }: Partial & { - sharedBoundTag: string; + id: string; }): ScreenTransitionConfig => { return { gestureEnabled: true, @@ -278,11 +278,11 @@ export const SharedIGImage = ({ const navigationStyles = bounds({ - id: sharedBoundTag, + id, }).navigation.zoom() ?? {}; // Extract raw style values from bounds result (legacy format) - const sourceStyle = navigationStyles[sharedBoundTag] as + const sourceStyle = navigationStyles[id] as | Record | undefined; const containerStyle = navigationStyles[ @@ -347,7 +347,7 @@ export const SharedIGImage = ({ pointerEvents: current.gesture.dismissing ? "none" : "auto", }, }, - [sharedBoundTag]: { + [id]: { style: { ...(sourceStyle ?? {}), transform: [ @@ -384,10 +384,10 @@ export const SharedIGImage = ({ }; export const SharedAppleMusic = ({ - sharedBoundTag, + id, ...config }: Partial & { - sharedBoundTag: string; + id: string; }): ScreenTransitionConfig => { return { enableTransitions: true, @@ -434,7 +434,7 @@ export const SharedAppleMusic = ({ const dragXScale = interpolate(normX, [0, 1], xScaleOuput); const dragYScale = interpolate(normY, [0, 1], yScaleOuput); - const boundValues = bounds({ id: sharedBoundTag }).values({ + const boundValues = bounds({ id }).values({ method: focused ? "content" : "transform", anchor: "top", scaleMode: "uniform", @@ -453,7 +453,7 @@ export const SharedAppleMusic = ({ * =============================== */ if (focused) { - const maskedValues = bounds({ id: sharedBoundTag }).values({ + const maskedValues = bounds({ id }).values({ space: "absolute", method: "size", target: "fullscreen", @@ -545,7 +545,7 @@ export const SharedAppleMusic = ({ const contentScale = interpolate(progress, [1, 2], [1, 0.9], "clamp"); return { - [sharedBoundTag]: { + [id]: { style: { transform: [ { translateX: dragX || 0 }, @@ -593,10 +593,10 @@ export const SharedAppleMusic = ({ }; export const SharedXImage = ({ - sharedBoundTag, + id, ...config }: Partial & { - sharedBoundTag: string; + id: string; }): ScreenTransitionConfig => { return { //@ts-expect-error - Should not lead to any issues @@ -616,7 +616,7 @@ export const SharedXImage = ({ const navigationStyles = bounds({ - id: sharedBoundTag, + id, }).navigation.zoom() ?? {}; const maskStyle = navigationStyles[NAVIGATION_MASK_ELEMENT_STYLE_ID] as | Record diff --git a/packages/react-native-screen-transitions/src/shared/providers/register-bounds.provider.tsx b/packages/react-native-screen-transitions/src/shared/providers/register-bounds.provider.tsx deleted file mode 100644 index 31038539..00000000 --- a/packages/react-native-screen-transitions/src/shared/providers/register-bounds.provider.tsx +++ /dev/null @@ -1,411 +0,0 @@ -import { useFocusEffect } from "@react-navigation/native"; -import { type ReactNode, useCallback, useMemo, useRef } from "react"; -import type { View } from "react-native"; -import { - type AnimatedRef, - measure, - runOnJS, - runOnUI, - type StyleProps, - useAnimatedReaction, - useSharedValue, -} from "react-native-reanimated"; -import type { SharedValue } from "react-native-reanimated/lib/typescript/commonTypes"; -import useStableCallback from "../hooks/use-stable-callback"; -import useStableCallbackValue from "../hooks/use-stable-callback-value"; -import { AnimationStore } from "../stores/animation.store"; -import { - createPendingPairKey, - createScreenPairKey, -} from "../stores/bounds/helpers/link-pairs.helpers"; -import { getEntry } from "../stores/bounds/internals/entries"; -import { getDestination, getSource } from "../stores/bounds/internals/links"; -import { prepareStyleForBounds } from "../utils/bounds/helpers/styles/styles"; -import createProvider from "../utils/create-provider"; -import { applyMeasuredBoundsWrites } from "./helpers/measured-bounds-writes"; -import { useDescriptorsStore } from "./screen/descriptors"; - -/** - * @deprecated Internal legacy provider for the old shared-bound-tag registration - * path. Do not build new boundary behavior here; use the Transition.Boundary - * measurement pipeline instead. - */ -interface MaybeMeasureAndStoreParams { - onPress?: ((...args: unknown[]) => void) | undefined; - shouldSetSource?: boolean; - shouldSetDestination?: boolean; -} - -interface RegisterBoundsRenderProps { - handleInitialLayout: () => void; - captureActiveOnPress: () => void; -} - -interface RegisterBoundsProviderProps { - sharedBoundTag?: string; - animatedRef: AnimatedRef; - style: StyleProps; - onPress?: ((...args: unknown[]) => void) | undefined; - remeasureOnFocus?: boolean; - children: (props: RegisterBoundsRenderProps) => ReactNode; -} - -interface RegisterBoundsContextValue { - updateSignal: SharedValue; -} - -const getRouteParamId = ( - route: { params?: object } | undefined, -): string | null => { - const params = route?.params as Record | undefined; - const rawId = params?.id; - return typeof rawId === "string" ? rawId : null; -}; - -const matchesSelectionTag = ( - tag: string | undefined, - selectedId: string | null, -): boolean => { - "worklet"; - if (!tag || !selectedId) return false; - if (tag === selectedId) return true; - if (tag.endsWith(`:${selectedId}`)) return true; - if (tag.endsWith(`-${selectedId}`)) return true; - return false; -}; - -/** - * Handles initial layout measurement for destination elements. - * Only measures if an animation is in progress (local or parent). - */ -const useInitialLayoutHandler = (params: { - sharedBoundTag?: string; - currentScreenKey: string; - ancestorKeys: string[]; - maybeMeasureAndStore: (options: MaybeMeasureAndStoreParams) => void; -}) => { - const { - sharedBoundTag, - currentScreenKey, - ancestorKeys, - maybeMeasureAndStore, - } = params; - - const isAnimating = AnimationStore.getValue( - currentScreenKey, - "progressAnimating", - ); - - // Check if any ancestor is animating - const ancestorAnimations = ancestorKeys.map((key) => - AnimationStore.getValue(key, "progressAnimating"), - ); - - const hasMeasuredOnLayout = useSharedValue(false); - - return useCallback(() => { - "worklet"; - if (!sharedBoundTag || hasMeasuredOnLayout.get()) return; - - // Check if current or any ancestor is animating - let isAnyAnimating = isAnimating.get(); - for (let i = 0; i < ancestorAnimations.length; i++) { - if (ancestorAnimations[i].get()) { - isAnyAnimating = 1; - break; - } - } - - if (!isAnyAnimating) return; - - maybeMeasureAndStore({ - shouldSetSource: false, - shouldSetDestination: true, - }); - - hasMeasuredOnLayout.set(true); - }, [ - sharedBoundTag, - hasMeasuredOnLayout, - isAnimating, - ancestorAnimations, - maybeMeasureAndStore, - ]); -}; - -/** - * Measures non-pressable elements when screen becomes blurred. - * Captures bounds right before transition starts. - */ -const useBlurMeasurement = (params: { - enabled: boolean; - sharedBoundTag?: string; - selectedRouteId: string | null; - ancestorKeys: string[]; - maybeMeasureAndStore: (options: MaybeMeasureAndStoreParams) => void; -}) => { - const currentScreenKey = useDescriptorsStore( - (store) => store.derivations.currentScreenKey, - ); - const { - enabled, - sharedBoundTag, - selectedRouteId, - ancestorKeys, - maybeMeasureAndStore, - } = params; - const hasCapturedSource = useRef(false); - - const ancestorClosing = [currentScreenKey, ...ancestorKeys].map((key) => - AnimationStore.getValue(key, "closing"), - ); - - const maybeMeasureOnBlur = useStableCallbackValue(() => { - "worklet"; - if (!enabled) return; - if (!matchesSelectionTag(sharedBoundTag, selectedRouteId)) return; - - //.some doesnt work here apparently... :-( - for (const closing of ancestorClosing) { - if (closing.get()) return; - } - - maybeMeasureAndStore({ shouldSetSource: true }); - }); - - useFocusEffect( - useCallback(() => { - hasCapturedSource.current = false; - - if (!enabled) { - return; - } - - return () => { - if (!sharedBoundTag || hasCapturedSource.current) return; - runOnUI(maybeMeasureOnBlur)(); - }; - }, [enabled, sharedBoundTag, maybeMeasureOnBlur]), - ); - - return { - markSourceCaptured: () => { - hasCapturedSource.current = true; - }, - }; -}; - -/** - * Syncs child measurements when parent updates. - */ -const useParentSyncReaction = (params: { - parentContext: RegisterBoundsContextValue | null; - maybeMeasureAndStore: (options?: MaybeMeasureAndStoreParams) => void; -}) => { - const { parentContext, maybeMeasureAndStore } = params; - - useAnimatedReaction( - () => parentContext?.updateSignal.get(), - (value) => { - "worklet"; - if (value === 0 || value === undefined) return; - maybeMeasureAndStore(); - }, - ); -}; - -const registerBoundsBundle = createProvider("RegisterBounds", { - guarded: false, -})( - ( - { style, onPress, sharedBoundTag, animatedRef, children }, - { useParentStore }, - ) => { - const currentScreenKey = useDescriptorsStore( - (store) => store.derivations.currentScreenKey, - ); - const selectedNextRouteId = useDescriptorsStore((store) => - getRouteParamId(store.next?.route), - ); - const ancestorKeys = useDescriptorsStore( - (store) => store.derivations.ancestorKeys, - ); - const previousScreenKey = useDescriptorsStore( - (store) => store.derivations.previousScreenKey, - ); - // Context & signals - const parentContext = useParentStore(); - - const ownSignal = useSharedValue(0); - const updateSignal: SharedValue = - parentContext?.updateSignal ?? ownSignal; - - const isAnimating = AnimationStore.getValue( - currentScreenKey, - "progressAnimating", - ); - const preparedStyles = useMemo(() => prepareStyleForBounds(style), [style]); - - const emitUpdate = useStableCallbackValue(() => { - "worklet"; - const isRoot = !parentContext; - if (isRoot) updateSignal.set(updateSignal.get() + 1); - }); - - const maybeMeasureAndStore = useStableCallbackValue( - ({ - onPress, - shouldSetSource, - shouldSetDestination, - }: MaybeMeasureAndStoreParams = {}) => { - "worklet"; - if (!sharedBoundTag) { - if (onPress) runOnJS(onPress)(); - return; - } - - const sourcePairKey = createPendingPairKey(currentScreenKey); - const destinationPairKey = previousScreenKey - ? createScreenPairKey(previousScreenKey, currentScreenKey) - : undefined; - const pendingSourcePairKey = previousScreenKey - ? createPendingPairKey(previousScreenKey) - : undefined; - - if (shouldSetSource && isAnimating.get()) { - const existing = getEntry(sharedBoundTag, currentScreenKey); - if (existing?.bounds) { - applyMeasuredBoundsWrites({ - entryTag: sharedBoundTag, - linkId: sharedBoundTag, - currentScreenKey, - measured: existing.bounds, - preparedStyles, - linkWrite: { - type: "source", - pairKey: sourcePairKey, - }, - }); - } - - if (onPress) runOnJS(onPress)(); - return; - } - - const hasPendingSource = pendingSourcePairKey - ? getSource(pendingSourcePairKey, sharedBoundTag) !== null - : false; - const hasSource = - getSource(sourcePairKey, sharedBoundTag) !== null || - (destinationPairKey - ? getSource(destinationPairKey, sharedBoundTag) !== null - : false); - const hasDestination = destinationPairKey - ? getDestination(destinationPairKey, sharedBoundTag) !== null - : false; - - const wantsSetSource = !!shouldSetSource; - const wantsSetDestination = !!shouldSetDestination; - const wantsSnapshotOnly = !wantsSetSource && !wantsSetDestination; - - const canSetSource = wantsSetSource; - const canSetDestination = - wantsSetDestination && (hasPendingSource || hasSource); - const canSnapshotOnly = - wantsSnapshotOnly && - (hasPendingSource || hasSource || hasDestination); - - if (!canSetSource && !canSetDestination && !canSnapshotOnly) { - if (onPress) runOnJS(onPress)(); - return; - } - - const measured = measure(animatedRef); - if (!measured) { - if (onPress) runOnJS(onPress)(); - return; - } - - emitUpdate(); - - applyMeasuredBoundsWrites({ - entryTag: sharedBoundTag, - linkId: sharedBoundTag, - currentScreenKey, - measured, - preparedStyles, - linkWrite: canSetSource - ? { - type: "source", - pairKey: sourcePairKey, - } - : canSetDestination && destinationPairKey - ? { - type: "destination", - pairKey: destinationPairKey, - } - : undefined, - }); - - if (canSetSource && canSetDestination && destinationPairKey) { - applyMeasuredBoundsWrites({ - entryTag: sharedBoundTag, - linkId: sharedBoundTag, - currentScreenKey, - measured, - preparedStyles, - linkWrite: { - type: "destination", - pairKey: destinationPairKey, - }, - }); - } - - if (onPress) runOnJS(onPress)(); - }, - ); - - const handleInitialLayout = useInitialLayoutHandler({ - sharedBoundTag, - currentScreenKey, - ancestorKeys, - maybeMeasureAndStore, - }); - - // Side effects - const { markSourceCaptured } = useBlurMeasurement({ - enabled: !onPress, - sharedBoundTag, - selectedRouteId: selectedNextRouteId, - maybeMeasureAndStore, - ancestorKeys, - }); - - useParentSyncReaction({ parentContext, maybeMeasureAndStore }); - - const captureActiveOnPress = useStableCallback(() => { - if (!sharedBoundTag) { - onPress?.(); - return; - } - runOnUI(maybeMeasureAndStore)({ onPress, shouldSetSource: true }); - markSourceCaptured(); - }); - - return { - value: { updateSignal }, - children: <>{children({ handleInitialLayout, captureActiveOnPress })}, - }; - }, -); - -/** - * Legacy bounds registration provider used by transition-aware components. - * - * @deprecated Prefer the newer bounds system (`Transition.Boundary`, `bounds()`, - * and navigation-style bounds helpers) for new code. This provider remains only - * for backwards compatibility with the older shared-bound-tag registration path. - */ -const RegisterBoundsProvider = registerBoundsBundle.RegisterBoundsProvider; - -export { RegisterBoundsProvider }; diff --git a/packages/react-native-screen-transitions/src/shared/types/screen.types.ts b/packages/react-native-screen-transitions/src/shared/types/screen.types.ts index 1b88f0cd..0da609b8 100644 --- a/packages/react-native-screen-transitions/src/shared/types/screen.types.ts +++ b/packages/react-native-screen-transitions/src/shared/types/screen.types.ts @@ -169,38 +169,6 @@ export type TransitionAwareProps = AnimatedProps & { * } */ styleId?: string; - - /** - * Marks this component for measurement to enable shared element transitions. - * Components with the same tag across different screens will animate between each other. - * - * @example - * // Screen A: - * - * - * - * - * // Screen B: - * - * - * - * - * @deprecated Use `Transition.Boundary` with `id` instead. - */ - sharedBoundTag?: string; - - /** - * Re-measures this component when the screen regains focus and updates - * any matching shared-bound source link in place. - * - * Useful when layout can change while unfocused (for example, programmatic - * ScrollView/FlatList scrolling triggered from another screen). - * - * @deprecated Legacy `sharedBoundTag` refresh option. The boundary - * measurement pipeline refreshes layout through `Transition.Boundary`. - * @default false - */ - remeasureOnFocus?: boolean; }; export type ScreenTransitionConfig = { From c7bbfc199a5e2cfab88b326bc845dd9910fcf4d2 Mon Sep 17 00:00:00 2001 From: ed Date: Fri, 24 Jul 2026 12:38:51 -0400 Subject: [PATCH 03/12] fix: synchronize transition lifecycle state Own canonical motion and settlement in AnimationStore across entry, starts, interrupts, and resets. Keep willAnimate as a one-frame prestart pulse and start real non-entry motion on the UI clock. Preserve pan and pinch takeover, cancel/reset, and interrupted-entrance settlement. Refresh boundaries only at motion start and keep grouped source selection current. Require destination measurements to match the active entrance identity. Prevent target-bound geometry from being overwritten by transformed refresh measurements. Expose gesture initiator and retain deprecated compatibility fields with replacement guidance. Hydrate public animating and settled directly from canonical animation facts. Cover initial state, timing, snaps, gestures, groups, measurement, and target-bound zoom. Extend the Reanimated test clock for deferred completion callbacks. Route-keyed hydration optimization is intentionally excluded; this is the baseline state. --- .../bounds/measurement-rules.test.ts | 40 ++ .../__tests__/bounds/refresh-signals.test.ts | 161 +++++- .../bounds/zoom-bound-target.test.ts | 137 +++++ .../lifecycle/gesture-lifecycle-state.test.ts | 521 +++++++++++++++++- .../initial-interpolator-state.test.ts | 102 ++++ .../__tests__/transition-state-rules.test.ts | 21 +- .../src/shared/animation/snap-to.ts | 1 + .../hooks/lifecycles/use-refresh-boundary.ts | 23 - .../boundary/utils/refresh-signals.ts | 24 +- .../screen-container/layers/backdrop.tsx | 1 + .../hooks/use-open-transition-intent.ts | 1 + .../hooks/use-transition-start-controller.ts | 6 + .../src/shared/constants.ts | 1 + .../helpers/hydrate-transition-state/index.ts | 25 +- .../screen/animation/helpers/pipeline.ts | 6 +- .../gestures/pan/behavior/pan-lifecycle.ts | 24 +- .../screen/gestures/pan/behavior/pan-reset.ts | 14 + .../pinch/behavior/pinch-lifecycle.ts | 23 +- .../gestures/pinch/behavior/pinch-reset.ts | 10 + .../src/shared/stores/animation.store.ts | 19 + .../src/shared/types/gesture.types.ts | 11 + .../utils/animation/animate-to-progress.ts | 36 +- test/setup.ts | 11 +- 23 files changed, 1085 insertions(+), 133 deletions(-) create mode 100644 packages/react-native-screen-transitions/src/shared/__tests__/initial-interpolator-state.test.ts diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/bounds/measurement-rules.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/bounds/measurement-rules.test.ts index 1757b635..51a3b136 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/bounds/measurement-rules.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/bounds/measurement-rules.test.ts @@ -112,6 +112,46 @@ describe("bounds client measurement contract", () => { expect(getSignal()).toEqual({ pairKey, action: "complete" }); }); + it("requires a destination measurement for the current entrance identity", () => { + const pairKey = createScreenPairKey("screen-a", "screen-b:entrance-2"); + const getSignal = () => + getInitialDestinationMeasurementSignal({ + enabled: true, + destinationPairKey: pairKey, + linkId: "card", + destinationPresent: true, + sourcePresent: true, + linkState: pairs.get(), + }); + + // This destination came from the previous B route instance. It is a real + // link, but cannot complete the new A -> B entrance. + BoundStore.link.setSource( + pairKey, + "card", + "screen-a", + createBounds(), + ); + BoundStore.link.setDestination( + pairKey, + "card", + "screen-b:entrance-1", + createBounds(), + ); + + expect(getSignal()).toEqual({ pairKey, action: "measure" }); + + // One accepted measurement for this boundary identity completes this + // entrance. A later reaction observes completion rather than measuring again. + BoundStore.link.setDestination( + pairKey, + "card", + "screen-b:entrance-2", + createBounds(), + ); + expect(getSignal()).toEqual({ pairKey, action: "complete" }); + }); + it("auto source capture waits for destination then emits once", () => { const pairKey = createScreenPairKey("screen-a", "screen-b"); const measuredTargets: Array<{ type: "source"; pairKey: string }> = []; diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/bounds/refresh-signals.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/bounds/refresh-signals.test.ts index c4aa38ed..96c06515 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/bounds/refresh-signals.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/bounds/refresh-signals.test.ts @@ -1,6 +1,29 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { getRefreshBoundarySignal } from "../../components/boundary/utils/refresh-signals"; +import { applyMeasuredBoundsWrites } from "../../providers/helpers/measured-bounds-writes"; +import { startPanBase } from "../../providers/screen/gestures/pan/behavior/pan-lifecycle"; +import { AnimationStore } from "../../stores/animation.store"; +import { BoundStore } from "../../stores/bounds"; import { createScreenPairKey } from "../../stores/bounds/helpers/link-pairs.helpers"; +import { pairs } from "../../stores/bounds/internals/state"; +import { GestureStore } from "../../stores/gesture.store"; +import { SystemStore } from "../../stores/system.store"; + +const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + +beforeEach(() => { + pairs.set({}); + globalThis.requestAnimationFrame = (() => 1) as typeof requestAnimationFrame; +}); + +afterEach(() => { + if (originalRequestAnimationFrame) { + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + return; + } + + Reflect.deleteProperty(globalThis, "requestAnimationFrame"); +}); describe("refresh boundary signals", () => { it("keeps destination refreshes for close retargeting", () => { @@ -14,10 +37,6 @@ describe("refresh boundary signals", () => { linkId: "card", shouldRefresh: true, closing: true, - entering: false, - animating: false, - progress: 1, - gestureInProgress: false, }), ).toEqual({ type: "destination", @@ -25,4 +44,136 @@ describe("refresh boundary signals", () => { signal: "destination|screen-a<>screen-b|screen-b|closing", }); }); + + it("refreshes the active grouped source before an interactive close", () => { + const pairKey = createScreenPairKey("palette", "detail"); + + expect( + getRefreshBoundarySignal({ + enabled: true, + currentScreenKey: "detail", + sourcePairKey: pairKey, + linkId: "hot-coral", + group: "colors", + shouldRefresh: true, + closing: false, + linkState: { + [pairKey]: { + links: {}, + groups: { + colors: { + activeId: "hot-coral", + initialId: "electric-violet", + }, + }, + }, + }, + }), + ).toEqual({ + type: "source", + pairKey, + signal: `source|${pairKey}|colors|hot-coral|settled`, + }); + }); + + it("refreshes and resolves the active group member at the interactive-dismiss pulse", () => { + const pairKey = createScreenPairKey("palette", "detail"); + const routeKey = "detail-boundary-refresh"; + const animations = AnimationStore.getBag(routeKey); + const gestures = GestureStore.getBag(routeKey); + const system = SystemStore.getBag(routeKey); + const runtime = { + participation: { canDismiss: true, effectiveSnapPoints: {} }, + policy: { gestureReleaseVelocityScale: 1 }, + stores: { animations, gestures, system }, + }; + const electricViolet = { + x: 24, + y: 48, + pageX: 24, + pageY: 48, + width: 80, + height: 80, + }; + const hotCoral = { + x: 132, + y: 216, + pageX: 132, + pageY: 216, + width: 80, + height: 80, + }; + + try { + BoundStore.link.setSource( + pairKey, + "electric-violet", + "palette", + electricViolet, + {}, + "colors", + ); + BoundStore.link.setActiveGroupId( + pairKey, + "colors", + "electric-violet", + ); + BoundStore.link.setActiveGroupId(pairKey, "colors", "hot-coral"); + + expect( + BoundStore.link.getPair("colors:hot-coral", { + entering: false, + currentScreenKey: "palette", + nextScreenKey: "detail", + }).sourceBounds, + ).toEqual(electricViolet); + + gestures.active.set("vertical"); + startPanBase(runtime as any); + + const signal = getRefreshBoundarySignal({ + enabled: true, + currentScreenKey: "palette", + nextScreenKey: routeKey, + sourcePairKey: pairKey, + linkId: "hot-coral", + group: "colors", + shouldRefresh: animations.willAnimate.get() === 1, + closing: false, + linkState: pairs.get(), + }); + + expect(signal).toEqual({ + type: "source", + pairKey, + signal: `source|${pairKey}|colors|hot-coral|settled`, + }); + + if (!signal) { + throw new Error("Expected a source refresh signal for Hot Coral"); + } + + applyMeasuredBoundsWrites({ + entryTag: "colors:hot-coral", + linkId: "hot-coral", + group: "colors", + currentScreenKey: "palette", + measured: hotCoral, + preparedStyles: {}, + linkWrite: signal, + }); + + expect( + BoundStore.link.getPair("colors:hot-coral", { + entering: false, + currentScreenKey: "palette", + nextScreenKey: "detail", + }).sourceBounds, + ).toEqual(hotCoral); + } finally { + AnimationStore.clearBag(routeKey); + GestureStore.clearBag(routeKey); + SystemStore.clearBag(routeKey); + } + }); }); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/bounds/zoom-bound-target.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/bounds/zoom-bound-target.test.ts index b4ba0676..d7749739 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/bounds/zoom-bound-target.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/bounds/zoom-bound-target.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it } from "bun:test"; +import { makeMutable } from "react-native-reanimated"; +import { getInitialDestinationMeasurementSignal } from "../../components/boundary/utils/destination-signals"; +import { getRefreshBoundarySignal } from "../../components/boundary/utils/refresh-signals"; import { getInitialSourceCaptureSignal } from "../../components/boundary/utils/source-signals"; import { NAVIGATION_MASK_ELEMENT_STYLE_ID } from "../../constants"; +import { applyMeasuredBoundsWrites } from "../../providers/helpers/measured-bounds-writes"; +import { AnimationStore } from "../../stores/animation.store"; import { BoundStore } from "../../stores/bounds"; import { createScreenPairKey } from "../../stores/bounds/helpers/link-pairs.helpers"; import { pairs } from "../../stores/bounds/internals/state"; @@ -9,6 +14,7 @@ import { getZoomContentAnchor, getZoomContentTarget, } from "../../utils/bounds/navigation/zoom/targets"; +import { animateToProgress } from "../../utils/animation/animate-to-progress"; const SCREEN_LAYOUT = { width: 390, height: 844 }; const PAIR_KEY = createScreenPairKey("screen-a", "screen-b"); @@ -140,6 +146,35 @@ beforeEach(() => { }); describe("zoom bound target", () => { + it("does not refresh completed target bounds during an initial entrance", () => { + registerSource(); + registerDestination(); + const animations = AnimationStore.getBag("screen-b"); + + animateToProgress({ + target: "open", + emitWillAnimate: false, + animations, + targetProgress: makeMutable(0), + animationProgress: makeMutable(0), + }); + + const refresh = getRefreshBoundarySignal({ + enabled: true, + currentScreenKey: "screen-b", + destinationPairKey: PAIR_KEY, + linkId: "card", + shouldRefresh: animations.willAnimate.get() === 1, + closing: false, + linkState: pairs.get(), + }); + + expect(refresh).toBeNull(); + expect(BoundStore.link.getDestination(PAIR_KEY, "card")?.bounds).toEqual( + DESTINATION_BOUNDS, + ); + }); + it("keeps bound targeting opt-in", () => { registerSource(); registerDestination(); @@ -253,6 +288,108 @@ describe("zoom bound target", () => { expect(unfocusedContent.transform[0]?.scale).toBeCloseTo(0.9, 10); }); + it("places the destination bound over the source while the navigation mask enters", () => { + registerSource(); + registerDestination(); + + const styles = buildZoomStyles({ + tag: "card", + props: createZoomProps({ focused: true, progress: 0 }), + zoomOptions: { target: "bound" }, + }); + const contentTransform = styles.content?.style?.transform as any[]; + const contentTranslateX = contentTransform[0]?.translateX as number; + const contentTranslateY = contentTransform[1]?.translateY as number; + const contentScale = contentTransform[2]?.scale as number; + const mask = styles[NAVIGATION_MASK_ELEMENT_STYLE_ID]?.style as any; + const screenCenterX = SCREEN_LAYOUT.width / 2; + const screenCenterY = SCREEN_LAYOUT.height / 2; + const destinationCenterX = + DESTINATION_BOUNDS.pageX + DESTINATION_BOUNDS.width / 2; + const destinationCenterY = + DESTINATION_BOUNDS.pageY + DESTINATION_BOUNDS.height / 2; + const placedDestinationCenterX = + screenCenterX + + (destinationCenterX - screenCenterX) * contentScale + + contentTranslateX; + const placedDestinationCenterY = + screenCenterY + + (destinationCenterY - screenCenterY) * contentScale + + contentTranslateY; + const sourceCenterX = SOURCE_BOUNDS.pageX + SOURCE_BOUNDS.width / 2; + const sourceCenterY = SOURCE_BOUNDS.pageY + SOURCE_BOUNDS.height / 2; + + expect(placedDestinationCenterX).toBeCloseTo(sourceCenterX, 8); + expect(placedDestinationCenterY).toBeCloseTo(sourceCenterY, 8); + expect(contentScale).toBeCloseTo( + SOURCE_BOUNDS.width / DESTINATION_BOUNDS.width, + 8, + ); + expect(mask.width).toBeCloseTo(SOURCE_BOUNDS.width, 8); + expect(mask.height).toBeCloseTo(SOURCE_BOUNDS.height, 8); + }); + + it("keeps a source-correct mask while a stale destination write misplaces bound content", () => { + registerSource(); + const staleDestination = { + ...DESTINATION_BOUNDS, + pageY: DESTINATION_BOUNDS.pageY + 180, + y: DESTINATION_BOUNDS.y + 180, + }; + const signal = getInitialDestinationMeasurementSignal({ + enabled: true, + destinationPairKey: PAIR_KEY, + linkId: "card", + destinationPresent: true, + sourcePresent: true, + linkState: pairs.get(), + }); + + expect(signal).toEqual({ pairKey: PAIR_KEY, action: "measure" }); + applyMeasuredBoundsWrites({ + entryTag: "card", + linkId: "card", + currentScreenKey: "screen-b", + measured: staleDestination, + preparedStyles: {}, + linkWrite: { type: "destination", pairKey: PAIR_KEY }, + }); + + const staleStyles = buildZoomStyles({ + tag: "card", + props: createZoomProps({ focused: true, progress: 0 }), + zoomOptions: { target: "bound" }, + }); + const staleTransform = staleStyles.content?.style?.transform as any[]; + const staleMask = staleStyles[NAVIGATION_MASK_ELEMENT_STYLE_ID] + ?.style as any; + + applyMeasuredBoundsWrites({ + entryTag: "card", + linkId: "card", + currentScreenKey: "screen-b", + measured: DESTINATION_BOUNDS, + preparedStyles: {}, + linkWrite: { type: "destination", pairKey: PAIR_KEY }, + }); + + const measuredStyles = buildZoomStyles({ + tag: "card", + props: createZoomProps({ focused: true, progress: 0 }), + zoomOptions: { target: "bound" }, + }); + const measuredTransform = measuredStyles.content?.style?.transform as any[]; + const measuredMask = measuredStyles[NAVIGATION_MASK_ELEMENT_STYLE_ID] + ?.style as any; + + expect(staleMask.width).toBeCloseTo(measuredMask.width, 8); + expect(staleMask.height).toBeCloseTo(measuredMask.height, 8); + expect(staleTransform[1]?.translateY).not.toBeCloseTo( + measuredTransform[1]?.translateY, + 8, + ); + }); + it("keeps the tracked source attached to the bound-target content", () => { registerSource(); registerDestination(); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/gestures/lifecycle/gesture-lifecycle-state.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/gestures/lifecycle/gesture-lifecycle-state.test.ts index 7e1abcca..6b7f1f1e 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/gestures/lifecycle/gesture-lifecycle-state.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/gestures/lifecycle/gesture-lifecycle-state.test.ts @@ -1,8 +1,17 @@ import { afterEach, describe, expect, it } from "bun:test"; +import React from "react"; +import { act, create } from "react-test-renderer"; import type { SharedValue } from "react-native-reanimated"; +import { + createScreenTransitionState, + DEFAULT_SCREEN_TRANSITION_OPTIONS, +} from "../../../constants"; +import { hydrateTransitionState } from "../../../providers/screen/animation/helpers/hydrate-transition-state"; +import type { BuiltState } from "../../../providers/screen/animation/helpers/hydrate-transition-state/types"; import { finalizePanRelease, startPanBase, + trackPanGesture, } from "../../../providers/screen/gestures/pan/behavior/pan-lifecycle"; import type { GestureCompositionOwner } from "../../../providers/screen/gestures/types"; import { @@ -13,8 +22,15 @@ import { updateAbsolutePinchFocalPoint, updatePinchRotation, } from "../../../providers/screen/gestures/pinch/activation/use-pinch-activation"; +import { useTransitionStartController } from "../../../components/screen-lifecycle/hooks/use-transition-start-controller"; import type { AnimationStoreMap } from "../../../stores/animation.store"; +import { AnimationStore } from "../../../stores/animation.store"; import type { GestureStoreMap } from "../../../stores/gesture.store"; +import { GestureStore } from "../../../stores/gesture.store"; +import { + LifecycleTransitionRequestKind, + SystemStore, +} from "../../../stores/system.store"; import { animateToProgress } from "../../../utils/animation/animate-to-progress"; const originalRequestAnimationFrame = globalThis.requestAnimationFrame; @@ -140,6 +156,75 @@ const createRuntime = ( return { runtime: runtime as any, gestures, animations }; }; +let storedRuntimeId = 0; + +const createStoredRuntime = () => { + const routeKey = `gesture-lifecycle-${storedRuntimeId++}`; + const animations = AnimationStore.getBag(routeKey); + const gestures = GestureStore.getBag(routeKey); + const system = SystemStore.getBag(routeKey); + const runtime = { + participation: { canDismiss: true, effectiveSnapPoints: {} }, + policy: { gestureReleaseVelocityScale: 1 }, + stores: { gestures, animations, system }, + }; + + return { + runtime: runtime as any, + gestures, + animations, + clear: () => { + AnimationStore.clearBag(routeKey); + GestureStore.clearBag(routeKey); + SystemStore.clearBag(routeKey); + }, + }; +}; + +const exposeToInterpolator = ({ + runtime, + gestures, + animations, +}: ReturnType, sortedNumericSnapPoints: number[] = []) => { + let observed: ReturnType | undefined; + const screenStyleInterpolator = ({ current }: { current: typeof observed }) => { + observed = current; + return null; + }; + const state: BuiltState = { + transitionProgress: animations.transitionProgress, + visualProgress: animations.visualProgress, + stackProgress: animations.stackProgress, + willAnimate: animations.willAnimate, + closing: animations.closing, + progressAnimating: animations.progressAnimating, + progressSettled: animations.progressSettled, + entering: animations.entering, + gesture: gestures, + route: { key: "details", name: "Details" }, + options: DEFAULT_SCREEN_TRANSITION_OPTIONS, + optionsSlot: {}, + targetProgress: runtime.stores.system.targetProgress, + resolvedAutoSnapPoint: shared(-1), + measuredContentLayout: shared(null), + scrollMetadata: shared(null), + contentLayoutSlot: { width: 0, height: 0 }, + hasAutoSnapPoint: false, + sortedNumericSnapPoints, + unwrapped: createScreenTransitionState( + { key: "details", name: "Details" }, + undefined, + DEFAULT_SCREEN_TRANSITION_OPTIONS, + ), + }; + + screenStyleInterpolator({ + current: hydrateTransitionState(state, { width: 390, height: 844 }), + }); + + return observed; +}; + const restoreRequestAnimationFrame = ( value: typeof requestAnimationFrame | undefined, ) => { @@ -177,6 +262,36 @@ afterEach(() => { }); describe("gesture lifecycle state", () => { + it("does not pulse willAnimate when an entering screen starts its initial open", () => { + const raf = installDeferredAnimationFrame(); + const animations = createAnimations(); + const system = SystemStore.getBag("initial-open-contract"); + animations.entering.set(1); + system.actions.requestLifecycleTransition( + LifecycleTransitionRequestKind.Open, + 1, + ); + + const Harness = () => { + useTransitionStartController({ + current: { + route: { key: "initial-open-contract" }, + options: {}, + } as any, + animations, + system, + }); + return null; + }; + + act(() => { + create(React.createElement(Harness)); + }); + + expect(animations.willAnimate.get()).toBe(0); + raf.restore(); + }); + it("marks opening as entering before the willAnimate pulse is observed", () => { const raf = installDeferredAnimationFrame(); const animations = createAnimations(); @@ -225,6 +340,112 @@ describe("gesture lifecycle state", () => { expect(animationProgress.get()).toBe(0); }); + it("exposes a committed programmatic close at its motion-start pulse", () => { + const raf = installDeferredAnimationFrame(); + const { runtime, gestures, animations } = createRuntime(); + + animateToProgress({ + target: "close", + spec: { close: { duration: 200, __defer: true } as any }, + animations, + targetProgress: runtime.stores.system.targetProgress, + animationProgress: runtime.stores.system.animationProgress, + }); + + expect(exposeToInterpolator({ runtime, gestures, animations })).toMatchObject({ + willAnimate: 1, + closing: 1, + animating: 0, + }); + + raf.flush(); + raf.restore(); + + expect(exposeToInterpolator({ runtime, gestures, animations })).toMatchObject({ + closing: 1, + willAnimate: 0, + animating: 1, + }); + }); + + it("treats the first snap-point entrance as entering until it settles at the fallback", () => { + const raf = installDeferredAnimationFrame(); + const state = createRuntime(); + state.animations.transitionProgress.set(0); + state.animations.visualProgress.set(0); + state.animations.stackProgress.set(0); + state.animations.entering.set(1); + state.animations.progressSettled.set(0); + + animateToProgress({ + target: 0.25, + animations: state.animations, + targetProgress: state.runtime.stores.system.targetProgress, + animationProgress: state.runtime.stores.system.animationProgress, + }); + + expect(exposeToInterpolator(state, [0.25, 1])).toMatchObject({ + progress: 0, + entering: 1, + }); + + raf.flush(); + raf.restore(); + + expect(exposeToInterpolator(state, [0.25, 1])).toMatchObject({ + progress: 0.25, + entering: 0, + snapIndex: 0, + }); + }); + + it("does not re-enter when expanding from the fallback snap point", () => { + const raf = installDeferredAnimationFrame(); + const state = createRuntime(); + state.animations.transitionProgress.set(0.25); + state.animations.visualProgress.set(0.25); + state.animations.stackProgress.set(0.25); + state.runtime.stores.system.targetProgress.set(0.25); + + animateToProgress({ + target: 1, + markEntering: false, + animations: state.animations, + targetProgress: state.runtime.stores.system.targetProgress, + animationProgress: state.runtime.stores.system.animationProgress, + }); + + expect(exposeToInterpolator(state, [0.25, 1])).toMatchObject({ + entering: 0, + }); + + raf.flush(); + raf.restore(); + }); + + it("does not close when collapsing between nonzero snap points", () => { + const raf = installDeferredAnimationFrame(); + const state = createRuntime(); + state.animations.transitionProgress.set(1); + state.animations.visualProgress.set(1); + state.animations.stackProgress.set(1); + state.runtime.stores.system.targetProgress.set(1); + + animateToProgress({ + target: 0.25, + animations: state.animations, + targetProgress: state.runtime.stores.system.targetProgress, + animationProgress: state.runtime.stores.system.animationProgress, + }); + + expect(exposeToInterpolator(state, [0.25, 1])).toMatchObject({ + closing: 0, + }); + + raf.flush(); + raf.restore(); + }); + it("clears a stale closing flag when a retained screen reopens", () => { const raf = installDeferredAnimationFrame(); const animations = createAnimations(); @@ -261,11 +482,34 @@ describe("gesture lifecycle state", () => { expect(animations.willAnimate.get()).toBe(0); }); + it("exposes an accepted idle pan with its public initiator and one pulse", () => { + const raf = installDeferredAnimationFrame(); + const { runtime, gestures, animations } = createRuntime(); + gestures.active.set("vertical"); + + startPanBase(runtime); + + expect(exposeToInterpolator({ runtime, gestures, animations })).toMatchObject({ + willAnimate: 1, + animating: 0, + gesture: { dragging: 1, initiator: "vertical" }, + }); + + raf.flush(); + raf.restore(); + + expect(exposeToInterpolator({ runtime, gestures, animations })).toMatchObject({ + willAnimate: 0, + animating: 1, + }); + }); + it("does not emit willAnimate when a pan restarts while gesture values are settling", () => { const raf = installDeferredAnimationFrame(); const { runtime, gestures, animations } = createRuntime(); gestures.settling.set(1); gestures.normY.set(0.25); + animations.progressAnimating.set(1); startPanBase(runtime); @@ -277,6 +521,24 @@ describe("gesture lifecycle state", () => { raf.restore(); }); + it("does not emit a second public pulse when re-grabbing during reset", () => { + const raf = installDeferredAnimationFrame(); + const { runtime, gestures, animations } = createRuntime(); + gestures.settling.set(1); + gestures.normY.set(0.25); + animations.progressAnimating.set(1); + + startPanBase(runtime); + + expect(exposeToInterpolator({ runtime, gestures, animations })).toMatchObject({ + willAnimate: 0, + gesture: { dragging: 1 }, + }); + + raf.flush(); + raf.restore(); + }); + it("emits willAnimate when a pan restarts after gesture values reached rest", () => { const raf = installDeferredAnimationFrame(); const { runtime, gestures, animations } = createRuntime(); @@ -296,8 +558,9 @@ describe("gesture lifecycle state", () => { }); it("clears stale pinch-only state when a pan starts", () => { - const { runtime, gestures } = createRuntime(); + const { runtime, gestures, animations } = createRuntime(); gestures.settling.set(1); + animations.progressAnimating.set(1); gestures.scale.set(0.72); gestures.normScale.set(-0.28); gestures.focalX.set(240); @@ -328,6 +591,7 @@ describe("gesture lifecycle state", () => { const { runtime, gestures, animations } = createRuntime(); gestures.settling.set(1); gestures.normScale.set(0.25); + animations.progressAnimating.set(1); startPinchBase(runtime); @@ -427,6 +691,105 @@ describe("gesture lifecycle state", () => { expect(gestures.settling.get()).toBe(1); }); + it("returns a cancelled gesture to public rest without closing", () => { + const state = createRuntime(); + state.gestures.dragging.set(1); + + finalizePanRelease( + { + target: 1, + shouldDismiss: false, + initialVelocity: 0, + transitionSpec: undefined, + resetSpec: undefined, + }, + state.runtime, + undefined, + { width: 390, height: 844 }, + { velocityX: 0, velocityY: 0 } as any, + ); + + expect(exposeToInterpolator(state)).toMatchObject({ + closing: 0, + settled: 1, + gesture: { dragging: 0, dismissing: 0, settling: 0 }, + }); + }); + + it("keeps public settlement pending through a cancelled gesture handoff", () => { + const raf = installDeferredAnimationFrame(); + const state = createRuntime(); + + startPanBase(state.runtime); + + expect(exposeToInterpolator(state)).toMatchObject({ + settled: 0, + gesture: { dragging: 1 }, + }); + + finalizePanRelease( + { + target: 1, + shouldDismiss: false, + initialVelocity: 0, + transitionSpec: undefined, + resetSpec: { duration: 200, __finished: false } as any, + }, + state.runtime, + undefined, + { width: 390, height: 844 }, + { velocityX: 0, velocityY: 0 } as any, + ); + + expect(exposeToInterpolator(state)).toMatchObject({ + settled: 0, + gesture: { dragging: 0, settling: 1 }, + }); + + state.gestures.settling.set(0); + state.animations.progressAnimating.set(0); + state.animations.progressSettled.set(1); + + expect(exposeToInterpolator(state)).toMatchObject({ + settled: 1, + }); + + raf.flush(); + raf.restore(); + }); + + it("clears a stale visual-settle signal when a drag takes over", () => { + const state = createRuntime(); + state.animations.progressAnimating.set(1); + state.animations.progressSettled.set(1); + + startPanBase(state.runtime); + + expect(exposeToInterpolator(state)).toMatchObject({ + settled: 0, + gesture: { dragging: 1 }, + }); + + finalizePanRelease( + { + target: 1, + shouldDismiss: false, + initialVelocity: 0, + transitionSpec: undefined, + resetSpec: { duration: 200, __finished: false } as any, + }, + state.runtime, + undefined, + { width: 390, height: 844 }, + { velocityX: 0, velocityY: 0 } as any, + ); + + expect(exposeToInterpolator(state)).toMatchObject({ + settled: 0, + gesture: { dragging: 0, settling: 1 }, + }); + }); + it("does not mark cancelled pan reset as an entering transition", () => { const { runtime, animations } = createRuntime(); @@ -500,6 +863,127 @@ describe("gesture lifecycle state", () => { expect(animations.progressAnimating.get()).toBe(0); }); + it("keeps visual motion active while a cancelled drag resets to rest", () => { + const raf = installDeferredAnimationFrame(); + const state = createRuntime(); + state.gestures.active.set("vertical"); + + try { + startPanBase(state.runtime); + state.gestures.y.set(220); + state.gestures.normY.set(220 / 844); + state.gestures.internal.progressDeltaY.set(220 / 844); + + finalizePanRelease( + { + target: 1, + shouldDismiss: false, + initialVelocity: 0, + transitionSpec: { + open: { duration: 200, __finished: false } as any, + close: { duration: 200 } as any, + }, + resetSpec: { duration: 200, __finished: false } as any, + }, + state.runtime, + undefined, + { width: 390, height: 844 }, + { velocityX: 0, velocityY: 0 } as any, + ); + + expect(exposeToInterpolator(state)).toMatchObject({ + animating: 1, + settled: 0, + gesture: { dragging: 0, settling: 1 }, + }); + } finally { + raf.restore(); + } + }); + + it("keeps visual motion active while a cancelled pinch resets to rest", () => { + const state = createRuntime(); + state.gestures.dragging.set(1); + state.gestures.active.set("pinch-in"); + state.gestures.scale.set(0.7); + state.gestures.normScale.set(-0.3); + + finalizePinchRelease( + { + target: 1, + shouldDismiss: false, + initialVelocity: 0, + handoffVelocity: 0, + transitionSpec: undefined, + resetSpec: { duration: 200, __finished: false } as any, + }, + state.runtime, + undefined, + ); + + expect(exposeToInterpolator(state)).toMatchObject({ + animating: 1, + settled: 0, + gesture: { dragging: 0, settling: 1 }, + }); + }); + + it("keeps public motion pending when an interrupted entrance finishes during a drag", () => { + const raf = installDeferredAnimationFrame(); + const deferredCallbacks: Array<() => void> = []; + globalThis.__reanimatedDeferredTimingCallbacks = deferredCallbacks; + const state = createStoredRuntime(); + state.animations.transitionProgress.set(0); + state.animations.visualProgress.set(0); + state.animations.entering.set(1); + state.animations.progressSettled.set(0); + + try { + animateToProgress({ + target: "open", + spec: { open: { duration: 200, __defer: true } as any }, + emitWillAnimate: false, + animations: state.animations, + targetProgress: state.runtime.stores.system.targetProgress, + animationProgress: state.runtime.stores.system.animationProgress, + isDragging: state.gestures.dragging, + }); + + state.gestures.active.set("vertical"); + startPanBase(state.runtime); + trackPanGesture( + { + translationX: 0, + translationY: 220, + velocityX: 0, + velocityY: 0, + }, + { + translationX: 0, + translationY: 220, + velocityX: 0, + velocityY: 0, + }, + state.gestures, + { width: 390, height: 844 }, + ); + + expect(deferredCallbacks).toHaveLength(1); + deferredCallbacks[0]?.(); + raf.flush(); + + expect(exposeToInterpolator(state)).toMatchObject({ + animating: 1, + settled: 0, + gesture: { dragging: 1 }, + }); + } finally { + Reflect.deleteProperty(globalThis, "__reanimatedDeferredTimingCallbacks"); + raf.restore(); + state.clear(); + } + }); + it("resets pan values without dismissing when pinch owns the composition", () => { const { runtime, gestures, animations } = createRuntime(); const composition = shared("pinch"); @@ -587,6 +1071,41 @@ describe("gesture lifecycle state", () => { expect(gestures.internal.snapshot.active.get()).toBe("vertical"); }); + it("sets public closing only when a pan dismissal is committed", () => { + const raf = installDeferredAnimationFrame(); + const state = createRuntime(); + state.gestures.active.set("vertical"); + + startPanBase(state.runtime); + + expect(exposeToInterpolator(state)).toMatchObject({ + closing: 0, + gesture: { dragging: 1 }, + }); + + finalizePanRelease( + { + target: 0, + shouldDismiss: true, + initialVelocity: 0, + transitionSpec: undefined, + resetSpec: undefined, + }, + state.runtime, + () => {}, + { width: 390, height: 844 }, + { velocityX: 0, velocityY: 0 } as any, + ); + + expect(exposeToInterpolator(state)).toMatchObject({ + closing: 1, + gesture: { dragging: 0, dismissing: 1 }, + }); + + raf.flush(); + raf.restore(); + }); + it("snapshots raw pan displacement during a dismissing release", () => { const { runtime, gestures } = createRuntime(); gestures.dragging.set(1); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/initial-interpolator-state.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/initial-interpolator-state.test.ts new file mode 100644 index 00000000..a8b4ce8b --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/__tests__/initial-interpolator-state.test.ts @@ -0,0 +1,102 @@ +import { beforeAll, describe, expect, it, mock } from "bun:test"; +import type { ScreenInterpolationProps } from "../types/animation.types"; + +const descriptors: { + current: unknown; + next: unknown; + previous: unknown; +} = { + current: undefined, + next: undefined, + previous: undefined, +}; + +mock.module("react", () => ({ + useMemo: (factory: () => T) => factory(), +})); + +mock.module("react-native", () => ({ + Platform: { OS: "ios" }, + useWindowDimensions: () => ({ width: 390, height: 844 }), +})); + +mock.module("react-native-reanimated", () => ({ + clamp: (value: number, lower: number, upper: number) => + Math.min(Math.max(value, lower), upper), + useDerivedValue: () => ({ get: () => undefined as T }), + useSharedValue: (value: T) => ({ + get: () => value, + set: () => {}, + modify: () => {}, + }), +})); + +mock.module("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), +})); + +mock.module("../hooks/navigation/use-stack", () => ({ + useStack: () => false, +})); + +mock.module("../providers/screen/descriptors", () => ({ + useDescriptorsStore: (selector: (state: typeof descriptors) => unknown) => + selector(descriptors), +})); + +mock.module( + "../providers/screen/animation/helpers/use-build-transition-state", + () => ({ + useBuildTransitionState: () => undefined, + }), +); + +let useScreenAnimationPipeline: typeof import("../providers/screen/animation/helpers/pipeline").useScreenAnimationPipeline; + +describe("initial interpolator state", () => { + beforeAll(async () => { + ({ useScreenAnimationPipeline } = await import( + "../providers/screen/animation/helpers/pipeline" + )); + }); + + it("exposes an entering pushed screen to the public interpolator", () => { + let observedNext: ScreenInterpolationProps["next"]; + + descriptors.current = { + route: { key: "home", name: "Home" }, + options: {}, + }; + descriptors.next = { + route: { key: "details", name: "Details" }, + options: { + screenStyleInterpolator: ({ next }: ScreenInterpolationProps) => { + observedNext = next; + return null; + }, + }, + }; + descriptors.previous = undefined; + + const pipeline = useScreenAnimationPipeline(); + const props = pipeline.screenInterpolatorProps.get(); + + pipeline.nextInterpolator?.({ + ...props, + bounds: {} as ScreenInterpolationProps["bounds"], + transition: () => null, + }); + + expect(observedNext).toMatchObject({ + progress: 0, + transitionProgress: 0, + entering: 1, + animating: 1, + closing: 0, + willAnimate: 0, + settled: 0, + logicallySettled: 0, + gesture: { dragging: 0, dismissing: 0, settling: 0 }, + }); + }); +}); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/transition-state-rules.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/transition-state-rules.test.ts index ad2fdf27..25bc4396 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/transition-state-rules.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/transition-state-rules.test.ts @@ -224,7 +224,7 @@ describe("transition state rules", () => { expect(hydrated.settled).toBe(0); }); - it("derives public animating from active gesture reset", () => { + it("does not derive public animating from gesture reset state", () => { const hydrated = hydrate( createBuiltState({ progressAnimating: 0, @@ -232,11 +232,11 @@ describe("transition state rules", () => { }), ); - expect(hydrated.animating).toBe(1); - expect(hydrated.settled).toBe(0); + expect(hydrated.animating).toBe(0); + expect(hydrated.settled).toBe(1); }); - it("derives public animating from residual rotation without changing progress", () => { + it("does not derive public animating from residual gesture values", () => { const hydrated = hydrate( createBuiltState({ progress: 1, @@ -249,8 +249,8 @@ describe("transition state rules", () => { expect(hydrated.transitionProgress).toBe(1); expect(hydrated.progress).toBe(1); - expect(hydrated.animating).toBe(1); - expect(hydrated.settled).toBe(0); + expect(hydrated.animating).toBe(0); + expect(hydrated.settled).toBe(1); }); it("derives settled when progress and gestures are at rest", () => { @@ -326,7 +326,7 @@ describe("transition state rules", () => { expect(hydrate(state).logicallySettled).toBe(0); }); - it("keeps settled false while dragging", () => { + it("does not let dragging override canonical animation state", () => { const state = createBuiltState({ progress: 1, targetProgress: 1, @@ -336,9 +336,9 @@ describe("transition state rules", () => { const hydrated = hydrate(state); - expect(hydrated.animating).toBe(1); - expect(hydrated.settled).toBe(0); - expect(hydrated.logicallySettled).toBe(0); + expect(hydrated.animating).toBe(0); + expect(hydrated.settled).toBe(1); + expect(hydrated.logicallySettled).toBe(1); }); it("allows visual settlement while a dismissing gesture holds residual values", () => { @@ -536,4 +536,5 @@ describe("transition state rules", () => { expect(hydrated.transitionProgress).toBe(1); expect(hydrated.progress).toBe(0.75); }); + }); diff --git a/packages/react-native-screen-transitions/src/shared/animation/snap-to.ts b/packages/react-native-screen-transitions/src/shared/animation/snap-to.ts index f2ce3a94..fdeba6fe 100644 --- a/packages/react-native-screen-transitions/src/shared/animation/snap-to.ts +++ b/packages/react-native-screen-transitions/src/shared/animation/snap-to.ts @@ -60,6 +60,7 @@ export function snapDescriptorToIndex( animateToProgress({ target: targetProgress, + markEntering: false, animations, targetProgress: targetProgressValue, animationProgress: animationProgressValue, diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-refresh-boundary.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-refresh-boundary.ts index 866a2b29..2f12e38f 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-refresh-boundary.ts +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-refresh-boundary.ts @@ -3,7 +3,6 @@ import { useDescriptorsStore } from "../../../../providers/screen/descriptors"; import { AnimationStore } from "../../../../stores/animation.store"; import { pairs } from "../../../../stores/bounds/internals/state"; import type { BoundTag } from "../../../../stores/bounds/types"; -import { GestureStore } from "../../../../stores/gesture.store"; import type { MeasureBoundary } from "../../types"; import { getRefreshBoundarySignal } from "../../utils/refresh-signals"; @@ -38,21 +37,6 @@ export const useRefreshBoundary = ({ "willAnimate", ); const refreshClosing = AnimationStore.getValue(refreshScreenKey, "closing"); - const refreshEntering = AnimationStore.getValue(refreshScreenKey, "entering"); - const refreshAnimating = AnimationStore.getValue( - refreshScreenKey, - "progressAnimating", - ); - const refreshProgress = AnimationStore.getValue( - refreshScreenKey, - "transitionProgress", - ); - const refreshDragging = GestureStore.getValue(refreshScreenKey, "dragging"); - const refreshDismissing = GestureStore.getValue( - refreshScreenKey, - "dismissing", - ); - const refreshSettling = GestureStore.getValue(refreshScreenKey, "settling"); useAnimatedReaction( () => { @@ -68,13 +52,6 @@ export const useRefreshBoundary = ({ group, shouldRefresh: !!refreshWillAnimate.get(), closing: !!refreshClosing.get(), - entering: !!refreshEntering.get(), - animating: !!refreshAnimating.get(), - progress: refreshProgress.get(), - gestureInProgress: - !!refreshDragging.get() || - !!refreshDismissing.get() || - !!refreshSettling.get(), linkState: pairs.get(), }); }, diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/utils/refresh-signals.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/utils/refresh-signals.ts index f2c98027..db1462ac 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/utils/refresh-signals.ts +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/utils/refresh-signals.ts @@ -37,10 +37,6 @@ export const getRefreshBoundarySignal = (params: { group?: string; shouldRefresh: boolean; closing: boolean; - entering: boolean; - animating: boolean; - progress: number; - gestureInProgress: boolean; linkState?: LinkPairsState; }): RefreshBoundarySignal | null => { "worklet"; @@ -55,30 +51,12 @@ export const getRefreshBoundarySignal = (params: { group, shouldRefresh, closing, - entering, - animating, - progress, - gestureInProgress, linkState, } = params; if (!enabled) return null; - // This is temporary for a patch fix. However, this architecture should - // change: our signals aren't currently doing their intended job, so right - // now we'll add one more guard until the shape is much more tested and - // bulletproof. - if (gestureInProgress) return null; - - const canRefreshPreCloseDestination = - shouldRefresh && closing && !entering && !animating && progress >= 1; - - const canRefreshSettledDestination = shouldRefresh && !closing && !entering; - - // Guards: - // 1) A user may dismiss via back button, in this case, we should remeasure. - // 2) If a user drags during entering/closing animations we should NOT remeasure. - if (!canRefreshPreCloseDestination && !canRefreshSettledDestination) { + if (!shouldRefresh) { return null; } diff --git a/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/backdrop.tsx b/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/backdrop.tsx index fb79dc01..ab0bd6fc 100644 --- a/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/backdrop.tsx +++ b/packages/react-native-screen-transitions/src/shared/components/screen-container/layers/backdrop.tsx @@ -103,6 +103,7 @@ export const BackdropLayer = memo(function BackdropLayer({ animateToProgress({ target, + markEntering: false, spec, animations, targetProgress, diff --git a/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-open-transition-intent.ts b/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-open-transition-intent.ts index e092e129..3781105d 100644 --- a/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-open-transition-intent.ts +++ b/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-open-transition-intent.ts @@ -80,6 +80,7 @@ export function useOpenTransitionIntent( return; } + animations.entering.set(1); requestLifecycleTransition( LifecycleTransitionRequestKind.Open, initialProgress ?? 1, diff --git a/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-transition-start-controller.ts b/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-transition-start-controller.ts index 2d9b460c..3b394f7d 100644 --- a/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-transition-start-controller.ts +++ b/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-transition-start-controller.ts @@ -1,6 +1,7 @@ import { useAnimatedReaction } from "react-native-reanimated"; import type { BaseDescriptor } from "../../../providers/screen/descriptors"; import type { AnimationStoreMap } from "../../../stores/animation.store"; +import { GestureStore } from "../../../stores/gesture.store"; import { LifecycleTransitionRequestKind, type SystemStoreMap, @@ -29,6 +30,7 @@ export const useTransitionStartController = ({ } = system; const { clearLifecycleTransitionRequest } = system.actions; const transitionSpec = current.options.transitionSpec; + const isDragging = GestureStore.getValue(current.route.key, "dragging"); useAnimatedReaction( () => { @@ -61,10 +63,14 @@ export const useTransitionStartController = ({ animateToProgress({ target, spec: transitionSpec, + emitWillAnimate: + kind !== LifecycleTransitionRequestKind.Open || + !animations.entering.get(), animations, targetProgress, animationProgress, onAnimationFinish, + isDragging, }); clearLifecycleTransitionRequest(); diff --git a/packages/react-native-screen-transitions/src/shared/constants.ts b/packages/react-native-screen-transitions/src/shared/constants.ts index 9a69d908..59dadb0e 100644 --- a/packages/react-native-screen-transitions/src/shared/constants.ts +++ b/packages/react-native-screen-transitions/src/shared/constants.ts @@ -71,6 +71,7 @@ const DEFAULT_GESTURE_VALUES = { dragging: 0, settling: 0, active: null, + initiator: "none", direction: null, // Deprecated aliases diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/hydrate-transition-state/index.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/hydrate-transition-state/index.ts index adab038e..c330e5bb 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/hydrate-transition-state/index.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/hydrate-transition-state/index.ts @@ -1,4 +1,3 @@ -import { EPSILON } from "../../../../../constants"; import type { ScreenTransitionOptions, TransitionInterpolatorOptions, @@ -92,6 +91,7 @@ export const hydrateTransitionState = ( out.gesture.dragging = s.gesture.dragging.get(); out.gesture.settling = s.gesture.settling.get(); out.gesture.active = s.gesture.active.get(); + out.gesture.initiator = out.gesture.active ?? "none"; out.gesture.direction = s.gesture.direction.get(); const handoff = out.gesture.handoff; const useHandoffSnapshot = out.gesture.dismissing; @@ -166,7 +166,7 @@ export const hydrateTransitionState = ( x: s.gesture.internal.progressDeltaX.get(), y: s.gesture.internal.progressDeltaY.get(), }, - effectiveOptions?.gestureDirection ?? s.options.gestureDirection, + options.gestureDirection, getResolvedSnapBounds( s.sortedNumericSnapPoints, s.hasAutoSnapPoint ? s.resolvedAutoSnapPoint.get() : null, @@ -174,35 +174,18 @@ export const hydrateTransitionState = ( ), ); - // Unsure where else to place this if im being honest. - // I think for here is fine if (s.visualProgress.get() !== out.progress) { s.visualProgress.set(out.progress); } - const hasResidualGestureValues = - Math.abs(out.gesture.normX) > EPSILON || - Math.abs(out.gesture.normY) > EPSILON || - Math.abs(out.gesture.normScale) > EPSILON || - Math.abs(out.gesture.rotation) > EPSILON; - - const isGestureActive = - out.gesture.dragging || - out.gesture.settling || - (!out.gesture.dismissing && hasResidualGestureValues) - ? 1 - : 0; - - const isProgressAnimating = s.progressAnimating.get(); - - out.animating = isProgressAnimating || isGestureActive ? 1 : 0; + out.animating = s.progressAnimating.get(); out.gesture.normalizedX = out.gesture.normX; out.gesture.normalizedY = out.gesture.normY; out.gesture.isDismissing = out.gesture.dismissing; out.gesture.isDragging = out.gesture.dragging; - out.settled = s.progressSettled.get() && !isGestureActive ? 1 : 0; + out.settled = s.progressSettled.get(); out.logicallySettled = out.settled; out.meta = s.meta; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/pipeline.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/pipeline.ts index 43349a14..7531ad1b 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/pipeline.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/pipeline.ts @@ -157,7 +157,7 @@ const createInitialInterpolatorProps = ({ progress: currentProgress, entering: currentEntering, animating: currentEntering, - willAnimate: currentEntering, + willAnimate: 0, }); const previous = prevDescriptor ? createInitialTransitionState({ @@ -173,7 +173,7 @@ const createInitialInterpolatorProps = ({ progress: 0, entering: 1, animating: 1, - willAnimate: 1, + willAnimate: 0, settled: 0, }) : undefined; @@ -312,10 +312,8 @@ export function useScreenAnimationPipeline(): ScreenAnimationPipeline { }); const propsRevisionState = useSharedValue({ value: 0 }); - const screenInterpolatorPropsRevision = useDerivedValue(() => { "worklet"; - screenInterpolatorProps.modify((frame) => { "worklet"; const interpolatorOptions = selectedInterpolatorOptions.get(); diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/pan-lifecycle.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/pan-lifecycle.ts index 85df871b..72756c2b 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/pan-lifecycle.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/pan-lifecycle.ts @@ -1,7 +1,7 @@ import { clamp, runOnJS, type SharedValue } from "react-native-reanimated"; -import { EPSILON, FALSE, TRUE } from "../../../../../constants"; +import { EPSILON, TRUE } from "../../../../../constants"; +import { emitMotionStart } from "../../../../../stores/animation.store"; import { animateToProgress } from "../../../../../utils/animation/animate-to-progress"; -import { emit } from "../../../../../utils/animation/emit"; import { normalizeGestureTranslation, resolveGestureVelocity, @@ -29,16 +29,7 @@ export const startPanBase = (runtime: PanGestureRuntime) => { stores: { gestures, animations }, } = runtime; - const wasSettling = gestures.settling.get(); - const hasResidualGesture = - Math.abs(gestures.normX.get()) > EPSILON || - Math.abs(gestures.normY.get()) > EPSILON || - Math.abs(gestures.normScale.get()) > EPSILON || - Math.abs(gestures.rotation.get()) > EPSILON; - - if (!wasSettling || !hasResidualGesture) { - emit(animations.willAnimate, TRUE, FALSE); - } + emitMotionStart(animations); gestures.dragging.set(TRUE); gestures.dismissing.set(0); @@ -133,6 +124,9 @@ export const finalizePanRelease = ( system.targetProgress.set(plan.commitProgress); } + const progressAlreadyAtTarget = + Math.abs(animations.transitionProgress.get() - plan.target) <= EPSILON; + if (canDriveRelease && plan.shouldDismiss) { snapshotGestureHandoff(gestures, { velocity: plan.handoffVelocity, @@ -142,6 +136,8 @@ export const finalizePanRelease = ( resetPanGestureValues({ plan, gestures, + animations, + completeMotion: !plan.shouldDismiss && progressAlreadyAtTarget, updateLifecycle: canDriveRelease, }); @@ -149,12 +145,8 @@ export const finalizePanRelease = ( return; } - const progressAlreadyAtTarget = - Math.abs(animations.transitionProgress.get() - plan.target) <= EPSILON; - if (!plan.shouldDismiss && progressAlreadyAtTarget) { system.targetProgress.set(plan.target); - animations.progressAnimating.set(FALSE); return; } diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/pan-reset.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/pan-reset.ts index 15856743..67fb6d54 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/pan-reset.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/pan-reset.ts @@ -1,4 +1,5 @@ import { FALSE, TRUE } from "../../../../../constants"; +import type { AnimationStoreMap } from "../../../../../stores/animation.store"; import type { GestureStoreMap } from "../../../../../stores/gesture.store"; import { animateMany } from "../../shared/reset"; import { @@ -10,12 +11,16 @@ import type { PanReleasePlan } from "../../types"; interface ResetPanGestureValuesProps { plan: PanReleasePlan; gestures: GestureStoreMap; + animations?: AnimationStoreMap; + completeMotion?: boolean; updateLifecycle?: boolean; } export const resetPanGestureValues = ({ plan, gestures, + animations, + completeMotion = false, updateLifecycle = true, }: ResetPanGestureValuesProps) => { "worklet"; @@ -28,8 +33,17 @@ export const resetPanGestureValues = ({ gestures.active.set(null); gestures.direction.set(null); gestures.settling.set(FALSE); + if (completeMotion) { + animations?.progressAnimating.set(FALSE); + animations?.progressSettled.set(TRUE); + } }; + if (animations && updateLifecycle && !plan.shouldDismiss) { + animations.progressAnimating.set(TRUE); + animations.progressSettled.set(FALSE); + } + clearRawPanValues(gestures); if (updateLifecycle) { diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pinch/behavior/pinch-lifecycle.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pinch/behavior/pinch-lifecycle.ts index a10cb941..c11e8dcb 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pinch/behavior/pinch-lifecycle.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pinch/behavior/pinch-lifecycle.ts @@ -1,7 +1,7 @@ import { clamp, runOnJS } from "react-native-reanimated"; -import { EPSILON, FALSE, TRUE } from "../../../../../constants"; +import { EPSILON, TRUE } from "../../../../../constants"; +import { emitMotionStart } from "../../../../../stores/animation.store"; import { animateToProgress } from "../../../../../utils/animation/animate-to-progress"; -import { emit } from "../../../../../utils/animation/emit"; import { normalizePinchScale } from "../../shared/physics"; import { snapshotGestureHandoff } from "../../shared/snapshot"; import { clearTransformTrackingValues } from "../../shared/values"; @@ -19,15 +19,7 @@ export const startPinchBase = (runtime: PinchGestureRuntime) => { stores: { gestures, animations }, } = runtime; - const wasSettling = gestures.settling.get(); - const hasResidualGesture = - Math.abs(gestures.normScale.get()) > EPSILON || - Math.abs(gestures.rotation.get()) > EPSILON; - - if (!wasSettling || !hasResidualGesture) { - emit(animations.willAnimate, TRUE, FALSE); - } - + emitMotionStart(animations); gestures.dragging.set(TRUE); gestures.dismissing.set(0); gestures.settling.set(0); @@ -82,6 +74,9 @@ export const finalizePinchRelease = ( system.targetProgress.set(release.commitProgress); } + const progressAlreadyAtTarget = + Math.abs(animations.transitionProgress.get() - release.target) <= EPSILON; + if (release.shouldDismiss) { snapshotGestureHandoff(gestures, { velocity: release.handoffVelocity, @@ -91,6 +86,7 @@ export const finalizePinchRelease = ( resetPinchGestureValues({ spec: release.resetSpec, gestures, + animations, shouldDismiss: release.shouldDismiss, resetValuesImmediately: release.resetValuesImmediately, }); @@ -99,6 +95,11 @@ export const finalizePinchRelease = ( runOnJS(requestDismiss)(); } + if (!release.shouldDismiss && progressAlreadyAtTarget) { + system.targetProgress.set(release.target); + return; + } + animateToProgress({ target: release.target, onAnimationFinish: release.shouldDismiss ? dismissScreen : undefined, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pinch/behavior/pinch-reset.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pinch/behavior/pinch-reset.ts index 1b2638e8..7f2663b4 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pinch/behavior/pinch-reset.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pinch/behavior/pinch-reset.ts @@ -1,4 +1,5 @@ import { FALSE, TRUE } from "../../../../../constants"; +import type { AnimationStoreMap } from "../../../../../stores/animation.store"; import type { GestureStoreMap } from "../../../../../stores/gesture.store"; import type { AnimationConfig } from "../../../../../types/animation.types"; import { animateMany } from "../../shared/reset"; @@ -11,6 +12,7 @@ import { interface ResetPinchGestureValuesProps { spec?: AnimationConfig; gestures: GestureStoreMap; + animations?: AnimationStoreMap; shouldDismiss: boolean; resetValuesImmediately?: boolean; } @@ -18,6 +20,7 @@ interface ResetPinchGestureValuesProps { export const resetPinchGestureValues = ({ spec, gestures, + animations, shouldDismiss, resetValuesImmediately = false, }: ResetPinchGestureValuesProps) => { @@ -32,8 +35,15 @@ export const resetPinchGestureValues = ({ gestures.active.set(null); gestures.direction.set(null); gestures.settling.set(FALSE); + animations?.progressAnimating.set(FALSE); + animations?.progressSettled.set(TRUE); }; + if (animations && !shouldDismiss) { + animations.progressAnimating.set(TRUE); + animations.progressSettled.set(FALSE); + } + clearRawTransformValues(gestures); gestures.dragging.set(FALSE); diff --git a/packages/react-native-screen-transitions/src/shared/stores/animation.store.ts b/packages/react-native-screen-transitions/src/shared/stores/animation.store.ts index b207ba79..2dc17ddb 100644 --- a/packages/react-native-screen-transitions/src/shared/stores/animation.store.ts +++ b/packages/react-native-screen-transitions/src/shared/stores/animation.store.ts @@ -3,6 +3,8 @@ import { makeMutable, type SharedValue, } from "react-native-reanimated"; +import { FALSE, TRUE } from "../constants"; +import { emit } from "../utils/animation/emit"; import { createStore } from "../utils/create-store"; export type AnimationStoreMap = { @@ -16,6 +18,23 @@ export type AnimationStoreMap = { entering: SharedValue; }; +export const emitMotionStart = (animations: AnimationStoreMap) => { + "worklet"; + + animations.progressSettled.set(FALSE); + + if (animations.progressAnimating.get()) { + return false; + } + + emit(animations.willAnimate, TRUE, FALSE); + requestAnimationFrame(() => { + "worklet"; + animations.progressAnimating.set(TRUE); + }); + return true; +}; + function createAnimationBag(): AnimationStoreMap { return { transitionProgress: makeMutable(0), diff --git a/packages/react-native-screen-transitions/src/shared/types/gesture.types.ts b/packages/react-native-screen-transitions/src/shared/types/gesture.types.ts index 95b5e445..b606e836 100644 --- a/packages/react-native-screen-transitions/src/shared/types/gesture.types.ts +++ b/packages/react-native-screen-transitions/src/shared/types/gesture.types.ts @@ -222,8 +222,14 @@ export type GestureValues = { raw: RawGestureValues; /** * The gesture that is currently active. + * + * @deprecated Use `initiator` for the accepted activation identity. */ active: ActiveGesture | null; + /** + * The accepted activation identity for the current gesture attempt. + */ + initiator: ActiveGesture | "none"; /** * The initial pan direction that activated the gesture. * @@ -240,6 +246,9 @@ export type GestureValues = { handoff: GestureHandoffValues; /** * A flag indicating if the screen is in the process of dismissing (0 or 1). + * + * @deprecated Use `closing` for public screen lifecycle state. Release handoff + * details do not have a public replacement. */ dismissing: number; /** @@ -248,6 +257,8 @@ export type GestureValues = { dragging: number; /** * A flag indicating if released gesture values are animating back to neutral. + * + * @deprecated Use `animating` or `settled` for public motion state. */ settling: number; /** @deprecated Use `normX` instead. */ diff --git a/packages/react-native-screen-transitions/src/shared/utils/animation/animate-to-progress.ts b/packages/react-native-screen-transitions/src/shared/utils/animation/animate-to-progress.ts index 3235623e..64401491 100644 --- a/packages/react-native-screen-transitions/src/shared/utils/animation/animate-to-progress.ts +++ b/packages/react-native-screen-transitions/src/shared/utils/animation/animate-to-progress.ts @@ -1,6 +1,9 @@ import { runOnJS, type SharedValue } from "react-native-reanimated"; import { FALSE, TRUE } from "../../constants"; -import type { AnimationStoreMap } from "../../stores/animation.store"; +import { + type AnimationStoreMap, + emitMotionStart, +} from "../../stores/animation.store"; import type { TransitionSpec } from "../../types/animation.types"; import { animate, isSpringAnimationConfig } from "./animate"; @@ -21,6 +24,7 @@ interface AnimateToProgressProps { markEntering?: boolean; /** Optional initial velocity for spring-based progress (units: progress/sec). */ initialVelocity?: number; + isDragging?: SharedValue; } const setTransitionLifecycleFlags = ( @@ -64,6 +68,7 @@ export const animateToProgress = ({ emitWillAnimate = true, markEntering = true, initialVelocity, + isDragging, }: AnimateToProgressProps) => { "worklet"; @@ -82,15 +87,10 @@ export const animateToProgress = ({ ? { ...config, velocity: initialVelocity } : config; - const { - transitionProgress, - willAnimate, - progressAnimating, - progressSettled, - entering, - } = animations; + const { transitionProgress, progressAnimating, progressSettled, entering } = + animations; - const startAnimation = () => { + const startAnimation = (activateMotion: boolean) => { "worklet"; const { from: animationProgressFrom, to: animationProgressTo } = resolveAnimationProgressRange(transitionProgress.get(), value); @@ -118,7 +118,9 @@ export const animateToProgress = ({ return; } - progressAnimating.set(TRUE); //<-- Do not move this into the callback + if (activateMotion) { + progressAnimating.set(TRUE); + } progressSettled.set(FALSE); transitionProgress.set( animate( @@ -126,11 +128,10 @@ export const animateToProgress = ({ effectiveConfig, (state) => { "worklet"; - if (state.settled) { - progressSettled.set(TRUE); - } - if (!state.finished) return; + if (isDragging?.get()) return; + + progressSettled.set(TRUE); animationProgress.set(animationProgressTo); @@ -175,14 +176,13 @@ export const animateToProgress = ({ if (emitWillAnimate) { setTransitionLifecycleFlags(animations, isClosing, markEntering); - willAnimate.set(TRUE); + emitMotionStart(animations); requestAnimationFrame(() => { "worklet"; - willAnimate.set(FALSE); - startAnimation(); + startAnimation(false); }); return; } - startAnimation(); + startAnimation(true); }; diff --git a/test/setup.ts b/test/setup.ts index 13603934..f28409cf 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -45,6 +45,9 @@ declare global { var __reanimatedMeasureSpy: | ((ref: { current?: { tag?: string } }) => void) | undefined; + var __reanimatedDeferredTimingCallbacks: + | Array<() => void> + | undefined; } globalThis.resetMutableRegistry = () => { for (const { obj, initial } of mutableObjects) { @@ -125,9 +128,15 @@ mock.module("react-native-reanimated", () => ({ React.useRef(createTestMutable(initial)).current, withTiming: ( toValue: number, - config?: { __finished?: boolean }, + config?: { __defer?: boolean; __finished?: boolean }, callback?: (finished?: boolean) => void, ) => { + if (config?.__defer && callback) { + globalThis.__reanimatedDeferredTimingCallbacks?.push(() => { + callback(true); + }); + return toValue; + } callback?.(config?.__finished ?? true); return toValue; }, From fc07f5e46cbb498e674f240a0e74d65582d2b4de Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 25 Jul 2026 09:33:13 -0400 Subject: [PATCH 04/12] fix: stabilize transition styles and measurements Freeze settled style ownership across interpolator handoffs. Defer reserved-slot resets until their lifecycle is complete. Stage destination interpolator styles behind the visibility gate. Preserve native coordinates while correcting only the internal visibility offset. --- apps/e2e/app/[stackType]/bounds/_layout.tsx | 4 + apps/e2e/app/[stackType]/bounds/index.tsx | 6 + .../reveal-transformed-source/_layout.tsx | 33 ++ .../reveal-transformed-source/constants.ts | 1 + .../reveal-transformed-source/destination.tsx | 43 ++ .../reveal-transformed-source/index.tsx | 78 ++++ apps/e2e/app/example/_layout.tsx | 79 ++-- apps/e2e/app/example/constants.ts | 1 + apps/e2e/app/example/index.tsx | 107 ++++- apps/e2e/app/example/modal/_layout.tsx | 27 -- apps/e2e/app/example/modal/b.tsx | 33 -- apps/e2e/app/example/modal/index.tsx | 31 -- apps/e2e/app/example/sheet.tsx | 142 +++++++ apps/e2e/app/example/zoom.tsx | 102 +++++ apps/e2e/app/index.tsx | 5 +- .../bounds/measurement-rules.test.ts | 75 ++-- ...resolve-interpolator-style-handoff.test.ts | 166 ++++++++ .../styles/resolve-slot-styles.test.ts | 397 ++++++++++-------- .../styles/select-interpolator-frame.test.ts | 40 ++ .../__tests__/styles/visibility-gate.test.ts | 42 +- .../components/boundary/hooks/use-measurer.ts | 23 +- .../hooks/use-host-measurement.ts | 24 +- .../boundary/utils/measured-bounds.ts | 61 ++- .../screen-container/layers/content.tsx | 19 +- .../providers/screen/origin.provider.tsx | 43 -- .../providers/screen/styles/constants.ts | 14 +- .../resolve-interpolator-style-handoff.ts | 138 ++++++ .../helpers/resolve-slot-styles/index.ts | 75 +++- .../helpers/select-interpolator-frame.ts | 41 ++ .../screen/styles/helpers/visibility-gate.ts | 25 ++ .../hooks/use-interpolated-style-maps.tsx | 146 ++++--- .../providers/screen/styles/slot.provider.tsx | 10 +- 32 files changed, 1486 insertions(+), 545 deletions(-) create mode 100644 apps/e2e/app/[stackType]/bounds/reveal-transformed-source/_layout.tsx create mode 100644 apps/e2e/app/[stackType]/bounds/reveal-transformed-source/constants.ts create mode 100644 apps/e2e/app/[stackType]/bounds/reveal-transformed-source/destination.tsx create mode 100644 apps/e2e/app/[stackType]/bounds/reveal-transformed-source/index.tsx create mode 100644 apps/e2e/app/example/constants.ts delete mode 100644 apps/e2e/app/example/modal/_layout.tsx delete mode 100644 apps/e2e/app/example/modal/b.tsx delete mode 100644 apps/e2e/app/example/modal/index.tsx create mode 100644 apps/e2e/app/example/sheet.tsx create mode 100644 apps/e2e/app/example/zoom.tsx create mode 100644 packages/react-native-screen-transitions/src/shared/__tests__/styles/resolve-interpolator-style-handoff.test.ts create mode 100644 packages/react-native-screen-transitions/src/shared/__tests__/styles/select-interpolator-frame.test.ts delete mode 100644 packages/react-native-screen-transitions/src/shared/providers/screen/origin.provider.tsx create mode 100644 packages/react-native-screen-transitions/src/shared/providers/screen/styles/helpers/resolve-interpolator-style-handoff.ts create mode 100644 packages/react-native-screen-transitions/src/shared/providers/screen/styles/helpers/select-interpolator-frame.ts diff --git a/apps/e2e/app/[stackType]/bounds/_layout.tsx b/apps/e2e/app/[stackType]/bounds/_layout.tsx index 2016ef37..c3a7d459 100644 --- a/apps/e2e/app/[stackType]/bounds/_layout.tsx +++ b/apps/e2e/app/[stackType]/bounds/_layout.tsx @@ -13,6 +13,10 @@ export default function BoundsLayout() { + + + { + "worklet"; + return bounds({ + id: REVEAL_TRANSFORMED_SOURCE_ID, + }).navigation.zoom(); + }, + transitionSpec: Transition.Specs.Zoom, + }} + /> + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/reveal-transformed-source/constants.ts b/apps/e2e/app/[stackType]/bounds/reveal-transformed-source/constants.ts new file mode 100644 index 00000000..89962070 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/reveal-transformed-source/constants.ts @@ -0,0 +1 @@ +export const REVEAL_TRANSFORMED_SOURCE_ID = "reveal-transformed-source-card"; diff --git a/apps/e2e/app/[stackType]/bounds/reveal-transformed-source/destination.tsx b/apps/e2e/app/[stackType]/bounds/reveal-transformed-source/destination.tsx new file mode 100644 index 00000000..f483a2eb --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/reveal-transformed-source/destination.tsx @@ -0,0 +1,43 @@ +import { router } from "expo-router"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import Transition from "react-native-screen-transitions"; +import { ScreenHeader } from "@/components/screen-header"; +import { useTheme } from "@/theme"; +import { REVEAL_TRANSFORMED_SOURCE_ID } from "./constants"; + +export default function RevealTransformedSourceDestination() { + const theme = useTheme(); + return ( + + + + + + Reveal target + + + router.back()} + > + Back + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1 }, + content: { flex: 1, gap: 18, padding: 16 }, + target: { alignItems: "center", borderRadius: 28, flex: 1, justifyContent: "center" }, + targetText: { fontSize: 28, fontWeight: "800" }, + backButton: { alignItems: "center", borderRadius: 12, padding: 14 }, +}); diff --git a/apps/e2e/app/[stackType]/bounds/reveal-transformed-source/index.tsx b/apps/e2e/app/[stackType]/bounds/reveal-transformed-source/index.tsx new file mode 100644 index 00000000..eb16a37a --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/reveal-transformed-source/index.tsx @@ -0,0 +1,78 @@ +import { router } from "expo-router"; +import { StyleSheet, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import Transition from "react-native-screen-transitions"; +import { ScreenHeader } from "@/components/screen-header"; +import { + buildStackPath, + useResolvedStackType, +} from "@/components/stack-examples/stack-routing"; +import { useTheme } from "@/theme"; +import { REVEAL_TRANSFORMED_SOURCE_ID } from "./constants"; + +export default function RevealTransformedSourceIndex() { + const stackType = useResolvedStackType(); + const theme = useTheme(); + + return ( + + + + + { + router.push( + buildStackPath( + stackType, + "bounds/reveal-transformed-source/destination", + ) as never, + ); + }} + > + + Open Reveal + + + Source boundary + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1 }, + content: { flex: 1, gap: 18, padding: 16 }, + sheetBackground: { + alignItems: "center", + borderRadius: 28, + gap: 18, + padding: 28, + }, + sourceCard: { + alignItems: "center", + borderRadius: 18, + height: 96, + justifyContent: "center", + width: 168, + }, + sourceTitle: { fontSize: 18, fontWeight: "800" }, + sourceHint: { fontSize: 12, fontWeight: "700", marginTop: 5, opacity: 0.82 }, +}); diff --git a/apps/e2e/app/example/_layout.tsx b/apps/e2e/app/example/_layout.tsx index 7f099c11..7c79f4d8 100644 --- a/apps/e2e/app/example/_layout.tsx +++ b/apps/e2e/app/example/_layout.tsx @@ -2,54 +2,49 @@ import { interpolate } from "react-native-reanimated"; import type { ScreenTransitionConfig } from "react-native-screen-transitions"; import Transition from "react-native-screen-transitions"; import { BlankStack } from "@/layouts/blank-stack"; +import { SHEET_ZOOM_BOUNDARY_ID } from "./constants"; -const modalOptions: ScreenTransitionConfig = { +const sheetOptions: ScreenTransitionConfig = { gestureEnabled: true, - gestureTracking: "always", gestureDirection: "vertical", + snapPoints: [0.5], + initialSnapIndex: 0, + screenStyleInterpolator: ({ layouts: { screen: { height }, }, - progress, - focused, - active, current, + focused, }) => { "worklet"; - const isGestureDisabled = !current.options.gestureEnabled; - - const translateY = focused - ? interpolate(progress, [0, 1], [height, 0], "clamp") - : 0; - - const gestureSensitivity = isGestureDisabled - ? interpolate(progress, [0, 0.25], [1, 0.1], "clamp") - : 1; - - const gestureReleaseVelocityScale = isGestureDisabled ? 0 : 1; - - const contentStyle = { - transform: [{ translateY }], - maxHeight: focused ? height * 0.9 : undefined, - marginTop: focused ? "auto" : undefined, - } as const; - - const backdropStyle = focused - ? { - backgroundColor: "#00000050", - opacity: active.progress, - } - : undefined; + const translateY = interpolate( + current.progress, + [0, 1], + [height, 0], + "clamp", + ); return { - options: { - gestureSensitivity, - gestureReleaseVelocityScale, + content: { + style: { + transform: [{ translateY }], + }, }, - content: { style: contentStyle }, - backdrop: { style: backdropStyle }, + backdrop: focused + ? { + style: { + backgroundColor: "black", + opacity: interpolate( + current.progress, + [0, 0.5], + [0, 0.4], + "clamp", + ), + }, + } + : undefined, }; }, transitionSpec: { @@ -58,11 +53,23 @@ const modalOptions: ScreenTransitionConfig = { }, }; +const zoomOptions: ScreenTransitionConfig = { + gestureEnabled: true, + gestureDirection: ["bidirectional", "pinch-in"], + navigationMaskEnabled: true, + screenStyleInterpolator: ({ bounds }) => { + "worklet"; + return bounds(SHEET_ZOOM_BOUNDARY_ID).navigation.zoom(); + }, + transitionSpec: Transition.Specs.Zoom, +}; + export default function ExampleLayout() { return ( - - + + + ); } diff --git a/apps/e2e/app/example/constants.ts b/apps/e2e/app/example/constants.ts new file mode 100644 index 00000000..9ac64cac --- /dev/null +++ b/apps/e2e/app/example/constants.ts @@ -0,0 +1 @@ +export const SHEET_ZOOM_BOUNDARY_ID = "sheet-style-reset-zoom"; diff --git a/apps/e2e/app/example/index.tsx b/apps/e2e/app/example/index.tsx index afa2e40c..c31d6f4a 100644 --- a/apps/e2e/app/example/index.tsx +++ b/apps/e2e/app/example/index.tsx @@ -1,22 +1,103 @@ import { router } from "expo-router"; -import { Button, View } from "react-native"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; import { useTheme } from "@/theme"; export default function ExampleIndex() { const theme = useTheme(); + return ( - -