diff --git a/.release-it.json b/.release-it.json index c93fa32f..dd4734db 100644 --- a/.release-it.json +++ b/.release-it.json @@ -53,6 +53,7 @@ "before:git:release": "node ../../.github/scripts/release-notes.mjs ${version} --check", "after:bump": [ "node ../../.github/scripts/sync-bun-lock-version.mjs", + "git add ../../bun.lock", "bun run --cwd ../.. build", "npm pack --dry-run" ] diff --git a/apps/e2e/.maestro/inactive-hide-thaw.yaml b/apps/e2e/.maestro/inactive-hide-thaw.yaml new file mode 100644 index 00000000..7be25cd8 --- /dev/null +++ b/apps/e2e/.maestro/inactive-hide-thaw.yaml @@ -0,0 +1,55 @@ +appId: "com.eds2002.e2e" +name: "Inactive hide thaw restores gestures" +tags: + - maestro + - regression +--- +- launchApp: + clearState: true +- openLink: "e2e:///blank-stack/inactive-behavior" +- assertVisible: + id: "blank-inactive-behavior-hide" +- tapOn: + id: "blank-inactive-behavior-hide" +- assertVisible: + id: "inactive-behavior-hide-push-next" + +# Control: the same screen gesture works before it has ever been frozen. +- swipe: + start: "2%, 50%" + end: "99%, 50%" + duration: 250 +- waitForAnimationToEnd +- assertVisible: + id: "blank-inactive-behavior-hide" +- tapOn: + id: "blank-inactive-behavior-hide" +- assertVisible: + id: "inactive-behavior-hide-push-next" + +# Push far enough for the original route to become inactive, frozen, and hidden. +- tapOn: + id: "inactive-behavior-hide-push-next" +- waitForAnimationToEnd +- tapOn: + id: "inactive-behavior-hide-push-next" +- waitForAnimationToEnd + +# Pop both newer routes so the original route thaws and becomes active again. +- tapOn: + id: "inactive-behavior-hide-back" +- waitForAnimationToEnd +- tapOn: + id: "inactive-behavior-hide-back" +- waitForAnimationToEnd + +# The gesture detector on the previously hidden route must reattach. +- swipe: + start: "2%, 50%" + end: "99%, 50%" + duration: 250 +- waitForAnimationToEnd +- assertVisible: + id: "blank-inactive-behavior-hide" +- assertNotVisible: + id: "inactive-behavior-hide-push-next" diff --git a/apps/e2e/app/[stackType]/bounds/_layout.tsx b/apps/e2e/app/[stackType]/bounds/_layout.tsx index 855d65ae..283047b1 100644 --- a/apps/e2e/app/[stackType]/bounds/_layout.tsx +++ b/apps/e2e/app/[stackType]/bounds/_layout.tsx @@ -12,6 +12,9 @@ export default function BoundsLayout() { return ( + + + @@ -23,6 +26,7 @@ export default function BoundsLayout() { name="handoff-multiflow" options={{ ...IOSSlide() }} /> + ); } diff --git a/apps/e2e/app/[stackType]/bounds/example-1/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-1/_layout.tsx new file mode 100644 index 00000000..0460bb70 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-1/_layout.tsx @@ -0,0 +1,30 @@ +import Transition from "react-native-screen-transitions"; +import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; +import { BlankStack } from "@/layouts/blank-stack"; +import { Stack } from "@/layouts/stack"; +import { nestedBoundaryZoomInterpolator } from "../nested-boundary-example"; + +export default function NestedSourceExampleLayout() { + const stackType = useResolvedStackType(); + const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; + const screenOptions = + stackType === "native-stack" ? { enableTransitions: true } : undefined; + + return ( + + + + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/example-1/destination.tsx b/apps/e2e/app/[stackType]/bounds/example-1/destination.tsx new file mode 100644 index 00000000..3d321f48 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-1/destination.tsx @@ -0,0 +1,5 @@ +import { NestedBoundaryDestination } from "../nested-boundary-example"; + +export default function ParentDestinationScreen() { + return ; +} diff --git a/apps/e2e/app/[stackType]/bounds/example-1/nested/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-1/nested/_layout.tsx new file mode 100644 index 00000000..fbb2eb26 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-1/nested/_layout.tsx @@ -0,0 +1,16 @@ +import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; +import { BlankStack } from "@/layouts/blank-stack"; +import { Stack } from "@/layouts/stack"; + +export default function NestedSourceLevelOneLayout() { + const stackType = useResolvedStackType(); + const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; + const screenOptions = + stackType === "native-stack" ? { enableTransitions: true } : undefined; + + return ( + + + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/_layout.tsx new file mode 100644 index 00000000..39409293 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/_layout.tsx @@ -0,0 +1,16 @@ +import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; +import { BlankStack } from "@/layouts/blank-stack"; +import { Stack } from "@/layouts/stack"; + +export default function NestedSourceLevelTwoLayout() { + const stackType = useResolvedStackType(); + const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; + const screenOptions = + stackType === "native-stack" ? { enableTransitions: true } : undefined; + + return ( + + + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/nested/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/nested/_layout.tsx new file mode 100644 index 00000000..8b4ec823 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/nested/_layout.tsx @@ -0,0 +1,16 @@ +import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; +import { BlankStack } from "@/layouts/blank-stack"; +import { Stack } from "@/layouts/stack"; + +export default function NestedSourceLevelThreeLayout() { + const stackType = useResolvedStackType(); + const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; + const screenOptions = + stackType === "native-stack" ? { enableTransitions: true } : undefined; + + return ( + + + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/nested/source.tsx b/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/nested/source.tsx new file mode 100644 index 00000000..90c7e91d --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/nested/source.tsx @@ -0,0 +1,10 @@ +import { NestedBoundarySource } from "../../../../nested-boundary-example"; + +export default function DeeplyNestedSourceScreen() { + return ( + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/example-2/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-2/_layout.tsx new file mode 100644 index 00000000..f64d3b73 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-2/_layout.tsx @@ -0,0 +1,30 @@ +import Transition from "react-native-screen-transitions"; +import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; +import { BlankStack } from "@/layouts/blank-stack"; +import { Stack } from "@/layouts/stack"; +import { nestedBoundaryZoomInterpolator } from "../nested-boundary-example"; + +export default function NestedDestinationExampleLayout() { + const stackType = useResolvedStackType(); + const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; + const screenOptions = + stackType === "native-stack" ? { enableTransitions: true } : undefined; + + return ( + + + + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/example-2/nested/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-2/nested/_layout.tsx new file mode 100644 index 00000000..132085ac --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-2/nested/_layout.tsx @@ -0,0 +1,16 @@ +import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; +import { BlankStack } from "@/layouts/blank-stack"; +import { Stack } from "@/layouts/stack"; + +export default function NestedDestinationLevelOneLayout() { + const stackType = useResolvedStackType(); + const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; + const screenOptions = + stackType === "native-stack" ? { enableTransitions: true } : undefined; + + return ( + + + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/_layout.tsx new file mode 100644 index 00000000..fd49b2c1 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/_layout.tsx @@ -0,0 +1,16 @@ +import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; +import { BlankStack } from "@/layouts/blank-stack"; +import { Stack } from "@/layouts/stack"; + +export default function NestedDestinationLevelTwoLayout() { + const stackType = useResolvedStackType(); + const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; + const screenOptions = + stackType === "native-stack" ? { enableTransitions: true } : undefined; + + return ( + + + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/nested/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/nested/_layout.tsx new file mode 100644 index 00000000..20489712 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/nested/_layout.tsx @@ -0,0 +1,16 @@ +import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; +import { BlankStack } from "@/layouts/blank-stack"; +import { Stack } from "@/layouts/stack"; + +export default function NestedDestinationLevelThreeLayout() { + const stackType = useResolvedStackType(); + const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; + const screenOptions = + stackType === "native-stack" ? { enableTransitions: true } : undefined; + + return ( + + + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/nested/destination.tsx b/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/nested/destination.tsx new file mode 100644 index 00000000..82e075f6 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/nested/destination.tsx @@ -0,0 +1,5 @@ +import { NestedBoundaryDestination } from "../../../../nested-boundary-example"; + +export default function DeeplyNestedDestinationScreen() { + return ; +} diff --git a/apps/e2e/app/[stackType]/bounds/example-2/source.tsx b/apps/e2e/app/[stackType]/bounds/example-2/source.tsx new file mode 100644 index 00000000..0f92a025 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-2/source.tsx @@ -0,0 +1,10 @@ +import { NestedBoundarySource } from "../nested-boundary-example"; + +export default function ParentSourceScreen() { + return ( + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/example-3/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-3/_layout.tsx new file mode 100644 index 00000000..404573ff --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-3/_layout.tsx @@ -0,0 +1,30 @@ +import Transition from "react-native-screen-transitions"; +import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; +import { BlankStack } from "@/layouts/blank-stack"; +import { Stack } from "@/layouts/stack"; +import { nestedBoundaryZoomInterpolator } from "../nested-boundary-example"; + +export default function NeighboringNestedExampleLayout() { + const stackType = useResolvedStackType(); + const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; + const screenOptions = + stackType === "native-stack" ? { enableTransitions: true } : undefined; + + return ( + + + + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/example-3/nested-1/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-3/nested-1/_layout.tsx new file mode 100644 index 00000000..3cc80982 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-3/nested-1/_layout.tsx @@ -0,0 +1,16 @@ +import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; +import { BlankStack } from "@/layouts/blank-stack"; +import { Stack } from "@/layouts/stack"; + +export default function NeighboringNestedSourceLayout() { + const stackType = useResolvedStackType(); + const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; + const screenOptions = + stackType === "native-stack" ? { enableTransitions: true } : undefined; + + return ( + + + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/example-3/nested-1/source.tsx b/apps/e2e/app/[stackType]/bounds/example-3/nested-1/source.tsx new file mode 100644 index 00000000..7b3d906a --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-3/nested-1/source.tsx @@ -0,0 +1,10 @@ +import { NestedBoundarySource } from "../../nested-boundary-example"; + +export default function NeighboringNestedSourceScreen() { + return ( + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/example-3/nested-2/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-3/nested-2/_layout.tsx new file mode 100644 index 00000000..5b4c7d2a --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-3/nested-2/_layout.tsx @@ -0,0 +1,16 @@ +import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; +import { BlankStack } from "@/layouts/blank-stack"; +import { Stack } from "@/layouts/stack"; + +export default function NeighboringNestedDestinationLayout() { + const stackType = useResolvedStackType(); + const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; + const screenOptions = + stackType === "native-stack" ? { enableTransitions: true } : undefined; + + return ( + + + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/example-3/nested-2/destination.tsx b/apps/e2e/app/[stackType]/bounds/example-3/nested-2/destination.tsx new file mode 100644 index 00000000..cb4a0c96 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/example-3/nested-2/destination.tsx @@ -0,0 +1,5 @@ +import { NestedBoundaryDestination } from "../../nested-boundary-example"; + +export default function NeighboringNestedDestinationScreen() { + return ; +} diff --git a/apps/e2e/app/[stackType]/bounds/index.tsx b/apps/e2e/app/[stackType]/bounds/index.tsx index 826e41ea..4d69ef0d 100644 --- a/apps/e2e/app/[stackType]/bounds/index.tsx +++ b/apps/e2e/app/[stackType]/bounds/index.tsx @@ -6,6 +6,24 @@ import { import { ListScreen } from "@/components/ui"; const BOUNDS_EXAMPLES = [ + { + id: "example-1/nested/nested/nested/source", + title: "Deeply nested source → parent destination", + description: + "Runtime group, bound target, and clipping controls across three nested stacks", + }, + { + id: "example-2/source", + title: "Parent source → deeply nested destination", + description: + "Runtime group, bound target, and clipping controls into three nested stacks", + }, + { + id: "example-3/nested-1/source", + title: "Nested source → neighboring nested destination", + description: + "Runtime group, bound target, and clipping controls across sibling nested stacks", + }, { id: "style-id", title: ".reveal()", @@ -35,6 +53,12 @@ const BOUNDS_EXAMPLES = [ title: "Handoff multiflow", description: "One payload handed across a multi-screen push and pop flow", }, + { + id: "stacking", + title: "Repeated route stacking", + description: + "Alternating route pushes with independently selectable card and button sources", + }, ]; export default function BoundsHubIndex() { diff --git a/apps/e2e/app/[stackType]/bounds/nested-boundary-example.tsx b/apps/e2e/app/[stackType]/bounds/nested-boundary-example.tsx new file mode 100644 index 00000000..db9a68b9 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/nested-boundary-example.tsx @@ -0,0 +1,336 @@ +import { useNavigation } from "@react-navigation/native"; +import { router, useLocalSearchParams } from "expo-router"; +import { useState } from "react"; +import { Pressable, StyleSheet, Switch, Text, View } from "react-native"; +import { makeMutable } from "react-native-reanimated"; +import { SafeAreaView } from "react-native-safe-area-context"; +import type { ScreenTransitionConfig } from "react-native-screen-transitions"; +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"; + +const BOUNDARY_GROUP = "nested-stack-group"; + +const BOUNDARY_ITEMS = [ + { id: "violet", label: "Violet", color: "#7C3AED" }, + { id: "coral", label: "Coral", color: "#F43F5E" }, + { id: "cyan", label: "Cyan", color: "#0891B2" }, +] as const; + +const activeNestedBoundaryId = makeMutable(BOUNDARY_ITEMS[0].id); + +type ExampleParams = { + groups?: string; + id?: string; + targetBound?: string; +}; + +type NestedBoundaryMeta = { + nestedBoundaryGroups?: string; + nestedBoundaryTargetBound?: string; +}; + +type NestedBoundaryNavigation = { + setOptions: (options: { meta: NestedBoundaryMeta }) => void; +}; + +const isEnabled = (value: unknown) => value === "true"; + +const getRouteParam = (route: { params?: object } | undefined, key: string) => { + "worklet"; + const params = route?.params as Record | undefined; + return params?.[key]; +}; + +export const nestedBoundaryZoomInterpolator: ScreenTransitionConfig["screenStyleInterpolator"] = + ({ active, bounds, current, next, previous }) => { + "worklet"; + const groups = + active.meta?.nestedBoundaryGroups ?? + next?.meta?.nestedBoundaryGroups ?? + current.meta?.nestedBoundaryGroups ?? + previous?.meta?.nestedBoundaryGroups ?? + getRouteParam(active.route, "groups") ?? + getRouteParam(next?.route, "groups") ?? + getRouteParam(current.route, "groups") ?? + getRouteParam(previous?.route, "groups"); + const targetBoundParam = + active.meta?.nestedBoundaryTargetBound ?? + next?.meta?.nestedBoundaryTargetBound ?? + current.meta?.nestedBoundaryTargetBound ?? + previous?.meta?.nestedBoundaryTargetBound ?? + getRouteParam(active.route, "targetBound") ?? + getRouteParam(next?.route, "targetBound") ?? + getRouteParam(current.route, "targetBound") ?? + getRouteParam(previous?.route, "targetBound"); + const id = activeNestedBoundaryId.get(); + const group = groups === "true" ? BOUNDARY_GROUP : undefined; + const targetBound = targetBoundParam === "true"; + const boundary = bounds({ id, group }); + + return targetBound + ? boundary.navigation.zoom({ target: "bound" }) + : boundary.navigation.zoom(); + }; + +type ToggleRowProps = { + label: string; + value: boolean; + onValueChange: (value: boolean) => void; + testID: string; +}; + +function ToggleRow({ label, value, onValueChange, testID }: ToggleRowProps) { + const theme = useTheme(); + + return ( + + {label} + + + ); +} + +export function NestedBoundarySource({ + destination, + title, +}: { + destination: string; + title: string; +}) { + const stackType = useResolvedStackType(); + const theme = useTheme(); + const navigation = useNavigation() as NestedBoundaryNavigation; + const [groups, setGroups] = useState(true); + const [targetBound, setTargetBound] = useState(true); + const [escapeClipping, setEscapeClipping] = useState(true); + + const openDestination = (id: string) => { + activeNestedBoundaryId.set(id); + navigation.setOptions({ + meta: { + nestedBoundaryGroups: String(groups), + nestedBoundaryTargetBound: String(targetBound), + }, + }); + router.push({ + pathname: buildStackPath(stackType, destination) as never, + params: { + groups: String(groups), + id, + targetBound: String(targetBound), + }, + }); + }; + + return ( + + + + + + + + + + + {BOUNDARY_ITEMS.map((item, index) => ( + + openDestination(item.id)} + > + SOURCE + {item.label} + + + ))} + + + + ); +} + +export function NestedBoundaryDestination({ title }: { title: string }) { + const theme = useTheme(); + const params = useLocalSearchParams(); + const groups = isEnabled(params.groups); + const [activeId, setActiveId] = useState(params.id ?? BOUNDARY_ITEMS[0].id); + const activeItem = + BOUNDARY_ITEMS.find((item) => item.id === activeId) ?? BOUNDARY_ITEMS[0]; + const selectBoundary = (id: string) => { + activeNestedBoundaryId.set(id); + setActiveId(id); + }; + + return ( + + + + + {BOUNDARY_ITEMS.map((item) => { + const active = item.id === activeItem.id; + return ( + + DESTINATION + {item.label} + + ); + })} + + + {BOUNDARY_ITEMS.map((item) => { + const active = item.id === activeItem.id; + return ( + selectBoundary(item.id)} + > + + {item.label} + + + ); + })} + + + Groups: {groups ? "on" : "off"} · target bound:{" "} + {isEnabled(params.targetBound) ? "on" : "off"} · dismisses to:{" "} + {activeItem.label} + + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1 }, + content: { flex: 1, padding: 16, gap: 28 }, + destinationContent: { + flex: 1, + padding: 24, + justifyContent: "center", + gap: 24, + }, + toggles: { gap: 10 }, + toggleRow: { + minHeight: 54, + borderRadius: 16, + paddingHorizontal: 16, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + toggleLabel: { fontSize: 16, fontWeight: "600" }, + sourceCards: { flex: 1, gap: 12 }, + sourceCardContainer: { + height: 92, + }, + sourceCard: { + height: "100%", + width: "100%", + borderRadius: 28, + padding: 18, + justifyContent: "flex-end", + overflow: "hidden", + }, + sourceCard1: { width: "46%" }, + sourceCard2: { width: "58%", alignSelf: "flex-end" }, + sourceCard3: { width: "72%", alignSelf: "center" }, + sourceCardTitle: { color: "#FFFFFF", fontSize: 18, fontWeight: "800" }, + destinationCardStage: { + height: 280, + width: "100%", + }, + destinationCard: { + ...StyleSheet.absoluteFillObject, + height: 280, + width: "100%", + borderRadius: 36, + backgroundColor: "#7C3AED", + padding: 24, + justifyContent: "flex-end", + overflow: "hidden", + }, + inactiveDestinationCard: { + opacity: 0, + }, + cardEyebrow: { + color: "rgba(255,255,255,0.72)", + fontSize: 11, + fontWeight: "800", + letterSpacing: 1.2, + }, + cardTitle: { + color: "#FFFFFF", + fontSize: 22, + fontWeight: "800", + marginTop: 4, + }, + idControls: { flexDirection: "row", gap: 8 }, + idControl: { + flex: 1, + alignItems: "center", + paddingVertical: 12, + borderRadius: 14, + }, + summary: { fontSize: 14, lineHeight: 21, textAlign: "center" }, +}); diff --git a/apps/e2e/app/[stackType]/bounds/stacking/[id].tsx b/apps/e2e/app/[stackType]/bounds/stacking/[id].tsx new file mode 100644 index 00000000..ec8dd695 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/stacking/[id].tsx @@ -0,0 +1,157 @@ +import { StackActions, useNavigation } from "@react-navigation/native"; +import { useLocalSearchParams } 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 { + parseStackDepth, + STACKING_BUTTON_BOUNDARY_ID, + STACKING_CARD_BOUNDARY_ID, + type StackingBoundaryId, +} from "./constants"; +import { StackingCardContent } from "./stacking-card-content"; + +export default function StackingBoundsDetail() { + const { depth: depthParam, id: idParam } = useLocalSearchParams<{ + depth?: string | string[]; + id?: string | string[]; + }>(); + const depth = parseStackDepth(depthParam); + const routeId = Array.isArray(idParam) + ? idParam[0] + : (idParam ?? STACKING_CARD_BOUNDARY_ID); + const navigation = useNavigation(); + const theme = useTheme(); + + const pushIndex = (id: StackingBoundaryId) => { + navigation.dispatch( + StackActions.push("index", { + depth: String(depth + 1), + id, + }), + ); + }; + + return ( + + + + + pushIndex(STACKING_CARD_BOUNDARY_ID)} + > + + + + + + + + Pair isolation + + + This route entered from {routeId}. Tap the card or black button to + choose the source for the next index instance. + + + + pushIndex(STACKING_BUTTON_BOUNDARY_ID)} + > + + + Push index again + + + depth {depth + 1} → + + + + + + Back pops normally · button keeps stacking + + + + ); +} + +const styles = StyleSheet.create({ + body: { + fontSize: 15, + lineHeight: 22, + marginTop: 8, + }, + button: { + alignItems: "center", + borderCurve: "continuous", + borderRadius: 18, + flexDirection: "row", + justifyContent: "space-between", + paddingHorizontal: 18, + paddingVertical: 16, + }, + buttonMeta: { + fontFamily: "monospace", + fontSize: 12, + opacity: 0.68, + }, + buttonText: { + fontSize: 15, + fontWeight: "700", + }, + content: { + gap: 26, + paddingBottom: 48, + paddingHorizontal: 18, + paddingTop: 12, + }, + copy: { + paddingHorizontal: 4, + }, + hero: { + backgroundColor: "#172554", + borderCurve: "continuous", + borderRadius: 42, + height: 390, + overflow: "hidden", + }, + hint: { + fontFamily: "monospace", + fontSize: 11, + textAlign: "center", + }, + screen: { + flex: 1, + }, + title: { + fontSize: 24, + fontWeight: "700", + letterSpacing: -0.5, + }, +}); diff --git a/apps/e2e/app/[stackType]/bounds/stacking/_layout.tsx b/apps/e2e/app/[stackType]/bounds/stacking/_layout.tsx new file mode 100644 index 00000000..8d883cf2 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/stacking/_layout.tsx @@ -0,0 +1,65 @@ +import type { ScreenTransitionConfig } from "react-native-screen-transitions"; +import Transition from "react-native-screen-transitions"; +import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; +import { BlankStack } from "@/layouts/blank-stack"; +import { Stack } from "@/layouts/stack"; + +const getRouteBoundaryId = (route: { params?: object } | undefined) => { + "worklet"; + const params = route?.params as { id?: unknown } | undefined; + return typeof params?.id === "string" ? params.id : ""; +}; + +const stackingInterpolator: ScreenTransitionConfig["screenStyleInterpolator"] = + ({ active, bounds, current, next, previous }) => { + "worklet"; + const id = + getRouteBoundaryId(active.route) || + getRouteBoundaryId(next?.route) || + getRouteBoundaryId(previous?.route) || + getRouteBoundaryId(current.route); + + if (!id) return {}; + + return bounds(id).navigation.zoom(); + }; + +export default function StackingBoundsLayout() { + const stackType = useResolvedStackType(); + const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; + const navigatorScreenOptions = + stackType === "native-stack" ? { enableTransitions: true } : undefined; + + return ( + + + getRouteBoundaryId(route) + ? { + backdropBehavior: "dismiss", + gestureDirection: ["bidirectional", "pinch-in"], + gestureEnabled: true, + gestureProgressMode: "freeform", + navigationMaskEnabled: true, + screenStyleInterpolator: stackingInterpolator, + transitionSpec: Transition.Specs.Zoom, + } + : {} + } + /> + + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/stacking/constants.ts b/apps/e2e/app/[stackType]/bounds/stacking/constants.ts new file mode 100644 index 00000000..bca749a2 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/stacking/constants.ts @@ -0,0 +1,12 @@ +export const STACKING_CARD_BOUNDARY_ID = "stacking-card"; +export const STACKING_BUTTON_BOUNDARY_ID = "stacking-button"; + +export type StackingBoundaryId = + | typeof STACKING_CARD_BOUNDARY_ID + | typeof STACKING_BUTTON_BOUNDARY_ID; + +export function parseStackDepth(value: string | string[] | undefined): number { + const rawValue = Array.isArray(value) ? value[0] : value; + const parsedValue = Number.parseInt(rawValue ?? "0", 10); + return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : 0; +} diff --git a/apps/e2e/app/[stackType]/bounds/stacking/index.tsx b/apps/e2e/app/[stackType]/bounds/stacking/index.tsx new file mode 100644 index 00000000..41a2a1f0 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/stacking/index.tsx @@ -0,0 +1,164 @@ +import { StackActions, useNavigation } from "@react-navigation/native"; +import { useLocalSearchParams } 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 { + parseStackDepth, + STACKING_BUTTON_BOUNDARY_ID, + STACKING_CARD_BOUNDARY_ID, + type StackingBoundaryId, +} from "./constants"; +import { StackingCardContent } from "./stacking-card-content"; + +export default function StackingBoundsIndex() { + const { depth: depthParam } = useLocalSearchParams<{ + depth?: string | string[]; + }>(); + const depth = parseStackDepth(depthParam); + const navigation = useNavigation(); + const theme = useTheme(); + + const pushDetail = (id: StackingBoundaryId) => { + navigation.dispatch( + StackActions.push("[id]", { + depth: String(depth + 1), + id, + }), + ); + }; + + return ( + + + + + + + + + CURRENT ROUTE INSTANCE + + index + + Tap either bound below. The selected id decides whether Zoom starts + from the card or the button. + + + + pushDetail(STACKING_CARD_BOUNDARY_ID)} + > + + + + + + pushDetail(STACKING_BUTTON_BOUNDARY_ID)} + > + + + Push from button + + + depth {depth + 1} → + + + + + + Both paths push the same dynamic screen + + + + ); +} + +const styles = StyleSheet.create({ + button: { + alignItems: "center", + borderCurve: "continuous", + borderRadius: 18, + flexDirection: "row", + justifyContent: "space-between", + paddingHorizontal: 18, + paddingVertical: 16, + }, + buttonMeta: { + fontFamily: "monospace", + fontSize: 12, + opacity: 0.68, + }, + buttonText: { + fontSize: 15, + fontWeight: "700", + }, + card: { + backgroundColor: "#172554", + borderCurve: "continuous", + borderRadius: 28, + height: 250, + overflow: "hidden", + }, + content: { + gap: 28, + paddingBottom: 48, + paddingHorizontal: 18, + paddingTop: 12, + }, + explainer: { + borderCurve: "continuous", + borderRadius: 18, + padding: 16, + }, + explainerLabel: { + fontSize: 10, + fontWeight: "700", + letterSpacing: 1.2, + }, + explainerText: { + fontSize: 13, + lineHeight: 19, + marginTop: 8, + }, + hint: { + fontFamily: "monospace", + fontSize: 12, + textAlign: "center", + }, + route: { + fontFamily: "monospace", + fontSize: 20, + fontWeight: "700", + marginTop: 5, + }, + screen: { + flex: 1, + }, +}); diff --git a/apps/e2e/app/[stackType]/bounds/stacking/stacking-card-content.tsx b/apps/e2e/app/[stackType]/bounds/stacking/stacking-card-content.tsx new file mode 100644 index 00000000..283859e0 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/stacking/stacking-card-content.tsx @@ -0,0 +1,88 @@ +import { StyleSheet, Text, View } from "react-native"; + +export function StackingCardContent({ + compact = false, +}: { + compact?: boolean; +}) { + return ( + + + + + + + + + ROUTE RELAY + + One boundary.{"\n"}Every pair. + + id: stacking-card + + + ); +} + +const styles = StyleSheet.create({ + caption: { + color: "rgba(255,255,255,0.62)", + fontFamily: "monospace", + fontSize: 10, + marginTop: 8, + }, + content: { + flex: 1, + justifyContent: "flex-end", + overflow: "hidden", + padding: 20, + }, + copy: { + zIndex: 1, + }, + eyebrow: { + color: "#67E8F9", + fontSize: 11, + fontWeight: "700", + letterSpacing: 1.4, + marginBottom: 8, + }, + orbitInner: { + backgroundColor: "#67E8F9", + borderRadius: 999, + height: 30, + width: 30, + }, + orbitMiddle: { + alignItems: "center", + borderColor: "rgba(103,232,249,0.46)", + borderRadius: 999, + borderWidth: 1, + height: 94, + justifyContent: "center", + width: 94, + }, + orbitOuter: { + alignItems: "center", + borderColor: "rgba(103,232,249,0.18)", + borderRadius: 999, + borderWidth: 1, + height: 178, + justifyContent: "center", + position: "absolute", + right: -24, + top: -38, + width: 178, + }, + title: { + color: "white", + fontSize: 34, + fontWeight: "700", + letterSpacing: -1.1, + lineHeight: 36, + }, + titleCompact: { + fontSize: 26, + lineHeight: 28, + }, +}); diff --git a/apps/e2e/app/[stackType]/bounds/zoom/[id].tsx b/apps/e2e/app/[stackType]/bounds/zoom/[id].tsx index dbb7dba3..05bf3c51 100644 --- a/apps/e2e/app/[stackType]/bounds/zoom/[id].tsx +++ b/apps/e2e/app/[stackType]/bounds/zoom/[id].tsx @@ -115,13 +115,13 @@ function DetailPage({ - {item.color.toUpperCase()} - + diff --git a/apps/e2e/app/[stackType]/bounds/zoom/_layout.tsx b/apps/e2e/app/[stackType]/bounds/zoom/_layout.tsx index e4403c3f..4216d419 100644 --- a/apps/e2e/app/[stackType]/bounds/zoom/_layout.tsx +++ b/apps/e2e/app/[stackType]/bounds/zoom/_layout.tsx @@ -28,7 +28,7 @@ const navigationZoomInterpolator: ScreenTransitionConfig["screenStyleInterpolato const navigationStyles = bounds({ id, group: ZOOM_GROUP, - }).navigation.zoom({ target: "bound" }); + }).navigation.zoom(); return { ...navigationStyles, diff --git a/apps/e2e/bun.lock b/apps/e2e/bun.lock index 1965bc1b..ae984fe3 100644 --- a/apps/e2e/bun.lock +++ b/apps/e2e/bun.lock @@ -24,7 +24,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", - "react-native-gesture-handler": "~2.28.0", + "react-native-gesture-handler": "~2.31.1", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", @@ -476,6 +476,8 @@ "@types/react": ["@types/react@19.1.17", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA=="], + "@types/react-test-renderer": ["@types/react-test-renderer@19.1.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ=="], + "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], @@ -1382,7 +1384,7 @@ "react-native": ["react-native@0.81.5", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "@react-native/virtualized-lists": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw=="], - "react-native-gesture-handler": ["react-native-gesture-handler@2.28.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A=="], + "react-native-gesture-handler": ["react-native-gesture-handler@2.31.2", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "@types/react-test-renderer": "^19.1.0", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A=="], "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="], diff --git a/apps/e2e/layouts/blank-stack.tsx b/apps/e2e/layouts/blank-stack.tsx index ab6e5c6d..37c74923 100644 --- a/apps/e2e/layouts/blank-stack.tsx +++ b/apps/e2e/layouts/blank-stack.tsx @@ -5,7 +5,6 @@ import type { } from "@react-navigation/native"; import { withLayoutContext } from "expo-router"; import type { ComponentProps } from "react"; -import { Platform } from "react-native"; import { type BlankStackNavigationEventMap, type BlankStackNavigationOptions, @@ -15,7 +14,7 @@ import { const { Navigator } = createBlankStackNavigator(); function BlankStackNavigator(props: ComponentProps) { - return ; + return ; } export const BlankStack = withLayoutContext< diff --git a/apps/e2e/package.json b/apps/e2e/package.json index 627a1bd9..ddeb89bf 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -40,11 +40,11 @@ "react-dom": "19.2.0", "react-native": "0.83.10", "react-native-fast-squircle": "^1.1.1", - "react-native-gesture-handler": "~2.30.0", - "react-native-reanimated": "4.2.1", + "react-native-gesture-handler": "~2.31.1", + "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screen-transitions": "workspace:*", - "react-native-screens": "~4.23.0", + "react-native-screens": "~4.16.0", "react-native-teleport": "1.2.0", "react-native-web": "~0.21.0", "react-native-worklets": "0.7.4", diff --git a/bun.lock b/bun.lock index b0c13a95..f6d7e041 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,8 @@ "@types/bun": "^1.3.14", "commitlint": "^21.0.1", "lefthook": "^2.1.6", + "react": "19.2.0", + "react-test-renderer": "19.1.0", "release-it": "^20.2.0", }, }, @@ -41,11 +43,11 @@ "react-dom": "19.2.0", "react-native": "0.83.10", "react-native-fast-squircle": "^1.1.1", - "react-native-gesture-handler": "~2.30.0", - "react-native-reanimated": "4.2.1", + "react-native-gesture-handler": "~2.31.1", + "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screen-transitions": "workspace:*", - "react-native-screens": "~4.23.0", + "react-native-screens": "~4.16.0", "react-native-teleport": "1.2.0", "react-native-web": "~0.21.0", "react-native-worklets": "0.7.4", @@ -87,7 +89,7 @@ }, "packages/react-native-screen-transitions": { "name": "react-native-screen-transitions", - "version": "3.12.0", + "version": "3.13.0", "devDependencies": { "@testing-library/react-native": "13.3.3", "@types/react": "~19.1.0", @@ -1206,6 +1208,8 @@ "@types/react-native": ["@types/react-native@0.73.0", "", { "dependencies": { "react-native": "*" } }, "sha512-6ZRPQrYM72qYKGWidEttRe6M5DZBEV5F+MHMHqd4TTYx0tfkcdrUFGdef6CCxY0jXU7wldvd/zA/b0A/kTeJmA=="], + "@types/react-test-renderer": ["@types/react-test-renderer@19.1.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ=="], + "@types/retry": ["@types/retry@0.12.2", "", {}, "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow=="], "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], @@ -2904,7 +2908,7 @@ "react-native-fast-squircle": ["react-native-fast-squircle@1.1.5", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-mgSldhUWkkY3FRRcpmZqyRzrPgQZmiMyU09UyuPi1vp/jolerC64RpnNue3/eTdihQzU9PEwCreROzSon4CmOQ=="], - "react-native-gesture-handler": ["react-native-gesture-handler@2.30.1", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-xIUBDo5ktmJs++0fZlavQNvDEE4PsihWhSeJsJtoz4Q6p0MiTM9TgrTgfEgzRR36qGPytFoeq+ShLrVwGdpUdA=="], + "react-native-gesture-handler": ["react-native-gesture-handler@2.31.2", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "@types/react-test-renderer": "^19.1.0", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A=="], "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.3.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA=="], @@ -3038,7 +3042,7 @@ "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], @@ -3758,6 +3762,8 @@ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "e2e/@types/react": ["@types/react@19.1.17", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA=="], + "e2e/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], @@ -3896,7 +3902,7 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "react-native-builder-bob/@babel/preset-typescript": ["@babel/preset-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ=="], + "react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "react-native-builder-bob/babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.34.0", "", { "dependencies": { "hermes-parser": "0.34.0" } }, "sha512-q4xeAymMrot/21MHA3+fd5mcFF7stx6ntKFO/Of5ldyDpgTBcK1l0NiHAh4NdHHdb4aHqHgQOy7r6yk0IIlz8Q=="], @@ -3920,8 +3926,6 @@ "react-native-worklets/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "react-test-renderer/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], - "read-package-up/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "read-pkg/normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], @@ -4148,8 +4152,6 @@ "@types/react-native/react-native/babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.29.1", "", { "dependencies": { "hermes-parser": "0.29.1" } }, "sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA=="], - "@types/react-native/react-native/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], - "@types/react-native/react-native/ws": ["ws@6.2.4", "", { "dependencies": { "async-limiter": "~1.0.0" } }, "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], diff --git a/package.json b/package.json index a8c0382a..35d80f94 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "@types/bun": "^1.3.14", "commitlint": "^21.0.1", "lefthook": "^2.1.6", + "react": "19.2.0", + "react-test-renderer": "19.1.0", "release-it": "^20.2.0" }, "commitlint": { diff --git a/packages/docs/src/content/docs/inactive-behavior.mdx b/packages/docs/src/content/docs/inactive-behavior.mdx index c7eaf891..e7d41181 100644 --- a/packages/docs/src/content/docs/inactive-behavior.mdx +++ b/packages/docs/src/content/docs/inactive-behavior.mdx @@ -43,7 +43,10 @@ With `react-native-screens`, blank stack sets `activityState={0}`, `shouldFreeze Use `hide` when back navigation should stay warm, but inactive views do not need to remain visible or keep rendering. It can reduce inactive React render pressure and native presentation work. It does not release JS memory, because the component tree is still mounted. - `hide` moves the inactive screen off-screen instead of using `display: "none"`. React Native Gesture Handler can fail to reattach the gesture detector after a screen is removed from the native view hierarchy. The off-screen transform is the current trade-off for keeping hidden screens warm without breaking gestures when they become active again. + `hide` uses `display: "none"`. Gesture reattachment after a hidden screen + becomes active again has been verified with React Native Gesture Handler + 2.31.1. If a restored screen is visible but its gestures do not respond, + update Gesture Handler to that known-good version. ## `pause` diff --git a/packages/docs/src/content/docs/updating-to-3-13.mdx b/packages/docs/src/content/docs/updating-to-3-13.mdx new file mode 100644 index 00000000..de6a5558 --- /dev/null +++ b/packages/docs/src/content/docs/updating-to-3-13.mdx @@ -0,0 +1,56 @@ +--- +title: "New in 3.13" +pageTitle: "New in 3.13" +summary: "v3.13 makes Boundary transitions more predictable and reduces animation work in deep retained stacks." +description: "v3.13 makes Boundary transitions more predictable and reduces animation work in deep retained stacks." +eyebrow: "Changelogs" +group: "Changelogs" +hidden: true +changelogDate: "August 2026" +order: -4 +to: /changelog/updating-to-3-13 +--- + +v3.13 makes bounds more global and retained stacks less expensive. Matching Boundaries can now coordinate across complex navigation structures without depending on where each Boundary happens to mount, while `stackProgress` no longer requires a navigator-wide update on every animation frame. + +The result is more predictable transitions across deeply nested navigators, repeated route pushes, dynamic Boundary IDs, and flows where only one side of a bounds transition exists. + +## Boundaries across complex navigation + +Matching Boundary IDs can now find each other across nested layouts and navigators without extra setup. This includes cases where the source and destination live several navigator levels apart or where the same ID is reused across repeated pushes of the same route. + +Source-only transitions, fullscreen and custom targets, and destinations that appear before their source are also handled as normal bounds flows. A matching destination is no longer required before a source can participate. + +Bounds transitions also account more reliably for scale and position styles applied directly to a Boundary. Forward and backward transitions refresh their endpoints before animating, keeping them aligned when a Boundary has moved, resized, or changed scale since the previous transition. + +Escaped Boundary portals and live handoffs follow the same behavior. Content remains with its current screen until the next destination is ready, reducing flicker during multi-screen push and pop sequences. + +## Less animation work in deep stacks + +`stackProgress` is still the accumulated visual progress from the current screen through the top of its navigator. Its public value and usage have not changed. + +Internally, the navigator previously recalculated and wrote `stackProgress` for every retained route whenever visual progress changed. v3.13 removes that navigator-wide reaction. A live interpolation frame now derives its value directly from the relevant route suffix, so the top screen reads one progress value and deeper consumers read only as far as their own route. + +In matched release-mode profiling on a Pixel 8 at retained depths of 1, 26, and 51, samples attributed to animation-frame work fell from 927 to 626, a 32.5% reduction, while total sampled CPU fell by 4.9%. Hermes time and native commit counts remained essentially flat. This isolates a real animation-thread improvement, but it does not remove the normal memory, layout, and native-tree cost of retaining more screens. + +## More reliable inactive-screen settlement + +Inactive presentation now waits for the paint-driving screen's visual progress to settle. This prevents an older screen from being hidden during the final visual frames of a nested transition even when its underlying transition clock has already completed. + +After safe paint, blank-stack screens using `inactiveBehavior: "hide"` or `"pause"` now freeze their retained React subtree when native screens are enabled. `hide` also uses `display: "none"` instead of moving the screen far outside the viewport. The screen remains mounted and can thaw with its local state intact; use `"unmount"` when an eligible subtree and its resources should actually be released. + +This freezing behavior is provided by `react-native-screens`. With `nativeScreens={false}`, hidden screens still use `display: "none"`, but the regular React Native view does not provide the same native-screen freeze behavior. + +## More predictable locked snap gestures + +`gestureSnapLocked` now keeps `animatedSnapIndex` pinned to the current detent while dragging, dismissing, and settling back after a cancelled gesture. A programmatic `snapTo()` issued during that settlement can still take over and animate to its requested snap point. + +Cancelled gestures now return to their starting snap point without carrying release velocity into the reset. Swipes that commit to a different snap point keep their velocity, preserving natural momentum between detents while keeping snap-backs soft and controlled. + +## Upgrade notes + +No Boundary or `stackProgress` API migration is required. Existing `id`, `group`, `handoff`, `escapeClipping`, `.styles()`, `.values()`, `.link()`, navigation zoom, reveal, and interpolator APIs retain the same shape. + +The `experimental_updateInactiveInterpolators` option included in `3.13.0-beta.0` was removed before the final release. Remove it if you tested that beta; there is no replacement option to configure. + +The hidden-screen gesture reattachment path was verified with React Native Gesture Handler 2.31.1. The package's peer range has not changed, but 2.31.1 is the known-good version to use if gestures on a hidden screen do not respond after it becomes active again. diff --git a/packages/react-native-screen-transitions/CHANGELOG.md b/packages/react-native-screen-transitions/CHANGELOG.md index bed61601..ecf3d2e6 100644 --- a/packages/react-native-screen-transitions/CHANGELOG.md +++ b/packages/react-native-screen-transitions/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [3.13.0](https://github.com/eds2002/react-native-screen-transitions/compare/v3.13.0-beta.1...v3.13.0) (2026-08-27) + +### Bug Fixes + +* **boundary:** portal target content through handoff host ([315b730](https://github.com/eds2002/react-native-screen-transitions/commit/315b730b4d08d1f706aadcc566c7fe40998b6f1a)) +* **boundary:** retain handoffs across pair changes ([7863659](https://github.com/eds2002/react-native-screen-transitions/commit/7863659949413b3569e34219c2db46204766f941)) + ## [3.12.0](https://github.com/eds2002/react-native-screen-transitions/compare/v3.11.2...v3.12.0) (2026-08-09) ### Features diff --git a/packages/react-native-screen-transitions/package.json b/packages/react-native-screen-transitions/package.json index b65a5b65..af64f0c7 100644 --- a/packages/react-native-screen-transitions/package.json +++ b/packages/react-native-screen-transitions/package.json @@ -1,6 +1,6 @@ { "name": "react-native-screen-transitions", - "version": "3.12.0", + "version": "3.13.0", "description": "Easy screen transitions for React Native and Expo", "author": "Ed", "license": "MIT", diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/activity-screen.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/activity-screen.test.ts new file mode 100644 index 00000000..a2c41d43 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/__tests__/activity-screen.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "bun:test"; +import { + HIDDEN_ACTIVITY_SCREEN_STYLE, + resolveActivityScreenPresentation, +} from "../components/activity/helpers"; + +describe("ActivityScreen presentation", () => { + it("freezes and removes settled inactive hide content from presentation", () => { + expect( + resolveActivityScreenPresentation({ + activity: "inactive", + inactiveBehavior: "hide", + waitForPaintDriver: false, + }), + ).toEqual({ + activityState: 0, + freezeOnBlur: true, + pointerEvents: "none", + shouldFreeze: true, + visible: false, + }); + expect(HIDDEN_ACTIVITY_SCREEN_STYLE).toEqual({ display: "none" }); + }); + + it("restores native presentation and updates when the screen becomes active", () => { + expect( + resolveActivityScreenPresentation({ + activity: "active", + inactiveBehavior: "hide", + waitForPaintDriver: false, + }), + ).toEqual({ + activityState: 2, + freezeOnBlur: true, + pointerEvents: "auto", + shouldFreeze: false, + visible: true, + }); + }); +}); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/bounds/coordinator.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/bounds/coordinator.test.ts new file mode 100644 index 00000000..ec3852c8 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/__tests__/bounds/coordinator.test.ts @@ -0,0 +1,542 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { makeMutable } from "react-native-reanimated"; +import { createScreenPairKey } from "../../stores/bounds/helpers/link-pairs.helpers"; +import { applyMeasuredBoundsWrites } from "../../providers/helpers/measured-bounds-writes"; +import { + abandonBoundaryMeasurement, + getBoundaryMeasurementRequest, + markBoundaryPortalReady, + registerBoundary, + registerScreen, + requestBoundaryMeasurements, + unregisterBoundary, + unregisterScreen, +} from "../../stores/bounds/internals/coordinator"; +import { boundaryRegistry, pairs } from "../../stores/bounds/internals/state"; + +const registerTree = () => { + registerScreen({ + screenKey: "source-root", + animationProgress: makeMutable(1), + pendingLifecycleStartBlockCount: makeMutable(0), + }); + registerScreen({ + screenKey: "source-level-1", + parentScreenKey: "source-root", + animationProgress: makeMutable(1), + pendingLifecycleStartBlockCount: makeMutable(0), + }); + registerScreen({ + screenKey: "source-level-2", + parentScreenKey: "source-level-1", + animationProgress: makeMutable(1), + pendingLifecycleStartBlockCount: makeMutable(0), + }); + registerScreen({ + screenKey: "source-leaf", + parentScreenKey: "source-level-2", + animationProgress: makeMutable(1), + pendingLifecycleStartBlockCount: makeMutable(0), + }); + + registerScreen({ + screenKey: "destination-root", + animationProgress: makeMutable(0), + pendingLifecycleStartBlockCount: makeMutable(0), + }); + registerScreen({ + screenKey: "destination-level-1", + parentScreenKey: "destination-root", + animationProgress: makeMutable(1), + pendingLifecycleStartBlockCount: makeMutable(0), + }); + registerScreen({ + screenKey: "destination-level-2", + parentScreenKey: "destination-level-1", + animationProgress: makeMutable(1), + pendingLifecycleStartBlockCount: makeMutable(0), + }); + registerScreen({ + screenKey: "destination-leaf", + parentScreenKey: "destination-level-2", + animationProgress: makeMutable(1), + pendingLifecycleStartBlockCount: makeMutable(0), + }); +}; + +const measured = { + x: 10, + y: 20, + pageX: 10, + pageY: 20, + width: 100, + height: 80, +}; + +const registerPair = (escapeClipping = false) => { + const blockCount = makeMutable(0); + registerScreen({ + screenKey: "source", + animationProgress: makeMutable(1), + pendingLifecycleStartBlockCount: makeMutable(0), + }); + registerScreen({ + screenKey: "destination", + animationProgress: makeMutable(0), + pendingLifecycleStartBlockCount: blockCount, + }); + registerBoundary({ + boundTag: { tag: "card", linkKey: "card" }, + screenKey: "source", + entry: escapeClipping ? { escapeClipping: true } : {}, + }); + registerBoundary({ + boundTag: { tag: "card", linkKey: "card" }, + screenKey: "destination", + entry: {}, + }); + + return { + blockCount, + pairKey: createScreenPairKey("source", "destination"), + }; +}; + +const measurePairSide = ( + pairKey: string, + type: "source" | "destination", + escapeClipping = false, +) => { + applyMeasuredBoundsWrites({ + entryTag: "card", + linkId: "card", + currentScreenKey: type, + measured, + preparedStyles: {}, + linkWrite: { type, pairKey }, + escapeClipping, + }); +}; + +beforeEach(() => { + (globalThis as any).resetMutableRegistry(); +}); + +describe("bounds coordinator", () => { + it("registers boundaries without measuring or creating a link", () => { + registerScreen({ + screenKey: "screen-a", + animationProgress: makeMutable(1), + pendingLifecycleStartBlockCount: makeMutable(0), + }); + + registerBoundary({ + boundTag: { tag: "card", linkKey: "card" }, + screenKey: "screen-a", + entry: {}, + }); + + expect(boundaryRegistry.get().card?.screens["screen-a"]?.bounds).toBeNull(); + expect(pairs.get()).toEqual({}); + }); + + it("routes one demand to deeply nested source and destination leaves", () => { + registerTree(); + registerBoundary({ + boundTag: { tag: "card", linkKey: "card" }, + screenKey: "source-leaf", + entry: {}, + }); + registerBoundary({ + boundTag: { tag: "card", linkKey: "card" }, + screenKey: "destination-leaf", + entry: {}, + }); + + const pairKey = createScreenPairKey("source-root", "destination-root"); + requestBoundaryMeasurements({ + pairKey, + tag: "card", + destination: true, + refresh: false, + }); + + expect(getBoundaryMeasurementRequest("card", "source-leaf")).toEqual({ + type: "source", + pairKey, + }); + expect( + getBoundaryMeasurementRequest("card", "destination-leaf"), + ).toEqual({ type: "destination", pairKey }); + }); + + it("does not route a sibling transition through an unrelated branch", () => { + registerTree(); + registerScreen({ + screenKey: "unrelated-root", + animationProgress: makeMutable(1), + pendingLifecycleStartBlockCount: makeMutable(0), + }); + registerScreen({ + screenKey: "unrelated-leaf", + parentScreenKey: "unrelated-root", + animationProgress: makeMutable(1), + pendingLifecycleStartBlockCount: makeMutable(0), + }); + registerBoundary({ + boundTag: { tag: "card", linkKey: "card" }, + screenKey: "unrelated-leaf", + entry: {}, + }); + + requestBoundaryMeasurements({ + pairKey: createScreenPairKey("source-root", "destination-root"), + tag: "card", + destination: true, + refresh: false, + }); + + expect(getBoundaryMeasurementRequest("card", "unrelated-leaf")).toBeNull(); + }); + + it("retargets a group only when the interpolator requests a new member", () => { + registerTree(); + const pairKey = createScreenPairKey("source-root", "destination-root"); + registerBoundary({ + boundTag: { tag: "colors:violet", linkKey: "violet", group: "colors" }, + screenKey: "source-leaf", + entry: {}, + }); + registerBoundary({ + boundTag: { tag: "colors:coral", linkKey: "coral", group: "colors" }, + screenKey: "source-level-2", + entry: {}, + }); + + requestBoundaryMeasurements({ + pairKey, + tag: "colors:violet", + destination: true, + refresh: false, + }); + registerBoundary({ + boundTag: { tag: "colors:coral", linkKey: "coral", group: "colors" }, + screenKey: "destination-leaf", + entry: {}, + }); + + expect(pairs.get()[pairKey]?.groups.colors?.activeId).toBe("violet"); + expect( + getBoundaryMeasurementRequest("colors:coral", "source-level-2"), + ).toBeNull(); + + requestBoundaryMeasurements({ + pairKey, + tag: "colors:coral", + destination: true, + refresh: false, + }); + + expect(pairs.get()[pairKey]?.groups.colors?.activeId).toBe("coral"); + expect( + getBoundaryMeasurementRequest("colors:coral", "source-level-2"), + ).toEqual({ type: "source", pairKey }); + expect( + getBoundaryMeasurementRequest("colors:coral", "destination-leaf"), + ).toEqual({ type: "destination", pairKey }); + }); + + it("does not let already-mounted destination members override the requested id", () => { + registerTree(); + registerBoundary({ + boundTag: { tag: "colors:violet", linkKey: "violet", group: "colors" }, + screenKey: "source-leaf", + entry: {}, + }); + registerBoundary({ + boundTag: { tag: "colors:coral", linkKey: "coral", group: "colors" }, + screenKey: "source-level-2", + entry: {}, + }); + registerBoundary({ + boundTag: { tag: "colors:coral", linkKey: "coral", group: "colors" }, + screenKey: "destination-leaf", + entry: {}, + }); + registerBoundary({ + boundTag: { + tag: "colors:violet", + linkKey: "violet", + group: "colors", + }, + screenKey: "destination-leaf", + entry: {}, + }); + const pairKey = createScreenPairKey("source-root", "destination-root"); + + requestBoundaryMeasurements({ + pairKey, + tag: "colors:violet", + destination: true, + refresh: false, + }); + + expect(pairs.get()[pairKey]?.groups.colors?.activeId).toBe("violet"); + expect(pairs.get()[pairKey]?.blockedDestinations?.violet).toBe(true); + expect( + getBoundaryMeasurementRequest("colors:violet", "source-leaf"), + ).toEqual({ type: "source", pairKey }); + expect( + getBoundaryMeasurementRequest("colors:coral", "source-level-2"), + ).toBeNull(); + }); + + it("releases a destination lifecycle block when its request is abandoned", () => { + const blockCount = makeMutable(0); + registerScreen({ + screenKey: "source", + animationProgress: makeMutable(1), + pendingLifecycleStartBlockCount: makeMutable(0), + }); + registerScreen({ + screenKey: "destination", + animationProgress: makeMutable(0), + pendingLifecycleStartBlockCount: blockCount, + }); + registerBoundary({ + boundTag: { tag: "card", linkKey: "card" }, + screenKey: "source", + entry: {}, + }); + registerBoundary({ + boundTag: { tag: "card", linkKey: "card" }, + screenKey: "destination", + entry: {}, + }); + const pairKey = createScreenPairKey("source", "destination"); + + requestBoundaryMeasurements({ + pairKey, + tag: "card", + destination: true, + refresh: false, + }); + expect(blockCount.get()).toBe(1); + + abandonBoundaryMeasurement({ + type: "destination", + pairKey, + tag: "card", + }); + expect(blockCount.get()).toBe(0); + }); + + it("releases a destination lifecycle block when its source gives up", () => { + const blockCount = makeMutable(0); + registerScreen({ + screenKey: "source", + animationProgress: makeMutable(1), + pendingLifecycleStartBlockCount: makeMutable(0), + }); + registerScreen({ + screenKey: "destination", + animationProgress: makeMutable(0), + pendingLifecycleStartBlockCount: blockCount, + }); + registerBoundary({ + boundTag: { tag: "card", linkKey: "card" }, + screenKey: "source", + entry: {}, + }); + registerBoundary({ + boundTag: { tag: "card", linkKey: "card" }, + screenKey: "destination", + entry: {}, + }); + const pairKey = createScreenPairKey("source", "destination"); + + requestBoundaryMeasurements({ + pairKey, + tag: "card", + destination: true, + refresh: false, + }); + expect(blockCount.get()).toBe(1); + + unregisterBoundary({ tag: "card", linkKey: "card" }, "source"); + expect(blockCount.get()).toBe(0); + }); + + it("does not rewrite a completed pair when its boundary unmounts", () => { + const { pairKey } = registerPair(); + requestBoundaryMeasurements({ + pairKey, + tag: "card", + destination: true, + refresh: false, + }); + measurePairSide(pairKey, "destination"); + measurePairSide(pairKey, "source"); + + const originalModify = pairs.modify; + let pairWrites = 0; + (pairs as any).modify = (...args: Parameters) => { + pairWrites += 1; + return originalModify.apply(pairs, args); + }; + + try { + unregisterBoundary( + { tag: "card", linkKey: "card" }, + "destination", + ); + expect(pairWrites).toBe(0); + } finally { + (pairs as any).modify = originalModify; + } + }); + + it("does not rewrite a blocked pair after its screen unregisters", () => { + const { pairKey } = registerPair(); + requestBoundaryMeasurements({ + pairKey, + tag: "card", + destination: true, + refresh: false, + }); + unregisterScreen("destination"); + + const originalModify = pairs.modify; + let pairWrites = 0; + (pairs as any).modify = (...args: Parameters) => { + pairWrites += 1; + return originalModify.apply(pairs, args); + }; + + try { + unregisterBoundary( + { tag: "card", linkKey: "card" }, + "destination", + ); + expect(pairWrites).toBe(0); + } finally { + (pairs as any).modify = originalModify; + } + }); + + it("holds the opening gate until both demanded sides have measured", () => { + const blockCount = makeMutable(0); + registerScreen({ + screenKey: "source", + animationProgress: makeMutable(1), + pendingLifecycleStartBlockCount: makeMutable(0), + }); + registerScreen({ + screenKey: "destination", + animationProgress: makeMutable(0), + pendingLifecycleStartBlockCount: blockCount, + }); + registerBoundary({ + boundTag: { tag: "card", linkKey: "card" }, + screenKey: "source", + entry: {}, + }); + registerBoundary({ + boundTag: { tag: "card", linkKey: "card" }, + screenKey: "destination", + entry: {}, + }); + const pairKey = createScreenPairKey("source", "destination"); + requestBoundaryMeasurements({ + pairKey, + tag: "card", + destination: true, + refresh: false, + }); + + applyMeasuredBoundsWrites({ + entryTag: "card", + linkId: "card", + currentScreenKey: "destination", + measured, + preparedStyles: {}, + linkWrite: { type: "destination", pairKey }, + }); + expect(blockCount.get()).toBe(1); + + applyMeasuredBoundsWrites({ + entryTag: "card", + linkId: "card", + currentScreenKey: "source", + measured, + preparedStyles: {}, + linkWrite: { type: "source", pairKey }, + }); + expect(blockCount.get()).toBe(0); + expect(pairs.get()[pairKey]?.links.card?.status).toBe("complete"); + }); + + it("holds an escape-clipping opening until its source portal is ready", () => { + const { blockCount, pairKey } = registerPair(true); + requestBoundaryMeasurements({ + pairKey, + tag: "card", + destination: true, + refresh: false, + }); + + measurePairSide(pairKey, "destination"); + measurePairSide(pairKey, "source", true); + + expect(blockCount.get()).toBe(1); + markBoundaryPortalReady(pairKey, "card"); + expect(blockCount.get()).toBe(0); + }); + + it("releases once the destination completes when the portal was ready first", () => { + const { blockCount, pairKey } = registerPair(true); + requestBoundaryMeasurements({ + pairKey, + tag: "card", + destination: true, + refresh: false, + }); + + measurePairSide(pairKey, "source", true); + markBoundaryPortalReady(pairKey, "card"); + expect(blockCount.get()).toBe(1); + + measurePairSide(pairKey, "destination"); + expect(blockCount.get()).toBe(0); + }); + + it("requires fresh portal readiness for a refreshed escape-clipping link", () => { + const { blockCount, pairKey } = registerPair(true); + + requestBoundaryMeasurements({ + pairKey, + tag: "card", + destination: true, + refresh: false, + }); + measurePairSide(pairKey, "source", true); + measurePairSide(pairKey, "destination"); + markBoundaryPortalReady(pairKey, "card"); + expect(blockCount.get()).toBe(0); + + requestBoundaryMeasurements({ + pairKey, + tag: "card", + destination: true, + refresh: true, + }); + expect(blockCount.get()).toBe(1); + + measurePairSide(pairKey, "source", true); + measurePairSide(pairKey, "destination"); + expect(blockCount.get()).toBe(1); + + markBoundaryPortalReady(pairKey, "card"); + expect(blockCount.get()).toBe(0); + }); +}); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/bounds/initial-destination-measurement.test.tsx b/packages/react-native-screen-transitions/src/shared/__tests__/bounds/initial-destination-measurement.test.tsx deleted file mode 100644 index 7dd1c47c..00000000 --- a/packages/react-native-screen-transitions/src/shared/__tests__/bounds/initial-destination-measurement.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; -import { act, create, type ReactTestRenderer } from "react-test-renderer"; -import type { BoundTag } from "../../stores/bounds/types"; -import { BoundStore } from "../../stores/bounds"; -import { createScreenPairKey } from "../../stores/bounds/helpers/link-pairs.helpers"; -import { SystemStore } from "../../stores/system.store"; - -(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = - true; - -const descriptorState = { - derivations: { - currentScreenKey: "screen-b", - nextScreenKey: undefined as string | undefined, - destinationPairKey: createScreenPairKey("screen-a", "screen-b"), - }, -}; - -let stackState = { - scenes: [] as Array<{ activity: string; route: { key: string } }>, -}; - -mock.module("../../providers/screen/descriptors", () => ({ - useDescriptorsStore: (selector: (state: typeof descriptorState) => T) => - selector(descriptorState), -})); - -mock.module("../../hooks/navigation/use-stack", () => ({ - useStack: (selector: (state: typeof stackState) => T) => - selector(stackState), -})); - -let useInitialDestinationMeasurement: typeof import("../../components/boundary/hooks/lifecycles/use-initial-destination-measurement").useInitialDestinationMeasurement; - -beforeAll(async () => { - ({ useInitialDestinationMeasurement } = await import( - "../../components/boundary/hooks/lifecycles/use-initial-destination-measurement" - )); -}); - -beforeEach(() => { - (globalThis as any).resetMutableRegistry(); - stackState = { scenes: [] }; - BoundStore.entry.set("card", "screen-a", {}); - BoundStore.entry.set("card", "screen-b", {}); - BoundStore.link.setSource( - createScreenPairKey("screen-a", "screen-b"), - "card", - "screen-a", - { - x: 10, - y: 20, - pageX: 10, - pageY: 20, - width: 100, - height: 100, - }, - ); -}); - -describe("initial destination measurement lifecycle", () => { - it("does not restart a completed initial measurement after dependency changes", () => { - const pairKey = createScreenPairKey("screen-a", "screen-b"); - const measurements: string[] = []; - - function Fixture({ - boundTag, - revision, - }: { - boundTag: BoundTag; - revision: number; - }) { - useInitialDestinationMeasurement({ - boundTag, - enabled: true, - measureBoundary: ({ pairKey: measuredPairKey }) => { - measurements.push(`${revision}:${measuredPairKey}`); - BoundStore.link.setDestination( - measuredPairKey, - "card", - "screen-b", - { - x: 30, - y: 40, - pageX: 30, - pageY: 40, - width: 200, - height: 200, - }, - ); - }, - }); - return null; - } - - let renderer: ReactTestRenderer; - act(() => { - renderer = create( - , - ); - }); - - stackState = { - scenes: [{ activity: "active", route: { key: "screen-b" } }], - }; - act(() => { - renderer!.update( - , - ); - }); - - expect(measurements).toEqual([`1:${pairKey}`]); - expect( - SystemStore.getValue("screen-b", "pendingLifecycleStartBlockCount").get(), - ).toBe(0); - }); -}); 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 deleted file mode 100644 index a24a0437..00000000 --- a/packages/react-native-screen-transitions/src/shared/__tests__/bounds/measurement-rules.test.ts +++ /dev/null @@ -1,542 +0,0 @@ -import { beforeEach, describe, expect, it } from "bun:test"; -import { getInitialDestinationMeasurementSignal } from "../../components/boundary/utils/destination-signals"; -import { - correctMeasuredBoundsForVisibilityGate, - isMeasurementInViewport, -} from "../../components/boundary/utils/measured-bounds"; -import { getRefreshBoundarySignal } from "../../components/boundary/utils/refresh-signals"; -import { getInitialSourceCaptureSignal } from "../../components/boundary/utils/source-signals"; -import { BoundStore, type Snapshot } from "../../stores/bounds"; -import { - createPendingPairKey, - createScreenPairKey, -} from "../../stores/bounds/helpers/link-pairs.helpers"; -import { pairs } from "../../stores/bounds/internals/state"; -import type { ScreenTransitionState } from "../../types/animation.types"; -import { computeBoundStyles } from "../../utils/bounds/helpers/styles/compute"; - -const createBounds = (): Snapshot["bounds"] => ({ - x: 0, - y: 0, - pageX: 0, - pageY: 0, - width: 100, - height: 100, -}); - -const createTransitionState = (screenKey: string) => - ({ - route: { - key: screenKey, - }, - }) as ScreenTransitionState; - -const requestBoundStyle = ( - id: string, - target?: "fullscreen" | Snapshot["bounds"], -) => - computeBoundStyles( - { - id, - current: createTransitionState("screen-a"), - next: createTransitionState("screen-b"), - progress: 0, - dimensions: { - width: 400, - height: 800, - }, - interpolationProps: {} as never, - }, - { - id, - target, - }, - ); - -beforeEach(() => { - (globalThis as any).resetMutableRegistry(); -}); - -describe("bounds client measurement contract", () => { - it("keeps destination startup gated until a registered pair is complete", () => { - const pairKey = createScreenPairKey("screen-a", "screen-b"); - const getSignal = ({ - destinationPresent = true, - sourcePresent = true, - }: { - destinationPresent?: boolean; - sourcePresent?: boolean; - } = {}) => - getInitialDestinationMeasurementSignal({ - enabled: true, - pairKey, - linkId: "card", - destinationPresent, - sourcePresent, - linkState: pairs.get(), - }); - - expect(getSignal({ destinationPresent: false })).toEqual({ - pairKey, - action: "wait", - }); - expect(getSignal({ sourcePresent: false })).toEqual({ - pairKey, - action: "release", - }); - expect(getSignal()).toEqual({ - pairKey, - action: "measure", - }); - - BoundStore.link.setDestination( - pairKey, - "card", - "screen-b", - createBounds(), - ); - - expect(getSignal()).toEqual({ - pairKey, - action: "wait", - }); - - BoundStore.link.setSource( - pairKey, - "card", - "screen-a", - createBounds(), - ); - - expect(getSignal()).toEqual({ pairKey, action: "complete" }); - }); - - it("keeps an unresolved destination source in the retry loop", () => { - expect( - getInitialDestinationMeasurementSignal({ - enabled: true, - linkId: "card", - destinationPresent: true, - sourcePresent: false, - linkState: pairs.get(), - }), - ).toEqual({ - pairKey: null, - action: "wait", - }); - }); - - it("auto source capture waits for destination then emits once", () => { - const pairKey = createScreenPairKey("screen-a", "screen-b"); - const measuredTargets: Array<{ type: "source"; pairKey: string }> = []; - let lastSignal: string | null = null; - - const runAutoSourceReaction = () => { - const captureSignal = getInitialSourceCaptureSignal({ - enabled: true, - sourcePairKey: pairKey, - linkId: "card", - linkState: pairs.get(), - }); - - if (!captureSignal || captureSignal.signal === lastSignal) { - return; - } - - lastSignal = captureSignal.signal; - measuredTargets.push({ - type: "source", - pairKey: captureSignal.pairKey, - }); - }; - - runAutoSourceReaction(); - runAutoSourceReaction(); - - expect(measuredTargets).toEqual([]); - - BoundStore.link.setDestination( - pairKey, - "card", - "screen-b", - createBounds(), - ); - - runAutoSourceReaction(); - runAutoSourceReaction(); - - expect(measuredTargets).toEqual([{ type: "source", pairKey }]); - }); - - it("does not request source capture for normal bounds without destination", () => { - const pairKey = createScreenPairKey("screen-a", "screen-b"); - - expect(requestBoundStyle("card")).toEqual({}); - expect( - getInitialSourceCaptureSignal({ - enabled: true, - sourcePairKey: pairKey, - linkId: "card", - linkState: pairs.get(), - }), - ).toBeNull(); - }); - - it("target-overridden bounds request source capture without destination", () => { - const pairKey = createScreenPairKey("screen-a", "screen-b"); - - expect(requestBoundStyle("card", "fullscreen")).toEqual({}); - expect( - getInitialSourceCaptureSignal({ - enabled: true, - sourcePairKey: pairKey, - linkId: "card", - linkState: pairs.get(), - }), - ).toEqual({ - pairKey, - signal: "source|screen-a<>screen-b|card", - }); - - BoundStore.link.setSource(pairKey, "card", "screen-a", createBounds()); - - expect(pairs.get()[pairKey].sourceRequests?.card).toBeUndefined(); - expect( - getInitialSourceCaptureSignal({ - enabled: true, - sourcePairKey: pairKey, - linkId: "card", - linkState: pairs.get(), - }), - ).toBeNull(); - }); - - it("custom target bounds request source capture without destination", () => { - const pairKey = createScreenPairKey("screen-a", "screen-b"); - - expect(requestBoundStyle("card", createBounds())).toEqual({}); - expect( - getInitialSourceCaptureSignal({ - enabled: true, - sourcePairKey: pairKey, - linkId: "card", - linkState: pairs.get(), - }), - ).toEqual({ - pairKey, - signal: "source|screen-a<>screen-b|card", - }); - }); - - it("target-overridden grouped source requests still respect active group", () => { - const pairKey = createScreenPairKey("screen-a", "screen-b"); - BoundStore.link.setActiveGroupId(pairKey, "colors", "2"); - - expect(requestBoundStyle("colors:1", "fullscreen")).toEqual({}); - expect( - getInitialSourceCaptureSignal({ - enabled: true, - sourcePairKey: pairKey, - linkId: "1", - group: "colors", - linkState: pairs.get(), - }), - ).toBeNull(); - - expect(requestBoundStyle("colors:2", "fullscreen")).toEqual({}); - expect( - getInitialSourceCaptureSignal({ - enabled: true, - sourcePairKey: pairKey, - linkId: "2", - group: "colors", - linkState: pairs.get(), - }), - ).toEqual({ - pairKey, - signal: "source|screen-a<>screen-b|colors|2", - }); - }); - - it("source capture rewrites the same pair-local link", () => { - const pendingPairKey = createPendingPairKey("screen-a"); - const first = createBounds(); - const second = { - ...createBounds(), - x: 20, - pageX: 20, - }; - - BoundStore.link.setSource(pendingPairKey, "card", "screen-a", first); - BoundStore.link.setSource(pendingPairKey, "card", "screen-a", second); - - expect(Object.keys(pairs.get()[pendingPairKey].links)).toEqual(["card"]); - expect(BoundStore.link.getSource(pendingPairKey, "card")?.bounds).toEqual( - second, - ); - expect( - BoundStore.link.getLink(pendingPairKey, "card")?.initialSource?.bounds, - ).toEqual(first); - }); - - it("releases inactive grouped destinations after the active id is established", () => { - const pairKey = createScreenPairKey("screen-a", "screen-b"); - BoundStore.link.setDestination( - pairKey, - "lime", - "screen-b", - createBounds(), - {}, - "colors", - ); - - const getSignal = (linkId: string) => - getInitialDestinationMeasurementSignal({ - enabled: true, - pairKey, - linkId, - group: "colors", - destinationPresent: true, - sourcePresent: true, - linkState: pairs.get(), - }); - - expect(getSignal("lime")).toEqual({ pairKey, action: "wait" }); - expect(getSignal("sky")).toEqual({ pairKey, action: "release" }); - expect(getSignal("electric")).toEqual({ pairKey, action: "release" }); - }); - - it("blocks destination measurements that are outside the viewport", () => { - expect(isMeasurementInViewport(createBounds(), 400, 800)).toBe(true); - expect( - isMeasurementInViewport( - { - ...createBounds(), - pageX: 1000, - }, - 400, - 800, - ), - ).toBe(false); - expect( - isMeasurementInViewport( - { - ...createBounds(), - width: 0, - }, - 400, - 800, - ), - ).toBe(false); - }); - - it("corrects visibility-gate frame skew while the screen is offset", () => { - const visibilityBlockOffset = 1601; - const destination = { - ...createBounds(), - pageX: 18, - pageY: 1749, - }; - const correct = (measured: Snapshot["bounds"]) => - correctMeasuredBoundsForVisibilityGate({ - measured, - visibilityBlocked: true, - visibilityBlockOffset, - viewportWidth: 400, - viewportHeight: 800, - }); - - expect(correct(destination)).toMatchObject({ - pageX: 18, - pageY: 148, - }); - - expect(correct({ ...destination, pageY: 148 })).toMatchObject({ - pageX: 18, - pageY: 148, - }); - }); - - it("preserves user-visible presentation offsets", () => { - const measured = { - ...createBounds(), - pageX: 24, - pageY: 587, - }; - - expect( - correctMeasuredBoundsForVisibilityGate({ - measured, - visibilityBlocked: false, - visibilityBlockOffset: 1601, - viewportWidth: 400, - viewportHeight: 800, - }), - ).toBe(measured); - }); - - it("does not reinterpret offscreen measurements after visibility opens", () => { - const measured = { - ...createBounds(), - pageY: 1749, - }; - - expect( - correctMeasuredBoundsForVisibilityGate({ - measured, - visibilityBlocked: false, - visibilityBlockOffset: 1601, - viewportWidth: 400, - viewportHeight: 800, - }), - ).toMatchObject({ pageY: 1749 }); - }); - - it("refreshes the current active grouped source at settled points", () => { - const pairKey = createScreenPairKey("screen-a", "screen-b"); - BoundStore.link.setActiveGroupId(pairKey, "colors", "2"); - BoundStore.link.setSource( - pairKey, - "2", - "screen-a", - createBounds(), - {}, - "colors", - ); - - const getSignal = (linkId: string) => - getRefreshBoundarySignal({ - enabled: true, - currentScreenKey: "screen-a", - sourcePairKey: pairKey, - linkId, - group: "colors", - shouldRefresh: true, - closing: false, - entering: false, - animating: false, - progress: 1, - linkState: pairs.get(), - }); - - expect(getSignal("1")).toBeNull(); - expect(getSignal("2")).toEqual({ - type: "source", - pairKey, - signal: "source|screen-a<>screen-b|colors|2|settled", - }); - }); - - it("refreshes the current active grouped destination member", () => { - const pairKey = createScreenPairKey("screen-a", "screen-b"); - BoundStore.link.setActiveGroupId(pairKey, "colors", "2"); - BoundStore.link.setSource( - pairKey, - "2", - "screen-a", - createBounds(), - {}, - "colors", - ); - - const getSignal = (linkId: string) => - getRefreshBoundarySignal({ - enabled: true, - currentScreenKey: "screen-b", - destinationPairKey: pairKey, - linkId, - group: "colors", - shouldRefresh: true, - closing: true, - entering: false, - animating: false, - progress: 1, - gestureInProgress: false, - linkState: pairs.get(), - }); - - expect(getSignal("1")).toBeNull(); - expect(getSignal("2")).toEqual({ - type: "destination", - pairKey, - signal: "destination|screen-a<>screen-b|colors|2|closing", - }); - }); - - it("refreshes a non-group destination once for a stable close signal", () => { - const pairKey = createScreenPairKey("screen-a", "screen-b"); - const measuredTargets: Array<{ type: "destination"; pairKey: string }> = []; - let previousSignal: string | null = null; - BoundStore.link.setDestination( - pairKey, - "card", - "screen-b", - createBounds(), - ); - - const runRefreshReaction = () => { - const refreshSignal = getRefreshBoundarySignal({ - enabled: true, - currentScreenKey: "screen-b", - destinationPairKey: pairKey, - linkId: "card", - shouldRefresh: true, - closing: true, - entering: false, - animating: false, - progress: 1, - gestureInProgress: false, - linkState: pairs.get(), - }); - - if (!refreshSignal || refreshSignal.signal === previousSignal) { - return; - } - - previousSignal = refreshSignal.signal; - measuredTargets.push({ - type: refreshSignal.type, - pairKey: refreshSignal.pairKey, - }); - }; - - runRefreshReaction(); - runRefreshReaction(); - - expect(measuredTargets).toEqual([{ type: "destination", pairKey }]); - }); - - it("refreshes a nested non-group source instead of its ancestor destination", () => { - const ancestorPairKey = createScreenPairKey("screen-a", "nested-route"); - const nestedPairKey = createScreenPairKey("nested-index", "deep-route"); - BoundStore.link.setSource( - nestedPairKey, - "card", - "nested-index", - createBounds(), - ); - - expect( - getRefreshBoundarySignal({ - enabled: true, - currentScreenKey: "nested-index", - sourcePairKey: nestedPairKey, - ancestorDestinationPairKey: ancestorPairKey, - nextScreenKey: "deep-route", - linkId: "card", - shouldRefresh: true, - closing: false, - entering: false, - animating: false, - progress: 1, - gestureInProgress: false, - linkState: pairs.get(), - }), - ).toEqual({ - type: "source", - pairKey: nestedPairKey, - signal: - "source|nested-index<>deep-route|nested-index|settled", - }); - }); -}); 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 deleted file mode 100644 index b3378185..00000000 --- a/packages/react-native-screen-transitions/src/shared/__tests__/bounds/refresh-signals.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -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("refreshes only a participating non-group source", () => { - const sourcePairKey = createScreenPairKey("screen-a", "screen-b"); - const previousPairKey = createScreenPairKey("screen-root", "screen-a"); - BoundStore.link.setSource( - sourcePairKey, - "card", - "screen-a", - { x: 0, y: 0, pageX: 0, pageY: 0, width: 100, height: 100 }, - {}, - ); - - const getSignal = (linkId: string) => - getRefreshBoundarySignal({ - enabled: true, - currentScreenKey: "screen-a", - nextScreenKey: "screen-b", - sourcePairKey, - destinationPairKey: previousPairKey, - linkId, - shouldRefresh: true, - closing: true, - linkState: pairs.get(), - }); - - expect(getSignal("card")).toEqual({ - type: "source", - pairKey: sourcePairKey, - signal: "source|screen-a<>screen-b|screen-a|closing", - }); - expect(getSignal("unrelated-card")).toBeNull(); - }); - - it("keeps destination refreshes for close retargeting", () => { - const pairKey = createScreenPairKey("screen-a", "screen-b"); - BoundStore.link.setDestination( - pairKey, - "card", - "screen-b", - { x: 0, y: 0, pageX: 0, pageY: 0, width: 100, height: 100 }, - {}, - ); - - const getSignal = (linkId: string) => - getRefreshBoundarySignal({ - enabled: true, - currentScreenKey: "screen-b", - destinationPairKey: pairKey, - linkId, - shouldRefresh: true, - closing: true, - linkState: pairs.get(), - }); - - expect(getSignal("card")).toEqual({ - type: "destination", - pairKey, - signal: "destination|screen-a<>screen-b|screen-b|closing", - }); - expect(getSignal("unrelated-card")).toBeNull(); - }); - - 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("does not treat the opening group member as a settled retarget", () => { - const pairKey = createScreenPairKey("palette", "detail"); - const linkState = { - [pairKey]: { - links: {}, - groups: { - colors: { - activeId: "electric-violet", - initialId: "electric-violet", - }, - }, - }, - }; - - expect( - getRefreshBoundarySignal({ - enabled: true, - currentScreenKey: "palette", - sourcePairKey: pairKey, - linkId: "electric-violet", - group: "colors", - shouldRefresh: false, - settled: true, - closing: false, - linkState, - }), - ).toBeNull(); - }); - - it("refreshes both sides of a settled non-initial group retarget", () => { - const pairKey = createScreenPairKey("palette", "detail"); - const linkState = { - [pairKey]: { - links: {}, - groups: { - colors: { - activeId: "hot-coral", - initialId: "electric-violet", - }, - }, - }, - }; - - expect( - getRefreshBoundarySignal({ - enabled: true, - currentScreenKey: "palette", - sourcePairKey: pairKey, - linkId: "hot-coral", - group: "colors", - shouldRefresh: false, - settled: true, - closing: false, - linkState, - }), - ).toEqual({ - type: "source", - pairKey, - signal: `source|${pairKey}|colors|hot-coral|retarget`, - }); - - expect( - getRefreshBoundarySignal({ - enabled: true, - currentScreenKey: "detail", - destinationPairKey: pairKey, - linkId: "hot-coral", - group: "colors", - shouldRefresh: false, - settled: true, - closing: false, - linkState, - }), - ).toEqual({ - type: "destination", - pairKey, - signal: `destination|${pairKey}|colors|hot-coral|retarget`, - }); - }); - - it("eagerly fills only a missing side and preserves the lifecycle retry", () => { - const pairKey = createScreenPairKey("palette", "detail"); - const bounds = { - x: 0, - y: 0, - pageX: 0, - pageY: 0, - width: 100, - height: 100, - }; - const getSourceSignal = (shouldRefresh: boolean, settled: boolean) => - getRefreshBoundarySignal({ - enabled: true, - currentScreenKey: "palette", - sourcePairKey: pairKey, - linkId: "hot-coral", - group: "colors", - shouldRefresh, - settled, - closing: false, - linkState: pairs.get(), - }); - const getDestinationSignal = () => - getRefreshBoundarySignal({ - enabled: true, - currentScreenKey: "detail", - destinationPairKey: pairKey, - linkId: "hot-coral", - group: "colors", - shouldRefresh: false, - settled: true, - closing: false, - linkState: pairs.get(), - }); - - BoundStore.link.setDestination( - pairKey, - "electric-violet", - "detail", - bounds, - {}, - "colors", - ); - BoundStore.link.setActiveGroupId(pairKey, "colors", "hot-coral"); - - expect(getSourceSignal(false, false)).toBeNull(); - expect(getSourceSignal(false, true)?.signal).toEndWith("|retarget"); - expect(getDestinationSignal()?.signal).toEndWith("|retarget"); - - BoundStore.link.setSource( - pairKey, - "hot-coral", - "palette", - bounds, - {}, - "colors", - ); - - expect(getSourceSignal(false, true)).toBeNull(); - expect(getDestinationSignal()?.signal).toEndWith("|retarget"); - - BoundStore.link.setDestination( - pairKey, - "hot-coral", - "detail", - bounds, - {}, - "colors", - ); - - expect(getDestinationSignal()).toBeNull(); - expect(getSourceSignal(true, false)?.signal).toEndWith("|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/store.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/bounds/store.test.ts index a249724c..f6511d62 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/bounds/store.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/bounds/store.test.ts @@ -7,11 +7,7 @@ import { createScreenPairKey, } from "../../stores/bounds/helpers/link-pairs.helpers"; import { pairs } from "../../stores/bounds/internals/state"; -import { getMatchingSourceScreenKey } from "../../stores/bounds/internals/entries"; -import { - getPairKeyForSource, - requestSourceMeasure, -} from "../../stores/bounds/internals/links"; +import { getPairKeyForDestination } from "../../stores/bounds/internals/links"; import { createBoundsAccessor } from "../../utils/bounds"; import { computeBoundStyles } from "../../utils/bounds/helpers/styles/compute"; import type { EntryPatch } from "../../stores/bounds/types"; @@ -77,66 +73,6 @@ beforeEach(() => { }); describe("BoundStore.entry", () => { - it("prefers the direct outgoing screen over retained matching boundaries", () => { - registerBoundaryPresence("card", "screen-a"); - registerBoundaryPresence("card", "screen-b"); - registerBoundaryPresence("card", "screen-c"); - - expect( - getMatchingSourceScreenKey("card", "screen-c", "screen-b"), - ).toBe("screen-b"); - }); - - it("completes A to C without linking through retained closing B", () => { - registerBoundaryPresence("card", "screen-a"); - registerBoundaryPresence("card", "screen-b"); - const closingPairKey = createScreenPairKey("screen-a", "screen-b"); - BoundStore.link.setSource( - closingPairKey, - "card", - "screen-a", - createBounds(10, 20), - ); - BoundStore.link.setDestination( - closingPairKey, - "card", - "screen-b", - createBounds(30, 40), - ); - - registerBoundaryPresence("card", "screen-c"); - const sourceScreenKey = getMatchingSourceScreenKey( - "card", - "screen-c", - "screen-b", - ["screen-b"], - ); - expect(sourceScreenKey).toBe("screen-a"); - - const nextPairKey = createScreenPairKey(sourceScreenKey!, "screen-c"); - BoundStore.link.setDestination( - nextPairKey, - "card", - "screen-c", - createBounds(50, 60), - ); - BoundStore.link.setSource( - nextPairKey, - "card", - sourceScreenKey!, - createBounds(10, 20), - ); - - expect(BoundStore.link.getLink(nextPairKey, "card")).toMatchObject({ - source: { screenKey: "screen-a" }, - destination: { screenKey: "screen-c" }, - }); - expect(BoundStore.link.getLink(closingPairKey, "card")).toMatchObject({ - source: { screenKey: "screen-a" }, - destination: { screenKey: "screen-b" }, - }); - }); - it("writes and updates measured entries by direct screen key", () => { const first = createBounds(10, 20, 200, 300); const second = createBounds(15, 25, 220, 320); @@ -298,14 +234,6 @@ describe("applyMeasuredBoundsWrites", () => { }); describe("BoundStore.link pair writes", () => { - it("finds a source pair requested before its destination measures", () => { - const pairKey = createScreenPairKey("screen-a", "screen-b"); - - requestSourceMeasure(pairKey, "card"); - - expect(getPairKeyForSource("card", "screen-a")).toBe(pairKey); - }); - it("sets a source by pair key and id", () => { const pairKey = createScreenPairKey("screen-a", "screen-b"); const bounds = createBounds(); @@ -559,6 +487,40 @@ describe("BoundStore.link pair writes", () => { }); describe("BoundStore.link.getPair", () => { + it("finds a parent pair from its nested grouped destination", () => { + const pairKey = createScreenPairKey("source", "nested-route"); + + BoundStore.link.setSource( + pairKey, + "violet", + "source", + createBounds(10, 20), + {}, + "colors", + ); + BoundStore.link.setDestination( + pairKey, + "violet", + "destination-leaf", + createBounds(100, 120), + {}, + "colors", + ); + BoundStore.link.setActiveGroupId(pairKey, "colors", "cyan"); + BoundStore.link.setSource( + pairKey, + "cyan", + "source", + createBounds(30, 40), + {}, + "colors", + ); + + expect( + getPairKeyForDestination("colors:cyan", "destination-leaf"), + ).toBe(pairKey); + }); + it("resolves a nested source for a root destination", () => { const pairKey = createScreenPairKey("tray-a", "palette"); const source = createBounds(10, 20); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/bounds/visual-transform-bounds.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/bounds/visual-transform-bounds.test.ts index 2467ac46..95dd90d0 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/bounds/visual-transform-bounds.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/bounds/visual-transform-bounds.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; import type { MeasuredDimensions } from "react-native-reanimated"; import { BoundStore } from "../../stores/bounds"; import { createScreenPairKey } from "../../stores/bounds/helpers/link-pairs.helpers"; +import { pairs } from "../../stores/bounds/internals/state"; import type { ResolvedTransitionPair } from "../../stores/bounds/types"; import { createBoundsAccessor } from "../../utils/bounds"; import { computeBoundStyles } from "../../utils/bounds/helpers/styles/compute"; @@ -161,4 +162,35 @@ describe("visual transform bounds", () => { scaleY: 1, }); }); + + it("requests fresh endpoint measurements before animation styles attach", () => { + const pairKey = createScreenPairKey("screen-a", "screen-b"); + BoundStore.link.setSource( + pairKey, + "card", + "screen-a", + createBounds(20, 20, 100, 100), + ); + BoundStore.link.setDestination( + pairKey, + "card", + "screen-b", + createBounds(200, 200, 200, 200), + ); + const bounds = createBoundsAccessor( + () => + ({ + current: { route: { key: "screen-a" } }, + next: { route: { key: "screen-b" } }, + active: { animating: 0, willAnimate: 1 }, + progress: 2, + layouts: { screen: { width: 400, height: 800 } }, + }) as any, + ); + + expect(bounds("card").styles()).toEqual({}); + expect(pairs.get()[pairKey]?.sourceRequests?.card).toBe(true); + expect(pairs.get()[pairKey]?.destinationRequests?.card).toBe(true); + expect(pairs.get()[pairKey]?.refreshingLinks?.card).toBe(true); + }); }); 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 b68b84e9..1205822a 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,11 +1,6 @@ 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"; @@ -14,7 +9,6 @@ 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"); @@ -146,35 +140,6 @@ 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", - pairKey: 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(); @@ -336,16 +301,6 @@ describe("zoom bound target", () => { pageY: DESTINATION_BOUNDS.pageY + 180, y: DESTINATION_BOUNDS.y + 180, }; - const signal = getInitialDestinationMeasurementSignal({ - enabled: true, - pairKey: PAIR_KEY, - linkId: "card", - destinationPresent: true, - sourcePresent: true, - linkState: pairs.get(), - }); - - expect(signal).toEqual({ pairKey: PAIR_KEY, action: "measure" }); applyMeasuredBoundsWrites({ entryTag: "card", linkId: "card", @@ -468,6 +423,18 @@ describe("zoom bound target", () => { zoomOptions: { target: "bound" }, }), ).toEqual({}); + expect(pairs.get()[PAIR_KEY]?.destinationRequests?.card).toBe(true); + }); + + it("requests the source when a bound-target pair has neither side", () => { + expect( + buildZoomStyles({ + tag: "card", + props: createZoomProps({ focused: true, progress: 0 }), + zoomOptions: { target: "bound" }, + }), + ).toEqual({}); + expect(pairs.get()[PAIR_KEY]?.sourceRequests?.card).toBe(true); }); }); @@ -478,17 +445,7 @@ describe("zoom source-only target", () => { props: createZoomProps({ focused: true, progress: 0 }), }); - expect( - getInitialSourceCaptureSignal({ - enabled: true, - sourcePairKey: PAIR_KEY, - linkId: "card", - linkState: pairs.get(), - }), - ).toEqual({ - pairKey: PAIR_KEY, - signal: "source|screen-a<>screen-b|card", - }); + expect(pairs.get()[PAIR_KEY]?.sourceRequests?.card).toBe(true); }); it("builds opening styles from the source and implicit target alone", () => { diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/create-provider.test.tsx b/packages/react-native-screen-transitions/src/shared/__tests__/create-provider.test.tsx index 5fed0bdb..352aa68d 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/create-provider.test.tsx +++ b/packages/react-native-screen-transitions/src/shared/__tests__/create-provider.test.tsx @@ -11,18 +11,55 @@ type TestProviderProps = { value: number; }; -const { TestProvider, useTestStore } = createProvider("Test", { - guarded: true, +let injectedFactoryArgument: unknown; +const testProviderFactory = createProvider("Test", { global: true })< + TestProviderProps, + { value: number } +>(({ children, id, value }, ...injectedArguments: unknown[]) => { + injectedFactoryArgument = injectedArguments[0]; + + return { + children, + key: id, + value: { value }, + }; +}); +const { TestProvider, useOptionalTestStore, useTestStore } = testProviderFactory; + +const parentObservations: Record = {}; +const { PathProvider, useOptionalPathStore } = createProvider("Path", { global: true, -})(({ children, id, value }) => ({ - children, - key: id, - value: { value }, -})); +})( + ({ children, id, value }) => { + parentObservations[id] = useOptionalPathStore( + (store) => store?.value ?? null, + ); + return { children, key: id, value: { value } }; + }, +); describe("createProvider global stores", () => { + it("returns the provider plus strict and optional store hooks", () => { + expect(Object.keys(testProviderFactory).sort()).toEqual([ + "TestProvider", + "useOptionalTestStore", + "useTestStore", + ]); + }); + + it("does not inject a hidden read primitive into the provider factory", () => { + let renderer: ReactTestRenderer; + + act(() => { + renderer = create(); + }); + + expect(injectedFactoryArgument).toBeUndefined(); + act(() => renderer.unmount()); + }); + it("preserves local context selectors", () => { - let observed = 0; + let observed: number | null = null; let renderer: ReactTestRenderer; function Reader() { @@ -42,55 +79,71 @@ describe("createProvider global stores", () => { act(() => renderer.unmount()); }); - it("reads a provider store by key outside its context", () => { - let observed: number | null = null; + it("throws when the strict store hook has no surrounding provider", () => { + function Reader() { + useTestStore((store) => store.value); + return null; + } + + expect(() => { + act(() => { + create(); + }); + }).toThrow("TestStore is unavailable"); + }); + + it("reads a nullable surrounding provider through the optional hook", () => { + const observed: Array = []; let renderer: ReactTestRenderer; function Reader() { - observed = useTestStore("screen-a", (store) => store.value); + observed.push(useOptionalTestStore((store) => store?.value ?? null)); return null; } act(() => { renderer = create( - <> - + <> + + - , + + , ); }); - expect(observed).toBe(1); + expect(observed).toEqual([null, 7]); act(() => renderer.unmount()); }); - it("resolves a keyed read from its matching local provider immediately", () => { - const observed: Array = []; + it("reads a provider store by key outside its context after registration", () => { + let observed: number | null = null; let renderer: ReactTestRenderer; function Reader() { - observed.push(useTestStore("screen-a", (store) => store.value)); + observed = useOptionalTestStore("screen-a", (store) => store.value); return null; } act(() => { renderer = create( - + <> + - , + , ); }); - expect(observed).toEqual([1]); + expect(observed).toBe(1); act(() => renderer.unmount()); }); - it("does not resolve a keyed read from an unrelated local provider", () => { + it("returns null from an optional keyed read with no matching provider", () => { let observed: number | null = 1; let renderer: ReactTestRenderer; function Reader() { - observed = useTestStore("screen-b", (store) => store.value); + observed = useOptionalTestStore("screen-b", (store) => store.value); return null; } @@ -106,12 +159,29 @@ describe("createProvider global stores", () => { act(() => renderer.unmount()); }); - it("isolates keyed stores and forwards updates from their providers", () => { + it("throws when a strict keyed read has no matching provider", () => { + function Reader() { + useTestStore("screen-b", (store) => store.value); + return null; + } + + expect(() => { + act(() => { + create( + + + , + ); + }); + }).toThrow("TestStore is unavailable for key \"screen-b\""); + }); + + it("isolates keyed stores and subscribes to provider updates", () => { const observed: Record = {}; let renderer: ReactTestRenderer; function Reader({ id }: { id: string }) { - observed[id] = useTestStore(id, (store) => store.value); + observed[id] = useOptionalTestStore(id, (store) => store.value); return null; } @@ -138,42 +208,18 @@ describe("createProvider global stores", () => { act(() => renderer.unmount()); }); - it("does not let stale cleanup remove a newer store with the same key", () => { - let observed: number | null = null; + it("reads the surrounding provider during nested provider construction", () => { let renderer: ReactTestRenderer; - function Reader() { - observed = useTestStore("shared", (store) => store.value); - return null; - } - - const renderTree = (showFirst: boolean, showSecond: boolean) => ( - <> - {showFirst ? ( - - ) : null} - {showSecond ? ( - - ) : null} - - - ); - act(() => { - renderer = create(renderTree(true, true)); - }); - expect(observed).toBe(2); - - act(() => { - renderer.update(renderTree(false, true)); - }); - expect(observed).toBe(2); - - act(() => { - renderer.update(renderTree(false, false)); + renderer = create( + + + , + ); }); - expect(observed).toBeNull(); + expect(parentObservations).toMatchObject({ parent: null, child: 7 }); act(() => renderer.unmount()); }); }); 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 d45a2e1e..834745d8 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 @@ -123,7 +123,6 @@ const createGestureStore = (): GestureStoreMap => { const createAnimations = (): AnimationStoreMap => ({ transitionProgress: shared(1), visualProgress: shared(1), - stackProgress: shared(1), willAnimate: shared(0), progressAnimating: shared(0), progressSettled: shared(1), @@ -196,7 +195,6 @@ const exposeToInterpolator = ({ const state: BuiltState = { transitionProgress: animations.transitionProgress, visualProgress: animations.visualProgress, - stackProgress: animations.stackProgress, willAnimate: animations.willAnimate, closing: animations.closing, progressAnimating: animations.progressAnimating, @@ -411,7 +409,6 @@ describe("gesture lifecycle state", () => { 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); @@ -442,7 +439,6 @@ describe("gesture lifecycle state", () => { 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({ @@ -466,7 +462,6 @@ describe("gesture lifecycle state", () => { 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({ diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/gestures/pan-release/pan-release.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/gestures/pan-release/pan-release.test.ts index 6f71087e..c2910512 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/gestures/pan-release/pan-release.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/gestures/pan-release/pan-release.test.ts @@ -68,6 +68,7 @@ const createGestureSnapshotStore = () => ({ const createSnapRuntime = ({ progress = 0.5, baseline = 0.5, + targetProgress = baseline, activeGesture = "horizontal-inverted" as ActiveGesture, gestureSnapLocked = false, lockedSnapPoint = 1 as number | null, @@ -104,6 +105,7 @@ const createSnapRuntime = ({ }, system: { resolvedAutoSnapPoint: shared(0), + targetProgress: shared(targetProgress), }, gestures: { active: shared(activeGesture), @@ -123,6 +125,7 @@ const createRelease = ( ): PanReleaseResult => ({ target: 0, shouldDismiss: true, + isCancelled: false, initialVelocity: 4, transitionSpec: undefined, resetSpec: undefined, @@ -167,9 +170,33 @@ describe("pan release plan", () => { expect(plan.handoffVelocity).toBeCloseTo(0.15, 5); }); - it("resets cancelled gestures with release velocity", () => { + it("resets cancelled gestures without release velocity", () => { const plan = buildPanReleasePlan( - createRelease({ target: 1, shouldDismiss: false }), + createRelease({ + target: 1, + shouldDismiss: false, + isCancelled: true, + }), + createRuntime(), + dimensions, + rawEvent, + ); + + expect(plan.progressVelocity).toBe(0); + expect(plan.resetVelocityX).toBe(0); + expect(plan.resetVelocityY).toBe(0); + expect(plan.resetVelocityNormX).toBe(0); + expect(plan.resetVelocityNormY).toBe(0); + expect(plan.handoffVelocity).toBe(0); + }); + + it("preserves release velocity for a committed snap", () => { + const plan = buildPanReleasePlan( + createRelease({ + target: 0.5, + shouldDismiss: false, + isCancelled: false, + }), createRuntime(), dimensions, rawEvent, @@ -211,6 +238,7 @@ describe("snap pan release", () => { expect(release.target).toBe(1); expect(release.commitProgress).toBe(1); + expect(release.isCancelled).toBe(false); }); it("still snaps back when the live drag has not crossed the midpoint", () => { @@ -227,6 +255,7 @@ describe("snap pan release", () => { expect(release.target).toBe(0.5); expect(release.commitProgress).toBe(0.725); + expect(release.isCancelled).toBe(true); }); it("carries committed progress into the release plan", () => { diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/gestures/policy/gesture-policy.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/gestures/policy/gesture-policy.test.ts index cd20de56..4b420bcb 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/gestures/policy/gesture-policy.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/gestures/policy/gesture-policy.test.ts @@ -67,7 +67,7 @@ describe("prevented removal", () => { const config = resolveScreenGestureConfig({ options: { gestureEnabled: true }, isFirstKey: false, - gestureContext: null, + ancestorGestures: [], isRemovePrevented: true, }); 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 index 09f9f5d4..b7eabfd9 100644 --- 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 @@ -19,6 +19,7 @@ mock.module("react-native-safe-area-context", () => ({ mock.module("../hooks/navigation/use-stack", () => ({ useStack: () => false, + useStackProgressEntries: () => [], })); mock.module("../providers/screen/descriptors", () => ({ diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/overlay/active-overlay-presentation.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/active-overlay-presentation.test.ts index 074756c1..3df7a40c 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/overlay/active-overlay-presentation.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/active-overlay-presentation.test.ts @@ -107,6 +107,32 @@ describe("floating overlay presentation", () => { ]); }); + it("lets a new overlay bypass a retained closing checkpoint", () => { + const scenes = [ + createScene("A", OverlayA), + createScene("B"), + createScene("C-1", OverlayC), + createScene("C-2", OverlayC), + ]; + scenes[2].activity = "closing"; + + const transitions = getFloatOverlayTransitions( + getFloatOverlayStack(scenes, true), + scenes, + ); + + expect( + transitions.map(({ scene, driverScene }) => [ + scene.route.key, + driverScene.route.key, + ]), + ).toEqual([ + ["A", "C-2"], + ["C-1", "C-1"], + ["C-2", "C-2"], + ]); + }); + it("with A through E overlays, keeps D visible underneath E", () => { const scenes = [ createScene("A", OverlayA), diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-host-lifecycle.test.tsx b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-host-lifecycle.test.tsx index 49a9672a..8023833b 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-host-lifecycle.test.tsx +++ b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-host-lifecycle.test.tsx @@ -35,7 +35,7 @@ let stackState: { const useScreenAnimationStore = (key: string) => animationStores.get(key) ?? null; -const useScreenSlots = ( +const useScreenSlotStore = ( key: string, selector?: (store: ScreenSlotContextValue) => Selected, ) => { @@ -49,6 +49,7 @@ const useScreenSlots = ( mock.module("../../hooks/navigation/use-stack", () => ({ useStack: () => stackState, + useStackProgressEntries: () => [], })); mock.module("@react-navigation/native", () => ({ @@ -64,15 +65,18 @@ mock.module("@react-navigation/native", () => ({ })); mock.module("../../providers/screen/animation/animation.provider", () => ({ + useOptionalScreenAnimationStore: useScreenAnimationStore, useScreenAnimationStore, })); mock.module("../../providers/screen/animation", () => ({ + useOptionalScreenAnimationStore: useScreenAnimationStore, useScreenAnimationStore, })); mock.module("../../providers/screen/styles/slot.provider", () => ({ - useScreenSlots, + useOptionalScreenSlotStore: useScreenSlotStore, + useScreenSlotStore, })); mock.module("../../components/overlay/hooks/use-overlay-slot", () => ({ @@ -97,19 +101,29 @@ const shared = (value: T) => ({ modify: () => {}, }); -const createAnimationStore = (routeKey: string) => - ({ - screenInterpolatorProps: shared({ +const createAnimationStore = (routeKey: string) => { + const screenInterpolatorProps = shared({ current: { route: { key: routeKey, name: routeKey }, layouts: { screen: { width: 390, height: 844 } }, }, stackProgress: 1, - }), - screenInterpolatorPropsRevision: shared(0), - ancestorScreenAnimationSources: [], - descendantScreenAnimationSources: shared([]), - }) as unknown as ScreenAnimationContextValue; + }); + const screenInterpolatorPropsRevision = shared(0); + + return { + screenInterpolatorProps, + screenInterpolatorPropsRevision, + transitionSources: [ + { + screenInterpolatorProps, + screenInterpolatorPropsRevision, + boundsAccessor: {}, + }, + ], + transitionOriginIndex: 0, + } as unknown as ScreenAnimationContextValue; +}; const createSlotStore = (opacity = 0.5) => ({ diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-interpolator-frame.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-interpolator-frame.test.ts index b01a2f00..267c236f 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-interpolator-frame.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-interpolator-frame.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { createOverlayInterpolatorFrame } from "../../components/overlay/helpers/create-overlay-interpolator-frame"; +import { + createOverlayInterpolatorFrame, + shouldUseOverlayGestureDriver, +} from "../../components/overlay/helpers/create-overlay-interpolator-frame"; import type { ScreenInterpolatorFrame } from "../../providers/screen/animation/helpers/pipeline"; -const createFrame = (key: string, progress: number) => +const createFrame = (key: string, progress: number, stackProgress = progress) => ({ current: { route: { key }, @@ -12,18 +15,43 @@ const createFrame = (key: string, progress: number) => layouts: { screen: { width: 390, height: 844 } }, }, insets: { top: 0, right: 0, bottom: 0, left: 0 }, + stackProgress, }) as ScreenInterpolatorFrame; describe("overlay interpolator frame", () => { + it("hands a retained closing driver's overlay to the underlying gesture", () => { + const overlayFrame = createFrame("C", 1); + const driverFrame = createFrame("D", 0.2); + overlayFrame.current.gesture = { + dragging: 1, + dismissing: 0, + settling: 0, + } as ScreenInterpolatorFrame["current"]["gesture"]; + + expect(shouldUseOverlayGestureDriver(overlayFrame, driverFrame)).toBe(true); + + overlayFrame.current.gesture.dragging = 0; + overlayFrame.current.gesture.dismissing = 1; + expect(shouldUseOverlayGestureDriver(overlayFrame, driverFrame)).toBe(true); + + overlayFrame.current.gesture.dismissing = 0; + overlayFrame.current.gesture.settling = 1; + expect(shouldUseOverlayGestureDriver(overlayFrame, driverFrame)).toBe(true); + + overlayFrame.current.gesture.settling = 0; + expect(shouldUseOverlayGestureDriver(overlayFrame, driverFrame)).toBe(false); + }); + it("presents sparse overlays as adjacent to the destination interpolator", () => { const frame = createOverlayInterpolatorFrame({ - overlayFrame: createFrame("A", 1), + overlayFrame: createFrame("A", 1, 2.25), driverFrame: createFrame("C", 0.25), }); expect(frame.current.route.key).toBe("A"); expect(frame.next?.route.key).toBe("C"); expect(frame.progress).toBe(1.25); + expect(frame.stackProgress).toBe(2.25); expect(frame.active.route.key).toBe("C"); expect(frame.inactive?.route.key).toBe("A"); }); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/portal/active-handoff-receiver.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/portal/active-handoff-receiver.test.ts index 7358331e..8cf08062 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/portal/active-handoff-receiver.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/portal/active-handoff-receiver.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "bun:test"; import { + hasNestedHandoffTopology, resolveActiveHandoffReceiver, resolveHandoffAttachmentCandidate, + resolveNestedHandoffAttachmentCandidate, resolvePreviousHandoffReceiver, } from "../../components/boundary/portal/components/boundary-content-portal/helpers/active-handoff-receiver"; @@ -11,6 +13,55 @@ const scene = (key: string, activity = "active") => ({ }); describe("active handoff receiver", () => { + it("classifies nesting from route structure rather than active receiver churn", () => { + expect( + hasNestedHandoffTopology({ + inheritedSourcePair: false, + pairDestinationScreenKey: "player", + transitionDestinationScreenKey: "player", + }), + ).toBe(false); + expect( + hasNestedHandoffTopology({ + inheritedSourcePair: false, + pairDestinationScreenKey: "destination-leaf", + transitionDestinationScreenKey: "destination-root", + }), + ).toBe(true); + expect( + hasNestedHandoffTopology({ + inheritedSourcePair: true, + pairDestinationScreenKey: "destination", + transitionDestinationScreenKey: "destination", + }), + ).toBe(true); + }); + + it("keeps a nested source attached until its ancestor destination is ready", () => { + const base = { + attachedReceiverScreenKey: "source-leaf", + currentScreenKey: "source-leaf", + hasActiveCloseFinished: false, + interpolatorReady: false, + pairDestinationScreenKey: "destination-leaf", + }; + + expect(resolveNestedHandoffAttachmentCandidate(base)).toBe("source-leaf"); + expect( + resolveNestedHandoffAttachmentCandidate({ + ...base, + interpolatorReady: true, + }), + ).toBe("destination-leaf"); + expect( + resolveNestedHandoffAttachmentCandidate({ + ...base, + attachedReceiverScreenKey: "destination-leaf", + hasActiveCloseFinished: true, + }), + ).toBe("source-leaf"); + }); + it("follows the focused route through A to B to C", () => { expect( resolveActiveHandoffReceiver({ @@ -130,6 +181,63 @@ describe("active handoff receiver", () => { ).toBe("player-b"); }); + it("keeps a closing payload absent from the fresh pair until close finishes", () => { + const params = { + activeReceiverClosing: true, + activeReceiverScreenKey: "waterfall-media", + attachedReceiverScreenKey: "waterfall-media", + interpolatorReady: false, + pairChangedDuringClose: true, + pairDestinationScreenKey: null, + pairHasBoundaryLink: false, + previousReceiverScreenKey: "world", + }; + + expect( + resolveHandoffAttachmentCandidate({ + ...params, + hasActiveCloseFinished: false, + }), + ).toBe("waterfall-media"); + expect( + resolveHandoffAttachmentCandidate({ + ...params, + hasActiveCloseFinished: true, + }), + ).toBe("world"); + }); + + it("keeps a same-id closing payload while its fresh pair is incomplete", () => { + expect( + resolveHandoffAttachmentCandidate({ + activeReceiverClosing: true, + activeReceiverScreenKey: "media-a", + attachedReceiverScreenKey: "media-a", + hasActiveCloseFinished: false, + interpolatorReady: false, + pairChangedDuringClose: true, + pairDestinationScreenKey: null, + pairHasBoundaryLink: true, + previousReceiverScreenKey: "world", + }), + ).toBe("media-a"); + }); + + it("does not give a fresh payload to an unlinked closing receiver", () => { + expect( + resolveHandoffAttachmentCandidate({ + activeReceiverClosing: true, + activeReceiverScreenKey: "media-a", + attachedReceiverScreenKey: "media-b", + hasActiveCloseFinished: false, + interpolatorReady: false, + pairDestinationScreenKey: null, + pairHasBoundaryLink: false, + previousReceiverScreenKey: "world", + }), + ).toBe("media-b"); + }); + it("ignores a stale shallow pair while a deeper receiver closes", () => { expect( resolveHandoffAttachmentCandidate({ @@ -241,4 +349,5 @@ describe("active handoff receiver", () => { }), ).toBe("player-b"); }); + }); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/portal/host-readiness.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/portal/host-readiness.test.ts new file mode 100644 index 00000000..671b983f --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/__tests__/portal/host-readiness.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { resolveReadyPortalHostName } from "../../components/boundary/portal/components/boundary-portal/helpers/host-readiness"; + +describe("portal boundary host readiness", () => { + it("does not let a stale host clear the newly ready host", () => { + const previousHost = "portal:pair-a"; + const nextHost = "portal:pair-b"; + + const nextReady = resolveReadyPortalHostName({ + currentReadyHostName: previousHost, + hostName: nextHost, + ready: true, + }); + + expect( + resolveReadyPortalHostName({ + currentReadyHostName: nextReady, + hostName: previousHost, + ready: false, + }), + ).toBe(nextHost); + }); + + it("clears readiness when the active host becomes invalid", () => { + expect( + resolveReadyPortalHostName({ + currentReadyHostName: "portal:pair-a", + hostName: "portal:pair-a", + ready: false, + }), + ).toBeNull(); + }); +}); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/portal/local-measurement.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/portal/local-measurement.test.ts index 34c27cec..357b471b 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/portal/local-measurement.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/portal/local-measurement.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { resolveBoundaryLocalMeasurement } from "../../components/boundary/portal/components/boundary-portal/helpers/local-measurement"; +import { + resolveActiveBoundaryPortalPairKey, + resolveBoundaryLocalMeasurement, +} from "../../components/boundary/portal/components/boundary-portal/helpers/local-measurement"; const bounds = { x: 10, @@ -29,3 +32,54 @@ describe("resolveBoundaryLocalMeasurement", () => { ).toBeNull(); }); }); + +describe("resolveActiveBoundaryPortalPairKey", () => { + const measurement = { bounds, pairKey: "source<>destination" }; + const pairs = { + "source<>destination": { + groups: { cards: { activeId: "A", initialId: "A" } }, + links: {}, + }, + } as any; + + it("keeps the host mounted while the boundary slot is teleporting", () => { + expect( + resolveActiveBoundaryPortalPairKey( + measurement, + { props: { teleport: true } }, + "cards:A", + pairs, + ), + ).toBe(measurement.pairKey); + }); + + it("prepares only the active group member before its slot is published", () => { + expect( + resolveActiveBoundaryPortalPairKey( + measurement, + undefined, + "cards:A", + pairs, + ), + ).toBe(measurement.pairKey); + expect( + resolveActiveBoundaryPortalPairKey( + measurement, + undefined, + "cards:B", + pairs, + ), + ).toBeNull(); + }); + + it("unmounts the host when teleportation settles", () => { + expect( + resolveActiveBoundaryPortalPairKey( + measurement, + { props: { teleport: false } }, + "cards:A", + pairs, + ), + ).toBeNull(); + }); +}); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/screen-topology.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/screen-topology.test.ts new file mode 100644 index 00000000..48205372 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/__tests__/screen-topology.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, mock } from "bun:test"; +import { createScreenTopology } from "../factories/screen-topology"; + +describe("screen topology", () => { + it("owns direct parent and active-child relationships", () => { + const topology = createScreenTopology(); + + topology.register({ + screenKey: "root", + }); + topology.register({ + screenKey: "nested", + parentScreenKey: "root", + }); + topology.register({ + screenKey: "leaf", + parentScreenKey: "nested", + }); + + expect(topology.getRelationships("root")).toEqual({ + parentScreenKey: null, + activeChildScreenKey: null, + }); + expect(topology.getRelationships("nested")).toEqual({ + parentScreenKey: "root", + activeChildScreenKey: null, + }); + expect(topology.getRelationships("leaf")).toEqual({ + parentScreenKey: "nested", + activeChildScreenKey: null, + }); + }); + + it("supports children mounting before their parent", () => { + const topology = createScreenTopology(); + + topology.register({ + screenKey: "child", + parentScreenKey: "root", + }); + topology.register({ + screenKey: "root", + }); + + expect(topology.getRelationships("child").parentScreenKey).toBe("root"); + }); + + it("unregisters by unique route key", () => { + const topology = createScreenTopology(); + + topology.register({ + screenKey: "child", + parentScreenKey: "parent", + }); + topology.activate({ + screenKey: "child", + parentScreenKey: "parent", + }); + + topology.unregister("child"); + expect(topology.getRelationships("child").parentScreenKey).toBeNull(); + expect(topology.getRelationships("parent").activeChildScreenKey).toBeNull(); + }); + + it("safely stores object-like screen keys", () => { + const topology = createScreenTopology(); + + topology.register({ + screenKey: "__proto__", + parentScreenKey: "constructor", + }); + expect(topology.getRelationships("__proto__").parentScreenKey).toBe( + "constructor", + ); + }); + + it("models the active nested path without losing a mounted sibling", () => { + const topology = createScreenTopology(); + + for (const [screenKey, parentScreenKey] of [ + ["child-a", "root"], + ["child-b", "root"], + ["grandchild", "child-b"], + ] as const) { + topology.register({ + screenKey, + parentScreenKey, + }); + } + + const clearA = topology.activate({ + parentScreenKey: "root", + screenKey: "child-a", + }); + const clearB = topology.activate({ + parentScreenKey: "root", + screenKey: "child-b", + }); + topology.activate({ + parentScreenKey: "child-b", + screenKey: "grandchild", + }); + + expect(topology.getRelationships("root").activeChildScreenKey).toBe( + "child-b", + ); + expect(topology.getRelationships("child-b").activeChildScreenKey).toBe( + "grandchild", + ); + + clearB(); + expect(topology.getRelationships("root").activeChildScreenKey).toBe( + "child-a", + ); + + clearA(); + expect(topology.getRelationships("root").activeChildScreenKey).toBeNull(); + }); + + it("does not invalidate ancestors when only a direct child changes", () => { + const topology = createScreenTopology(); + const rootListener = mock(() => {}); + const childListener = mock(() => {}); + + topology.register({ screenKey: "child", parentScreenKey: "root" }); + topology.register({ screenKey: "grandchild", parentScreenKey: "child" }); + topology.activate({ screenKey: "child", parentScreenKey: "root" }); + topology.subscribe("root", rootListener); + topology.subscribe("child", childListener); + + topology.activate({ screenKey: "grandchild", parentScreenKey: "child" }); + + expect(rootListener).not.toHaveBeenCalled(); + expect(childListener).toHaveBeenCalledTimes(1); + }); + + it("keeps a registered relationship snapshot stable across subscriptions", () => { + const topology = createScreenTopology(); + topology.register({ screenKey: "child", parentScreenKey: "root" }); + const relationships = topology.getRelationships("child"); + + const unsubscribe = topology.subscribe("child", () => {}); + unsubscribe(); + + expect(topology.getRelationships("child")).toBe(relationships); + }); + + it("notifies only subscribers whose relationship snapshot changed", () => { + const topology = createScreenTopology(); + const rootListener = mock(() => {}); + const unrelatedListener = mock(() => {}); + const unsubscribeRoot = topology.subscribe("root", rootListener); + const unsubscribeUnrelated = topology.subscribe( + "unrelated", + unrelatedListener, + ); + + topology.register({ + screenKey: "child", + parentScreenKey: "root", + }); + topology.activate({ + parentScreenKey: "root", + screenKey: "child", + }); + const rootRelationships = topology.getRelationships("root"); + + topology.register({ + screenKey: "other-child", + parentScreenKey: "other-parent", + }); + + expect(rootListener).toHaveBeenCalledTimes(1); + expect(unrelatedListener).not.toHaveBeenCalled(); + expect(topology.getRelationships("root")).toBe(rootRelationships); + + unsubscribeRoot(); + unsubscribeUnrelated(); + }); +}); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/soft-dismiss.test.tsx b/packages/react-native-screen-transitions/src/shared/__tests__/soft-dismiss.test.tsx index a31c5f24..e80fc9b1 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/soft-dismiss.test.tsx +++ b/packages/react-native-screen-transitions/src/shared/__tests__/soft-dismiss.test.tsx @@ -74,16 +74,22 @@ mock.module("@react-navigation/native", () => ({ mock.module("../providers/screen/descriptors", () => ({ useDescriptorsStore: (selector: (store: any) => unknown) => - selector({ current, derivations: { ancestorKeys: [] } }), + selector({ + current, + derivations: { + currentScreenKey: route.key, + }, + }), })); mock.module("../hooks/navigation/use-stack", () => ({ useStack: (selector: (stack: any) => unknown) => selector({ requestDismiss: requestStackDismiss }), + useStackProgressEntries: () => [], })); mock.module("../providers/stack/blank-stack.provider", () => ({ - useBlankStackStore: (selector: (stack: any) => unknown) => + useOptionalBlankStackStore: (selector: (stack: any) => unknown) => selector({ handleCloseRoute: () => { blankCloseCount += 1; diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/stack-progress.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/stack-progress.test.ts index 183bc608..9984460c 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/stack-progress.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/stack-progress.test.ts @@ -1,44 +1,63 @@ import { describe, expect, it } from "bun:test"; import type { SharedValue } from "react-native-reanimated"; -import { - resolveStackProgress, - syncStackProgressValues, -} from "../providers/screen/animation/helpers/stack-progress"; +import { readStackProgress } from "../providers/screen/animation/helpers/stack-progress"; -const shared = (initial: number) => { - let current = initial; +const tracked = (value: number) => { + let reads = 0; return { - get: () => current, - set: (value: number) => { - current = value; - }, - get value() { - return current; - }, - set value(value: number) { - current = value; - }, - } as SharedValue; + progress: { + get: () => { + reads += 1; + return value; + }, + } as SharedValue, + getReads: () => reads, + }; }; describe("stack progress", () => { - it("syncs accumulated progress from each route to the top", () => { - const visualProgressValues = [shared(1), shared(0.75), shared(0.5)]; - const stackProgressValues = [shared(0), shared(0), shared(0)]; + it("accumulates only the requested route suffix", () => { + const first = tracked(1); + const second = tracked(0.75); + const top = tracked(0.5); + const entries = [ + { routeKey: "A", visualProgress: first.progress }, + { routeKey: "B", visualProgress: second.progress }, + { routeKey: "C", visualProgress: top.progress }, + ]; - syncStackProgressValues(visualProgressValues, stackProgressValues); - - expect(stackProgressValues[0]?.get()).toBe(2.25); - expect(stackProgressValues[1]?.get()).toBe(1.25); - expect(stackProgressValues[2]?.get()).toBe(0.5); + expect(readStackProgress(entries, "B", 0)).toBe(1.25); + expect(first.getReads()).toBe(0); + expect(second.getReads()).toBe(1); + expect(top.getReads()).toBe(1); }); - it("resolves freshly hydrated current and next progress over the root value", () => { - expect(resolveStackProgress(shared(2), 0, 0.75, 1, 0.5, 1)).toBe(1.25); + it("reads only the top route for a top-screen consumer", () => { + const first = tracked(1); + const second = tracked(1); + const top = tracked(0.4); + const entries = [ + { routeKey: "A", visualProgress: first.progress }, + { routeKey: "B", visualProgress: second.progress }, + { routeKey: "C", visualProgress: top.progress }, + ]; + + expect(readStackProgress(entries, "C", 0)).toBe(0.4); + expect(first.getReads()).toBe(0); + expect(second.getReads()).toBe(0); + expect(top.getReads()).toBe(1); }); - it("falls back to frame progress when no stack value is available", () => { - expect(resolveStackProgress(undefined, 0.5, 0, undefined, undefined, undefined)).toBe(0.5); + it("falls back to local frame progress after structural removal", () => { + const top = tracked(0.5); + + expect( + readStackProgress( + [{ routeKey: "B", visualProgress: top.progress }], + "A", + 0.75, + ), + ).toBe(0.75); }); }); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/styles/resolve-slot-styles.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/styles/resolve-slot-styles.test.ts index 4f5d8a65..df1e2022 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/styles/resolve-slot-styles.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/styles/resolve-slot-styles.test.ts @@ -251,6 +251,7 @@ describe("resolveSlotStyles", () => { }, props: { pointerEvents: "none", + teleport: true, }, }, }, @@ -269,6 +270,7 @@ describe("resolveSlotStyles", () => { }, props: { pointerEvents: "auto", + teleport: false, }, }); expect(omitted.nextPreviousStyleStatesBySlot).toEqual({}); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/styles/visibility-block-ownership.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/styles/visibility-block-ownership.test.ts new file mode 100644 index 00000000..87b01a23 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/__tests__/styles/visibility-block-ownership.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "bun:test"; +import { resolveVisibilityBlockOwnership } from "../../providers/screen/styles/helpers/visibility-block-ownership"; + +describe("visibility block ownership", () => { + it.each([ + [false, false, false, false], + [true, false, true, true], + [false, true, false, true], + [true, true, false, true], + ] as const)( + "local=%s ancestor=%s appliesOffset=%s effectiveBlocked=%s", + (localBlocked, ancestorBlocked, appliesOffset, effectiveBlocked) => { + expect( + resolveVisibilityBlockOwnership({ + localBlocked, + ancestorBlocked, + }), + ).toEqual({ appliesOffset, effectiveBlocked }); + }, + ); + + it("assigns one physical offset throughout a deeply blocked ancestry chain", () => { + let ancestorBlocked = false; + const ownership = [true, true, true, true].map((localBlocked) => { + const current = resolveVisibilityBlockOwnership({ + localBlocked, + ancestorBlocked, + }); + ancestorBlocked = current.effectiveBlocked; + return current; + }); + + expect(ownership.filter(({ appliesOffset }) => appliesOffset)).toHaveLength(1); + expect(ownership.map(({ effectiveBlocked }) => effectiveBlocked)).toEqual([ + true, + true, + true, + true, + ]); + }); + + it("hands offset ownership to the first locally blocked descendant", () => { + let ancestorBlocked = false; + const ownership = [false, false, true, true].map((localBlocked) => { + const current = resolveVisibilityBlockOwnership({ + localBlocked, + ancestorBlocked, + }); + ancestorBlocked = current.effectiveBlocked; + return current; + }); + + expect(ownership.map(({ appliesOffset }) => appliesOffset)).toEqual([ + false, + false, + true, + false, + ]); + }); +}); 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 c2b7ad13..d11aaf9e 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 @@ -4,27 +4,28 @@ import type { TransitionAccessorSource } from "../providers/screen/animation/hel import type { ScreenInterpolatorFrame } from "../providers/screen/animation/helpers/pipeline"; import { readScreenAnimationRevisions } from "../providers/screen/animation/helpers/read-screen-animation-revisions"; import type { - ScreenAnimationDescendantSources, - ScreenAnimationSource, + ScreenAnimationTransitionSource, ScreenInterpolatorPropsRevision, } from "../providers/screen/animation/types"; mock.module("../providers/screen/animation/animation.provider", () => ({ useScreenAnimationStore: () => ({ - screenInterpolatorProps: { get: () => ({}) }, - screenInterpolatorPropsRevision: { get: () => 0 }, - ancestorScreenAnimationSources: [], - descendantScreenAnimationSources: { get: () => [] }, + transitionSources: [], + transitionOriginIndex: 0, }), })); let createTransitionAccessor: typeof TransitionAccessorModule.createTransitionAccessor; -const createSource = (routeKey: string): TransitionAccessorSource => { +const createSource = ( + routeKey: string, + stackProgress = 0, +): TransitionAccessorSource => { const frame = { current: { route: { key: routeKey }, }, + stackProgress, } as unknown as ScreenInterpolatorFrame; return { @@ -56,13 +57,14 @@ const createTrackedRevision = () => { const createRevisionSource = ( revision: ScreenInterpolatorPropsRevision, -): ScreenAnimationSource => +): ScreenAnimationTransitionSource => ({ screenInterpolatorProps: { get: () => ({}), }, screenInterpolatorPropsRevision: revision, - }) as ScreenAnimationSource; + boundsAccessor: {}, + }) as ScreenAnimationTransitionSource; describe("createTransitionAccessor", () => { beforeAll(async () => { @@ -88,6 +90,13 @@ describe("createTransitionAccessor", () => { expect(transition({ depth: 0 })?.current.route.key).toBe("self"); }); + it("preserves stack progress from the selected frame", () => { + const source = createSource("self", 3.25); + const transition = createTransitionAccessor([source]); + + expect(transition()?.stackProgress).toBe(3.25); + }); + it("resolves negative depth to ancestors", () => { const grandparent = createSource("grandparent"); const parent = createSource("parent"); @@ -117,33 +126,6 @@ describe("createTransitionAccessor", () => { expect(transition({ depth: 2 })?.current.route.key).toBe("grandchild"); }); - it("reads descendant sources without rebuilding the accessor", () => { - const grandparent = createSource("grandparent"); - const parent = createSource("parent"); - const self = createSource("self"); - const child = createSource("child"); - const grandchild = createSource("grandchild"); - let registered = [] as ScreenAnimationDescendantSources["value"]; - const descendants: ScreenAnimationDescendantSources = { - get: () => registered, - } as ScreenAnimationDescendantSources; - const transition = createTransitionAccessor( - [grandparent, parent, self], - 2, - descendants, - ); - - expect(transition({ depth: 1 })).toBeNull(); - - registered = [ - { source: child, depth: 1 }, - { source: grandchild, depth: 2 }, - ]; - - expect(transition({ depth: 1 })?.current.route.key).toBe("child"); - expect(transition({ depth: 2 })?.current.route.key).toBe("grandchild"); - }); - it("resolves nested transition calls relative to the current scope", () => { const grandparent = createSource("grandparent"); const parent = createSource("parent"); @@ -181,22 +163,13 @@ describe("createTransitionAccessor", () => { const ancestor = createTrackedRevision(); const descendant = createTrackedRevision(); const external = createTrackedRevision(); - const descendants = { - get: () => [ - { - source: { - ...createRevisionSource(descendant.revision), - boundsAccessor: {}, - }, - depth: 1, - }, - ], - } as ScreenAnimationDescendantSources; readScreenAnimationRevisions( - self.revision, - [createRevisionSource(ancestor.revision)], - descendants, + [ + createRevisionSource(ancestor.revision), + createRevisionSource(self.revision), + createRevisionSource(descendant.revision), + ], [external.revision], ); 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 25bc4396..4f633280 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 @@ -167,7 +167,6 @@ const createBuiltState = ( return { transitionProgress: shared(overrides.progress ?? 1), visualProgress: shared(overrides.progress ?? 1), - stackProgress: shared(overrides.progress ?? 1), willAnimate: shared(0), closing: shared(overrides.closing ?? 0), progressAnimating: shared(overrides.progressAnimating ?? 0), diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/transition-state-snap-index.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/transition-state-snap-index.test.ts index 08af617e..3287d914 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/transition-state-snap-index.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/transition-state-snap-index.test.ts @@ -117,7 +117,6 @@ describe("hydrateTransitionState snap indices", () => { { transitionProgress: shared(1), visualProgress: shared(1), - stackProgress: shared(1), willAnimate: shared(0), closing: shared(0), progressAnimating: shared(0), @@ -178,7 +177,6 @@ describe("hydrateTransitionState snap indices", () => { { transitionProgress: shared(1), visualProgress: shared(1), - stackProgress: shared(1), willAnimate: shared(0), closing: shared(0), progressAnimating: shared(0), @@ -237,7 +235,6 @@ describe("hydrateTransitionState snap indices", () => { { transitionProgress: shared(0.5), visualProgress: shared(0.5), - stackProgress: shared(0.5), willAnimate: shared(0), closing: shared(0), progressAnimating: shared(0), @@ -263,6 +260,104 @@ describe("hydrateTransitionState snap indices", () => { expect(hydrated.snapIndex).toBe(1); }); + it("locks animatedSnapIndex through gesture motion without blocking programmatic snaps", () => { + const options = buildScreenTransitionOptions({ + gestureDirection: "vertical", + gestureSnapLocked: true, + }); + const state = createScreenTransitionState( + { + key: "route-a", + name: "RouteA", + }, + undefined, + options, + ); + const gesture = createGestureStore(); + const transitionProgress = shared(1); + const targetProgress = shared(1); + const builtState = { + transitionProgress, + visualProgress: shared(1), + willAnimate: shared(0), + closing: shared(0), + progressAnimating: shared(0), + entering: shared(0), + gesture, + route: state.route, + options, + optionsSlot: {}, + targetProgress, + progressSettled: shared(1), + resolvedAutoSnapPoint: shared(0.4), + measuredContentLayout: shared(null), + scrollMetadata: shared(null), + contentLayoutSlot: { width: 0, height: 0 }, + hasAutoSnapPoint: true, + sortedNumericSnapPoints: [1], + unwrapped: state, + }; + + gesture.active.set("vertical"); + gesture.dragging.set(1); + gesture.internal.progressDeltaY.set(0.3); + gesture.internal.lockedSnapPoint.set(1); + + const dragging = hydrateTransitionState(builtState, { + width: 390, + height: 844, + }); + + expect(dragging.progress).toBeCloseTo(0.7); + expect(dragging.animatedSnapIndex).toBe(1); + + gesture.dragging.set(0); + gesture.settling.set(1); + gesture.internal.progressDeltaY.set(0); + transitionProgress.set(0.7); + + expect( + hydrateTransitionState(builtState, { width: 390, height: 844 }) + .animatedSnapIndex, + ).toBe(1); + + targetProgress.set(0.4); + + expect( + hydrateTransitionState(builtState, { width: 390, height: 844 }) + .animatedSnapIndex, + ).toBeCloseTo(0.5); + + gesture.settling.set(0); + gesture.dismissing.set(1); + transitionProgress.set(0.3); + targetProgress.set(0); + + expect( + hydrateTransitionState(builtState, { width: 390, height: 844 }) + .animatedSnapIndex, + ).toBe(1); + + gesture.dismissing.set(0); + transitionProgress.set(0.7); + targetProgress.set(0.4); + + expect( + hydrateTransitionState(builtState, { width: 390, height: 844 }) + .animatedSnapIndex, + ).toBeCloseTo(0.5); + + gesture.dragging.set(1); + + expect( + hydrateTransitionState( + builtState, + { width: 390, height: 844 }, + { gestureSnapLocked: false }, + ).animatedSnapIndex, + ).toBeCloseTo(0.5); + }); + it("merges the resolved auto snap point without changing snap index ordering", () => { const state = createScreenTransitionState({ key: "route-a", @@ -273,7 +368,6 @@ describe("hydrateTransitionState snap indices", () => { { transitionProgress: shared(0.45), visualProgress: shared(0.45), - stackProgress: shared(0.45), willAnimate: shared(0), closing: shared(0), progressAnimating: shared(0), @@ -308,7 +402,6 @@ describe("hydrateTransitionState snap indices", () => { const builtState = { transitionProgress: shared(0.45), visualProgress: shared(0.45), - stackProgress: shared(0.45), willAnimate: shared(0), closing: shared(0), progressAnimating: shared(0), @@ -365,7 +458,6 @@ describe("hydrateTransitionState snap indices", () => { const builtState = { transitionProgress: shared(1), visualProgress: shared(1), - stackProgress: shared(1), willAnimate: shared(0), closing: shared(0), progressAnimating: shared(0), 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 b30298de..4ce5084c 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 @@ -73,7 +73,7 @@ function buildTransitionStackState({ const routes = state.routes as BaseStackRoute[]; const preloadedRoutes = getPreloadedRoutes(state) as BaseStackRoute[]; const allRoutes = routes.concat(preloadedRoutes); - const routeKeys: string[] = []; + const routeKeys = routes.map((route) => route.key); const scenes: ScreenTransitionsAdapterScene[] = []; const routeIndexByKey = new Map(); let shouldShowFloatOverlay = false; @@ -112,7 +112,6 @@ function buildTransitionStackState({ descriptor: normalizedDescriptor, previousDescriptor, }); - routeKeys.push(route.key); routeIndexByKey.set(route.key, sceneIndex); if ( diff --git a/packages/react-native-screen-transitions/src/shared/components/activity/helpers.ts b/packages/react-native-screen-transitions/src/shared/components/activity/helpers.ts index 7c877a42..949d4a24 100644 --- a/packages/react-native-screen-transitions/src/shared/components/activity/helpers.ts +++ b/packages/react-native-screen-transitions/src/shared/components/activity/helpers.ts @@ -1,8 +1,67 @@ import { IS_WEB } from "../../constants"; import type { InactiveBehavior } from "../../types/screen.types"; +import type { StackSceneActivity } from "../../types/stack.types"; export type { InactiveBehavior } from "../../types/screen.types"; export const DEFAULT_INACTIVE_BEHAVIOR: InactiveBehavior = IS_WEB ? "unmount" : "hide"; + +type ActivityState = 0 | 1 | 2; + +const ActivityStateByActivity = { + active: 2, + inert: 1, + inactive: 0, + closing: 1, +} satisfies Record; + +const PointerEventsByActivity = { + active: "auto", + inert: "auto", + inactive: "none", + closing: "none", +} as const satisfies Record; + +export const HIDDEN_ACTIVITY_SCREEN_STYLE = { display: "none" } as const; + +export function resolveActivityScreenPresentation({ + activity, + inactiveBehavior, + waitForPaintDriver, +}: { + activity: StackSceneActivity; + inactiveBehavior: InactiveBehavior; + waitForPaintDriver: boolean; +}) { + let activityState: ActivityState = ActivityStateByActivity[activity]; + let shouldFreeze = false; + let visible = activity !== "inactive"; + + if (activity === "inactive") { + if (inactiveBehavior === "keep") { + activityState = 1; + visible = true; + } else if (waitForPaintDriver) { + activityState = 1; + visible = true; + } else if (inactiveBehavior === "pause") { + activityState = 1; + shouldFreeze = true; + visible = true; + } else { + activityState = 0; + shouldFreeze = true; + visible = false; + } + } + + return { + activityState, + freezeOnBlur: true, + pointerEvents: PointerEventsByActivity[activity], + shouldFreeze, + visible, + }; +} 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 8fd7dc4d..e84745e5 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 @@ -1,6 +1,6 @@ import type * as React from "react"; import { memo } from "react"; -import { StyleSheet, View, type ViewProps } from "react-native"; +import { StyleSheet, View } from "react-native"; import { useDerivedValue } from "react-native-reanimated"; import { Screen } from "react-native-screens"; import { IS_WEB } from "../../../constants"; @@ -8,71 +8,40 @@ 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"; - -type ActivityState = 0 | 1 | 2; -const ActivityStateByActivity = { - active: 2, - inert: 1, - inactive: 0, - closing: 1, -} satisfies Record; - -const PointerEventsByActivity = { - active: "auto", - inert: "auto", - inactive: "none", - closing: "none", -} satisfies Record; +import { + DEFAULT_INACTIVE_BEHAVIOR, + HIDDEN_ACTIVITY_SCREEN_STYLE, + type InactiveBehavior, + resolveActivityScreenPresentation, +} from "../helpers"; interface ActivityScreenProps { - activity?: StackSceneActivity; children: React.ReactNode; inactiveBehavior?: InactiveBehavior; paintDriverRouteKey?: string; hasNestedState?: boolean; - routeKey?: string; + routeKey: string; } export const ActivityScreen = memo(function ActivityScreen({ - activity, children, inactiveBehavior, 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 scene = useBlankStackStore((store) => store.scenesByKey[routeKey]); + const stackPaintDriverRouteKey = useBlankStackStore((store) => + store.paintDriverRouteKeyByRouteKey.get(routeKey), ); - 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 stackInactiveBehavior = ( + scene.descriptor.options as { inactiveBehavior?: InactiveBehavior } + ).inactiveBehavior; const resolvedInactiveBehavior = inactiveBehavior ?? stackInactiveBehavior ?? DEFAULT_INACTIVE_BEHAVIOR; const resolvedPaintDriverRouteKey = paintDriverRouteKey ?? stackPaintDriverRouteKey; - const resolvedHasNestedState = - hasNestedState ?? (stackRoute ? "state" in stackRoute : false); + const resolvedHasNestedState = hasNestedState ?? "state" in scene.route; const nativeScreenDisabled = useStack((s) => s.flags.DISABLE_NATIVE_SCREENS); const paintDriverAnimations = resolvedPaintDriverRouteKey ? AnimationStore.getBag(resolvedPaintDriverRouteKey) @@ -92,47 +61,23 @@ export const ActivityScreen = memo(function ActivityScreen({ return false; } - return paintDriverAnimations.transitionProgress.get() >= 1; + return paintDriverAnimations.visualProgress.get() >= 1; }); const isPaintDriverSettledOnJS = useSharedValueState(isPaintDriverSettled); - let activityState: ActivityState = ActivityStateByActivity[resolvedActivity]; - let shouldFreeze = false; - let visible = resolvedActivity !== "inactive"; - - const shouldWaitForPaintDriver = !isPaintDriverSettledOnJS; - - if (resolvedActivity === "inactive") { - if (resolvedInactiveBehavior === "keep") { - activityState = 1; - visible = true; - } else if (shouldWaitForPaintDriver) { - // Delay hiding until the paint driver has settled to avoid - // a blank frame during the transition. - activityState = 1; - visible = true; - } else if (resolvedInactiveBehavior === "pause") { - activityState = 1; - shouldFreeze = true; - visible = true; - } else { - // `hide` freezes and hides native presentation. Non-nested - // `unmount` removes the React subtree through the JS guard below. - activityState = 0; - shouldFreeze = true; - visible = false; - } - } + const { visible, ...screenPresentation } = resolveActivityScreenPresentation({ + activity: scene.activity, + inactiveBehavior: resolvedInactiveBehavior, + waitForPaintDriver: !isPaintDriverSettledOnJS, + }); const shouldUnmount = resolvedInactiveBehavior === "unmount" && - resolvedActivity === "inactive" && + scene.activity === "inactive" && !resolvedHasNestedState && isPaintDriverSettledOnJS; - const pointerEvents = PointerEventsByActivity[resolvedActivity]; - if (shouldUnmount) { return null; } @@ -141,33 +86,23 @@ export const ActivityScreen = memo(function ActivityScreen({ if (IS_WEB || nativeScreenDisabled) { return ( - + {children} ); } return ( - + {children} ); }); -const HIDDEN_SCREEN_OFFSET = 10_000; const styles = StyleSheet.create({ - hidden: { - // NOTE: - // When setting a screen to display:"none", the gesture detector will not recognize anymore. Since I believe - // rngh is attaching itself to its nearest native view, this of course would kill the detector. - // To avoid this, we use a transform to move the screen off-screen instead of display: "none". This isn't my favorite approach, - // but i'm hoping react 19's activity could mitigate this better and avoid dependence on rns. - transform: [{ translateY: HIDDEN_SCREEN_OFFSET }], - }, + hidden: HIDDEN_ACTIVITY_SCREEN_STYLE, }); diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/components/boundary-lifecycle.tsx b/packages/react-native-screen-transitions/src/shared/components/boundary/components/boundary-lifecycle.tsx index cb23fd22..63db66c2 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/components/boundary-lifecycle.tsx +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/components/boundary-lifecycle.tsx @@ -1,7 +1,6 @@ import { memo } from "react"; import type { View } from "react-native"; import type { AnimatedRef } from "react-native-reanimated"; -import { useDescriptorsStore } from "../../../providers/screen/descriptors"; import type { BoundTag } from "../../../stores/bounds/types"; import { useBoundaryMeasurement } from "../hooks/use-boundary-measurement"; import type { @@ -32,14 +31,9 @@ export const BoundaryLifecycle = memo(function BoundaryLifecycle({ measuredRef, style, }: BoundaryLifecycleProps) { - const hasConfiguredInterpolator = useDescriptorsStore( - (s) => s.derivations.hasConfiguredInterpolator, - ); - useBoundaryMeasurement({ boundTag, enabled, - runtimeEnabled: enabled && hasConfiguredInterpolator, currentScreenKey, measuredRef, style, 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 9a065909..176115f3 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, - useBoundaryRootStore, + useOptionalBoundaryRootStore, } from "../providers/boundary-root.provider"; type BoundaryTargetProps = Omit< @@ -36,7 +36,7 @@ const BoundaryTargetInner = (props: InternalBoundaryTargetProps) => { style, ...rest } = props; - const rootContext = useBoundaryRootStore(); + const rootContext = useOptionalBoundaryRootStore(); 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/create-boundary-component.tsx b/packages/react-native-screen-transitions/src/shared/components/boundary/create-boundary-component.tsx index 2eaa5db0..8f0f51e8 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/create-boundary-component.tsx +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/create-boundary-component.tsx @@ -10,7 +10,10 @@ import { BOUNDARY_TARGET_ACTIVE_PROP, BoundaryTarget, } from "./components/boundary-target"; -import { BoundaryContentPortalHost } from "./portal/components/boundary-content-portal"; +import { + BoundaryContentPortal, + BoundaryContentPortalHost, +} from "./portal/components/boundary-content-portal"; import { BoundaryPortal } from "./portal/components/boundary-portal"; import { BoundaryRootProvider } from "./providers/boundary-root.provider"; import type { BoundaryComponentProps } from "./types"; @@ -91,7 +94,12 @@ export function createBoundaryComponent

( enabled={root.shouldRenderHandoffHost} screenKey={root.currentScreenKey} > - {targetResolution.children} + + {targetResolution.children} + diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-boundary-measurement-request.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-boundary-measurement-request.ts new file mode 100644 index 00000000..941a4f54 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-boundary-measurement-request.ts @@ -0,0 +1,102 @@ +import { useCallback, useEffect } from "react"; +import { + cancelAnimation, + useAnimatedReaction, + useSharedValue, + withDelay, + withTiming, +} from "react-native-reanimated"; +import { + abandonBoundaryMeasurement, + getBoundaryMeasurementRequest, +} from "../../../../stores/bounds/internals/coordinator"; +import { pairs } from "../../../../stores/bounds/internals/state"; +import type { BoundTag } from "../../../../stores/bounds/types"; +import { logger } from "../../../../utils/logger"; +import type { MeasureBoundary, MeasureTarget } from "../../types"; + +const RETRY_DELAY_MS = 16; +const MAX_RETRIES = 20; + +export const useBoundaryMeasurementRequest = (params: { + enabled: boolean; + boundTag: BoundTag; + currentScreenKey: string; + measureBoundary: MeasureBoundary; +}) => { + const { enabled, boundTag, currentScreenKey, measureBoundary } = params; + const retryClock = useSharedValue(0); + + const fulfillRequest = useCallback( + function retry(request: MeasureTarget, attempt: number) { + "worklet"; + if (!enabled) return; + + const pending = getBoundaryMeasurementRequest( + boundTag.tag, + currentScreenKey, + pairs.get(), + ); + if ( + pending?.type !== request.type || + pending.pairKey !== request.pairKey + ) { + return; + } + + if (measureBoundary(request)) return; + + if (attempt >= MAX_RETRIES) { + abandonBoundaryMeasurement({ ...request, tag: boundTag.tag }); + logger.warn( + `Boundary "${boundTag.tag}" on screen "${currentScreenKey}" could not complete its ${request.type} measurement after ${MAX_RETRIES} attempts; continuing without it.`, + ); + return; + } + + cancelAnimation(retryClock); + retryClock.set( + withDelay( + RETRY_DELAY_MS, + withTiming(retryClock.get() + 1, { duration: 0 }, (finished) => { + if (finished) retry(request, attempt + 1); + }), + ), + ); + }, + [boundTag.tag, currentScreenKey, enabled, measureBoundary, retryClock], + ); + + useEffect(() => { + return () => { + cancelAnimation(retryClock); + }; + }, [retryClock]); + + useAnimatedReaction( + () => { + "worklet"; + if (!enabled) return null; + // Read the mutable in the reaction itself. Hiding this read behind an + // imported helper prevented the mapper from subscribing to pair changes, + // so requests were only observed after a React refresh/remount. + return getBoundaryMeasurementRequest( + boundTag.tag, + currentScreenKey, + pairs.get(), + ); + }, + (request, previousRequest) => { + "worklet"; + if ( + !request || + (request.type === previousRequest?.type && + request.pairKey === previousRequest.pairKey) + ) { + return; + } + + fulfillRequest(request, 0); + }, + ); +}; diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-boundary-presence.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-boundary-presence.ts index db981d42..0ee880db 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-boundary-presence.ts +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-boundary-presence.ts @@ -1,9 +1,9 @@ import { useLayoutEffect } from "react"; import { runOnUI } from "react-native-reanimated"; import { - removeEntry, - setEntry, -} from "../../../../stores/bounds/internals/entries"; + registerBoundary, + unregisterBoundary, +} from "../../../../stores/bounds/internals/coordinator"; import type { BoundTag } from "../../../../stores/bounds/types"; import type { BoundaryConfigProps } from "../../types"; @@ -23,19 +23,28 @@ export const useBoundaryPresence = (params: { handoff, escapeClipping, } = params; - const { tag } = boundTag; - useLayoutEffect(() => { if (!enabled) return; - runOnUI(setEntry)(tag, currentScreenKey, { - boundaryConfig, - handoff: handoff ? true : null, - escapeClipping: escapeClipping ? true : null, + runOnUI(registerBoundary)({ + boundTag, + screenKey: currentScreenKey, + entry: { + boundaryConfig, + handoff: handoff ? true : null, + escapeClipping: escapeClipping ? true : null, + }, }); return () => { - runOnUI(removeEntry)(tag, currentScreenKey); + runOnUI(unregisterBoundary)(boundTag, currentScreenKey); }; - }, [enabled, tag, currentScreenKey, boundaryConfig, handoff, escapeClipping]); + }, [ + enabled, + boundTag, + currentScreenKey, + boundaryConfig, + handoff, + escapeClipping, + ]); }; diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-initial-destination-measurement.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-initial-destination-measurement.ts deleted file mode 100644 index 147f0f73..00000000 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-initial-destination-measurement.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { useCallback, useLayoutEffect, useMemo } from "react"; -import { - cancelAnimation, - runOnUI, - useAnimatedReaction, - useSharedValue, - withDelay, - withTiming, -} from "react-native-reanimated"; -import { useStack } from "../../../../hooks/navigation/use-stack"; -import { useDescriptorsStore } from "../../../../providers/screen/descriptors"; -import { AnimationStore } from "../../../../stores/animation.store"; -import { - createScreenPairKey, - getSourceScreenKeyFromPairKey, -} from "../../../../stores/bounds/helpers/link-pairs.helpers"; -import { - getEntry, - getMatchingSourceScreenKey, -} from "../../../../stores/bounds/internals/entries"; -import { getLink } from "../../../../stores/bounds/internals/links"; -import { pairs } from "../../../../stores/bounds/internals/state"; -import type { BoundTag } from "../../../../stores/bounds/types"; -import { SystemStore } from "../../../../stores/system.store"; -import { logger } from "../../../../utils/logger"; -import type { MeasureBoundary } from "../../types"; -import { getInitialDestinationMeasurementSignal } from "../../utils/destination-signals"; - -// A missed layout should be retried on the next frame. The previous 100 ms -// interval made each ordinary Android layout miss visibly delay navigation. -const HANDSHAKE_RETRY_DELAY_MS = 16; -/** - * A destination whose initial handshake never completes must not hold the - * transition gate forever. After this budget, release the block with a warning - * so the open proceeds without that boundary. - */ -const MAX_HANDSHAKE_RETRIES = 20; - -interface UseInitialDestinationMeasurementParams { - boundTag: BoundTag; - enabled: boolean; - measureBoundary: MeasureBoundary; -} - -export const useInitialDestinationMeasurement = ({ - boundTag, - enabled, - measureBoundary, -}: UseInitialDestinationMeasurementParams) => { - const { tag, linkKey, group } = boundTag; - const currentScreenKey = useDescriptorsStore( - (s) => s.derivations.currentScreenKey, - ); - const nextScreenKey = useDescriptorsStore((s) => s.derivations.nextScreenKey); - const destinationPairKey = useDescriptorsStore( - (s) => s.derivations.destinationPairKey, - ); - const destinationEnabled = enabled && !nextScreenKey; - const canReceiveDestination = destinationEnabled && !!destinationPairKey; - const preferredSourceScreenKey = destinationPairKey - ? getSourceScreenKeyFromPairKey(destinationPairKey) - : undefined; - const stackScenes = useStack((store) => store.scenes); - // A retained closing screen can still have registered boundaries, but it - // cannot own a new transition link. - const closingSourceScreenKeys = useMemo( - () => - stackScenes - .filter((scene) => scene.activity === "closing") - .map((scene) => scene.route.key), - [stackScenes], - ); - const progress = AnimationStore.getValue( - currentScreenKey, - "transitionProgress", - ); - - const { - actions: { blockLifecycleStart, unblockLifecycleStart }, - } = SystemStore.getBag(currentScreenKey); - - const isBlockingLifecycleStart = useSharedValue(0); - const retryToken = useSharedValue(0); - const handshakeRetries = useSharedValue(0); - const hasGivenUp = useSharedValue(0); - const hasFinishedInitialMeasurement = useSharedValue(0); - - const releaseLifecycleStartBlock = useCallback(() => { - "worklet"; - cancelAnimation(retryToken); - - if (!isBlockingLifecycleStart.get()) { - return; - } - - hasFinishedInitialMeasurement.set(1); - isBlockingLifecycleStart.set(0); - unblockLifecycleStart(); - }, [ - hasFinishedInitialMeasurement, - isBlockingLifecycleStart, - retryToken, - unblockLifecycleStart, - ]); - - const claimLifecycleStartBlock = useCallback(() => { - "worklet"; - if ( - !canReceiveDestination || - hasFinishedInitialMeasurement.get() || - !getMatchingSourceScreenKey(tag, currentScreenKey) - ) { - return; - } - - // The progress check and block claim must share one UI-thread operation. - // Otherwise a JS-thread layout effect can observe zero, enqueue the block, - // and let the opening animation start before that block reaches the UI thread. - if (progress.get() > 0 || isBlockingLifecycleStart.get()) { - return; - } - - blockLifecycleStart(); - isBlockingLifecycleStart.set(1); - }, [ - blockLifecycleStart, - canReceiveDestination, - currentScreenKey, - hasFinishedInitialMeasurement, - isBlockingLifecycleStart, - progress, - tag, - ]); - - useLayoutEffect(() => { - if (!canReceiveDestination) { - return; - } - - runOnUI(claimLifecycleStartBlock)(); - - return () => { - // This is an abandonment fallback, not a second release. Run it on the UI - // runtime so it serializes with the handshake's guarded release. - runOnUI(releaseLifecycleStartBlock)(); - }; - }, [ - claimLifecycleStartBlock, - canReceiveDestination, - releaseLifecycleStartBlock, - ]); - - useAnimatedReaction( - () => { - "worklet"; - - if ( - !canReceiveDestination || - hasFinishedInitialMeasurement.get() || - isBlockingLifecycleStart.get() <= 0 - ) { - return null; - } - - if (progress.get() > 0) { - return null; - } - - const retryTick = retryToken.get(); - const sourceScreenKey = getMatchingSourceScreenKey( - tag, - currentScreenKey, - preferredSourceScreenKey, - closingSourceScreenKeys, - ); - const pairKey = sourceScreenKey - ? createScreenPairKey(sourceScreenKey, currentScreenKey) - : undefined; - const signal = getInitialDestinationMeasurementSignal({ - enabled: destinationEnabled, - pairKey, - linkId: linkKey, - group, - destinationPresent: getEntry(tag, currentScreenKey) !== null, - sourcePresent: sourceScreenKey !== null, - linkState: pairs.get(), - }); - - return [ - signal?.pairKey ?? null, - signal?.action ?? null, - retryTick, - ] as const; - }, - (next, previous) => { - "worklet"; - if (!next || hasFinishedInitialMeasurement.get()) { - return; - } - - const [measurePairKey, action, retryTick] = next; - if (!action) { - return; - } - - const previousMeasurePairKey = previous?.[0]; - const previousAction = previous?.[1]; - const previousRetryTick = previous?.[2]; - const shouldHandleSignal = - measurePairKey !== previousMeasurePairKey || - action !== previousAction || - retryTick !== previousRetryTick; - - if (!shouldHandleSignal) { - return; - } - - if (hasGivenUp.get()) { - return; - } - - if (action === "release") { - releaseLifecycleStartBlock(); - handshakeRetries.set(0); - return; - } - - if (action === "measure" && measurePairKey) { - measureBoundary({ - type: "destination", - pairKey: measurePairKey, - }); - } - - const link = measurePairKey ? getLink(measurePairKey, linkKey) : null; - const linkComplete = !!link?.source && !!link.destination; - - if (linkComplete || action === "complete") { - cancelAnimation(retryToken); - handshakeRetries.set(0); - hasFinishedInitialMeasurement.set(1); - releaseLifecycleStartBlock(); - return; - } - - if (handshakeRetries.get() >= MAX_HANDSHAKE_RETRIES) { - hasGivenUp.set(1); - releaseLifecycleStartBlock(); - logger.warn( - `Boundary "${linkKey}" never formed a complete source/destination link after ${MAX_HANDSHAKE_RETRIES} attempts; releasing the transition gate without it. One side is likely off-viewport or unmounted.`, - ); - return; - } - - // Keep the lifecycle blocked while registration, measurement, or source - // attachment settles. The retry token also retries rejected destination bounds. - handshakeRetries.set(handshakeRetries.get() + 1); - cancelAnimation(retryToken); - retryToken.set( - withDelay( - HANDSHAKE_RETRY_DELAY_MS, - withTiming(retryToken.get() + 1, { duration: 0 }), - ), - ); - }, - ); -}; diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-initial-source-measurement.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-initial-source-measurement.ts deleted file mode 100644 index e45a159b..00000000 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-initial-source-measurement.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useAnimatedReaction, useSharedValue } from "react-native-reanimated"; -import { useDescriptorsStore } from "../../../../providers/screen/descriptors"; -import { getPairKeyForSource } from "../../../../stores/bounds/internals/links"; -import { pairs } from "../../../../stores/bounds/internals/state"; -import type { BoundTag } from "../../../../stores/bounds/types"; -import type { MeasureBoundary } from "../../types"; -import { getInitialSourceCaptureSignal } from "../../utils/source-signals"; - -export const useInitialSourceMeasurement = (params: { - enabled: boolean; - measureBoundary: MeasureBoundary; - boundTag: BoundTag; -}) => { - const { enabled, measureBoundary, boundTag } = params; - const currentScreenKey = useDescriptorsStore( - (s) => s.derivations.currentScreenKey, - ); - const lastSourceCaptureSignal = useSharedValue(null); - - useAnimatedReaction( - () => { - "worklet"; - const sourcePairKey = - getPairKeyForSource(boundTag.tag, currentScreenKey) ?? undefined; - return getInitialSourceCaptureSignal({ - enabled, - sourcePairKey, - linkId: boundTag.linkKey, - group: boundTag.group, - linkState: sourcePairKey ? pairs.get() : undefined, - }); - }, - (captureSignal) => { - "worklet"; - if (!enabled || !captureSignal) { - lastSourceCaptureSignal.set(null); - return; - } - - if (lastSourceCaptureSignal.get() === captureSignal.signal) { - return; - } - - lastSourceCaptureSignal.set(captureSignal.signal); - measureBoundary({ - type: "source", - pairKey: captureSignal.pairKey, - }); - }, - ); -}; 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 deleted file mode 100644 index 99460757..00000000 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/lifecycles/use-refresh-boundary.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { useAnimatedReaction } from "react-native-reanimated"; -import { useDescriptorsStore } from "../../../../providers/screen/descriptors"; -import { AnimationStore } from "../../../../stores/animation.store"; -import { - getPairKeyForDestination, - getPairKeyForSource, -} from "../../../../stores/bounds/internals/links"; -import { pairs } from "../../../../stores/bounds/internals/state"; -import type { BoundTag } from "../../../../stores/bounds/types"; -import type { MeasureBoundary } from "../../types"; -import { getRefreshBoundarySignal } from "../../utils/refresh-signals"; - -interface UseRefreshBoundaryParams { - enabled: boolean; - boundTag: BoundTag; - measureBoundary: MeasureBoundary; -} - -export const useRefreshBoundary = ({ - enabled, - boundTag, - measureBoundary, -}: UseRefreshBoundaryParams) => { - const { linkKey, group } = boundTag; - const currentScreenKey = useDescriptorsStore( - (s) => s.derivations.currentScreenKey, - ); - const nextScreenKey = useDescriptorsStore((s) => s.derivations.nextScreenKey); - // Source-side boundaries refresh from the next screen's lifecycle pulse. - // Destination-side boundaries have no next screen, so they refresh from self. - const refreshScreenKey = nextScreenKey ?? currentScreenKey; - const refreshWillAnimate = AnimationStore.getValue( - refreshScreenKey, - "willAnimate", - ); - const refreshSettled = AnimationStore.getValue( - refreshScreenKey, - "progressSettled", - ); - const refreshClosing = AnimationStore.getValue(refreshScreenKey, "closing"); - - useAnimatedReaction( - () => { - "worklet"; - - if (!enabled) return null; - - const shouldRefresh = !!refreshWillAnimate.get(); - const settled = !!refreshSettled.get(); - // A group's active member can change while the transition is settled - // (for example, paging a destination gallery). Let that member publish - // fresh bounds even though there is no willAnimate lifecycle pulse yet. - if (!shouldRefresh && (!group || !settled)) { - return null; - } - const sourcePairKey = - getPairKeyForSource(boundTag.tag, currentScreenKey) ?? undefined; - const destinationPairKey = - getPairKeyForDestination(boundTag.tag, currentScreenKey) ?? undefined; - - return getRefreshBoundarySignal({ - enabled, - currentScreenKey, - sourcePairKey, - destinationPairKey, - linkId: linkKey, - group, - shouldRefresh, - settled, - closing: !!refreshClosing.get(), - linkState: pairs.get(), - }); - }, - (refreshSignal, prevRefreshSignal) => { - "worklet"; - - if ( - !refreshSignal || - refreshSignal.signal === prevRefreshSignal?.signal - ) { - return; - } - - measureBoundary({ - type: refreshSignal.type, - pairKey: refreshSignal.pairKey, - }); - }, - ); -}; diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/use-boundary-measurement.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/use-boundary-measurement.ts index c777f8ae..a347b674 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/use-boundary-measurement.ts +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/hooks/use-boundary-measurement.ts @@ -7,18 +7,14 @@ import type { BoundaryConfigProps, BoundaryLocalMeasurementValue, } from "../types"; +import { useBoundaryMeasurementRequest } from "./lifecycles/use-boundary-measurement-request"; import { useBoundaryPresence } from "./lifecycles/use-boundary-presence"; -import { useInitialDestinationMeasurement } from "./lifecycles/use-initial-destination-measurement"; -import { useInitialSourceMeasurement } from "./lifecycles/use-initial-source-measurement"; -import { useRefreshBoundary } from "./lifecycles/use-refresh-boundary"; import { useMeasurer } from "./use-measurer"; interface UseBoundaryMeasurementParams { boundTag: BoundTag; /** Raw `enabled` prop — drives the measurer and the passive-source gate. */ enabled: boolean; - /** `enabled && hasConfiguredInterpolator` — gates presence + lifecycle. */ - runtimeEnabled: boolean; currentScreenKey: string; /** Surface to measure: a nested target's placeholder, else the root. */ measuredRef: AnimatedRef; @@ -38,7 +34,6 @@ interface UseBoundaryMeasurementParams { export const useBoundaryMeasurement = ({ boundTag, enabled, - runtimeEnabled, currentScreenKey, measuredRef, style, @@ -66,9 +61,6 @@ export const useBoundaryMeasurement = ({ localMeasurement, }); - // Presence and source capture must not depend on this screen owning an - // interpolator: a nested source can participate in a transition owned by a - // different navigator. useBoundaryPresence({ enabled, boundTag, @@ -78,21 +70,10 @@ export const useBoundaryMeasurement = ({ escapeClipping, }); - useInitialSourceMeasurement({ + useBoundaryMeasurementRequest({ enabled, - measureBoundary, - boundTag, - }); - - useInitialDestinationMeasurement({ - boundTag, - enabled: runtimeEnabled, - measureBoundary, - }); - - useRefreshBoundary({ - enabled: runtimeEnabled, boundTag, + currentScreenKey, measureBoundary, }); }; 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 2f7a0836..92262c9f 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 @@ -3,10 +3,9 @@ import type { View } from "react-native"; import { useWindowDimensions } from "react-native"; import type { AnimatedRef, StyleProps } from "react-native-reanimated"; import { applyMeasuredBoundsWrites } from "../../../providers/helpers/measured-bounds-writes"; -import { useScreenSlots } from "../../../providers/screen/styles"; +import { useScreenSlotStore } from "../../../providers/screen/styles"; import type { BoundTag } from "../../../stores/bounds/types"; import { ScrollStore } from "../../../stores/scroll.store"; -import { SystemStore } from "../../../stores/system.store"; import { getVisibilityBlockOffset } from "../../../utils/visibility-block-offset"; import type { BoundaryLocalMeasurementValue, MeasureBoundary } from "../types"; import { @@ -42,23 +41,20 @@ export const useMeasurer = ({ const scrollState = ScrollStore.getValue(currentScreenKey, "coordination"); const scrollMetadata = ScrollStore.getValue(currentScreenKey, "metadata"); - const pendingLifecycleStartBlockCount = SystemStore.getValue( - currentScreenKey, - "pendingLifecycleStartBlockCount", - ); - const { visibilityBlocked } = useScreenSlots(); + const screenSlotStore = useScreenSlotStore(); + const { visibilityBlocked } = screenSlotStore; return useCallback( (target) => { "worklet"; - if (!enabled) return; + if (!enabled) return false; const measured = measureWithOverscrollAwareness( measuredAnimatedRef, scrollState.get(), ); - if (!measured) return; + if (!measured) return false; const correctedMeasured = correctMeasuredBoundsForVisibilityGate({ measured, @@ -75,26 +71,15 @@ export const useMeasurer = ({ }); } - /** - * - Destination Pass - - * Be strict while lifecycle start is blocked for destination capture. - * This is the initial attach window: the transition has not started yet, - * and malformed off-screen destination measurements should keep the - * lifecycle blocked until a valid retry lands. - */ - const shouldGuardDestinationViewport = - pendingLifecycleStartBlockCount.get() > 0 || !!boundTag.group; - const viewportAllowsDestinationWrite = target.type !== "destination" || - !shouldGuardDestinationViewport || isMeasurementInViewport( correctedMeasured, viewportWidth, viewportHeight, ); - if (!viewportAllowsDestinationWrite) return; + if (!viewportAllowsDestinationWrite) return false; const measuredWithScroll = attachScrollSnapshotToMeasuredBounds( correctedMeasured, @@ -112,6 +97,8 @@ export const useMeasurer = ({ handoff, escapeClipping, }); + + return true; }, [ enabled, @@ -126,7 +113,6 @@ export const useMeasurer = ({ viewportHeight, scrollState, scrollMetadata, - pendingLifecycleStartBlockCount, visibilityBlocked, ], ); diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-content-portal/helpers/active-handoff-receiver.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-content-portal/helpers/active-handoff-receiver.ts index 4a192ea9..0e03e44f 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-content-portal/helpers/active-handoff-receiver.ts +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-content-portal/helpers/active-handoff-receiver.ts @@ -13,6 +13,23 @@ type ResolveActiveHandoffReceiverParams = { scenes: ReceiverScene[]; }; +export const hasNestedHandoffTopology = ({ + inheritedSourcePair, + pairDestinationScreenKey, + transitionDestinationScreenKey, +}: { + inheritedSourcePair: boolean; + pairDestinationScreenKey: string | null; + transitionDestinationScreenKey?: string; +}) => { + "worklet"; + return ( + inheritedSourcePair || + (!!pairDestinationScreenKey && + pairDestinationScreenKey !== transitionDestinationScreenKey) + ); +}; + export const resolveActiveHandoffReceiver = ({ focusedIndex, routes, @@ -57,6 +74,7 @@ export const resolveHandoffAttachmentCandidate = ({ interpolatorReady, pairChangedDuringClose = false, pairDestinationScreenKey, + pairHasBoundaryLink, previousReceiverScreenKey, }: { activeReceiverClosing: boolean; @@ -66,9 +84,20 @@ export const resolveHandoffAttachmentCandidate = ({ interpolatorReady: boolean; pairChangedDuringClose?: boolean; pairDestinationScreenKey: string | null; + pairHasBoundaryLink?: boolean; previousReceiverScreenKey: string | null; }) => { "worklet"; + const currentPairHasBoundaryLink = + pairHasBoundaryLink ?? pairDestinationScreenKey !== null; + + if ( + activeReceiverClosing && + attachedReceiverScreenKey !== activeReceiverScreenKey && + !currentPairHasBoundaryLink + ) { + return attachedReceiverScreenKey; + } if ( activeReceiverClosing && @@ -79,6 +108,16 @@ export const resolveHandoffAttachmentCandidate = ({ return pairDestinationScreenKey; } + if ( + activeReceiverClosing && + pairChangedDuringClose && + !currentPairHasBoundaryLink + ) { + return hasActiveCloseFinished + ? previousReceiverScreenKey + : attachedReceiverScreenKey; + } + if ( hasActiveCloseFinished && attachedReceiverScreenKey === activeReceiverScreenKey @@ -101,3 +140,26 @@ export const resolveHandoffAttachmentCandidate = ({ return activeReceiverScreenKey; }; + +export const resolveNestedHandoffAttachmentCandidate = ({ + attachedReceiverScreenKey, + currentScreenKey, + hasActiveCloseFinished, + interpolatorReady, + pairDestinationScreenKey, +}: { + attachedReceiverScreenKey: string; + currentScreenKey: string; + hasActiveCloseFinished: boolean; + interpolatorReady: boolean; + pairDestinationScreenKey: string | null; +}) => { + "worklet"; + + if (hasActiveCloseFinished) return currentScreenKey; + if (interpolatorReady && pairDestinationScreenKey) { + return pairDestinationScreenKey; + } + + return attachedReceiverScreenKey; +}; diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-content-portal/hooks/use-boundary-content-portal-attachment.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-content-portal/hooks/use-boundary-content-portal-attachment.ts index 8ae289c9..1ce8dd55 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-content-portal/hooks/use-boundary-content-portal-attachment.ts +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-content-portal/hooks/use-boundary-content-portal-attachment.ts @@ -1,7 +1,10 @@ import { useAnimatedProps, useSharedValue } from "react-native-reanimated"; import { useStack } from "../../../../../../hooks/navigation/use-stack"; import { useDescriptorsStore } from "../../../../../../providers/screen/descriptors"; -import { useScreenSlots } from "../../../../../../providers/screen/styles"; +import { + useOptionalScreenSlotStore, + useScreenSlotStore, +} from "../../../../../../providers/screen/styles"; import { hasCloseTransitionFinished } from "../../../../../../providers/screen/styles/helpers/transition-visual-state"; import { AnimationStore } from "../../../../../../stores/animation.store"; import { getLinkKeyFromTag } from "../../../../../../stores/bounds/helpers/link-pairs.helpers"; @@ -11,8 +14,10 @@ import { SystemStore } from "../../../../../../stores/system.store"; import { PORTAL_HOST_NAME_RESET_VALUE } from "../../../utils/naming"; import { isTeleportEnabled } from "../../../utils/teleport-control"; import { + hasNestedHandoffTopology, resolveActiveHandoffReceiver, resolveHandoffAttachmentCandidate, + resolveNestedHandoffAttachmentCandidate, resolvePreviousHandoffReceiver, } from "../helpers/active-handoff-receiver"; import { createBoundaryContentPortalHostName } from "../helpers/host-name"; @@ -24,14 +29,26 @@ interface UseBoundaryContentPortalAttachmentParams { export const useBoundaryContentPortalAttachment = ({ boundaryId, }: UseBoundaryContentPortalAttachmentParams) => { - const { slotsMap } = useScreenSlots(); + const slotsMap = useScreenSlotStore((store) => store.slotsMap); const currentScreenKey = useDescriptorsStore( (s) => s.derivations.currentScreenKey, ); const nextScreenKey = useDescriptorsStore((s) => s.derivations.nextScreenKey); const sourcePairKey = useDescriptorsStore((s) => s.derivations.sourcePairKey); - const destinationSlots = useScreenSlots(nextScreenKey ?? currentScreenKey); + const transitionSourcePairKey = useDescriptorsStore( + (s) => s.derivations.transitionSourcePairKey, + ); + const transitionDestinationScreenKey = useDescriptorsStore( + (s) => s.derivations.transitionDestinationScreenKey, + ); + const isNestedSource = !sourcePairKey && !!transitionSourcePairKey; + const resolvedSourcePairKey = sourcePairKey ?? transitionSourcePairKey; + const resolvedDestinationScreenKey = + nextScreenKey ?? transitionDestinationScreenKey; + const destinationSlots = useOptionalScreenSlotStore( + resolvedDestinationScreenKey ?? currentScreenKey, + ); const unavailableInterpolatorReady = useSharedValue(0); const interpolatorReady = destinationSlots?.interpolatorReady ?? unavailableInterpolatorReady; @@ -48,6 +65,18 @@ export const useBoundaryContentPortalAttachment = ({ activeReceiverScreenKey ?? currentScreenKey, "closing", ); + const nestedDestinationAnimationProgress = SystemStore.getValue( + isNestedSource && resolvedDestinationScreenKey + ? resolvedDestinationScreenKey + : currentScreenKey, + "animationProgress", + ); + const nestedDestinationClosing = AnimationStore.getValue( + isNestedSource && resolvedDestinationScreenKey + ? resolvedDestinationScreenKey + : currentScreenKey, + "closing", + ); const attachedReceiverScreenKey = useSharedValue(currentScreenKey); const sourcePairBeforeClose = useSharedValue(null); @@ -63,24 +92,30 @@ export const useBoundaryContentPortalAttachment = ({ } = slot?.props ?? {}; const shouldTeleport = isTeleportEnabled(teleport); - const closing = activeReceiverClosing.get(); - const animationProgress = activeReceiverAnimationProgress.get(); + const closing = isNestedSource + ? nestedDestinationClosing.get() + : activeReceiverClosing.get(); + const animationProgress = isNestedSource + ? nestedDestinationAnimationProgress.get() + : activeReceiverAnimationProgress.get(); if (!closing) { - sourcePairBeforeClose.set(sourcePairKey ?? null); + sourcePairBeforeClose.set(resolvedSourcePairKey ?? null); } const pairChangedDuringClose = !!closing && - !!sourcePairKey && - sourcePairKey !== sourcePairBeforeClose.get(); + !!resolvedSourcePairKey && + resolvedSourcePairKey !== sourcePairBeforeClose.get(); const hasActiveCloseFinished = hasCloseTransitionFinished({ closing, animationProgress, }); - const pair = sourcePairKey ? pairs.get()[sourcePairKey] : null; + const pair = resolvedSourcePairKey + ? pairs.get()[resolvedSourcePairKey] + : null; const link = pair?.links[getLinkKeyFromTag(boundaryId)]; const pairDestination = @@ -92,18 +127,32 @@ export const useBoundaryContentPortalAttachment = ({ const isInterpolatorReady = interpolatorReady.get(); const attachedScreenKey = attachedReceiverScreenKey.get(); - - const nextReceiverScreenKey = resolveHandoffAttachmentCandidate({ - activeReceiverClosing: !!closing, - activeReceiverScreenKey, - attachedReceiverScreenKey: attachedScreenKey, - hasActiveCloseFinished, - interpolatorReady: !!isInterpolatorReady, - pairChangedDuringClose, + const usesNestedReceiver = hasNestedHandoffTopology({ + inheritedSourcePair: isNestedSource, pairDestinationScreenKey: pairDestination, - previousReceiverScreenKey, + transitionDestinationScreenKey: resolvedDestinationScreenKey, }); + const nextReceiverScreenKey = usesNestedReceiver + ? resolveNestedHandoffAttachmentCandidate({ + attachedReceiverScreenKey: attachedScreenKey, + currentScreenKey, + hasActiveCloseFinished, + interpolatorReady: !!isInterpolatorReady, + pairDestinationScreenKey: pairDestination, + }) + : resolveHandoffAttachmentCandidate({ + activeReceiverClosing: !!closing, + activeReceiverScreenKey, + attachedReceiverScreenKey: attachedScreenKey, + hasActiveCloseFinished, + interpolatorReady: !!isInterpolatorReady, + pairChangedDuringClose, + pairDestinationScreenKey: pairDestination, + pairHasBoundaryLink: link !== undefined, + previousReceiverScreenKey, + }); + const receiverEntry = nextReceiverScreenKey ? getEntry(boundaryId, nextReceiverScreenKey) : null; @@ -118,10 +167,13 @@ export const useBoundaryContentPortalAttachment = ({ !!isInterpolatorReady && nextReceiverScreenKey === pairDestination; - const canActivateReceiver = - returningFromActiveClose || - activatingPairDestination || - animationProgress > 0; + const canActivateReceiver = usesNestedReceiver + ? nextReceiverScreenKey === attachedScreenKey || + hasActiveCloseFinished || + activatingPairDestination + : returningFromActiveClose || + activatingPairDestination || + animationProgress > 0; if (nextReceiverScreenKey && receiverReady && canActivateReceiver) { attachedReceiverScreenKey.set(nextReceiverScreenKey); diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/components/portal-boundary-host.tsx b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/components/portal-boundary-host.tsx index 2943c775..dc9daac9 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/components/portal-boundary-host.tsx +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/components/portal-boundary-host.tsx @@ -13,7 +13,9 @@ import Animated, { } from "react-native-reanimated"; import { NO_STYLES } from "../../../../../../constants"; import { composeSlotStyleWithLocalTransform } from "../../../../../../providers/screen/styles/helpers/compose-slot-style"; +import { markBoundaryPortalReady } from "../../../../../../stores/bounds/internals/coordinator"; import { NativePortalHost, PORTAL_POINTER_EVENTS } from "../../../teleport"; +import { resolveReadyPortalHostName } from "../helpers/host-readiness"; import { resolveBoundaryLocalMeasurement } from "../helpers/local-measurement"; import { resolvePortalOffsetStyle } from "../helpers/offset-style"; import type { ActivePortalBoundaryHost } from "../stores/portal-boundary-host.store"; @@ -57,8 +59,15 @@ export const PortalBoundaryHost = memo(function PortalBoundaryHost({ }, (ready) => { "worklet"; + host.portalHostReady.set( + resolveReadyPortalHostName({ + currentReadyHostName: host.portalHostReady.get(), + hostName: host.portalHostName, + ready, + }), + ); if (ready) { - host.portalHostReady.set(true); + markBoundaryPortalReady(host.pairKey, host.boundaryId); } }, ); diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/helpers/attachment.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/helpers/attachment.ts new file mode 100644 index 00000000..f0a19dec --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/helpers/attachment.ts @@ -0,0 +1,19 @@ +import type { BoundaryTeleportControl } from "../../../../../../types/animation.types"; +import { isTeleportEnabled } from "../../../utils/teleport-control"; + +export const shouldAttachBoundaryPortal = ({ + focused, + portalHostReady, + slotActive, + teleport, +}: { + focused: boolean; + portalHostReady: boolean; + slotActive: boolean; + teleport?: BoundaryTeleportControl; +}) => { + "worklet"; + return ( + slotActive && !focused && portalHostReady && isTeleportEnabled(teleport) + ); +}; diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/helpers/host-readiness.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/helpers/host-readiness.ts new file mode 100644 index 00000000..ac5d6b43 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/helpers/host-readiness.ts @@ -0,0 +1,14 @@ +export const resolveReadyPortalHostName = ({ + currentReadyHostName, + hostName, + ready, +}: { + currentReadyHostName: string | null; + hostName: string; + ready: boolean; +}) => { + "worklet"; + + if (ready) return hostName; + return currentReadyHostName === hostName ? null : currentReadyHostName; +}; diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/helpers/local-measurement.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/helpers/local-measurement.ts index cb866b21..f24d9488 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/helpers/local-measurement.ts +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/helpers/local-measurement.ts @@ -1,5 +1,40 @@ -import type { ScreenPairKey } from "../../../../../../stores/bounds/types"; +import { + getGroupKeyFromTag, + getLinkKeyFromTag, +} from "../../../../../../stores/bounds/helpers/link-pairs.helpers"; +import type { + LinkPairsState, + ScreenPairKey, +} from "../../../../../../stores/bounds/types"; +import type { NormalizedTransitionSlotStyle } from "../../../../../../types/animation.types"; import type { BoundaryLocalMeasurement } from "../../../../types"; +import { isTeleportEnabled } from "../../../utils/teleport-control"; + +export const resolveBoundaryPortalPairKey = ( + measurement: BoundaryLocalMeasurement | null, +): ScreenPairKey | null => { + "worklet"; + return measurement?.pairKey ?? null; +}; + +export const resolveActiveBoundaryPortalPairKey = ( + measurement: BoundaryLocalMeasurement | null, + slot: NormalizedTransitionSlotStyle | undefined, + boundaryId: string, + pairsState: LinkPairsState, +): ScreenPairKey | null => { + "worklet"; + if (!measurement) return null; + + const group = getGroupKeyFromTag(boundaryId); + if (group) { + const activeId = pairsState[measurement.pairKey]?.groups[group]?.activeId; + if (activeId !== getLinkKeyFromTag(boundaryId)) return null; + } + + if (slot && !isTeleportEnabled(slot.props?.teleport)) return null; + return measurement.pairKey; +}; export const resolveBoundaryLocalMeasurement = ( measurement: BoundaryLocalMeasurement | null, diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/hooks/use-active-portal-boundary-host.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/hooks/use-active-portal-boundary-host.ts index f50ed17e..72e9a78d 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/hooks/use-active-portal-boundary-host.ts +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/hooks/use-active-portal-boundary-host.ts @@ -4,11 +4,13 @@ import { type SharedValue, useAnimatedReaction, } from "react-native-reanimated"; -import { getPairKeyForSource } from "../../../../../../stores/bounds/internals/links"; +import { pairs } from "../../../../../../stores/bounds/internals/state"; import type { ScreenPairKey } from "../../../../../../stores/bounds/types"; import type { NormalizedTransitionInterpolatedStyle } from "../../../../../../types/animation.types"; import type { BoundaryLocalMeasurementValue } from "../../../../types"; import { createBoundaryPortalHostName } from "../../../utils/naming"; +import { isTeleportEnabled } from "../../../utils/teleport-control"; +import { resolveActiveBoundaryPortalPairKey } from "../helpers/local-measurement"; import { mountPortalBoundaryHost, unmountPortalBoundaryHostByName, @@ -16,17 +18,15 @@ import { type UseActivePortalBoundaryHostParams = { boundaryId: string; - currentScreenKey: string; escapeHostKey?: string; localMeasurement: BoundaryLocalMeasurementValue; portalHostName: SharedValue; - portalHostReady: SharedValue; + portalHostReady: SharedValue; slotsMap: SharedValue; }; export const useActivePortalBoundaryHost = ({ boundaryId, - currentScreenKey, escapeHostKey, localMeasurement, portalHostName, @@ -44,13 +44,12 @@ export const useActivePortalBoundaryHost = ({ useAnimatedReaction( () => { "worklet"; - const pairKey = getPairKeyForSource(boundaryId, currentScreenKey); - const measurement = localMeasurement.get(); - if (!pairKey || measurement?.pairKey !== pairKey) { - return null; - } - - return pairKey; + return resolveActiveBoundaryPortalPairKey( + localMeasurement.get(), + slotsMap.get()[boundaryId], + boundaryId, + pairs.get(), + ); }, (pairKey, previousPairKey) => { "worklet"; @@ -58,6 +57,16 @@ export const useActivePortalBoundaryHost = ({ return; } + const slot = slotsMap.get()[boundaryId]; + if ( + pairKey === null && + localMeasurement.get() !== null && + slot && + !isTeleportEnabled(slot.props?.teleport) + ) { + localMeasurement.set(null); + } + runOnJS(updateActivePairKey)(pairKey); }, ); @@ -65,7 +74,7 @@ export const useActivePortalBoundaryHost = ({ useLayoutEffect(() => { if (!activePairKey || !escapeHostKey) { portalHostName.set(null); - portalHostReady.set(false); + portalHostReady.set(null); return; } @@ -88,7 +97,7 @@ export const useActivePortalBoundaryHost = ({ return () => { portalHostName.set(null); - portalHostReady.set(false); + portalHostReady.set(null); unmountPortalBoundaryHostByName(nextPortalHostName); }; }, [ diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/hooks/use-boundary-portal-attachment.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/hooks/use-boundary-portal-attachment.ts index a2a028b4..96385e27 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/hooks/use-boundary-portal-attachment.ts +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/hooks/use-boundary-portal-attachment.ts @@ -1,9 +1,12 @@ +import { useIsFocused } from "@react-navigation/native"; import { useAnimatedProps, useSharedValue } from "react-native-reanimated"; import { useDescriptorsStore } from "../../../../../../providers/screen/descriptors"; -import { useScreenSlots } from "../../../../../../providers/screen/styles"; +import { useScreenSlotStore } from "../../../../../../providers/screen/styles"; +import { pairs } from "../../../../../../stores/bounds/internals/state"; import { useBoundaryRootStore } from "../../../../providers/boundary-root.provider"; import { PORTAL_HOST_NAME_RESET_VALUE } from "../../../utils/naming"; -import { isTeleportEnabled } from "../../../utils/teleport-control"; +import { shouldAttachBoundaryPortal } from "../helpers/attachment"; +import { resolveActiveBoundaryPortalPairKey } from "../helpers/local-measurement"; import { useActiveHostKey } from "../stores/host-registry.store"; import { useActivePortalBoundaryHost } from "./use-active-portal-boundary-host"; @@ -15,23 +18,22 @@ export const useBoundaryPortalAttachment = ({ boundaryId, }: UseBoundaryPortalAttachmentParams) => { const localMeasurement = useBoundaryRootStore((root) => { - if (!root) { - throw new Error("Boundary portal attachment requires a boundary root."); - } - return root.localMeasurement; }); const currentScreenKey = useDescriptorsStore( (s) => s.derivations.currentScreenKey, ); - const { slotsMap } = useScreenSlots(); + // React Navigation resolves focus through the entire parent navigator chain. + // A leaf that is still selected in its own one-screen stack becomes unfocused + // when any ancestor route is covered. + const focused = useIsFocused(); + const slotsMap = useScreenSlotStore((store) => store.slotsMap); const portalHostName = useSharedValue(null); - const portalHostReady = useSharedValue(false); + const portalHostReady = useSharedValue(null); const escapeHostKey = useActiveHostKey(currentScreenKey); useActivePortalBoundaryHost({ boundaryId, - currentScreenKey, escapeHostKey, localMeasurement, portalHostName, @@ -49,20 +51,31 @@ export const useBoundaryPortalAttachment = ({ ...slotProps } = slot?.props ?? {}; - const shouldAttach = - slot !== undefined && - isTeleportEnabled(teleport) && - portalHostReady.get(); + const activePortalHostName = portalHostName.get(); + const shouldAttach = shouldAttachBoundaryPortal({ + focused, + portalHostReady: + activePortalHostName !== null && + portalHostReady.get() === activePortalHostName, + slotActive: + resolveActiveBoundaryPortalPairKey( + localMeasurement.get(), + slot, + boundaryId, + pairs.get(), + ) !== null && slot !== undefined, + teleport, + }); const hostName = shouldAttach - ? portalHostName.get() + ? activePortalHostName : PORTAL_HOST_NAME_RESET_VALUE; return { ...slotProps, hostName, }; - }); + }, [focused]); return { teleportProps }; }; diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/stores/portal-boundary-host.store.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/stores/portal-boundary-host.store.ts index 696d4f96..eca6d1d2 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/stores/portal-boundary-host.store.ts +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-portal/stores/portal-boundary-host.store.ts @@ -9,7 +9,7 @@ export type ActivePortalBoundaryHost = { localMeasurement: BoundaryLocalMeasurementValue; pairKey: string; portalHostName: string; - portalHostReady: SharedValue; + portalHostReady: SharedValue; slotsMap: SharedValue; }; diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/teleport.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/teleport.ts index 6a430f5f..be854e56 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/teleport.ts +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/teleport.ts @@ -1,4 +1,4 @@ -import { type ComponentType, createElement, type ReactNode } from "react"; +import type { ComponentType, ReactNode } from "react"; /** * `react-native-teleport` is an optional peer dependency. The require sits in a @@ -9,33 +9,12 @@ import { type ComponentType, createElement, type ReactNode } from "react"; * inline rendering; everything else keeps working. */ let mod: any = null; -let managerMod: any = null; -let providerViewMod: any = null; try { mod = require("react-native-teleport"); - managerMod = require("react-native-teleport/lib/module/contexts/PortalManager"); - providerViewMod = require("react-native-teleport/lib/module/views/PortalProvider"); } catch {} -const NativePortalProviderView: ComponentType<{ children: ReactNode }> | null = - providerViewMod?.default ?? null; -const PortalManagerProvider: ComponentType<{ children: ReactNode }> | null = - managerMod?.PortalManagerProvider ?? null; - const SafeNativePortalProvider: ComponentType<{ children: ReactNode }> | null = - PortalManagerProvider - ? ({ children }: { children: ReactNode }) => { - const managedChildren = createElement( - PortalManagerProvider, - null, - children, - ); - - return NativePortalProviderView - ? createElement(NativePortalProviderView, null, managedChildren) - : managedChildren; - } - : null; + mod?.PortalProvider ?? null; export const isTeleportAvailable = mod !== null && SafeNativePortalProvider !== null; 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 009d4c74..3cb2ee4f 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,7 +14,7 @@ import { useComposedSlotStyles, useSlotStackingStyles, } from "../../../providers/screen/styles"; -import { useBlankStackStore } from "../../../providers/stack/blank-stack.provider"; +import { useOptionalBlankStackStore } 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"; @@ -68,10 +68,14 @@ type BoundaryRootProviderProps = Pick< targetStyle?: unknown; }; -export const { BoundaryRootProvider, useBoundaryRootStore } = createProvider( - "BoundaryRoot", - { guarded: false }, -)( +export const { + BoundaryRootProvider, + useBoundaryRootStore, + useOptionalBoundaryRootStore, +} = createProvider("BoundaryRoot")< + BoundaryRootProviderProps, + BoundaryRootContextValue +>( ({ children, config, @@ -98,7 +102,7 @@ export const { BoundaryRootProvider, useBoundaryRootStore } = createProvider( const currentScreenKey = useDescriptorsStore( (s) => s.derivations.currentScreenKey, ); - const isCurrentScreenClosing = useBlankStackStore( + const isCurrentScreenClosing = useOptionalBlankStackStore( (store) => portalRuntime.handoff && store?.scenesByKey[currentScreenKey]?.activity === "closing", diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/types.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/types.ts index 48e1a331..d6f215c8 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/types.ts +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/types.ts @@ -60,7 +60,7 @@ export type MeasureTarget = pairKey: ScreenPairKey; }; -export type MeasureBoundary = (target: MeasureTarget) => void; +export type MeasureBoundary = (target: MeasureTarget) => boolean; export type BoundaryLocalMeasurement = { bounds: MeasuredDimensions; diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/utils/destination-signals.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/utils/destination-signals.ts deleted file mode 100644 index 57abba54..00000000 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/utils/destination-signals.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - getActiveGroupId, - getLinkKeyFromTag, -} from "../../../stores/bounds/helpers/link-pairs.helpers"; -import type { - LinkPairsState, - ScreenPairKey, -} from "../../../stores/bounds/types"; - -export type InitialDestinationMeasurementAction = - | "wait" - | "release" - | "measure" - | "complete"; - -export type InitialDestinationMeasurementSignal = { - pairKey: ScreenPairKey | null; - action: InitialDestinationMeasurementAction; -}; - -export const getInitialDestinationMeasurementSignal = (params: { - enabled: boolean; - pairKey?: ScreenPairKey; - linkId: string; - group?: string; - destinationPresent: boolean; - sourcePresent: boolean; - linkState?: LinkPairsState; -}): InitialDestinationMeasurementSignal | null => { - "worklet"; - const { - enabled, - pairKey, - linkId, - group, - destinationPresent, - sourcePresent, - linkState, - } = params; - if (!enabled) { - return null; - } - - if (!pairKey) { - return { pairKey: null, action: "wait" }; - } - - if (!destinationPresent) { - return { pairKey, action: "wait" }; - } - - if (!sourcePresent) { - return { pairKey, action: "release" }; - } - - const linkKey = getLinkKeyFromTag(linkId); - const activeGroupId = - group && linkState ? getActiveGroupId(linkState, pairKey, group) : null; - - if (activeGroupId && activeGroupId !== linkKey) { - return { pairKey, action: "release" }; - } - - const link = linkState?.[pairKey]?.links?.[linkKey]; - - if (!link?.destination) { - return { pairKey, action: "measure" }; - } - - if (!link.source) { - return { pairKey, action: "wait" }; - } - - return { pairKey, action: "complete" }; -}; 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 deleted file mode 100644 index 4648413d..00000000 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/utils/refresh-signals.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { - LinkPairsState, - ScreenPairKey, -} from "../../../stores/bounds/types"; -import type { MeasureTarget } from "../types"; - -const SOURCE_SIGNAL_PREFIX = "source|"; -const DESTINATION_SIGNAL_PREFIX = "destination|"; - -type RefreshBoundarySignal = MeasureTarget & { - signal: string; -}; - -const buildRefreshSignal = ( - type: MeasureTarget["type"], - pairKey: ScreenPairKey, - key: string, -): RefreshBoundarySignal => { - "worklet"; - const prefix = - type === "source" ? SOURCE_SIGNAL_PREFIX : DESTINATION_SIGNAL_PREFIX; - return { - type, - pairKey, - signal: `${prefix}${pairKey}|${key}`, - }; -}; - -export const getRefreshBoundarySignal = (params: { - enabled: boolean; - currentScreenKey: string; - sourcePairKey?: ScreenPairKey; - destinationPairKey?: ScreenPairKey; - linkId: string; - group?: string; - shouldRefresh: boolean; - settled?: boolean; - closing: boolean; - linkState?: LinkPairsState; -}): RefreshBoundarySignal | null => { - "worklet"; - const { - enabled, - currentScreenKey, - sourcePairKey, - destinationPairKey, - linkId, - group, - shouldRefresh, - settled = false, - closing, - linkState, - } = params; - - if (!enabled) return null; - - if (!shouldRefresh && (!group || !settled)) { - return null; - } - - // A source may move while its destination is active, so refresh whichever - // side of the pair this boundary currently represents. - if (!group) { - if (sourcePairKey) { - const sourcePair = linkState?.[sourcePairKey]; - const participates = - !!sourcePair?.links?.[linkId] || !!sourcePair?.sourceRequests?.[linkId]; - - if (!participates) { - return null; - } - - return buildRefreshSignal( - "source", - sourcePairKey, - [currentScreenKey, closing ? "closing" : "settled"].join("|"), - ); - } - - const refreshDestinationPairKey = destinationPairKey; - - if (!refreshDestinationPairKey) { - return null; - } - - if (!linkState?.[refreshDestinationPairKey]?.links?.[linkId]) { - return null; - } - - return buildRefreshSignal( - "destination", - refreshDestinationPairKey, - [currentScreenKey, closing ? "closing" : "settled"].join("|"), - ); - } - - // Source side: - // When the activeId changes, trigger a refresh to ensure the source bounds are captured. - if (sourcePairKey) { - const pair = linkState?.[sourcePairKey]; - const groupState = pair?.groups?.[group]; - const activeId = groupState?.activeId; - - if (activeId !== linkId) { - return null; - } - - // The opening member is captured by the initial handshake. A settled - // refresh is only needed after selection moves to another member. - if ( - !shouldRefresh && - (groupState?.initialId === undefined || - activeId === groupState.initialId || - !!pair?.links?.[linkId]?.source) - ) { - return null; - } - - return buildRefreshSignal( - "source", - sourcePairKey, - [ - group, - linkId, - shouldRefresh ? (closing ? "closing" : "settled") : "retarget", - ].join("|"), - ); - } - - const refreshDestinationPairKey = destinationPairKey; - - if (!refreshDestinationPairKey) return null; - - // Destination side: - // When the activeId changes, trigger a refresh to ensure the destination bounds are captured. - const pair = linkState?.[refreshDestinationPairKey]; - const groupState = pair?.groups?.[group]; - const activeId = groupState?.activeId; - - // Destination retargeting should only measure a concrete member that already - // participates in the pair. Missing members fall back to initialId at resolve. - if (activeId !== linkId) { - return null; - } - - if ( - !shouldRefresh && - (groupState?.initialId === undefined || - activeId === groupState.initialId || - !!pair?.links?.[linkId]?.destination) - ) { - return null; - } - - return buildRefreshSignal( - "destination", - refreshDestinationPairKey, - [ - group, - linkId, - shouldRefresh ? (closing ? "closing" : "settled") : "retarget", - ].join("|"), - ); -}; diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/utils/source-signals.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/utils/source-signals.ts deleted file mode 100644 index d4596468..00000000 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/utils/source-signals.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { - LinkPairsState, - ScreenPairKey, -} from "../../../stores/bounds/types"; - -const SOURCE_SIGNAL_PREFIX = "source|"; - -type SourceCaptureSignal = { - pairKey: ScreenPairKey; - signal: string; -}; - -export const getInitialSourceCaptureSignal = (params: { - enabled: boolean; - sourcePairKey?: ScreenPairKey; - linkId: string; - group?: string; - linkState?: LinkPairsState; -}): SourceCaptureSignal | null => { - "worklet"; - const { enabled, sourcePairKey, linkId, group, linkState } = params; - - if (!enabled || !sourcePairKey) { - return null; - } - - const pair = linkState?.[sourcePairKey]; - const link = pair?.links?.[linkId]; - const hasSourceRequest = pair?.sourceRequests?.[linkId]; - - if ((!link?.destination && !hasSourceRequest) || link?.source) { - return null; - } - - if (group) { - const activeId = linkState?.[sourcePairKey]?.groups?.[group]?.activeId; - - // Passive grouped sources should not measure every mounted item. Once a - // group has an active id, only that concrete member can auto-capture. - if (activeId && activeId !== linkId) { - return null; - } - } - - const signalParts = group ? [group, linkId] : [linkId]; - - return { - pairKey: sourcePairKey, - signal: `${SOURCE_SIGNAL_PREFIX}${sourcePairKey}|${signalParts.join("|")}`, - }; -}; diff --git a/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/create-overlay-interpolator-frame.ts b/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/create-overlay-interpolator-frame.ts index 51b0f368..a4d5110f 100644 --- a/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/create-overlay-interpolator-frame.ts +++ b/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/create-overlay-interpolator-frame.ts @@ -1,6 +1,19 @@ import { updateDerivations } from "../../../providers/screen/animation/helpers/derivations"; import type { ScreenInterpolatorFrame } from "../../../providers/screen/animation/helpers/pipeline"; +export const shouldUseOverlayGestureDriver = ( + overlayFrame: ScreenInterpolatorFrame, + driverFrame: ScreenInterpolatorFrame, +): boolean => { + "worklet"; + const gesture = overlayFrame.current.gesture; + + return ( + driverFrame.current.route.key !== overlayFrame.current.route.key && + !!(gesture.dragging || gesture.dismissing || gesture.settling) + ); +}; + export const createOverlayInterpolatorFrame = ({ overlayFrame, driverFrame, @@ -22,7 +35,7 @@ export const createOverlayInterpolatorFrame = ({ }; updateDerivations(frame); - frame.stackProgress = frame.progress; + frame.stackProgress = overlayFrame.stackProgress; frame.logicallySettled = frame.active.settled; return frame; diff --git a/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/get-active-overlay.ts b/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/get-active-overlay.ts index 03fa85c5..ea9dd8d8 100644 --- a/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/get-active-overlay.ts +++ b/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/get-active-overlay.ts @@ -74,8 +74,33 @@ export function getFloatOverlayTransitions( ): FloatOverlayTransitionEntry[] { const topScene = scenes[scenes.length - 1]; - return overlayStack.map((entry, index) => ({ - ...entry, - driverScene: overlayStack[index + 1]?.scene ?? topScene ?? entry.scene, - })); + return overlayStack.map((entry, index) => { + const nextEntry = overlayStack[index + 1]; + let nextPresentedEntry: FloatOverlayEntry | undefined; + + for ( + let nextIndex = index + 1; + nextIndex < overlayStack.length; + nextIndex++ + ) { + const candidate = overlayStack[nextIndex]; + if (candidate?.scene.activity !== "closing") { + nextPresentedEntry = candidate; + break; + } + } + + const driverScene = + entry.scene.activity === "closing" && nextEntry + ? entry.scene + : (nextPresentedEntry?.scene ?? + nextEntry?.scene ?? + topScene ?? + entry.scene); + + return { + ...entry, + driverScene, + }; + }); } diff --git a/packages/react-native-screen-transitions/src/shared/components/overlay/hooks/use-overlay-slot.ts b/packages/react-native-screen-transitions/src/shared/components/overlay/hooks/use-overlay-slot.ts index f50ff030..fd7a1c70 100644 --- a/packages/react-native-screen-transitions/src/shared/components/overlay/hooks/use-overlay-slot.ts +++ b/packages/react-native-screen-transitions/src/shared/components/overlay/hooks/use-overlay-slot.ts @@ -15,11 +15,15 @@ import type { ScreenStyleInterpolator, } from "../../../types/animation.types"; import { getVisibilityBlockOffset } from "../../../utils/visibility-block-offset"; -import { createOverlayInterpolatorFrame } from "../helpers/create-overlay-interpolator-frame"; +import { + createOverlayInterpolatorFrame, + shouldUseOverlayGestureDriver, +} from "../helpers/create-overlay-interpolator-frame"; import { runOverlaySlotInterpolator } from "../helpers/run-overlay-slot-interpolator"; export const useOverlaySlot = ({ overlayAnimationStore, + overlayInterpolator, driverAnimationStore, previousOverlayAnimationStore, driverInterpolator, @@ -27,6 +31,7 @@ export const useOverlaySlot = ({ isIncoming, }: { overlayAnimationStore: ScreenAnimationContextValue; + overlayInterpolator: ScreenStyleInterpolator | undefined; driverAnimationStore: ScreenAnimationContextValue; previousOverlayAnimationStore?: ScreenAnimationContextValue; driverInterpolator: ScreenStyleInterpolator | undefined; @@ -34,11 +39,16 @@ export const useOverlaySlot = ({ isIncoming: boolean; }) => { const { height } = useWindowDimensions(); - const transition = useBuildTransitionAccessor(driverAnimationStore); + const overlayTransition = useBuildTransitionAccessor(overlayAnimationStore); + const driverTransition = useBuildTransitionAccessor(driverAnimationStore); const interpolatorSharedValues = useMemo( - () => collectInterpolatorSharedValues([driverInterpolator]), - [driverInterpolator], + () => + collectInterpolatorSharedValues([ + overlayInterpolator, + driverInterpolator, + ]), + [overlayInterpolator, driverInterpolator], ); const overlaySlot = useDerivedValue< @@ -53,17 +63,25 @@ export const useOverlaySlot = ({ interpolatorSharedValues[index]?.get(); } + const overlayFrame = overlayAnimationStore.screenInterpolatorProps.get(); + const driverFrame = driverAnimationStore.screenInterpolatorProps.get(); + const overlayOwnsGesture = shouldUseOverlayGestureDriver( + overlayFrame, + driverFrame, + ); const frame = createOverlayInterpolatorFrame({ - overlayFrame: overlayAnimationStore.screenInterpolatorProps.get(), - driverFrame: driverAnimationStore.screenInterpolatorProps.get(), + overlayFrame, + driverFrame: overlayOwnsGesture ? overlayFrame : driverFrame, previousOverlayFrame: previousOverlayAnimationStore?.screenInterpolatorProps.get(), }); return runOverlaySlotInterpolator({ frame, - interpolator: driverInterpolator, - transition, + interpolator: overlayOwnsGesture + ? overlayInterpolator + : driverInterpolator, + transition: overlayOwnsGesture ? overlayTransition : driverTransition, }); }); diff --git a/packages/react-native-screen-transitions/src/shared/components/overlay/variations/overlay-host.tsx b/packages/react-native-screen-transitions/src/shared/components/overlay/variations/overlay-host.tsx index 49466197..267314dc 100644 --- a/packages/react-native-screen-transitions/src/shared/components/overlay/variations/overlay-host.tsx +++ b/packages/react-native-screen-transitions/src/shared/components/overlay/variations/overlay-host.tsx @@ -7,11 +7,11 @@ import { StyleSheet, View } from "react-native"; import Animated, { useDerivedValue } from "react-native-reanimated"; import { snapDescriptorToIndex } from "../../../animation/snap-to"; import { useStack } from "../../../hooks/navigation/use-stack"; -import { useScreenAnimationStore } from "../../../providers/screen/animation"; +import { useOptionalScreenAnimationStore } from "../../../providers/screen/animation"; import type { ScreenAnimationContextValue } from "../../../providers/screen/animation/animation.provider"; import { type ScreenSlotContextValue, - useScreenSlots, + useOptionalScreenSlotStore, } from "../../../providers/screen/styles/slot.provider"; import type { OverlayProps } from "../../../types/overlay.types"; import type { @@ -39,12 +39,16 @@ export const OverlayHost = memo(function OverlayHost({ activity, layerIndex, }: OverlayHostProps) { - const overlayAnimationStore = useScreenAnimationStore(scene.route.key); - const driverAnimationStore = useScreenAnimationStore(driverScene.route.key); - const previousOverlayAnimationStore = useScreenAnimationStore( + const overlayAnimationStore = useOptionalScreenAnimationStore( + scene.route.key, + ); + const driverAnimationStore = useOptionalScreenAnimationStore( + driverScene.route.key, + ); + const previousOverlayAnimationStore = useOptionalScreenAnimationStore( previousOverlayScene?.route.key ?? scene.route.key, ); - const driverSlots = useScreenSlots(driverScene.route.key); + const driverSlots = useOptionalScreenSlotStore(driverScene.route.key); const overlayComponentRef = useRef(scene.descriptor.options.overlay); const OverlayComponent = overlayComponentRef.current; const readyResourcesRef = useRef(null); @@ -105,6 +109,7 @@ function ReadyOverlayHost({ const focusedDescriptor = focusedScene?.descriptor; const { animatedProps, animatedStyle } = useOverlaySlot({ overlayAnimationStore, + overlayInterpolator: scene.descriptor.options.screenStyleInterpolator, driverAnimationStore, previousOverlayAnimationStore: previousOverlayAnimationStore ?? undefined, driverInterpolator: driverScene.descriptor.options.screenStyleInterpolator, 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 1050a64b..d5978b1d 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 @@ -1,10 +1,9 @@ -/** biome-ignore-all lint/style/noNonNullAssertion: */ 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 { useDescriptorsStore } from "../../../providers/screen/descriptors"; -import { useGestureStore } from "../../../providers/screen/gestures"; +import { useScreenGestureStore } from "../../../providers/screen/gestures"; import { useSlotProps, useSlotStyles } from "../../../providers/screen/styles"; import type { ScreenContentComponentProps } from "../../../types"; import { ScreenFallbackHost } from "../../boundary/portal/components/boundary-portal/components/host"; @@ -21,7 +20,7 @@ type Props = { export const ContentLayer = memo( ({ children, pointerEvents, isBackdropActive }: Props) => { - const gestureContext = useGestureStore(); + const gestureContext = useScreenGestureStore(); const ContentComponent = useDescriptorsStore( (store) => store.options.contentComponent, ); @@ -71,7 +70,7 @@ export const ContentLayer = memo( ); return ( - + {AnimatedContentComponent ? ( void; } { const routeKey = current.route.key; + const flags = useStackCoreStore((store) => store.flags); const { STACK_TYPE: stackType, TRANSITIONS_ALWAYS_ON: transitionsAlwaysOn } = - useStackCoreStore((store) => store.flags); - const handleCloseRoute = useBlankStackStore( + flags; + const handleCloseRoute = useOptionalBlankStackStore( (store) => store?.handleCloseRoute, ); - const isBlankStackClosing = useBlankStackStore( + const isBlankStackClosing = useOptionalBlankStackStore( (store) => store?.scenesByKey[routeKey]?.activity === "closing", ); - const ancestorKeys = useDescriptorsStore( - (store) => store.derivations.ancestorKeys, - ); - const parentScreenKey = ancestorKeys[0]; + const { parentScreenKey } = useCurrentScreenRelationships(); const { dismissScreen, requestDismiss } = useNavigationHelpers(); const pendingActionRef = useRef(null); diff --git a/packages/react-native-screen-transitions/src/shared/factories/screen-topology/create-screen-topology.ts b/packages/react-native-screen-transitions/src/shared/factories/screen-topology/create-screen-topology.ts new file mode 100644 index 00000000..e20625e1 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/factories/screen-topology/create-screen-topology.ts @@ -0,0 +1,199 @@ +import type { + ActiveScreenRegistration, + ScreenRelationships, + ScreenTopology, + ScreenTopologyRegistration, +} from "./types"; + +type ScreenNode = { + parentScreenKey: string | null; + activeChildScreenKeys: string[]; +}; + +const EMPTY_RELATIONSHIPS: ScreenRelationships = { + parentScreenKey: null, + activeChildScreenKey: null, +}; + +const areRelationshipsEqual = ( + left: ScreenRelationships, + right: ScreenRelationships, +): boolean => + left.parentScreenKey === right.parentScreenKey && + left.activeChildScreenKey === right.activeChildScreenKey; + +export const createScreenTopology = (): ScreenTopology => { + const nodes = new Map(); + const listenersByScreenKey = new Map void>>(); + const relationshipCache = new Map(); + + const getNode = (screenKey: string) => nodes.get(screenKey); + const ensureNode = (screenKey: string) => { + const existing = getNode(screenKey); + if (existing) return existing; + + const node: ScreenNode = { + parentScreenKey: null, + activeChildScreenKeys: [], + }; + nodes.set(screenKey, node); + return node; + }; + + const removeNodeIfEmpty = (screenKey: string) => { + const node = getNode(screenKey); + if ( + node && + !node.parentScreenKey && + node.activeChildScreenKeys.length === 0 + ) { + nodes.delete(screenKey); + } + }; + + const getParent = (screenKey: string) => + getNode(screenKey)?.parentScreenKey ?? null; + + const getActiveChild = (screenKey: string) => { + const activeChildScreenKeys = getNode(screenKey)?.activeChildScreenKeys; + return activeChildScreenKeys?.[activeChildScreenKeys.length - 1] ?? null; + }; + + const materializeRelationships = (screenKey: string): ScreenRelationships => { + const parentScreenKey = getParent(screenKey); + const activeChildScreenKey = getActiveChild(screenKey); + + if (!parentScreenKey && !activeChildScreenKey) { + return EMPTY_RELATIONSHIPS; + } + + return { parentScreenKey, activeChildScreenKey }; + }; + + const getRelationships = (screenKey: string) => { + const cached = relationshipCache.get(screenKey); + if (cached) return cached; + + const relationships = materializeRelationships(screenKey); + relationshipCache.set(screenKey, relationships); + return relationships; + }; + + const mutate = (affectedScreenKeys: Set, mutation: () => void) => { + const previousRelationships = new Map(); + for (const screenKey of affectedScreenKeys) { + if (listenersByScreenKey.has(screenKey)) { + previousRelationships.set(screenKey, getRelationships(screenKey)); + } + } + + mutation(); + for (const screenKey of affectedScreenKeys) { + relationshipCache.delete(screenKey); + } + + for (const [screenKey, previous] of previousRelationships) { + const listeners = listenersByScreenKey.get(screenKey); + if (!listeners) continue; + + const next = getRelationships(screenKey); + if (areRelationshipsEqual(previous, next)) { + relationshipCache.set(screenKey, previous); + continue; + } + + for (const listener of listeners) listener(); + } + }; + + const register = ({ + screenKey, + parentScreenKey, + }: ScreenTopologyRegistration) => { + mutate(new Set([screenKey]), () => { + ensureNode(screenKey).parentScreenKey = parentScreenKey ?? null; + }); + }; + + const unregister = (screenKey: string) => { + const node = getNode(screenKey); + if (!node) return; + + const affectedScreenKeys = new Set([screenKey]); + if (node.parentScreenKey) { + affectedScreenKeys.add(node.parentScreenKey); + } + + mutate(affectedScreenKeys, () => { + if (node.parentScreenKey) { + const parent = getNode(node.parentScreenKey); + if (parent) { + parent.activeChildScreenKeys = parent.activeChildScreenKeys.filter( + (activeScreenKey) => activeScreenKey !== screenKey, + ); + } + removeNodeIfEmpty(node.parentScreenKey); + } + + nodes.delete(screenKey); + }); + }; + + const activate = (registration: ActiveScreenRegistration) => { + if (getParent(registration.screenKey) !== registration.parentScreenKey) { + throw new Error( + "Screen topology can only activate a direct containment child.", + ); + } + + const affectedScreenKeys = new Set([registration.parentScreenKey]); + mutate(affectedScreenKeys, () => { + const parent = ensureNode(registration.parentScreenKey); + parent.activeChildScreenKeys = [ + ...parent.activeChildScreenKeys.filter( + (screenKey) => screenKey !== registration.screenKey, + ), + registration.screenKey, + ]; + }); + + return () => { + const parent = getNode(registration.parentScreenKey); + if (!parent?.activeChildScreenKeys.includes(registration.screenKey)) { + return; + } + + mutate(affectedScreenKeys, () => { + parent.activeChildScreenKeys = parent.activeChildScreenKeys.filter( + (screenKey) => screenKey !== registration.screenKey, + ); + removeNodeIfEmpty(registration.parentScreenKey); + }); + }; + }; + + return { + register, + unregister, + activate, + getRelationships, + subscribe: (screenKey, listener) => { + const listeners = + listenersByScreenKey.get(screenKey) ?? new Set<() => void>(); + listeners.add(listener); + listenersByScreenKey.set(screenKey, listeners); + + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + listenersByScreenKey.delete(screenKey); + if (!getNode(screenKey)) { + relationshipCache.delete(screenKey); + } + } + }; + }, + }; +}; + +export const screenTopology = createScreenTopology(); diff --git a/packages/react-native-screen-transitions/src/shared/factories/screen-topology/index.ts b/packages/react-native-screen-transitions/src/shared/factories/screen-topology/index.ts new file mode 100644 index 00000000..3f7efa37 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/factories/screen-topology/index.ts @@ -0,0 +1,16 @@ +export { + createScreenTopology, + screenTopology, +} from "./create-screen-topology"; +export type { + ActiveScreenRegistration, + ScreenRelationships, + ScreenTopology, + ScreenTopologyRegistration, +} from "./types"; +export { useScreenRelationships } from "./use-screen-relationships"; +export { + registerWorkletScreen, + screenBelongsToScope, + unregisterWorkletScreen, +} from "./worklet"; diff --git a/packages/react-native-screen-transitions/src/shared/factories/screen-topology/types.ts b/packages/react-native-screen-transitions/src/shared/factories/screen-topology/types.ts new file mode 100644 index 00000000..9b9fd8b3 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/factories/screen-topology/types.ts @@ -0,0 +1,22 @@ +export type ScreenTopologyRegistration = { + screenKey: string; + parentScreenKey?: string; +}; + +export type ActiveScreenRegistration = { + parentScreenKey: string; + screenKey: string; +}; + +export type ScreenRelationships = Readonly<{ + parentScreenKey: string | null; + activeChildScreenKey: string | null; +}>; + +export type ScreenTopology = { + register(registration: ScreenTopologyRegistration): void; + unregister(screenKey: string): void; + activate(registration: ActiveScreenRegistration): () => void; + getRelationships(screenKey: string): ScreenRelationships; + subscribe(screenKey: string, listener: () => void): () => void; +}; diff --git a/packages/react-native-screen-transitions/src/shared/factories/screen-topology/use-screen-relationships.ts b/packages/react-native-screen-transitions/src/shared/factories/screen-topology/use-screen-relationships.ts new file mode 100644 index 00000000..cfe53e24 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/factories/screen-topology/use-screen-relationships.ts @@ -0,0 +1,15 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { screenTopology } from "./create-screen-topology"; + +export const useScreenRelationships = (screenKey: string) => { + const subscribe = useCallback( + (listener: () => void) => screenTopology.subscribe(screenKey, listener), + [screenKey], + ); + const getSnapshot = useCallback( + () => screenTopology.getRelationships(screenKey), + [screenKey], + ); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}; diff --git a/packages/react-native-screen-transitions/src/shared/factories/screen-topology/worklet.ts b/packages/react-native-screen-transitions/src/shared/factories/screen-topology/worklet.ts new file mode 100644 index 00000000..90ef9784 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/factories/screen-topology/worklet.ts @@ -0,0 +1,60 @@ +import { makeMutable } from "react-native-reanimated"; +import type { ScreenTopologyRegistration } from "./types"; + +type WorkletScreenNode = { + parentScreenKey?: string; +}; + +type WorkletScreenTopology = Record; + +const toStorageKey = (screenKey: string) => { + "worklet"; + return `screen:${screenKey}`; +}; + +const workletScreenTopology = makeMutable({}); + +export const registerWorkletScreen = ({ + screenKey, + parentScreenKey, +}: ScreenTopologyRegistration) => { + "worklet"; + workletScreenTopology.modify( + (value: T): T => { + "worklet"; + (value as WorkletScreenTopology)[toStorageKey(screenKey)] = { + parentScreenKey, + }; + return value; + }, + ); +}; + +export const unregisterWorkletScreen = (screenKey: string) => { + "worklet"; + workletScreenTopology.modify( + (value: T): T => { + "worklet"; + const storageKey = toStorageKey(screenKey); + delete value[storageKey]; + return value; + }, + ); +}; + +export const screenBelongsToScope = ( + screenKey: string, + scopeScreenKey: string, +): boolean => { + "worklet"; + if (screenKey === scopeScreenKey) return true; + + const state = workletScreenTopology.get(); + let parentScreenKey = state[toStorageKey(screenKey)]?.parentScreenKey; + while (parentScreenKey) { + if (parentScreenKey === scopeScreenKey) return true; + parentScreenKey = state[toStorageKey(parentScreenKey)]?.parentScreenKey; + } + + return false; +}; 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 d3197ead..e936227d 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 @@ -1,10 +1,7 @@ import type { Route } from "@react-navigation/native"; import { useCallback, useMemo } from "react"; import { snapDescriptorToIndex } from "../../animation/snap-to"; -import { - type BaseDescriptor, - useDescriptorsStore, -} from "../../providers/screen/descriptors"; +import { useDescriptorsStore } from "../../providers/screen/descriptors"; import type { ScreenTransitionConfig } from "../../types/screen.types"; import type { BaseStackNavigation } from "../../types/stack.types"; import { type StackContextValue, useStack } from "./use-stack"; @@ -65,9 +62,7 @@ export function useScreenState< >(): ScreenState { const { routes, scenes, routeKeys, focusedIndex } = useStack(); - const current = useDescriptorsStore( - (store) => store.current, - ) as BaseDescriptor; + const current = useDescriptorsStore((store) => store.current); const index = useMemo( () => routeKeys.indexOf(current.route.key), diff --git a/packages/react-native-screen-transitions/src/shared/hooks/navigation/use-stack.tsx b/packages/react-native-screen-transitions/src/shared/hooks/navigation/use-stack.tsx index 4126c89e..68839ac6 100644 --- a/packages/react-native-screen-transitions/src/shared/hooks/navigation/use-stack.tsx +++ b/packages/react-native-screen-transitions/src/shared/hooks/navigation/use-stack.tsx @@ -5,12 +5,10 @@ import { useCallback, useContext, useLayoutEffect, - useMemo, useRef, useSyncExternalStore, } from "react"; -import { useDerivedValue } from "react-native-reanimated"; -import { syncStackProgressValues } from "../../providers/screen/animation/helpers/stack-progress"; +import type { StackProgressEntry } from "../../providers/screen/animation/helpers/stack-progress"; import type { StackCoreContextValue } from "../../providers/stack/core.provider"; import { AnimationStore } from "../../stores/animation.store"; import type { OverlayProps } from "../../types/overlay.types"; @@ -50,6 +48,7 @@ export interface StackContextValue extends StackCoreContextValue { interface StackStoreApi { getSnapshot: () => StackContextValue; + getStackProgressEntries: () => readonly StackProgressEntry[]; subscribe: (listener: () => void) => () => void; } @@ -58,14 +57,24 @@ interface MutableStackStoreApi extends StackStoreApi { setSnapshot: (snapshot: StackContextValue) => boolean; } +const createStackProgressEntries = ( + routeKeys: readonly string[], +): readonly StackProgressEntry[] => + routeKeys.map((routeKey) => ({ + routeKey, + visualProgress: AnimationStore.getValue(routeKey, "visualProgress"), + })); + const createStackStore = ( initialSnapshot: StackContextValue, ): MutableStackStoreApi => { let snapshot = initialSnapshot; + let stackProgressEntries = createStackProgressEntries(snapshot.routeKeys); const listeners = new Set<() => void>(); return { getSnapshot: () => snapshot, + getStackProgressEntries: () => stackProgressEntries, notify: () => { for (const listener of listeners) { listener(); @@ -76,6 +85,11 @@ const createStackStore = ( return false; } + if (!Object.is(snapshot.routeKeys, nextSnapshot.routeKeys)) { + stackProgressEntries = createStackProgressEntries( + nextSnapshot.routeKeys, + ); + } snapshot = nextSnapshot; return true; }, @@ -91,30 +105,6 @@ const createStackStore = ( const StackContext = createContext(null); StackContext.displayName = "Stack"; -function StackProgressOwner({ routeKeys }: { routeKeys: string[] }) { - const visualProgressValues = useMemo( - () => - routeKeys.map((routeKey) => - AnimationStore.getValue(routeKey, "visualProgress"), - ), - [routeKeys], - ); - - const stackProgressValues = useMemo( - () => - routeKeys.map((routeKey) => - AnimationStore.getValue(routeKey, "stackProgress"), - ), - [routeKeys], - ); - - useDerivedValue(() => { - syncStackProgressValues(visualProgressValues, stackProgressValues); - }); - - return null; -} - export function StackProvider({ children, value, @@ -144,10 +134,17 @@ export function StackProvider({ }); return ( - - - {children} - + {children} + ); +} + +export function useStackProgressEntries(): readonly StackProgressEntry[] { + const store = useStackStore(); + + return useSyncExternalStore( + store.subscribe, + store.getStackProgressEntries, + store.getStackProgressEntries, ); } diff --git a/packages/react-native-screen-transitions/src/shared/providers/helpers/measured-bounds-writes.ts b/packages/react-native-screen-transitions/src/shared/providers/helpers/measured-bounds-writes.ts index adc785b4..c1b971e3 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/helpers/measured-bounds-writes.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/helpers/measured-bounds-writes.ts @@ -1,4 +1,5 @@ import type { MeasuredDimensions, StyleProps } from "react-native-reanimated"; +import { completeBoundaryMeasurement } from "../../stores/bounds/internals/coordinator"; import { setEntry } from "../../stores/bounds/internals/entries"; import { setDestination, setSource } from "../../stores/bounds/internals/links"; import type { ScreenPairKey } from "../../stores/bounds/types"; @@ -70,4 +71,8 @@ export const applyMeasuredBoundsWrites = ( { handoff }, ); } + + if (linkWrite) { + completeBoundaryMeasurement(linkWrite, entryTag); + } }; 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 bc80fb30..2619f4cb 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,16 +1,10 @@ -import { type ReactNode, useCallback, useLayoutEffect, useMemo } from "react"; -import { useSharedValue } from "react-native-reanimated"; +import { type ReactNode, useMemo } from "react"; import { createBoundsAccessor } from "../../../utils/bounds"; import createProvider from "../../../utils/create-provider"; import { useDescriptorsStore } from "../descriptors"; +import { useCurrentScreenRelationships } from "../use-current-screen-relationships"; import { useScreenAnimationPipeline } from "./helpers/pipeline"; -import type { - RegisterScreenAnimationDescendant, - ScreenAnimationAncestorDescendantRegistrar, - ScreenAnimationDescendantSources, - ScreenAnimationSource, - ScreenAnimationTransitionSource, -} from "./types"; +import type { ScreenAnimationTransitionSource } from "./types"; type Props = { children: ReactNode; @@ -19,203 +13,76 @@ type Props = { export type ScreenAnimationContextValue = ReturnType< typeof useScreenAnimationPipeline > & { - ancestorScreenAnimationSources: ScreenAnimationSource[]; - descendantScreenAnimationSources: ScreenAnimationDescendantSources; - registerDescendantScreenAnimationSource: RegisterScreenAnimationDescendant; - ancestorDescendantScreenAnimationRegistrars: ScreenAnimationAncestorDescendantRegistrar[]; + transitionSources: readonly ScreenAnimationTransitionSource[]; + transitionOriginIndex: number; + transitionSourcesThroughSelf: readonly ScreenAnimationTransitionSource[]; + transitionSourcesFromSelf: readonly ScreenAnimationTransitionSource[]; }; -export type ScreenAnimationContextResult = { - key: string; - value: ScreenAnimationContextValue; -}; - -export const { ScreenAnimationProvider, useScreenAnimationStore } = - createProvider("ScreenAnimation", { - guarded: true, - global: true, - })( - (_props, { useParentStore }): ScreenAnimationContextResult => { - const currentScreenKey = useDescriptorsStore( - (store) => store.derivations.currentScreenKey, - ); - const parentScreenInterpolatorProps = useParentStore( - (parentContext) => parentContext?.screenInterpolatorProps, - ); - const parentScreenInterpolatorPropsRevision = useParentStore( - (parentContext) => parentContext?.screenInterpolatorPropsRevision, - ); - const parentAncestorScreenAnimationSources = useParentStore( - (parentContext) => parentContext?.ancestorScreenAnimationSources, - ); - const parentRegisterDescendantScreenAnimationSource = useParentStore( - (parentContext) => - parentContext?.registerDescendantScreenAnimationSource, - ); - const parentAncestorDescendantScreenAnimationRegistrars = useParentStore( - (parentContext) => - parentContext?.ancestorDescendantScreenAnimationRegistrars, - ); - - const { - screenInterpolatorProps, - screenInterpolatorPropsRevision, - selectedInterpolatorOptions, - nextInterpolator, - currentInterpolator, - } = useScreenAnimationPipeline(); - - const selfScreenAnimationSource = useMemo( - () => ({ - screenInterpolatorProps, - screenInterpolatorPropsRevision, +const EMPTY_TRANSITION_SOURCES: readonly ScreenAnimationTransitionSource[] = []; + +const createScreenAnimationProvider = createProvider("ScreenAnimation", { + global: true, +}); + +export const { + ScreenAnimationProvider, + useOptionalScreenAnimationStore, + useScreenAnimationStore, +}: ReturnType = + createScreenAnimationProvider((_props) => { + const currentScreenKey = useDescriptorsStore( + (store) => store.derivations.currentScreenKey, + ); + const relationships = useCurrentScreenRelationships(); + const pipeline = useScreenAnimationPipeline(); + + const screenAnimationSource = useMemo( + () => ({ + screenInterpolatorProps: pipeline.screenInterpolatorProps, + screenInterpolatorPropsRevision: + pipeline.screenInterpolatorPropsRevision, + boundsAccessor: createBoundsAccessor(() => { + "worklet"; + return pipeline.screenInterpolatorProps.get(); }), - [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; - }, - ); - - 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 () => { - for (const cleanup of cleanups) { - cleanup(); - } - }; - }, [ - ancestorDescendantScreenAnimationRegistrars, - selfScreenAnimationTransitionSource, - ]); - - return { - key: currentScreenKey, - value: { - screenInterpolatorProps, - screenInterpolatorPropsRevision, - selectedInterpolatorOptions, - nextInterpolator, - currentInterpolator, - ancestorScreenAnimationSources, - descendantScreenAnimationSources, - registerDescendantScreenAnimationSource, - ancestorDescendantScreenAnimationRegistrars, - }, - }; - }, - ); + }), + [ + pipeline.screenInterpolatorProps, + pipeline.screenInterpolatorPropsRevision, + ], + ); + const ancestorSources = + useOptionalScreenAnimationStore( + relationships.parentScreenKey, + (store) => store.transitionSourcesThroughSelf, + ) ?? EMPTY_TRANSITION_SOURCES; + const descendantSources = + useOptionalScreenAnimationStore( + relationships.activeChildScreenKey, + (store) => store.transitionSourcesFromSelf, + ) ?? EMPTY_TRANSITION_SOURCES; + const transitionSourcesThroughSelf = useMemo( + () => [...ancestorSources, screenAnimationSource], + [ancestorSources, screenAnimationSource], + ); + const transitionSourcesFromSelf = useMemo( + () => [screenAnimationSource, ...descendantSources], + [screenAnimationSource, descendantSources], + ); + const transitionSources = useMemo( + () => [...transitionSourcesThroughSelf, ...descendantSources], + [transitionSourcesThroughSelf, descendantSources], + ); + + return { + key: currentScreenKey, + value: { + ...pipeline, + transitionSources, + transitionOriginIndex: ancestorSources.length, + transitionSourcesThroughSelf, + transitionSourcesFromSelf, + }, + }; + }); 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 d7d8fc1b..bc662017 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 @@ -3,13 +3,8 @@ import type { ScreenInterpolationProps, ScreenTransitionTarget, } from "../../../../../types/animation.types"; -import { createBoundsAccessor } from "../../../../../utils/bounds"; import type { ScreenAnimationContextValue } from "../../animation.provider"; -import type { - ScreenAnimationDescendantSources, - ScreenAnimationSource, - ScreenAnimationTransitionSource, -} from "../../types"; +import type { ScreenAnimationTransitionSource } from "../../types"; type TransitionSourceIndex = number; @@ -17,10 +12,7 @@ export type TransitionAccessorSource = ScreenAnimationTransitionSource; type TransitionAccessorStore = Pick< ScreenAnimationContextValue, - | "screenInterpolatorProps" - | "screenInterpolatorPropsRevision" - | "ancestorScreenAnimationSources" - | "descendantScreenAnimationSources" + "transitionSources" | "transitionOriginIndex" >; const resolveTargetIndex = ( @@ -48,29 +40,9 @@ const resolveTargetIndex = ( export const createTransitionAccessor = ( sources: readonly TransitionAccessorSource[], originIndex = 0, - descendantSources?: ScreenAnimationDescendantSources, ) => { "worklet"; - const getSources = (): readonly TransitionAccessorSource[] => { - "worklet"; - if (!descendantSources) { - return sources; - } - - const descendants = descendantSources.get(); - if (descendants.length === 0) { - return sources; - } - - const currentSources = sources.slice(); - for (let index = 0; index < descendants.length; index++) { - currentSources.push(descendants[index].source); - } - - return currentSources; - }; - const buildScope = ( sourceIndex: TransitionSourceIndex, currentSources: readonly TransitionAccessorSource[], @@ -104,64 +76,21 @@ export const createTransitionAccessor = ( return (target?: ScreenTransitionTarget): ScreenInterpolationProps | null => { "worklet"; - const currentSources = getSources(); - const targetIndex = resolveTargetIndex( - target, - originIndex, - currentSources.length, - ); + const targetIndex = resolveTargetIndex(target, originIndex, sources.length); if (targetIndex === -1) { return null; } - return buildScope(targetIndex, currentSources); + return buildScope(targetIndex, sources); }; }; -const buildSourceBoundsAccessor = (source: ScreenAnimationSource) => { - "worklet"; - return createBoundsAccessor(() => { - "worklet"; - return source.screenInterpolatorProps.get(); - }); -}; - export const useBuildTransitionAccessor = ({ - screenInterpolatorProps, - screenInterpolatorPropsRevision, - ancestorScreenAnimationSources, - descendantScreenAnimationSources, + transitionSources, + transitionOriginIndex, }: TransitionAccessorStore) => { - return useMemo(() => { - const selfSource = { - screenInterpolatorProps, - screenInterpolatorPropsRevision, - }; - - const transitionSources: TransitionAccessorSource[] = - ancestorScreenAnimationSources.map((source) => ({ - ...source, - boundsAccessor: buildSourceBoundsAccessor(source), - })); - - const selfTransitionSource = { - ...selfSource, - boundsAccessor: buildSourceBoundsAccessor(selfSource), - }; - - transitionSources.reverse(); - const originIndex = transitionSources.length; - transitionSources.push(selfTransitionSource); - - return createTransitionAccessor( - transitionSources, - originIndex, - descendantScreenAnimationSources, - ); - }, [ - screenInterpolatorProps, - screenInterpolatorPropsRevision, - ancestorScreenAnimationSources, - descendantScreenAnimationSources, - ]); + return useMemo( + () => createTransitionAccessor(transitionSources, transitionOriginIndex), + [transitionSources, transitionOriginIndex], + ); }; 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 c330e5bb..66e32ebe 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 @@ -210,14 +210,29 @@ export const hydrateTransitionState = ( const resolvedAutoSnap = s.hasAutoSnapPoint && autoSnapPoint > 0 ? autoSnapPoint : null; + const targetProgress = s.targetProgress.get(); + const lockedSnapPoint = s.gesture.internal.lockedSnapPoint.get(); + + const isSettlingToLockedSnapPoint = + out.gesture.settling && targetProgress === lockedSnapPoint; + + const isLockedGestureLifecycle = + options.gestureSnapLocked && + lockedSnapPoint !== null && + (out.gesture.dragging || + isSettlingToLockedSnapPoint || + out.gesture.dismissing); + + const animatedSnapProgress = isLockedGestureLifecycle + ? lockedSnapPoint + : out.progress; + out.animatedSnapIndex = computeAnimatedSnapIndex( - out.progress, + animatedSnapProgress, s.sortedNumericSnapPoints, resolvedAutoSnap, ); - const targetProgress = s.targetProgress.get(); - out.snapIndex = computeTargetSnapIndex( targetProgress, s.sortedNumericSnapPoints, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/hydrate-transition-state/types.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/hydrate-transition-state/types.ts index 116a8c5d..b7aa47e6 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/hydrate-transition-state/types.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/hydrate-transition-state/types.ts @@ -11,7 +11,6 @@ import type { BaseStackRoute } from "../../../../../types/stack.types"; export type BuiltState = { transitionProgress: SharedValue; visualProgress: SharedValue; - stackProgress: SharedValue; willAnimate: SharedValue; closing: SharedValue; progressAnimating: SharedValue; 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 7531ad1b..eb39619b 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 @@ -11,7 +11,10 @@ import { createScreenTransitionState, DEFAULT_SCREEN_TRANSITION_STATE, } from "../../../../constants"; -import { useStack } from "../../../../hooks/navigation/use-stack"; +import { + useStack, + useStackProgressEntries, +} from "../../../../hooks/navigation/use-stack"; import type { ScreenInterpolationProps, ScreenStyleInterpolator, @@ -24,7 +27,7 @@ import { updateDerivations } from "./derivations"; import { hasTransitionsEnabled } from "./has-transitions-enabled"; import { hydrateTransitionState } from "./hydrate-transition-state"; import type { SelectedInterpolatorOptions } from "./selected-interpolator-options"; -import { resolveStackProgress } from "./stack-progress"; +import { readStackProgress, type StackProgressEntry } from "./stack-progress"; import { useBuildTransitionState } from "./use-build-transition-state"; import { toPlainRoute, toPlainValue } from "./worklet"; @@ -212,6 +215,8 @@ const hydrateInterpolatorFrame = ({ prevAnimation, nextHasTransitions, interpolatorOptions, + stackProgressEntries, + currentRouteKey, }: { frame: TFrame; dimensions: ScreenInterpolatorFrame["layouts"]["screen"]; @@ -221,6 +226,8 @@ const hydrateInterpolatorFrame = ({ prevAnimation: BuiltTransitionState | undefined; nextHasTransitions: boolean; interpolatorOptions: SelectedInterpolatorOptions; + stackProgressEntries: readonly StackProgressEntry[]; + currentRouteKey: string; }): TFrame => { "worklet"; const shouldApplyOptionsToCurrent = interpolatorOptions.owner === "current"; @@ -228,12 +235,6 @@ const hydrateInterpolatorFrame = ({ interpolatorOptions.owner === "next" && !!nextAnimation && nextHasTransitions; - const previousCurrentProgress = currentAnimation?.visualProgress.get(); - const previousNextProgress = - nextAnimation && nextHasTransitions - ? nextAnimation.visualProgress.get() - : undefined; - frame.previous = prevAnimation ? hydrateTransitionState(prevAnimation, dimensions) : undefined; @@ -260,13 +261,10 @@ const hydrateInterpolatorFrame = ({ updateDerivations(frame); - frame.stackProgress = resolveStackProgress( - currentAnimation?.stackProgress, + frame.stackProgress = readStackProgress( + stackProgressEntries, + currentRouteKey, frame.progress, - frame.current.progress, - previousCurrentProgress, - frame.next?.progress, - previousNextProgress, ); frame.logicallySettled = frame.active.settled; @@ -279,14 +277,15 @@ export function useScreenAnimationPipeline(): ScreenAnimationPipeline { ); const dimensions = useWindowDimensions(); const insets = useSafeAreaInsets(); + const stackProgressEntries = useStackProgressEntries(); 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); const prevAnimation = useBuildTransitionState(prevDescriptor); + const currentRouteKey = currDescriptor.route.key; const nextRouteKey = nextDescriptor?.route?.key; const nextHasTransitions = @@ -326,15 +325,13 @@ export function useScreenAnimationPipeline(): ScreenAnimationPipeline { prevAnimation, nextHasTransitions, interpolatorOptions, + stackProgressEntries, + currentRouteKey, }); }, false); - // Critical reactive dependency for `screenInterpolatorProps`. - // - // `screenInterpolatorProps` is mutated in place to avoid allocating a large - // interpolator frame every tick. Consumers must read this revision before - // reading `screenInterpolatorProps`, otherwise Reanimated may not subscribe - // to frame updates and can observe stale transition state. + // `screenInterpolatorProps` is mutated in place. Consumers read this + // revision first so Reanimated subscribes to the hydrated frame. propsRevisionState.modify((revision) => { "worklet"; revision.value += 1; @@ -345,7 +342,7 @@ export function useScreenAnimationPipeline(): ScreenAnimationPipeline { }); const nextInterpolator = nextDescriptor?.options.screenStyleInterpolator; - const currentInterpolator = currDescriptor?.options.screenStyleInterpolator; + const currentInterpolator = currDescriptor.options.screenStyleInterpolator; return { screenInterpolatorProps, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/read-screen-animation-revisions.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/read-screen-animation-revisions.ts index 81684450..b29ac5a4 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/read-screen-animation-revisions.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/read-screen-animation-revisions.ts @@ -1,39 +1,15 @@ import type { SharedValue } from "react-native-reanimated"; -import type { - ScreenAnimationDescendantSources, - ScreenAnimationSource, - ScreenInterpolatorPropsRevision, -} from "../types"; +import type { ScreenAnimationTransitionSource } from "../types"; type ScreenInterpolatorExternalDeps = Pick, "get">[]; export const readScreenAnimationRevisions = ( - screenInterpolatorPropsRevision: ScreenInterpolatorPropsRevision, - ancestorScreenAnimationSources: ScreenAnimationSource[], - descendantScreenAnimationSources: ScreenAnimationDescendantSources, - screenInterpolatorExternalDeps?: ScreenInterpolatorExternalDeps, + sources: readonly ScreenAnimationTransitionSource[], + externalRevisions: ScreenInterpolatorExternalDeps = [], ) => { "worklet"; - screenInterpolatorPropsRevision.get(); - - for (let index = 0; index < ancestorScreenAnimationSources.length; index++) { - ancestorScreenAnimationSources[ - index - ]?.screenInterpolatorPropsRevision.get(); - } - - const descendantSources = descendantScreenAnimationSources.get(); - for (let index = 0; index < descendantSources.length; index++) { - descendantSources[index]?.source.screenInterpolatorPropsRevision.get(); - } - - if (screenInterpolatorExternalDeps) { - for ( - let index = 0; - index < screenInterpolatorExternalDeps.length; - index++ - ) { - screenInterpolatorExternalDeps[index]?.get(); - } + for (const source of sources) { + source.screenInterpolatorPropsRevision.get(); } + for (const revision of externalRevisions) revision.get(); }; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/stack-progress.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/stack-progress.ts index 56e8e2fe..a01ceb3f 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/stack-progress.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/stack-progress.ts @@ -1,44 +1,25 @@ import type { SharedValue } from "react-native-reanimated"; -export const syncStackProgressValues = ( - visualProgressValues: SharedValue[], - stackProgressValues: SharedValue[], -) => { - "worklet"; - let total = 0; - - for (let i = visualProgressValues.length - 1; i >= 0; i--) { - total += visualProgressValues[i]?.get() ?? 0; - const stackProgress = stackProgressValues[i]; - - if (stackProgress && stackProgress.get() !== total) { - stackProgress.set(total); - } - } +export type StackProgressEntry = { + routeKey: string; + visualProgress: SharedValue; }; -export const resolveStackProgress = ( - stackProgress: SharedValue | undefined, +export const readStackProgress = ( + entries: readonly StackProgressEntry[], + routeKey: string, fallbackProgress: number, - currentProgress: number, - previousCurrentProgress: number | undefined, - nextProgress: number | undefined, - previousNextProgress: number | undefined, ) => { "worklet"; - if (!stackProgress) { - return fallbackProgress; - } + let progress = 0; - let total = stackProgress.get(); - - if (previousCurrentProgress !== undefined) { - total += currentProgress - previousCurrentProgress; - } + for (let index = entries.length - 1; index >= 0; index--) { + const entry = entries[index]; + if (!entry) continue; - if (nextProgress !== undefined && previousNextProgress !== undefined) { - total += nextProgress - previousNextProgress; + progress += entry.visualProgress.get(); + if (entry.routeKey === routeKey) return progress; } - return total; + return fallbackProgress; }; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/use-build-transition-state.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/use-build-transition-state.ts index 25f77252..b187733c 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/use-build-transition-state.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/use-build-transition-state.ts @@ -22,7 +22,6 @@ import { toPlainRoute, toPlainValue } from "./worklet"; type BuiltState = { transitionProgress: SharedValue; visualProgress: SharedValue; - stackProgress: SharedValue; willAnimate: SharedValue; closing: SharedValue; progressAnimating: SharedValue; @@ -68,7 +67,6 @@ export const useBuildTransitionState = ( return { transitionProgress: AnimationStore.getValue(key, "transitionProgress"), visualProgress: AnimationStore.getValue(key, "visualProgress"), - stackProgress: AnimationStore.getValue(key, "stackProgress"), willAnimate: AnimationStore.getValue(key, "willAnimate"), closing: AnimationStore.getValue(key, "closing"), entering: AnimationStore.getValue(key, "entering"), 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 2f74dff3..50dbca3e 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,5 +1,6 @@ export { ScreenAnimationProvider, + useOptionalScreenAnimationStore, useScreenAnimationStore, } from "./animation.provider"; export { diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/types.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/types.ts index 5e5008a5..b5d58b80 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/types.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/types.ts @@ -15,30 +15,8 @@ export type ScreenAnimationTarget = | ScreenTransitionTarget | ScreenAnimationLegacyTarget; -export type ScreenAnimationSource = { +export type ScreenAnimationTransitionSource = { screenInterpolatorProps: SharedValue; screenInterpolatorPropsRevision: ScreenInterpolatorPropsRevision; -}; - -export type ScreenAnimationTransitionSource = ScreenAnimationSource & { boundsAccessor: BoundsAccessor; }; - -export type ScreenAnimationDescendantSource = { - source: ScreenAnimationTransitionSource; - depth: number; -}; - -export type ScreenAnimationDescendantSources = SharedValue< - ScreenAnimationDescendantSource[] ->; - -export type RegisterScreenAnimationDescendant = ( - source: ScreenAnimationTransitionSource, - depth: number, -) => () => void; - -export type ScreenAnimationAncestorDescendantRegistrar = { - register: RegisterScreenAnimationDescendant; - depth: number; -}; 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 55e704ec..50d34f9b 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 @@ -4,7 +4,7 @@ import type { ScreenInterpolationProps, ScreenTransitionTarget, } from "../../../types/animation.types"; -import { useScreenAnimationStore } from "./animation.provider"; +import { useOptionalScreenAnimationStore } from "./animation.provider"; import { useBuildTransitionAccessor } from "./helpers/accessors/use-build-transition-accessor"; import { readScreenAnimationRevisions } from "./helpers/read-screen-animation-revisions"; import type { @@ -35,32 +35,28 @@ export function useScreenAnimation( | DerivedValue | DerivedValue { const route = useRoute(); - const screenAnimationStore = useScreenAnimationStore(route.key); + const localAnimationStore = useOptionalScreenAnimationStore(); + const keyedAnimationStore = useOptionalScreenAnimationStore( + localAnimationStore ? null : route.key, + ); + const screenAnimationStore = localAnimationStore ?? keyedAnimationStore; if (!screenAnimationStore) { throw new Error( - `ScreenAnimation store for route "${route.key}" was not found`, + `ScreenAnimationStore is unavailable for route "${route.key}"`, ); } - const { - screenInterpolatorPropsRevision, - ancestorScreenAnimationSources, - descendantScreenAnimationSources, - } = screenAnimationStore; + const { transitionSources, transitionOriginIndex } = screenAnimationStore; const transition = useBuildTransitionAccessor(screenAnimationStore); const transitionTarget = normalizeScreenAnimationTarget( target, - ancestorScreenAnimationSources.length, + transitionOriginIndex, ); const animation = useDerivedValue(() => { "worklet"; - readScreenAnimationRevisions( - screenInterpolatorPropsRevision, - ancestorScreenAnimationSources, - descendantScreenAnimationSources, - ); + readScreenAnimationRevisions(transitionSources); return transition(transitionTarget); }); 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 97a4dc9d..b4b45b3a 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,12 +1,19 @@ import type { ReactNode } from "react"; -import { useMemo } from "react"; +import { useLayoutEffect, useMemo } from "react"; +import { runOnUI } from "react-native-reanimated"; import { useScreenTransitionsAdapterOptionalContext } from "../../../adapters/with-screen-transitions/context"; -import { useBlankStackStore } from "../../../providers/stack/blank-stack.provider"; +import { screenTopology } from "../../../factories/screen-topology"; +import { useStack } from "../../../hooks/navigation/use-stack"; +import { useOptionalBlankStackStore } from "../../../providers/stack/blank-stack.provider"; +import { + registerScreen, + unregisterScreen, +} from "../../../stores/bounds/internals/coordinator"; +import { SystemStore } from "../../../stores/system.store"; import type { BaseStackDescriptor } from "../../../types/stack.types"; import createProvider from "../../../utils/create-provider"; import type { DescriptorDerivations } from "./helpers/derive-descriptor-derivations"; import { deriveDescriptorDerivations } from "./helpers/derive-descriptor-derivations"; -import { getAncestorKeys } from "./helpers/get-ancestor-keys"; /** * Base descriptor interface - minimal contract for all stack types. @@ -41,18 +48,32 @@ type DescriptorsProviderProps = { routeKey?: string; }; -export const { DescriptorsProvider, useDescriptorsStore } = createProvider( - "Descriptors", - { guarded: true }, -)>( +const createDescriptorsProvider = createProvider("Descriptors", { + global: true, +})>; + +const { + DescriptorsProvider, + useDescriptorsStore, + useOptionalDescriptorsStore, +}: ReturnType = createDescriptorsProvider( ({ previous, current, next, routeKey, children }) => { - const blankStackCurrent = useBlankStackStore((store) => + const parentScreenKey = useOptionalDescriptorsStore( + (store) => store?.derivations.currentScreenKey, + ); + const parentTransitionSourcePairKey = useOptionalDescriptorsStore( + (store) => store?.derivations.transitionSourcePairKey, + ); + const parentTransitionDestinationScreenKey = useOptionalDescriptorsStore( + (store) => store?.derivations.transitionDestinationScreenKey, + ); + const blankStackCurrent = useOptionalBlankStackStore((store) => routeKey ? store?.scenesByKey[routeKey]?.descriptor : undefined, ); - const blankStackPrevious = useBlankStackStore((store) => + const blankStackPrevious = useOptionalBlankStackStore((store) => routeKey ? store?.scenesByKey[routeKey]?.previousDescriptor : undefined, ); - const blankStackNext = useBlankStackStore((store) => + const blankStackNext = useOptionalBlankStackStore((store) => routeKey ? store?.scenesByKey[routeKey]?.nextDescriptor : undefined, ); const adapterContext = useScreenTransitionsAdapterOptionalContext(); @@ -68,6 +89,17 @@ export const { DescriptorsProvider, useDescriptorsStore } = createProvider( const resolvedPrevious = previous ?? blankStackPrevious ?? adapterScene?.previousDescriptor; const resolvedNext = next ?? blankStackNext ?? adapterScene?.nextDescriptor; + const currentScreenKey = current?.route.key ?? routeKey; + const isActiveScreen = useStack((store) => { + const focusedScene = store.scenes[ + store.focusedIndex + ] as (typeof store.scenes)[number]; + + return ( + focusedScene.route.key === currentScreenKey && + focusedScene.activity === "active" + ); + }); if (!resolvedCurrent) { throw new Error( @@ -84,23 +116,76 @@ export const { DescriptorsProvider, useDescriptorsStore } = createProvider( [resolvedPrevious, resolvedCurrent, resolvedNext], ); - const ancestorKeys = useMemo( - () => getAncestorKeys(resolvedCurrent), - [resolvedCurrent], - ); + const derivations = useMemo(() => { + const localDerivations = deriveDescriptorDerivations({ + previous: resolvedPrevious, + current: resolvedCurrent, + next: resolvedNext, + }); - const derivations = useMemo( - () => - deriveDescriptorDerivations({ - previous: resolvedPrevious, - current: resolvedCurrent, - next: resolvedNext, - ancestorKeys, - }), - [resolvedPrevious, resolvedCurrent, resolvedNext, ancestorKeys], + return { + ...localDerivations, + transitionSourcePairKey: + localDerivations.sourcePairKey ?? + parentTransitionSourcePairKey ?? + undefined, + transitionDestinationScreenKey: + localDerivations.nextScreenKey ?? + parentTransitionDestinationScreenKey ?? + undefined, + }; + }, [ + resolvedPrevious, + resolvedCurrent, + resolvedNext, + parentTransitionSourcePairKey, + parentTransitionDestinationScreenKey, + ]); + const animationProgress = SystemStore.getValue( + derivations.currentScreenKey, + "animationProgress", ); + const pendingLifecycleStartBlockCount = SystemStore.getValue( + derivations.currentScreenKey, + "pendingLifecycleStartBlockCount", + ); + + useLayoutEffect(() => { + screenTopology.register({ + screenKey: derivations.currentScreenKey, + parentScreenKey, + }); + runOnUI(registerScreen)({ + screenKey: derivations.currentScreenKey, + parentScreenKey, + animationProgress, + pendingLifecycleStartBlockCount, + }); + + return () => { + screenTopology.unregister(derivations.currentScreenKey); + runOnUI(unregisterScreen)(derivations.currentScreenKey); + }; + }, [ + animationProgress, + derivations.currentScreenKey, + parentScreenKey, + pendingLifecycleStartBlockCount, + ]); + + useLayoutEffect(() => { + if (!isActiveScreen || !parentScreenKey) { + return; + } + + return screenTopology.activate({ + screenKey: derivations.currentScreenKey, + parentScreenKey, + }); + }, [isActiveScreen, derivations.currentScreenKey, parentScreenKey]); return { + key: derivations.currentScreenKey, value: { previous: resolvedPrevious, current: resolvedCurrent, @@ -113,3 +198,5 @@ export const { DescriptorsProvider, useDescriptorsStore } = createProvider( }; }, ); + +export { DescriptorsProvider, useDescriptorsStore }; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/descriptors/helpers/derive-descriptor-derivations.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/descriptors/helpers/derive-descriptor-derivations.ts index 82d88850..b99c8b6f 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/descriptors/helpers/derive-descriptor-derivations.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/descriptors/helpers/derive-descriptor-derivations.ts @@ -7,11 +7,13 @@ export interface DescriptorDerivations { currentScreenKey: string; nextScreenKey?: string; sourcePairKey?: ScreenPairKey; + /** Nearest source pair owned by this screen or one of its ancestors. */ + transitionSourcePairKey?: ScreenPairKey; + /** Destination root for the nearest source pair. */ + transitionDestinationScreenKey?: string; destinationPairKey?: ScreenPairKey; - parentScreenKey?: string; isFirstKey: boolean; isTopMostScreen: boolean; - ancestorKeys: string[]; hasConfiguredInterpolator: boolean; } @@ -19,14 +21,12 @@ interface Params { previous?: BaseStackDescriptor; current: BaseStackDescriptor; next?: BaseStackDescriptor; - ancestorKeys: string[]; } export function deriveDescriptorDerivations({ previous, current, next, - ancestorKeys, }: Params): DescriptorDerivations { const previousScreenKey = previous?.route.key; const currentScreenKey = current.route.key; @@ -52,11 +52,11 @@ export function deriveDescriptorDerivations({ currentScreenKey, nextScreenKey, sourcePairKey, + transitionSourcePairKey: sourcePairKey, + transitionDestinationScreenKey: nextScreenKey, destinationPairKey, - parentScreenKey: ancestorKeys[0], isFirstKey, isTopMostScreen, - ancestorKeys, hasConfiguredInterpolator, }; } diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/descriptors/helpers/get-ancestor-keys.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/descriptors/helpers/get-ancestor-keys.ts deleted file mode 100644 index 5122da1c..00000000 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/descriptors/helpers/get-ancestor-keys.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { BaseStackDescriptor } from "../../../../types/stack.types"; - -/** - * Builds nested navigator ancestor keys from immediate parent to root. - */ -export function getAncestorKeys(current: BaseStackDescriptor): string[] { - const ancestors: string[] = []; - const nav = current.navigation as any; - - if (typeof nav?.getParent !== "function") { - return ancestors; - } - - let parent = nav.getParent(); - - while (parent) { - const state = parent.getState(); - if (state?.routes && state.index !== undefined) { - const focusedRoute = state.routes[state.index]; - if (focusedRoute?.key) { - ancestors.push(focusedRoute.key); - } - } - parent = parent.getParent(); - } - - return ancestors; -} 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 230fa43b..300a0b4e 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 @@ -4,6 +4,7 @@ import { useSharedValue } from "react-native-reanimated"; import { ScrollStore } from "../../../stores/scroll.store"; import createProvider from "../../../utils/create-provider"; import { useDescriptorsStore } from "../descriptors"; +import { useCurrentScreenRelationships } from "../use-current-screen-relationships"; import { useScreenGestureConfig } from "./hooks/use-screen-gesture-config"; import { GestureOwnershipBridge } from "./ownership/gesture-ownership-bridge"; import { useBuildPanGesture } from "./pan/use-build-pan-gesture"; @@ -13,100 +14,110 @@ import { type GestureCompositionOwner, type GestureContextType, NO_DIRECTION_CLAIMS, + type ScreenGestureSource, } from "./types"; interface ScreenGestureProviderProps { children: React.ReactNode; } -export const { ScreenGestureProvider, useScreenGestureStore: useGestureStore } = - createProvider("ScreenGesture", { guarded: false })< - ScreenGestureProviderProps, - GestureContextType - >( - ( - { children }, - { useParentStore }, - ): { value: GestureContextType; children: React.ReactNode } => { - const currentScreenKey = useDescriptorsStore( - (store) => store.derivations.currentScreenKey, - ); - const isTopMostScreen = useDescriptorsStore( - (store) => store.derivations.isTopMostScreen, - ); - const gestureContext = useParentStore((parentContext) => - isTopMostScreen ? parentContext : null, - ); - const gestureConfig = useScreenGestureConfig(gestureContext); +type ScreenGestureStoreValue = GestureContextType & { + relationshipGestureSources: readonly ScreenGestureSource[]; +}; - 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); +const EMPTY_GESTURE_SOURCES: readonly ScreenGestureSource[] = []; - // 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 createScreenGestureProvider = createProvider("ScreenGesture", { + global: true, +}); - const panGesture = useBuildPanGesture({ +export const { + ScreenGestureProvider, + useOptionalScreenGestureStore, + useScreenGestureStore, +}: ReturnType = createScreenGestureProvider( + ({ children }) => { + const currentScreenKey = useDescriptorsStore( + (store) => store.derivations.currentScreenKey, + ); + const isTopMostScreen = useDescriptorsStore( + (store) => store.derivations.isTopMostScreen, + ); + const relationships = useCurrentScreenRelationships(); + const relationshipAncestorGestures = + useOptionalScreenGestureStore( + relationships.parentScreenKey, + (store) => store.relationshipGestureSources, + ) ?? EMPTY_GESTURE_SOURCES; + const ancestorGestures = isTopMostScreen + ? relationshipAncestorGestures + : EMPTY_GESTURE_SOURCES; + const gestureConfig = useScreenGestureConfig(ancestorGestures); + const scrollState = ScrollStore.getValue(currentScreenKey, "coordination"); + const childDirectionClaims = + useSharedValue(NO_DIRECTION_CLAIMS); + const gestureCompositionOwner = + useSharedValue(null); + const panGesture = useBuildPanGesture({ + scrollState, + gestureConfig, + childDirectionClaims, + gestureCompositionOwner, + }); + const pinchGesture = useBuildPinchGesture({ + gestureConfig, + gestureCompositionOwner, + }); + const detectorGesture = useMemo( + () => Gesture.Simultaneous(panGesture, pinchGesture), + [panGesture, pinchGesture], + ); + const source = useMemo( + () => ({ + routeKey: currentScreenKey, + detectorGesture, + panGesture, + pinchGesture, scrollState, - gestureConfig, + claimedDirections: gestureConfig.participation.claimedDirections, childDirectionClaims, - gestureCompositionOwner, - }); - - const pinchGesture = useBuildPinchGesture({ - gestureConfig, - gestureCompositionOwner, - }); - - 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 content = useMemo( - () => ( - - - {children} - - ), - [children], - ); + }), + [ + currentScreenKey, + detectorGesture, + panGesture, + pinchGesture, + scrollState, + gestureConfig.participation.claimedDirections, + childDirectionClaims, + ], + ); + const relationshipGestureSources = useMemo( + () => [source, ...ancestorGestures], + [source, ancestorGestures], + ); + const value = useMemo( + () => ({ + ...source, + ancestorGestures, + relationshipGestureSources, + }), + [source, ancestorGestures, relationshipGestureSources], + ); + const content = useMemo( + () => ( + + + {children} + + ), + [children], + ); - return { - value, - children: content, - }; - }, - ); + return { + key: currentScreenKey, + value, + children: content, + }; + }, +); 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 f7e359cd..f7bfb918 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 @@ -2,10 +2,10 @@ import { usePreventRemoveContext } from "@react-navigation/native"; import { useMemo } from "react"; import { useDescriptorsStore } from "../../descriptors"; import { resolveScreenGestureConfig } from "../shared/policy"; -import type { GestureContextType, ScreenGestureConfig } from "../types"; +import type { ScreenGestureConfig, ScreenGestureSource } from "../types"; export function useScreenGestureConfig( - gestureContext: GestureContextType | null, + ancestorGestures: readonly ScreenGestureSource[], ): ScreenGestureConfig { const options = useDescriptorsStore((store) => store.options); const isFirstKey = useDescriptorsStore( @@ -23,9 +23,9 @@ export function useScreenGestureConfig( resolveScreenGestureConfig({ options, isFirstKey, - gestureContext, + ancestorGestures, isRemovePrevented, }), - [isFirstKey, options, gestureContext, isRemovePrevented], + [isFirstKey, options, ancestorGestures, isRemovePrevented], ); } 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 ce959d4b..89d557a1 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,8 +2,7 @@ import { type ChainTarget, resolveChainTarget, } from "../../../../utils/resolve-chain-target"; -import { useGestureStore } from "../gestures.provider"; -import { walkGestureAncestors } from "../shared/ancestors"; +import { useOptionalScreenGestureStore } from "../gestures.provider"; export type ScreenGestureTarget = ChainTarget; @@ -21,13 +20,13 @@ export type ScreenGestureTarget = ChainTarget; * ``` */ export const useScreenGesture = (target?: ScreenGestureTarget) => { - const ctx = useGestureStore(); + const ctx = useOptionalScreenGestureStore(); return ( resolveChainTarget({ target, self: ctx, - ancestors: walkGestureAncestors(ctx?.gestureContext), + ancestors: ctx?.ancestorGestures ?? [], })?.panGesture ?? null ); }; 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 7b388403..141848c1 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,7 @@ export { ScreenGestureProvider, - useGestureStore, + useOptionalScreenGestureStore, + useScreenGestureStore, } from "./gestures.provider"; export type { DirectionClaim, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/gesture-ownership-bridge.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/gesture-ownership-bridge.tsx index fac30723..148948d9 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/gesture-ownership-bridge.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/gesture-ownership-bridge.tsx @@ -10,15 +10,14 @@ import { type BaseDescriptor, useDescriptorsStore, } from "../../../screen/descriptors"; -import { useBlankStackStore } from "../../../stack/blank-stack.provider"; -import { useGestureStore } from "../gestures.provider"; -import { walkGestureAncestors } from "../shared/ancestors"; +import { useOptionalBlankStackStore } from "../../../stack/blank-stack.provider"; +import { useScreenGestureStore } from "../gestures.provider"; import { resolveScreenGestureConfig } from "../shared/policy"; -import type { GestureContextType } from "../types"; +import type { ScreenGestureSource } from "../types"; import { resolveShadowingClaimDirections } from "./shadowing-claims"; type ShadowedAncestor = { - ancestor: GestureContextType; + ancestor: ScreenGestureSource; directions: Direction[]; }; @@ -40,15 +39,15 @@ const findShadowedDirections = ( }; const findShadowedAncestors = ( - parentContext: GestureContextType | null, + ancestorGestures: readonly ScreenGestureSource[], claimedDirections: ClaimedDirections, ) => { - if (!parentContext) { + if (ancestorGestures.length === 0) { return NO_SHADOWED_ANCESTORS; } const ancestors: ShadowedAncestor[] = []; - for (const ancestor of walkGestureAncestors(parentContext)) { + for (const ancestor of ancestorGestures) { const directions = findShadowedDirections( claimedDirections, ancestor.claimedDirections, @@ -107,7 +106,7 @@ const getDescriptorIsFirstKey = (descriptor: BaseDescriptor): boolean => { const getDescriptorClaimedDirections = ( descriptor: BaseDescriptor | undefined, - gestureContext: GestureContextType | null, + ancestorGestures: readonly ScreenGestureSource[], ): ClaimedDirections => { if (!descriptor) { return NO_CLAIMS; @@ -116,32 +115,20 @@ const getDescriptorClaimedDirections = ( return resolveScreenGestureConfig({ options: descriptor.options, isFirstKey: getDescriptorIsFirstKey(descriptor), - gestureContext, + ancestorGestures, }).participation.claimedDirections; }; -const requireGestureContext = ( - gestureContext: GestureContextType | null, -): GestureContextType => { - if (!gestureContext) { - throw new Error( - "GestureOwnershipBridge must be rendered within a ScreenGestureProvider", - ); - } - - return gestureContext; -}; - function ActiveGestureOwnershipBridge() { - const gestureContext = requireGestureContext(useGestureStore()); + const gestureContext = useScreenGestureStore(); const previous = useDescriptorsStore((store) => store.previous); - const isCurrentScreenClosing = useBlankStackStore( + const isCurrentScreenClosing = useOptionalBlankStackStore( (store) => store?.scenesByKey[gestureContext.routeKey]?.activity === "closing", ); const { claimedDirections, - gestureContext: parentContext, + ancestorGestures, routeKey: currentScreenKey, } = gestureContext; // A retained closing screen cannot receive touches, so its ownership must @@ -153,14 +140,14 @@ function ActiveGestureOwnershipBridge() { currentClaimedDirections: claimedDirections, previousClaimedDirections: getDescriptorClaimedDirections( previous, - parentContext, + ancestorGestures, ), }), - [isCurrentScreenClosing, claimedDirections, previous, parentContext], + [isCurrentScreenClosing, claimedDirections, previous, ancestorGestures], ); const shadowedAncestors = useMemo( - () => findShadowedAncestors(parentContext, effectiveClaimedDirections), - [parentContext, effectiveClaimedDirections], + () => findShadowedAncestors(ancestorGestures, effectiveClaimedDirections), + [ancestorGestures, effectiveClaimedDirections], ); useLayoutEffect(() => { diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/resolve-ownership.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/resolve-ownership.ts index 0b87fba9..6c67f3f0 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/resolve-ownership.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/resolve-ownership.ts @@ -16,7 +16,6 @@ import type { DirectionClaim } from "../types"; */ interface AncestorClaimsContext { claimedDirections: ClaimedDirections; - gestureContext: AncestorClaimsContext | null; } /** @@ -31,12 +30,12 @@ interface AncestorClaimsContext { * used in worklets since it's a plain object. * * @param selfClaims - The directions claimed by the current screen - * @param gestureContext - The gesture context chain (can be null if no ancestors) + * @param ancestors - Nearest-first ancestor gesture values from screen topology. * @returns Ownership status for all four directions */ export function resolveOwnership( selfClaims: ClaimedDirections, - gestureContext: AncestorClaimsContext | null, + ancestors: readonly AncestorClaimsContext[], ): DirectionOwnership { const result: DirectionOwnership = { ...NO_OWNERSHIP }; @@ -44,7 +43,7 @@ export function resolveOwnership( result[direction] = resolveDirectionOwnership( direction, selfClaims, - gestureContext, + ancestors, ); } @@ -57,7 +56,7 @@ export function resolveOwnership( function resolveDirectionOwnership( direction: Direction, selfClaims: ClaimedDirections, - gestureContext: AncestorClaimsContext | null, + ancestors: readonly AncestorClaimsContext[], ): OwnershipStatus { // Check self first if (selfClaims[direction]) { @@ -65,12 +64,10 @@ function resolveDirectionOwnership( } // Walk ancestors looking for a claim - let ancestor = gestureContext; - while (ancestor) { + for (const ancestor of ancestors) { if (ancestor.claimedDirections?.[direction]) { return "ancestor"; } - ancestor = ancestor.gestureContext; } // No one claims this direction 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 47e4c89d..05c372d4 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 @@ -9,6 +9,7 @@ import { GestureActivationState } from "../../../../../types/gesture.types"; import type { Direction } from "../../../../../types/ownership.types"; import { useDescriptorsStore } from "../../../descriptors"; import type { ScreenOptionsContextValue } from "../../../options"; +import { useCurrentScreenRelationships } from "../../../use-current-screen-relationships"; import { resolvePanRuntime } from "../../shared/runtime"; import type { DirectionClaimMap, @@ -39,9 +40,7 @@ export const usePanActivation = ({ const currentScreenKey = useDescriptorsStore( (store) => store.derivations.currentScreenKey, ); - const parentScreenKey = useDescriptorsStore( - (store) => store.derivations.parentScreenKey, - ); + const { parentScreenKey } = useCurrentScreenRelationships(); const ancestorDismissing = useMemo(() => { if (!parentScreenKey) return null; 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 a161518b..418aa2e1 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 @@ -109,6 +109,7 @@ export const finalizePanRelease = ( : { target: animations.transitionProgress.get(), shouldDismiss: false, + isCancelled: true, initialVelocity: 0, transitionSpec: undefined, resetSpec: policy.transitionSpec?.open, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/pan-release.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/pan-release.ts index 59c252a6..d7a1c0fc 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/pan-release.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/pan-release.ts @@ -1,4 +1,5 @@ import { clamp } from "react-native-reanimated"; +import { EPSILON } from "../../../../../constants"; import { getPanSnapAxisConfigForDirection, isResolvedPanGestureDirection, @@ -111,6 +112,7 @@ const buildInactivePanSnapRelease = ( return { target: stores.animations.transitionProgress.get(), shouldDismiss: false, + isCancelled: true, initialVelocity: 0, transitionSpec: policy.transitionSpec, resetSpec: policy.transitionSpec?.open, @@ -146,6 +148,7 @@ export const resolvePanRelease = ( return { target: shouldDismiss ? 0 : 1, shouldDismiss, + isCancelled: !shouldDismiss, initialVelocity: getPanReleaseProgressVelocity({ animations, shouldDismiss, @@ -205,9 +208,16 @@ export const resolveSnapPanRelease = ( const shouldDismiss = participation.canDismiss && result.shouldDismiss; const target = shouldDismiss ? 0 : result.targetProgress; + // Compare against the gesture's starting snap so only true snap-backs lose velocity. + const initialTarget = policy.gestureSnapLocked + ? (runtime.stores.gestures.internal.lockedSnapPoint.get() ?? + runtime.stores.system.targetProgress.get()) + : runtime.stores.system.targetProgress.get(); + return { target, shouldDismiss, + isCancelled: !shouldDismiss && Math.abs(target - initialTarget) <= EPSILON, initialVelocity: getProgressVelocityTowardTarget({ handoffVelocity: getPanReleaseHandoffVelocity( axisVelocity, @@ -238,7 +248,7 @@ export const buildPanReleasePlan = ( "worklet"; const { policy } = runtime; const releaseVelocityScale = Math.max(0, policy.gestureReleaseVelocityScale); - const resetVelocityScale = releaseVelocityScale; + const resetVelocityScale = release.isCancelled ? 0 : releaseVelocityScale; const resetVelocityX = resetVelocityScale === 0 ? 0 : rawEvent.velocityX * resetVelocityScale; const resetVelocityY = @@ -264,7 +274,7 @@ export const buildPanReleasePlan = ( return { target: release.target, shouldDismiss: release.shouldDismiss, - progressVelocity: release.initialVelocity, + progressVelocity: release.isCancelled ? 0 : release.initialVelocity, resetVelocityX, resetVelocityY, resetVelocityNormX: resetVelocityX / Math.max(1, dimensions.width), diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/use-pan-behavior.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/use-pan-behavior.ts index ff88bbd3..b8a62113 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/use-pan-behavior.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/pan/behavior/use-pan-behavior.ts @@ -97,6 +97,7 @@ export const usePanBehavior = ( ? { target: latestRuntime.stores.animations.transitionProgress.get(), shouldDismiss: false, + isCancelled: true, initialVelocity: 0, transitionSpec: undefined, resetSpec: latestRuntime.policy.transitionSpec?.open, 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 ced491f3..02511fab 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 @@ -14,21 +14,20 @@ const DEFAULT_SCROLL_METADATA_OWNER_CONTEXT: ScrollMetadataOwnerContextValue = { horizontal: false, }; -const { +export const { ScrollMetadataOwnerProvider, - useScrollMetadataOwnerStore: useMaybeScrollMetadataOwnerStore, -} = createProvider("ScrollMetadataOwner", { guarded: false })< + useOptionalScrollMetadataOwnerStore, +} = createProvider("ScrollMetadataOwner")< ScrollMetadataOwnerProviderProps, ScrollMetadataOwnerContextValue >(({ children, value }) => ({ children, value })); -export const useScrollMetadataOwnerStore = () => - useMaybeScrollMetadataOwnerStore() ?? DEFAULT_SCROLL_METADATA_OWNER_CONTEXT; - export const useScrollMetadataOwnerProviderValue = ( axis: ScrollGestureAxis, ) => { - const parent = useScrollMetadataOwnerStore(); + const parent = + useOptionalScrollMetadataOwnerStore() ?? + DEFAULT_SCROLL_METADATA_OWNER_CONTEXT; return useMemo(() => { if (parent[axis]) return parent; @@ -39,5 +38,3 @@ export const useScrollMetadataOwnerProviderValue = ( }; }, [axis, parent]); }; - -export { ScrollMetadataOwnerProvider }; 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 906cfe64..75cf610f 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 { useGestureStore } from "../gestures.provider"; +import { useOptionalScreenGestureStore } from "../gestures.provider"; import type { ScrollGestureAxis, ScrollGestureAxisState, @@ -18,8 +18,8 @@ import type { ScrollMetadataState, } from "../types"; import { + useOptionalScrollMetadataOwnerStore, useScrollMetadataOwnerProviderValue, - useScrollMetadataOwnerStore, } from "./scroll-metadata-owner"; import { clearScrollMetadataAxisState, @@ -88,21 +88,25 @@ const clearScrollMetadataAxis = ( export const useScrollGestureCoordination = ( props: ScrollGestureCoordinationProps, ) => { - const context = useGestureStore(); + const context = useOptionalScreenGestureStore(); const scrollDirection = props.direction ?? "vertical"; + const gesturePath = useMemo( + () => (context ? [context, ...context.ancestorGestures] : []), + [context], + ); - const metadataOwnerContext = useScrollMetadataOwnerStore(); + const metadataOwnerContext = useOptionalScrollMetadataOwnerStore(); const metadataOwnerProviderValue = useScrollMetadataOwnerProviderValue(scrollDirection); - const isFirstMetadataWriterInTree = !metadataOwnerContext[scrollDirection]; + const isFirstMetadataWriterInTree = !metadataOwnerContext?.[scrollDirection]; const [metadataWriterId] = useState(() => ScrollStore.createMetadataWriterId(), ); const [writesMetadata, setWritesMetadata] = useState(false); const { scrollStates, panGestures, pinchGestures, ownerRouteKeys } = useMemo( - () => walkUpScrollGestureCoordination(context, scrollDirection), - [context, scrollDirection], + () => walkUpScrollGestureCoordination(gesturePath, scrollDirection), + [gesturePath, scrollDirection], ); const routeKey = context?.routeKey; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/scroll-coordination/walk-up-scroll-gesture-coordination.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/scroll-coordination/walk-up-scroll-gesture-coordination.ts index f218f248..aabd5d0e 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/scroll-coordination/walk-up-scroll-gesture-coordination.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/scroll-coordination/walk-up-scroll-gesture-coordination.ts @@ -1,19 +1,18 @@ import type { SharedValue } from "react-native-reanimated"; import type { Direction } from "../../../../types/ownership.types"; -import { walkGestureAncestors } from "../shared/ancestors"; import type { - GestureContextType, PanGesture, PinchGesture, + ScreenGestureSource, ScrollGestureAxis, ScrollGestureState, } from "../types"; /** Walks up the gesture tree until it finds the owner for a specific direction. */ function findGestureOwnerForDirection( - ancestors: GestureContextType[], + ancestors: readonly ScreenGestureSource[], direction: Direction, -): GestureContextType | null { +): ScreenGestureSource | null { for (const ancestor of ancestors) { if (ancestor.claimedDirections?.[direction]) return ancestor; } @@ -28,8 +27,10 @@ const getDirectionsForAxis = ( ? ["vertical", "vertical-inverted"] : ["horizontal", "horizontal-inverted"]; -const collectAncestorPinchGestures = (ancestors: GestureContextType[]) => { - const pinchGestures: GestureContextType["pinchGesture"][] = []; +const collectAncestorPinchGestures = ( + ancestors: readonly ScreenGestureSource[], +) => { + const pinchGestures: ScreenGestureSource["pinchGesture"][] = []; for (const ancestor of ancestors) { if (!pinchGestures.includes(ancestor.pinchGesture)) { @@ -41,12 +42,12 @@ const collectAncestorPinchGestures = (ancestors: GestureContextType[]) => { }; const collectAxisOwners = ( - ancestors: GestureContextType[], + ancestors: readonly ScreenGestureSource[], directions: readonly [Direction, Direction], ) => { - const seenOwners: GestureContextType[] = []; - const panGestures: GestureContextType["panGesture"][] = []; - const scrollStates: GestureContextType["scrollState"][] = []; + const seenOwners: ScreenGestureSource[] = []; + const panGestures: ScreenGestureSource["panGesture"][] = []; + const scrollStates: ScreenGestureSource["scrollState"][] = []; const ownerRouteKeys: string[] = []; for (const direction of directions) { @@ -73,10 +74,9 @@ interface WalkUpScrollGestureCoordinationResult { } export function walkUpScrollGestureCoordination( - context: GestureContextType | null, + ancestors: readonly ScreenGestureSource[], axis: ScrollGestureAxis, ): WalkUpScrollGestureCoordinationResult { - const ancestors = walkGestureAncestors(context); const axisOwners = collectAxisOwners(ancestors, getDirectionsForAxis(axis)); return { diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/shared/ancestors.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/shared/ancestors.ts deleted file mode 100644 index c59b607b..00000000 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/shared/ancestors.ts +++ /dev/null @@ -1,27 +0,0 @@ -type GestureAncestorNode = { - gestureContext: T | null; - isIsolated?: boolean; -}; - -/** - * Returns the gesture context chain, starting at `context`, then walking upward. - * When `isIsolated` is provided, the walk stops at a stack isolation boundary. - */ -export function walkGestureAncestors>( - context: T | null | undefined, - isIsolated?: boolean, -): T[] { - const ancestors: T[] = []; - let ancestor = context ?? null; - - while (ancestor) { - if (isIsolated !== undefined && ancestor.isIsolated !== isIsolated) { - break; - } - - ancestors.push(ancestor); - ancestor = ancestor.gestureContext; - } - - return ancestors; -} diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/shared/policy.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/shared/policy.ts index 4c764bdf..8e05f7fe 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/shared/policy.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/shared/policy.ts @@ -25,11 +25,11 @@ import type { import { computeClaimedDirections } from "../ownership/compute-claimed-directions"; import { resolveOwnership } from "../ownership/resolve-ownership"; import type { - GestureContextType, PanGesturePolicy, PinchGesturePolicy, ScreenGestureConfig, ScreenGestureParticipation, + ScreenGestureSource, } from "../types"; import { getGestureDirectionEntries, @@ -305,12 +305,12 @@ export const resolvePinchPolicy = ( const resolveGestureParticipation = ({ options, isFirstKey, - gestureContext, + ancestorGestures, isRemovePrevented, }: { options: GesturePolicyOptions; isFirstKey: boolean; - gestureContext: GestureContextType | null; + ancestorGestures: readonly ScreenGestureSource[]; isRemovePrevented: boolean; }): ScreenGestureParticipation => { const canDismiss = @@ -342,25 +342,25 @@ const resolveGestureParticipation = ({ canTrackGesture, effectiveSnapPoints, claimedDirections, - ownershipStatus: resolveOwnership(claimedDirections, gestureContext), + ownershipStatus: resolveOwnership(claimedDirections, ancestorGestures), }; }; export const resolveScreenGestureConfig = ({ options, isFirstKey, - gestureContext, + ancestorGestures, isRemovePrevented = false, }: { options: ScreenTransitionConfig; isFirstKey: boolean; - gestureContext: GestureContextType | null; + ancestorGestures: readonly ScreenGestureSource[]; isRemovePrevented?: boolean; }): ScreenGestureConfig => { const participation = resolveGestureParticipation({ options, isFirstKey, - gestureContext, + ancestorGestures, isRemovePrevented, }); const hasSnapPoints = participation.effectiveSnapPoints.hasSnapPoints; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/types.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/types.ts index 4e511a30..ace6ba76 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/types.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/types.ts @@ -71,17 +71,20 @@ export const NO_DIRECTION_CLAIMS: DirectionClaimMap = { "horizontal-inverted": null, }; -export interface GestureContextType { +export interface ScreenGestureSource { routeKey: string; detectorGesture: ComposedGesture; panGesture: PanGesture; pinchGesture: PinchGesture; scrollState: SharedValue; - gestureContext: GestureContextType | null; claimedDirections: ClaimedDirections; childDirectionClaims: SharedValue; } +export interface GestureContextType extends ScreenGestureSource { + ancestorGestures: readonly ScreenGestureSource[]; +} + export interface ScreenGestureParticipation { /** Whether this route is the first route in its stack. First routes never track gestures. */ isFirstKey: boolean; @@ -172,6 +175,7 @@ export interface PinchTrackState { export interface PanReleaseResult { target: number; shouldDismiss: boolean; + isCancelled: boolean; initialVelocity: number; commitProgress?: number; transitionSpec: TransitionSpec | undefined; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/constants.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/constants.ts index c4fc736d..7e713e74 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/constants.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/constants.ts @@ -62,6 +62,7 @@ export const STYLE_RESET_VALUES: Record = { export const PROP_RESET_VALUES: Record = { hostName: PORTAL_HOST_NAME_RESET_VALUE, pointerEvents: "auto", + teleport: false, }; const RESERVED_STYLE_SLOT_IDS = { diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/helpers/visibility-block-ownership.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/helpers/visibility-block-ownership.ts new file mode 100644 index 00000000..d56c4a9a --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/helpers/visibility-block-ownership.ts @@ -0,0 +1,16 @@ +type VisibilityBlockOwnershipParams = { + localBlocked: boolean; + ancestorBlocked: boolean; +}; + +export const resolveVisibilityBlockOwnership = ({ + localBlocked, + ancestorBlocked, +}: VisibilityBlockOwnershipParams) => { + "worklet"; + + return { + appliesOffset: localBlocked && !ancestorBlocked, + effectiveBlocked: localBlocked || ancestorBlocked, + }; +}; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/slot-resolvers.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/slot-resolvers.tsx index 686e32a2..c25f2ebd 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/slot-resolvers.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/slot-resolvers.tsx @@ -6,14 +6,21 @@ import { composeSlotStyleWithLocalTransform, getLocalTransformForSlotComposition, } from "../helpers/compose-slot-style"; -import { useScreenSlots } from "../slot.provider"; +import { useOptionalScreenSlotStore } from "../slot.provider"; const useCurrentScreenSlotsMap = () => { const route = useRoute(); - const slotsMap = useScreenSlots(route.key, (store) => store.slotsMap); + const localSlotsMap = useOptionalScreenSlotStore( + (store) => store?.slotsMap ?? null, + ); + const keyedSlotsMap = useOptionalScreenSlotStore( + localSlotsMap ? null : route.key, + (store) => store.slotsMap, + ); + const slotsMap = localSlotsMap ?? keyedSlotsMap; if (!slotsMap) { - throw new Error(`ScreenSlot store for route "${route.key}" was not found`); + throw new Error(`ScreenSlotStore is unavailable for route "${route.key}"`); } return slotsMap; 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 cac58fc8..d1bbd1da 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 @@ -148,9 +148,6 @@ export const useInterpolatedStylesMap = ({ const screenInterpolatorProps = useScreenAnimationStore( (store) => store.screenInterpolatorProps, ); - const screenInterpolatorPropsRevision = useScreenAnimationStore( - (store) => store.screenInterpolatorPropsRevision, - ); const selectedInterpolatorOptions = useScreenAnimationStore( (store) => store.selectedInterpolatorOptions, ); @@ -160,17 +157,15 @@ export const useInterpolatedStylesMap = ({ const currentInterpolator = useScreenAnimationStore( (store) => store.currentInterpolator, ); - const ancestorScreenAnimationSources = useScreenAnimationStore( - (store) => store.ancestorScreenAnimationSources, + const transitionSources = useScreenAnimationStore( + (store) => store.transitionSources, ); - const descendantScreenAnimationSources = useScreenAnimationStore( - (store) => store.descendantScreenAnimationSources, + const transitionOriginIndex = useScreenAnimationStore( + (store) => store.transitionOriginIndex, ); const transition = useBuildTransitionAccessor({ - screenInterpolatorProps, - screenInterpolatorPropsRevision, - ancestorScreenAnimationSources, - descendantScreenAnimationSources, + transitionSources, + transitionOriginIndex, }); const hasCurrentInterpolator = !!currentInterpolator; const { closing: currentClosing, entering: currentEntering } = @@ -242,12 +237,7 @@ export const useInterpolatedStylesMap = ({ const localStylesMaps = useDerivedValue(() => { "worklet"; - readScreenAnimationRevisions( - screenInterpolatorPropsRevision, - ancestorScreenAnimationSources, - descendantScreenAnimationSources, - interpolatorSharedValues, - ); + readScreenAnimationRevisions(transitionSources, interpolatorSharedValues); const props = screenInterpolatorProps.get(); const { current, next } = props; 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 c911c06f..b9099676 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 @@ -1,5 +1,6 @@ import { useWindowDimensions } from "react-native"; import { + type SharedValue, useAnimatedProps, useAnimatedReaction, useAnimatedStyle, @@ -10,9 +11,18 @@ import { SystemStore } from "../../../../stores/system.store"; import { getVisibilityBlockOffset } from "../../../../utils/visibility-block-offset"; import { useDescriptorsStore } from "../../descriptors"; import { hasCloseTransitionFinished } from "../helpers/transition-visual-state"; +import { resolveVisibilityBlockOwnership } from "../helpers/visibility-block-ownership"; import { resolveScreenVisibilityGate } from "../helpers/visibility-gate"; -export const useMaybeBlockVisibility = (isFloatingOverlay?: boolean) => { +type Params = { + ancestorVisibilityBlocked: SharedValue | null; + isFloatingOverlay?: boolean; +}; + +export const useMaybeBlockVisibility = ({ + ancestorVisibilityBlocked, + isFloatingOverlay, +}: Params) => { const { height } = useWindowDimensions(); const currentScreenKey = useDescriptorsStore( (store) => store.derivations.currentScreenKey, @@ -25,29 +35,39 @@ export const useMaybeBlockVisibility = (isFloatingOverlay?: boolean) => { } = SystemStore.getBag(currentScreenKey); const hasVisibilityGateOpened = useSharedValue(false); - const shouldBlockVisibility = useSharedValue(!isFloatingOverlay); + const localVisibilityBlocked = useSharedValue(!isFloatingOverlay); + const effectiveVisibilityBlocked = useSharedValue(!isFloatingOverlay); useAnimatedReaction( () => { "worklet"; - return resolveScreenVisibilityGate({ - isFloatingOverlay, - hasVisibilityGateOpened: hasVisibilityGateOpened.get(), - pendingLifecycleStartBlockCount: pendingLifecycleStartBlockCount.get(), - pendingLifecycleRequestKind: pendingLifecycleRequestKind.get(), - animationProgress: animationProgress.get(), - entering: entering.get(), - }); + return { + gate: resolveScreenVisibilityGate({ + isFloatingOverlay, + hasVisibilityGateOpened: hasVisibilityGateOpened.get(), + pendingLifecycleStartBlockCount: + pendingLifecycleStartBlockCount.get(), + pendingLifecycleRequestKind: pendingLifecycleRequestKind.get(), + animationProgress: animationProgress.get(), + entering: entering.get(), + }), + ancestorBlocked: ancestorVisibilityBlocked?.get() ?? false, + }; }, - (gate) => { + ({ gate, ancestorBlocked }) => { "worklet"; if (gate.shouldOpenGate) { hasVisibilityGateOpened.set(true); } - shouldBlockVisibility.set(gate.shouldBlock); + const ownership = resolveVisibilityBlockOwnership({ + localBlocked: gate.shouldBlock, + ancestorBlocked, + }); + localVisibilityBlocked.set(gate.shouldBlock); + effectiveVisibilityBlocked.set(ownership.effectiveBlocked); }, ); @@ -66,10 +86,15 @@ export const useMaybeBlockVisibility = (isFloatingOverlay?: boolean) => { }; } + const ownership = resolveVisibilityBlockOwnership({ + localBlocked: localVisibilityBlocked.get(), + ancestorBlocked: ancestorVisibilityBlocked?.get() ?? false, + }); + return { transform: [ { - translateY: shouldBlockVisibility.get() ? offset : 0, + translateY: ownership.appliesOffset ? offset : 0, }, ], }; @@ -78,7 +103,7 @@ export const useMaybeBlockVisibility = (isFloatingOverlay?: boolean) => { const animatedProps = useAnimatedProps(() => { "worklet"; return { - pointerEvents: shouldBlockVisibility.get() + pointerEvents: effectiveVisibilityBlocked.get() ? ("none" as const) : ("box-none" as const), }; @@ -87,6 +112,6 @@ export const useMaybeBlockVisibility = (isFloatingOverlay?: boolean) => { return { animatedStyle, animatedProps, - shouldBlockVisibility, + visibilityBlocked: effectiveVisibilityBlocked, }; }; 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 728115fa..695db388 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 @@ -9,5 +9,6 @@ export { export { type ScreenSlotName, ScreenSlotProvider, - useScreenSlots, + useOptionalScreenSlotStore, + useScreenSlotStore, } from "./slot.provider"; 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 b6b40ef3..e8301b2e 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 @@ -8,6 +8,7 @@ import type { import type { NormalizedTransitionInterpolatedStyle } from "../../../types/animation.types"; import createProvider from "../../../utils/create-provider"; import { useDescriptorsStore } from "../descriptors"; +import { useCurrentScreenRelationships } from "../use-current-screen-relationships"; import { useInterpolatedStylesMap } from "./hooks/use-interpolated-style-maps"; import { useMaybeBlockVisibility } from "./hooks/use-maybe-block-visibility"; import { useResolvedStylesMap } from "./hooks/use-resolved-slot-style-map"; @@ -30,36 +31,49 @@ export type ScreenSlotContextValue = { visibilityBlocked: SharedValue; }; -export const { ScreenSlotProvider, useScreenSlotStore: useScreenSlots } = - createProvider("ScreenSlot", { - guarded: true, - global: true, - })(({ children }, { useParentStore }) => { - const ancestorStylesMap = useParentStore( - (parentContext) => parentContext?.slotsMap, - ); +const createScreenSlotProvider = createProvider("ScreenSlot", { + global: true, +}); + +export const { + ScreenSlotProvider, + useOptionalScreenSlotStore, + useScreenSlotStore, +}: ReturnType = createScreenSlotProvider( + ({ children }) => { const currentScreenKey = useDescriptorsStore( (store) => store.derivations.currentScreenKey, ); - const { animatedStyle, animatedProps, shouldBlockVisibility } = - useMaybeBlockVisibility(); + const { parentScreenKey } = useCurrentScreenRelationships(); + const ancestorStylesMap = useOptionalScreenSlotStore( + parentScreenKey, + (store) => store.slotsMap, + ); + const ancestorVisibilityBlocked = useOptionalScreenSlotStore( + parentScreenKey, + (store) => store.visibilityBlocked, + ); + const { animatedStyle, animatedProps, visibilityBlocked } = + useMaybeBlockVisibility({ + ancestorVisibilityBlocked: ancestorVisibilityBlocked ?? null, + }); const { interpolatorReady, localStylesMaps } = useInterpolatedStylesMap({ enabled: true, - visibilityBlocked: shouldBlockVisibility, + visibilityBlocked, }); const slotsMap = useResolvedStylesMap({ localStylesMaps, - ancestorStylesMap, + ancestorStylesMap: ancestorStylesMap ?? undefined, }); const value = useMemo( () => ({ interpolatorReady, slotsMap, - visibilityBlocked: shouldBlockVisibility, + visibilityBlocked, }), - [interpolatorReady, shouldBlockVisibility, slotsMap], + [interpolatorReady, slotsMap, visibilityBlocked], ); const content = useMemo( () => ( @@ -78,7 +92,8 @@ export const { ScreenSlotProvider, useScreenSlotStore: useScreenSlots } = value, children: content, }; - }); + }, +); const styles = StyleSheet.create({ container: { flex: 1 }, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/use-current-screen-relationships.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/use-current-screen-relationships.ts new file mode 100644 index 00000000..27467ebb --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/use-current-screen-relationships.ts @@ -0,0 +1,10 @@ +import { useScreenRelationships } from "../../factories/screen-topology"; +import { useDescriptorsStore } from "./descriptors"; + +export const useCurrentScreenRelationships = () => { + const screenKey = useDescriptorsStore( + (store) => store.derivations.currentScreenKey, + ); + + return useScreenRelationships(screenKey); +}; 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 d1c9943e..061550b9 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 @@ -42,11 +42,14 @@ type BlankStackStoreProviderProps = { value: BlankStackStoreValue; }; -const { BlankStackProvider: BlankStackStoreProvider, useBlankStackStore } = - createProvider("BlankStack", { guarded: false })< - BlankStackStoreProviderProps, - BlankStackStoreValue - >(({ children, value }) => ({ children, value })); +const { + BlankStackProvider: BlankStackStoreProvider, + useBlankStackStore, + useOptionalBlankStackStore, +} = createProvider("BlankStack")< + BlankStackStoreProviderProps, + BlankStackStoreValue +>(({ children, value }) => ({ children, value })); function BlankStackProvider({ state: stackState, @@ -68,6 +71,15 @@ function BlankStackProvider({ () => createScenesByKey(state.scenes), [state.scenes], ); + const paintDriverRouteKeyByRouteKey = useMemo(() => { + const paintDrivers = new Map(); + + for (let index = 0; index + 2 < state.routeKeys.length; index++) { + paintDrivers.set(state.routeKeys[index], state.routeKeys[index + 2]); + } + + return paintDrivers; + }, [state.routeKeys]); const stackValue = useMemo( () => ({ @@ -95,6 +107,7 @@ function BlankStackProvider({ routes: state.routes, scenes: state.scenes, scenesByKey, + paintDriverRouteKeyByRouteKey, focusedIndex, requestDismiss, shouldShowFloatOverlay: state.shouldShowFloatOverlay, @@ -111,4 +124,4 @@ function BlankStackProvider({ } export type { BlankStackProviderProps, BlankStackStoreValue }; -export { BlankStackProvider, useBlankStackStore }; +export { BlankStackProvider, useBlankStackStore, useOptionalBlankStackStore }; 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 9c8e01fb..b17a9096 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 @@ -45,7 +45,6 @@ const StackCoreRoot = memo(function StackCoreRoot({ export const { StackCoreProvider, useStackCoreStore } = createProvider( "StackCore", - { guarded: true }, )(({ config, children }) => { const { TRANSITIONS_ALWAYS_ON = false, 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 f92c4a28..933d8d96 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 @@ -23,7 +23,7 @@ type DirectStackStoreProviderProps = { }; const { DirectStackProvider: DirectStackStoreProvider, useDirectStackStore } = - createProvider("DirectStack", { guarded: true })< + createProvider("DirectStack")< DirectStackStoreProviderProps, DirectStackContextValue >(({ children, value }) => ({ children, value })); @@ -51,7 +51,7 @@ function DirectStackProvider({ useMemo(() => { const allRoutes = state.routes.concat(state.preloadedRoutes); const scenes: DirectStackScene[] = []; - const routeKeys: string[] = []; + const routeKeys = state.routes.map((route) => route.key); const allDescriptors: NativeStackDescriptorMap = { ...preloadedDescriptors, ...descriptors, @@ -85,8 +85,6 @@ function DirectStackProvider({ descriptor, isPreloaded, }); - routeKeys.push(route.key); - if ( !shouldShowFloatOverlay && descriptor.options?.enableTransitions === true && 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 2dc17ddb..a8f3e1ea 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 @@ -10,7 +10,6 @@ import { createStore } from "../utils/create-store"; export type AnimationStoreMap = { transitionProgress: SharedValue; visualProgress: SharedValue; - stackProgress: SharedValue; willAnimate: SharedValue; progressAnimating: SharedValue; progressSettled: SharedValue; @@ -39,7 +38,6 @@ function createAnimationBag(): AnimationStoreMap { return { transitionProgress: makeMutable(0), visualProgress: makeMutable(0), - stackProgress: makeMutable(0), willAnimate: makeMutable(0), closing: makeMutable(0), progressAnimating: makeMutable(0), @@ -58,7 +56,6 @@ export const AnimationStore = createStore({ disposeBag: (bag) => { cancelAnimation(bag.transitionProgress); cancelAnimation(bag.visualProgress); - cancelAnimation(bag.stackProgress); cancelAnimation(bag.willAnimate); cancelAnimation(bag.progressAnimating); cancelAnimation(bag.progressSettled); diff --git a/packages/react-native-screen-transitions/src/shared/stores/bounds/helpers/link-pairs.helpers.ts b/packages/react-native-screen-transitions/src/shared/stores/bounds/helpers/link-pairs.helpers.ts index 39d62e2f..d3fd0b27 100644 --- a/packages/react-native-screen-transitions/src/shared/stores/bounds/helpers/link-pairs.helpers.ts +++ b/packages/react-native-screen-transitions/src/shared/stores/bounds/helpers/link-pairs.helpers.ts @@ -137,6 +137,18 @@ export const ensurePairSourceRequests = ( return pair.sourceRequests; }; +export const ensurePairDestinationRequests = ( + state: LinkPairsState, + pairKey: ScreenPairKey, +): Record => { + "worklet"; + const pair = ensurePairState(state, pairKey); + if (!pair.destinationRequests) { + pair.destinationRequests = {}; + } + return pair.destinationRequests; +}; + export const removePairLink = ( state: LinkPairsState, pairKey: ScreenPairKey, diff --git a/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/clear.ts b/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/clear.ts index bf4d7730..f10ae7ec 100644 --- a/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/clear.ts +++ b/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/clear.ts @@ -1,7 +1,12 @@ import { hasAnyKeys } from "../helpers/keys"; import { isScreenPairKeyForScreen } from "../helpers/link-pairs.helpers"; import type { LinkPairsState, ScreenKey } from "../types"; -import { type BoundaryEntriesState, boundaryRegistry, pairs } from "./state"; +import { + type BoundaryEntriesState, + boundaryRegistry, + boundsScreens, + pairs, +} from "./state"; function clear(screenKey: ScreenKey) { "worklet"; @@ -30,6 +35,12 @@ function clear(screenKey: ScreenKey) { return state; }); + + boundsScreens.modify((state) => { + "worklet"; + delete state[screenKey]; + return state; + }); } export { clear }; diff --git a/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/coordinator.ts b/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/coordinator.ts new file mode 100644 index 00000000..ceb583f2 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/coordinator.ts @@ -0,0 +1,415 @@ +import type { SharedValue } from "react-native-reanimated"; +import { + registerWorkletScreen, + screenBelongsToScope, + unregisterWorkletScreen, +} from "../../../factories/screen-topology"; +import { + ensurePairDestinationRequests, + ensurePairGroups, + ensurePairLinks, + ensurePairSourceRequests, + getDestinationScreenKeyFromPairKey, + getGroupKeyFromTag, + getLinkKeyFromTag, + getSourceScreenKeyFromPairKey, +} from "../helpers/link-pairs.helpers"; +import type { + BoundsScreenNode, + BoundTag, + EntryPatch, + LinkKey, + LinkPairsState, + ScreenKey, + ScreenPairKey, + TagID, +} from "../types"; +import { removeEntry, setEntry } from "./entries"; +import { boundaryRegistry, boundsScreens, pairs } from "./state"; + +type MeasurementRequest = { + type: "source" | "destination"; + pairKey: ScreenPairKey; +}; + +type RegisterScreenParams = { + screenKey: ScreenKey; + parentScreenKey?: ScreenKey; + animationProgress: SharedValue; + pendingLifecycleStartBlockCount: SharedValue; +}; + +type RegisterBoundaryParams = { + boundTag: BoundTag; + screenKey: ScreenKey; + entry: EntryPatch; +}; + +type RequestBoundaryMeasurementsParams = { + pairKey: ScreenPairKey; + tag: TagID; + destination: boolean; + refresh: boolean; +}; + +const getRequestedLinkKey = ( + state: LinkPairsState, + pairKey: ScreenPairKey, + tag: TagID, +): LinkKey => { + "worklet"; + const requestedLinkKey = getLinkKeyFromTag(tag); + const group = getGroupKeyFromTag(tag); + return group + ? (state[pairKey]?.groups[group]?.activeId ?? requestedLinkKey) + : requestedLinkKey; +}; + +const getConcreteTag = ( + state: LinkPairsState, + pairKey: ScreenPairKey, + tag: TagID, +): TagID => { + "worklet"; + const group = getGroupKeyFromTag(tag); + const linkKey = getRequestedLinkKey(state, pairKey, tag); + return group ? `${group}:${linkKey}` : linkKey; +}; + +const hasRegisteredDestination = ( + pairKey: ScreenPairKey, + tag: TagID, +): boolean => { + "worklet"; + const destinationRoot = getDestinationScreenKeyFromPairKey(pairKey); + if (!destinationRoot) return false; + + const screens = boundaryRegistry.get()[tag]?.screens; + for (const screenKey in screens) { + if (screenBelongsToScope(screenKey, destinationRoot)) return true; + } + + return false; +}; + +const hasRegisteredSource = (pairKey: ScreenPairKey, tag: TagID): boolean => { + "worklet"; + const sourceRoot = getSourceScreenKeyFromPairKey(pairKey); + const screens = boundaryRegistry.get()[tag]?.screens; + for (const screenKey in screens) { + if (screenBelongsToScope(screenKey, sourceRoot)) return true; + } + + return false; +}; + +const claimDestinationBlock = (pairKey: ScreenPairKey, tag: TagID) => { + "worklet"; + const state = pairs.get(); + const linkKey = getRequestedLinkKey(state, pairKey, tag); + const pair = state[pairKey]; + if ( + !pair?.destinationRequests?.[linkKey] || + pair.blockedDestinations?.[linkKey] + ) { + return; + } + + const concreteTag = getConcreteTag(state, pairKey, tag); + if ( + !hasRegisteredSource(pairKey, concreteTag) || + !hasRegisteredDestination(pairKey, concreteTag) + ) { + return; + } + + const destinationRoot = getDestinationScreenKeyFromPairKey(pairKey); + const destinationScreen = boundsScreens.get()[destinationRoot]; + if (!destinationScreen || destinationScreen.animationProgress.get() > 0) { + return; + } + + destinationScreen.pendingLifecycleStartBlockCount.modify( + (count: T): T => { + "worklet"; + return (count + 1) as T; + }, + ); + + pairs.modify((current: T): T => { + "worklet"; + const currentPair = current[pairKey]; + if (!currentPair) return current; + if (!currentPair.blockedDestinations) { + currentPair.blockedDestinations = {}; + } + currentPair.blockedDestinations[linkKey] = true; + return current; + }); +}; + +const releaseDestinationBlock = (pairKey: ScreenPairKey, linkKey: LinkKey) => { + "worklet"; + const pair = pairs.get()[pairKey]; + if (!pair?.blockedDestinations?.[linkKey]) return; + + const destinationRoot = getDestinationScreenKeyFromPairKey(pairKey); + const destinationScreen = boundsScreens.get()[destinationRoot]; + destinationScreen?.pendingLifecycleStartBlockCount.modify( + (count: T): T => { + "worklet"; + return Math.max(0, count - 1) as T; + }, + ); + + pairs.modify((state: T): T => { + "worklet"; + delete state[pairKey]?.blockedDestinations?.[linkKey]; + return state; + }); +}; + +const releaseDestinationBlockWhenReady = ( + pairKey: ScreenPairKey, + linkKey: LinkKey, +) => { + "worklet"; + const pair = pairs.get()[pairKey]; + const link = pair?.links[linkKey]; + if (!link?.source || !link.destination) return; + if (link.source.escapeClipping && !pair.portalReadySources?.[linkKey]) return; + releaseDestinationBlock(pairKey, linkKey); +}; + +export function registerScreen(params: RegisterScreenParams) { + "worklet"; + const node: BoundsScreenNode = { + animationProgress: params.animationProgress, + pendingLifecycleStartBlockCount: params.pendingLifecycleStartBlockCount, + }; + registerWorkletScreen({ + screenKey: params.screenKey, + parentScreenKey: params.parentScreenKey, + }); + boundsScreens.modify((state: T): T => { + "worklet"; + (state as typeof boundsScreens.value)[params.screenKey] = node; + return state; + }); + + const state = pairs.get(); + for (const pairKey in state) { + const requests = state[pairKey]?.destinationRequests; + for (const linkKey in requests) { + claimDestinationBlock(pairKey, linkKey); + } + } +} + +export function unregisterScreen(screenKey: ScreenKey) { + "worklet"; + unregisterWorkletScreen(screenKey); + boundsScreens.modify((state: T): T => { + "worklet"; + delete state[screenKey]; + return state; + }); +} + +export function registerBoundary({ + boundTag, + screenKey, + entry, +}: RegisterBoundaryParams) { + "worklet"; + setEntry(boundTag.tag, screenKey, entry); + + const state = pairs.get(); + for (const pairKey in state) { + const linkKey = getRequestedLinkKey(state, pairKey, boundTag.tag); + if (state[pairKey]?.destinationRequests?.[linkKey]) { + claimDestinationBlock(pairKey, boundTag.tag); + } + } +} + +export function unregisterBoundary(boundTag: BoundTag, screenKey: ScreenKey) { + "worklet"; + removeEntry(boundTag.tag, screenKey); + if (!boundsScreens.get()[screenKey]) return; + + const state = pairs.get(); + for (const pairKey in state) { + const concreteTag = getConcreteTag(state, pairKey, boundTag.tag); + const type = + screenBelongsToScope(screenKey, getSourceScreenKeyFromPairKey(pairKey)) && + !hasRegisteredSource(pairKey, concreteTag) + ? "source" + : screenBelongsToScope( + screenKey, + getDestinationScreenKeyFromPairKey(pairKey), + ) && !hasRegisteredDestination(pairKey, concreteTag) + ? "destination" + : null; + if (!type) continue; + + abandonBoundaryMeasurement({ + type, + pairKey, + tag: getRequestedLinkKey(state, pairKey, boundTag.tag), + }); + } +} + +export function requestBoundaryMeasurements({ + pairKey, + tag, + destination, + refresh, +}: RequestBoundaryMeasurementsParams) { + "worklet"; + const requestedLinkKey = getLinkKeyFromTag(tag); + const group = getGroupKeyFromTag(tag); + const previousActiveId = group + ? pairs.get()[pairKey]?.groups[group]?.activeId + : undefined; + + if (previousActiveId && previousActiveId !== requestedLinkKey) { + releaseDestinationBlock(pairKey, previousActiveId); + } + + pairs.modify((state: T): T => { + "worklet"; + const groups = ensurePairGroups(state, pairKey); + if (group && groups[group]?.activeId !== requestedLinkKey) { + groups[group] = { + activeId: requestedLinkKey, + initialId: groups[group]?.initialId ?? requestedLinkKey, + }; + if (previousActiveId) { + delete state[pairKey]?.sourceRequests?.[previousActiveId]; + delete state[pairKey]?.destinationRequests?.[previousActiveId]; + } + } + + const linkKey = getRequestedLinkKey(state, pairKey, tag); + const pair = state[pairKey]; + const link = ensurePairLinks(state, pairKey)[linkKey]; + const refreshing = !!pair?.refreshingLinks?.[linkKey]; + const startsRefresh = refresh && !refreshing; + + if (!pair.refreshingLinks) pair.refreshingLinks = {}; + if (refresh) { + pair.refreshingLinks[linkKey] = true; + } else { + delete pair.refreshingLinks[linkKey]; + } + + if (!link?.source || startsRefresh) { + ensurePairSourceRequests(state, pairKey)[linkKey] = true; + delete pair.portalReadySources?.[linkKey]; + } + + if (destination) { + if (group) { + if (!pair.destinationGroupDemands) pair.destinationGroupDemands = {}; + pair.destinationGroupDemands[group] = true; + } else { + if (!pair.destinationLinkDemands) pair.destinationLinkDemands = {}; + pair.destinationLinkDemands[linkKey] = true; + } + + if (!link?.destination || startsRefresh) { + ensurePairDestinationRequests(state, pairKey)[linkKey] = true; + } + } + + return state; + }); + + if (destination) claimDestinationBlock(pairKey, tag); +} + +export function getBoundaryMeasurementRequest( + tag: TagID, + screenKey: ScreenKey, + state: LinkPairsState = pairs.get(), +): MeasurementRequest | null { + "worklet"; + const linkKey = getLinkKeyFromTag(tag); + const group = getGroupKeyFromTag(tag); + let match: MeasurementRequest | null = null; + + for (const pairKey in state) { + const pair = state[pairKey]; + if (group && pair?.groups[group]?.activeId !== linkKey) continue; + + const sourceRoot = getSourceScreenKeyFromPairKey(pairKey); + if ( + pair?.sourceRequests?.[linkKey] && + screenBelongsToScope(screenKey, sourceRoot) + ) { + match = { type: "source", pairKey }; + } + + const destinationRoot = getDestinationScreenKeyFromPairKey(pairKey); + if ( + pair?.destinationRequests?.[linkKey] && + destinationRoot && + screenBelongsToScope(screenKey, destinationRoot) + ) { + match = { type: "destination", pairKey }; + } + } + + return match; +} + +export function completeBoundaryMeasurement( + target: MeasurementRequest, + tag: TagID, +) { + "worklet"; + const linkKey = getLinkKeyFromTag(tag); + releaseDestinationBlockWhenReady(target.pairKey, linkKey); +} + +export function markBoundaryPortalReady(pairKey: ScreenPairKey, tag: TagID) { + "worklet"; + const linkKey = getLinkKeyFromTag(tag); + pairs.modify((state: T): T => { + "worklet"; + const pair = state[pairKey]; + if (!pair) return state; + if (!pair.portalReadySources) pair.portalReadySources = {}; + pair.portalReadySources[linkKey] = true; + return state; + }); + releaseDestinationBlockWhenReady(pairKey, linkKey); +} + +export function abandonBoundaryMeasurement( + params: MeasurementRequest & { tag: TagID }, +) { + "worklet"; + const linkKey = getLinkKeyFromTag(params.tag); + const pair = pairs.get()[params.pairKey]; + const hasPendingRequest = + params.type === "source" + ? !!pair?.sourceRequests?.[linkKey] + : !!pair?.destinationRequests?.[linkKey]; + const hasDestinationBlock = !!pair?.blockedDestinations?.[linkKey]; + if (!hasPendingRequest && !hasDestinationBlock) return; + + pairs.modify((state: T): T => { + "worklet"; + if (params.type === "source") { + delete state[params.pairKey]?.sourceRequests?.[linkKey]; + } else { + delete state[params.pairKey]?.destinationRequests?.[linkKey]; + } + return state; + }); + + releaseDestinationBlock(params.pairKey, linkKey); +} diff --git a/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/entries.ts b/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/entries.ts index cab88a5f..54017678 100644 --- a/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/entries.ts +++ b/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/entries.ts @@ -62,43 +62,6 @@ function getEntry(tag: TagID, key: ScreenKey): Entry | null { return boundaryRegistry.get()[tag]?.screens[key] ?? null; } -function getMatchingSourceScreenKey( - tag: TagID, - destinationScreenKey: ScreenKey, - preferredScreenKey?: ScreenKey, - excludedScreenKeys?: readonly ScreenKey[], -): ScreenKey | null { - "worklet"; - const screens = boundaryRegistry.get()[tag]?.screens; - if (!screens) return null; - const isExcluded = (screenKey: ScreenKey) => { - "worklet"; - for (let index = 0; index < (excludedScreenKeys?.length ?? 0); index++) { - if (excludedScreenKeys?.[index] === screenKey) { - return true; - } - } - return false; - }; - if ( - preferredScreenKey && - preferredScreenKey !== destinationScreenKey && - !isExcluded(preferredScreenKey) && - screens[preferredScreenKey] - ) { - return preferredScreenKey; - } - - let latestScreenKey: ScreenKey | null = null; - for (const screenKey in screens) { - if (screenKey !== destinationScreenKey && !isExcluded(screenKey)) { - latestScreenKey = screenKey; - } - } - - return latestScreenKey; -} - function setEntry(tag: TagID, screenKey: ScreenKey, patch: EntryPatch) { "worklet"; boundaryRegistry.modify((state: T): T => { @@ -127,4 +90,4 @@ function removeEntry(tag: TagID, screenKey: ScreenKey) { }); } -export { getEntry, getMatchingSourceScreenKey, removeEntry, setEntry }; +export { getEntry, removeEntry, setEntry }; diff --git a/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/links.ts b/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/links.ts index d8a7b87c..46a8a253 100644 --- a/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/links.ts +++ b/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/links.ts @@ -1,4 +1,5 @@ import type { MeasuredDimensions, StyleProps } from "react-native-reanimated"; +import { screenBelongsToScope } from "../../../factories/screen-topology"; import { createGroupTag, ensurePairGroups, @@ -11,7 +12,6 @@ import { getDestination as getPairDestination, getLink as getPairLink, getSource as getPairSource, - getSourceScreenKeyFromPairKey, } from "../helpers/link-pairs.helpers"; import type { BoundaryRuntimeFlags, @@ -315,33 +315,21 @@ function setDestination( group, runtimeFlags, ); + delete state[pairKey]?.destinationRequests?.[linkKey]; return state; }); } function setActiveGroupId(pairKey: ScreenPairKey, group: GroupKey, tag: TagID) { - "worklet"; - pairs.modify((state: T): T => { - "worklet"; - writeGroup(state, pairKey, group, getLinkKeyFromTag(tag)); - return state; - }); -} - -function requestSourceMeasure(pairKey: ScreenPairKey, tag: TagID) { "worklet"; pairs.modify((state: T): T => { "worklet"; const linkKey = getLinkKeyFromTag(tag); - const link = getPairLink(state, pairKey, linkKey); - - if (link?.source || state[pairKey]?.sourceRequests?.[linkKey]) { - return state; + writeGroup(state, pairKey, group, linkKey); + if (!getPairLink(state, pairKey, linkKey)?.source) { + ensurePairSourceRequests(state, pairKey)[linkKey] = true; } - - ensurePairSourceRequests(state, pairKey)[linkKey] = true; - return state; }); } @@ -374,14 +362,18 @@ function getResolvedLink( const state = pairs.get(); const linkKey = getLinkKeyFromTag(tag); const group = getGroupKeyFromTag(tag); - const link = getPairLink(state, pairKey, linkKey); + const activeId = group + ? state[pairKey]?.groups?.[group]?.activeId + : undefined; + const resolvedLinkKey = activeId ?? linkKey; + const link = getPairLink(state, pairKey, resolvedLinkKey); // Group active ids can update before the new member has a full source/destination // link. As soon as the requested member has source bounds, prefer it; only // fall back while the requested member has no source yet. if (!group || hasSourceLink(link)) { return { - tag, + tag: group ? createGroupTag(group, resolvedLinkKey) : tag, link, }; } @@ -404,34 +396,34 @@ function getResolvedLink( }; } -function getPairKeyForSource( +function getPairKeyForDestination( tag: TagID, screenKey: ScreenKey, ): ScreenPairKey | null { "worklet"; const state = pairs.get(); - const linkKey = getLinkKeyFromTag(tag); + const group = getGroupKeyFromTag(tag); for (const pairKey in state) { - if (getSourceScreenKeyFromPairKey(pairKey) !== screenKey) continue; + const resolvedLink = getResolvedLink(pairKey, tag).link; + if (!resolvedLink) continue; if ( - getResolvedLink(pairKey, tag).link?.destination || - state[pairKey]?.sourceRequests?.[linkKey] + screenBelongsToScope( + screenKey, + getDestinationScreenKeyFromPairKey(pairKey), + ) || + resolvedLink.destination?.screenKey === screenKey ) { return pairKey; } - } - return null; -} -function getPairKeyForDestination( - tag: TagID, - screenKey: ScreenKey, -): ScreenPairKey | null { - "worklet"; - const state = pairs.get(); - for (const pairKey in state) { - if (getDestinationScreenKeyFromPairKey(pairKey) !== screenKey) continue; - if (getResolvedLink(pairKey, tag).link) return pairKey; + if (!group) continue; + const links = state[pairKey]?.links; + for (const linkKey in links) { + const link = links[linkKey]; + if (link?.group === group && link.destination?.screenKey === screenKey) { + return pairKey; + } + } } return null; } @@ -457,10 +449,8 @@ export { getDestination, getLink, getPairKeyForDestination, - getPairKeyForSource, getResolvedLink, getSource, - requestSourceMeasure, setActiveGroupId, setDestination, setSource, diff --git a/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/state.ts b/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/state.ts index ab9e6d17..c8a8191c 100644 --- a/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/state.ts +++ b/packages/react-native-screen-transitions/src/shared/stores/bounds/internals/state.ts @@ -1,5 +1,11 @@ import { makeMutable } from "react-native-reanimated"; -import type { Entry, LinkPairsState, ScreenKey, TagID } from "../types"; +import type { + BoundsScreenState, + Entry, + LinkPairsState, + ScreenKey, + TagID, +} from "../types"; export type BoundaryEntriesState = Record< TagID, @@ -31,3 +37,6 @@ export const boundaryRegistry = makeMutable({}); * } */ export const pairs = makeMutable({}); + +/** Bounds-only runtime values keyed by screen. Relationships live in topology. */ +export const boundsScreens = makeMutable({}); diff --git a/packages/react-native-screen-transitions/src/shared/stores/bounds/types.ts b/packages/react-native-screen-transitions/src/shared/stores/bounds/types.ts index 1a368962..06cec927 100644 --- a/packages/react-native-screen-transitions/src/shared/stores/bounds/types.ts +++ b/packages/react-native-screen-transitions/src/shared/stores/bounds/types.ts @@ -1,4 +1,8 @@ -import type { MeasuredDimensions, StyleProps } from "react-native-reanimated"; +import type { + MeasuredDimensions, + SharedValue, + StyleProps, +} from "react-native-reanimated"; import type { BoundsMethod } from "../../types/bounds.types"; import type { ScreenKey } from "../../types/screen.types"; import type { @@ -128,6 +132,19 @@ export type LinkPairState = { links: Record; groups: Record; sourceRequests?: Record; + destinationRequests?: Record; + destinationLinkDemands?: Record; + destinationGroupDemands?: Record; + refreshingLinks?: Record; + blockedDestinations?: Record; + portalReadySources?: Record; }; export type LinkPairsState = Record; + +export type BoundsScreenNode = { + animationProgress: SharedValue; + pendingLifecycleStartBlockCount: SharedValue; +}; + +export type BoundsScreenState = Record; 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 7f250e2a..683691b8 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 @@ -36,6 +36,7 @@ export interface BlankStackStoreValue< routes: TDescriptor["route"][]; scenes: BaseStackScene[]; scenesByKey: Record>; + paintDriverRouteKeyByRouteKey: ReadonlyMap; focusedIndex: number; requestDismiss: (payload: { route: BaseStackRoute }) => boolean; shouldShowFloatOverlay: boolean; diff --git a/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/create-bounds-accessor-core.ts b/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/create-bounds-accessor-core.ts index 3790fcfa..f4c05855 100644 --- a/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/create-bounds-accessor-core.ts +++ b/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/create-bounds-accessor-core.ts @@ -66,12 +66,15 @@ const createBoundsAccessorParts = ({ styles: (options?: BoundsComputeOptions): BoundsStyleResult => { "worklet"; // Keep the component at its base layout for pre-animation refresh - // measurement, then remove generated styles again after settlement. - if (!props.active.animating) { + // measurement, then remove generated styles again after settlement. The + // pre-animation pass still has to reach the bounds coordinator so both + // endpoints are captured before generated transforms attach. + const shouldRenderStyles = !!props.active.animating; + if (!shouldRenderStyles && !props.active.willAnimate) { return NO_STYLES; } - return prepareBoundStyles({ + const preparedStyles = prepareBoundStyles({ props, options: { ...options, @@ -79,6 +82,8 @@ const createBoundsAccessorParts = ({ group: normalizedIdentity.group, }, }) as BoundsStyleResult; + + return shouldRenderStyles ? preparedStyles : NO_STYLES; }, values: getValues, math: ( diff --git a/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/create-link-accessor.ts b/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/create-link-accessor.ts index 593f2b4f..ddd71d32 100644 --- a/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/create-link-accessor.ts +++ b/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/create-link-accessor.ts @@ -1,3 +1,4 @@ +import { requestBoundaryMeasurements } from "../../../stores/bounds/internals/coordinator"; import { getPairKeyForDestination, getResolvedLink, @@ -7,6 +8,7 @@ import type { BoundsLink, } from "../../../types/bounds.types"; import type { BoundId } from "../types/options"; +import { resolveBoundsPairKey } from "./resolve-bounds-pair-key"; type GetProps = () => BoundsInterpolationProps; @@ -21,6 +23,15 @@ export const createLinkAccessor = (getProps: GetProps): LinkAccessor => { "worklet"; const props = getProps(); const stringTag = String(tag); + const requestedPairKey = resolveBoundsPairKey(props); + if (requestedPairKey) { + requestBoundaryMeasurements({ + pairKey: requestedPairKey, + tag: stringTag, + destination: true, + refresh: !!props.active.willAnimate, + }); + } const destinationScreenKey = props.next?.route.key ?? props.current?.route.key; const pairKey = destinationScreenKey diff --git a/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/prepare-bound-styles.ts b/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/prepare-bound-styles.ts index b6b950bc..5f06d777 100644 --- a/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/prepare-bound-styles.ts +++ b/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/prepare-bound-styles.ts @@ -66,11 +66,12 @@ export const syncActiveGroupId = (params: { const { props, id, group } = params; if (id == null || id === "" || !group) return; - const pairKey = resolveBoundsPairKey(props); + const tag = createBoundTag({ id, group }); + const pairKey = tag ? resolveBoundsPairKey(props) : null; if (!pairKey) return; const activeId = String(id); - if (getActiveGroupId(pairKey, group) === activeId) return; + if (getActiveGroupId(pairKey, group) !== null) return; setActiveGroupId(pairKey, group, activeId); }; diff --git a/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/resolve-bounds-pair-key.ts b/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/resolve-bounds-pair-key.ts index 501cd237..f9e7bf56 100644 --- a/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/resolve-bounds-pair-key.ts +++ b/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/resolve-bounds-pair-key.ts @@ -9,7 +9,6 @@ export const resolveBoundsPairKey = ( const currentScreenKey = props.current?.route.key; const previousScreenKey = props.previous?.route.key; const nextScreenKey = props.next?.route.key; - if (nextScreenKey && currentScreenKey) { return createScreenPairKey(currentScreenKey, nextScreenKey); } diff --git a/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/styles/compute.ts b/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/styles/compute.ts index be04447a..33ccde5d 100644 --- a/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/styles/compute.ts +++ b/packages/react-native-screen-transitions/src/shared/utils/bounds/helpers/styles/compute.ts @@ -6,7 +6,7 @@ import { NO_STYLES, } from "../../../../constants"; import { createScreenPairKey } from "../../../../stores/bounds/helpers/link-pairs.helpers"; -import { requestSourceMeasure } from "../../../../stores/bounds/internals/links"; +import { requestBoundaryMeasurements } from "../../../../stores/bounds/internals/coordinator"; import { resolveTransitionPair } from "../../../../stores/bounds/internals/resolver"; import type { ResolvedTransitionPair } from "../../../../stores/bounds/types"; import type { ScreenTransitionState } from "../../../../types/animation.types"; @@ -39,6 +39,7 @@ const resolveStartEnd = (params: { dimensions: Layout; computeOptions: BoundsOptions; resolvedPair?: ResolvedTransitionPair; + refresh: boolean; }) => { "worklet"; @@ -59,6 +60,15 @@ const resolveStartEnd = (params: { ? createScreenPairKey(currentScreenKey, nextScreenKey) : null; + if (sourceMeasurePairKey) { + requestBoundaryMeasurements({ + pairKey: sourceMeasurePairKey, + tag: String(params.id), + destination: !hasTargetOverride, + refresh: params.refresh, + }); + } + const resolvedPair = params.resolvedPair ?? resolveTransitionPair(String(params.id), { @@ -72,10 +82,6 @@ const resolveStartEnd = (params: { const destinationBounds = resolvedPair.destinationBounds; if (!sourceBounds) { - if (hasTargetOverride && sourceMeasurePairKey) { - requestSourceMeasure(sourceMeasurePairKey, String(params.id)); - } - return { start: null, end: null, @@ -157,6 +163,7 @@ export const computeBoundStyles = ( computeOptions, dimensions, resolvedPair, + refresh: !!interpolationProps?.active?.willAnimate, }); if (!start || !end) { 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 df55a147..1f53f99e 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 @@ -3,9 +3,11 @@ * 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. + * Store-only provider: values propagate exclusively through subscription + * selectors. `use${Name}Store` requires an available store, while + * `useOptional${Name}Store` preserves nullable bootstrap and outer-context + * access. 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 @@ -16,7 +18,7 @@ * module-level memoized component for the wrapper. * * Providers created with `global: true` return a `key` from their factory. - * Their generated store hook accepts that key to subscribe to the original + * Both generated store hooks accept that key to subscribe to the original * provider store from outside its React context. */ import { @@ -29,48 +31,52 @@ import { useSyncExternalStore, } from "react"; -type ProviderSnapshot< - ContextValue, - Guarded extends boolean, -> = Guarded extends true ? ContextValue : ContextValue | null; - -type ProviderSelector = ( - value: ProviderSnapshot, -) => Selected; +type ProviderStoreHook = { + (): ContextValue; + (selector: (value: ContextValue) => Selected): Selected; +}; -type ProviderStoreHook = { - (): ProviderSnapshot; +type GlobalProviderStoreHook = { + (key: string): ContextValue; ( - selector: ProviderSelector, + key: string, + selector: (value: ContextValue) => Selected, ): Selected; }; -type GlobalProviderStoreHook = { - (key: string): ContextValue | null; +type OptionalProviderStoreHook = { + (): ContextValue | null; + (selector: (value: ContextValue | null) => Selected): Selected; +}; + +type OptionalGlobalProviderStoreHook = { + (key: string | null): ContextValue | null; ( - key: string, + key: string | null, selector: (value: ContextValue) => Selected, ): Selected | null; }; type ResolvedProviderStoreHook< ContextValue, - Guarded extends boolean, Global extends boolean, -> = ProviderStoreHook & +> = ProviderStoreHook & (Global extends true ? GlobalProviderStoreHook : unknown); +type ResolvedOptionalProviderStoreHook< + ContextValue, + Global extends boolean, +> = OptionalProviderStoreHook & + (Global extends true + ? OptionalGlobalProviderStoreHook + : unknown); + type ProviderFactoryResult = { - value?: ContextValue; - enabled?: boolean; + value: ContextValue; children?: ReactNode; } & (Global extends true ? { key: string } : { key?: never }); -export type ProviderFactoryInternals = { - useParentStore: ProviderStoreHook; -}; - -export interface ProviderStoreApi { +interface ProviderStoreApi { getSnapshot: () => ContextValue | null; subscribe: (listener: () => void) => () => void; } @@ -161,17 +167,8 @@ const createProviderStore = ( const createProviderStoreRegistry = < ContextValue, >(): ProviderStoreRegistry => { - type Registration = { - store: ProviderStoreApi; - }; - const listenersByKey = new Map void>>(); - const registrationsByKey = new Map(); - - const getStore = (key: string) => { - const registrations = registrationsByKey.get(key); - return registrations?.[registrations.length - 1]?.store ?? null; - }; + const storesByKey = new Map>(); const notify = (key: string) => { for (const listener of listenersByKey.get(key) ?? []) { @@ -180,33 +177,14 @@ const createProviderStoreRegistry = < }; return { - getStore, + getStore: (key) => storesByKey.get(key) ?? null, register: (key, store) => { - const registration: Registration = { store }; - const registrations = registrationsByKey.get(key) ?? []; - registrationsByKey.set(key, [...registrations, registration]); + storesByKey.set(key, store); notify(key); return () => { - const currentRegistrations = registrationsByKey.get(key); - if (!currentRegistrations?.includes(registration)) { - return; - } - - const previousStore = getStore(key); - const nextRegistrations = currentRegistrations.filter( - (currentRegistration) => currentRegistration !== registration, - ); - - if (nextRegistrations.length === 0) { - registrationsByKey.delete(key); - } else { - registrationsByKey.set(key, nextRegistrations); - } - - if (getStore(key) !== previousStore) { - notify(key); - } + storesByKey.delete(key); + notify(key); }; }, subscribe: (key, listener) => { @@ -226,183 +204,135 @@ const createProviderStoreRegistry = < export default function createProvider< ProviderName extends string, - Guarded extends boolean = true, Global extends boolean = false, ->(name: ProviderName, options?: { guarded?: Guarded; global?: Global }) { +>(name: ProviderName, options?: { global?: Global }) { return ( factory: ( props: ProviderProps, - internals: ProviderFactoryInternals, ) => ProviderFactoryResult, ) => { - const { guarded = true, global = false } = options ?? {}; + const { global = false } = options ?? {}; const providerDisplayName = `${name}Provider`; const globalRegistry = global ? createProviderStoreRegistry() : null; - const keyByStore = global - ? new WeakMap, string>() - : null; const StoreContext = createContext | null>( null, ); StoreContext.displayName = `${name}Store`; - const useRegisteredStore = ( - key: string | null, - fallbackStore: ProviderStoreApi | null = null, - ) => { + const useStoreSelection = ( + required: boolean, + selectorOrKey?: + | string + | null + | ((value: ContextValue | null) => Selected), + globalSelector?: (value: ContextValue) => Selected, + ): Selected | ContextValue | null => { + const isGlobalLookup = + global && (typeof selectorOrKey === "string" || selectorOrKey === null); + const key = isGlobalLookup ? selectorOrKey : null; + const selector = (isGlobalLookup ? globalSelector : selectorOrKey) as + | ((value: ContextValue | null) => Selected) + | undefined; + const contextStore = useContext(StoreContext); + const selectorRef = useRef(selector); + selectorRef.current = selector; + const getStore = useCallback(() => { + if (isGlobalLookup) { + return key !== null && globalRegistry + ? globalRegistry.getStore(key) + : null; + } + + return contextStore; + }, [contextStore, isGlobalLookup, key]); const subscribe = useCallback( (listener: () => void) => { - if (key === null || !globalRegistry) { - return () => {}; - } - - return globalRegistry.subscribe(key, listener); + let unsubscribeStore = ( + getStore() ?? (NullProviderStore as ProviderStoreApi) + ).subscribe(listener); + const unsubscribeRegistry = + isGlobalLookup && key !== null && globalRegistry + ? globalRegistry.subscribe(key, () => { + unsubscribeStore(); + unsubscribeStore = ( + getStore() ?? + (NullProviderStore as ProviderStoreApi) + ).subscribe(listener); + listener(); + }) + : () => {}; + + return () => { + unsubscribeRegistry(); + unsubscribeStore(); + }; }, - [key], - ); - const getSnapshot = useCallback( - () => - key !== null && globalRegistry - ? (globalRegistry.getStore(key) ?? fallbackStore) - : null, - [key, fallbackStore], + [getStore, isGlobalLookup, key], ); - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); - }; + const getSelectedSnapshot = useCallback(() => { + const snapshot = getStore()?.getSnapshot() ?? null; - const createStoreHook = (strict: boolean, allowGlobalLookup: boolean) => { - if (!allowGlobalLookup || !globalRegistry) { - 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, + if (required && snapshot === null) { + throw new Error( + key === null + ? `${name}Store is unavailable` + : `${name}Store is unavailable for key "${key}"`, ); - }; - } + } - return ( - selectorOrKey?: string | ((value: ContextValue | null) => Selected), - globalSelector?: (value: ContextValue) => Selected, - ): Selected | ContextValue | null => { - const isGlobalLookup = typeof selectorOrKey === "string"; - const key = isGlobalLookup ? selectorOrKey : null; - const selector = (isGlobalLookup ? globalSelector : selectorOrKey) as - | ((value: ContextValue | null) => Selected) - | undefined; - const contextStore = useContext(StoreContext); - const matchingContextStore = - key !== null && - contextStore !== null && - keyByStore?.get(contextStore) === key - ? contextStore - : null; - const registeredStore = useRegisteredStore(key, matchingContextStore); - const store = isGlobalLookup ? registeredStore : contextStore; - const resolvedStore = - store ?? (NullProviderStore as ProviderStoreApi); - const selectorRef = useRef(selector); - selectorRef.current = selector; - - const getSelectedSnapshot = useCallback(() => { - if (!isGlobalLookup && strict && store === null) { - throw new Error( - `${name} store must be used within a ${name}Provider`, - ); - } - - const snapshot = resolvedStore.getSnapshot(); - - if (isGlobalLookup && snapshot === null) { - return null; - } - - if (!isGlobalLookup && strict && snapshot === null) { - throw new Error( - `${name} store must be used within an enabled ${name}Provider`, - ); - } - - return typeof selectorRef.current === "function" - ? selectorRef.current(snapshot) - : snapshot; - }, [isGlobalLookup, resolvedStore, store]); - - return useSyncExternalStore( - resolvedStore.subscribe, - getSelectedSnapshot, - getSelectedSnapshot, - ); - }; - }; + if (isGlobalLookup && snapshot === null) { + return null; + } - const useStoreSelector = createStoreHook(guarded, global); - const factoryInternals: ProviderFactoryInternals = { - useParentStore: createStoreHook(false, false) as ProviderStoreHook< - ContextValue, - false - >, - }; + return typeof selectorRef.current === "function" + ? selectorRef.current(snapshot) + : snapshot; + }, [getStore, isGlobalLookup, key, required]); + return useSyncExternalStore( + subscribe, + getSelectedSnapshot, + getSelectedSnapshot, + ); + }; + const useOptionalStoreSelector = ( + selectorOrKey?: + | string + | null + | ((value: ContextValue | null) => Selected), + globalSelector?: (value: ContextValue) => Selected, + ) => useStoreSelection(false, selectorOrKey, globalSelector); + const useStoreSelector = ( + selectorOrKey?: string | ((value: ContextValue) => Selected), + globalSelector?: (value: ContextValue) => Selected, + ) => + useStoreSelection( + true, + selectorOrKey as + | string + | ((value: ContextValue | null) => Selected) + | undefined, + globalSelector, + ); const Provider: React.FC = (props) => { const { children = (props as { children?: ReactNode }).children, - enabled = true, key, value, - } = factory(props, factoryInternals); - - if (!value) { - throw new Error( - `${name}Context value must be provided. You likely forgot to return it from the factory function.`, - ); - } - - const snapshotValue = enabled ? value : null; + } = factory(props); const storeRef = useRef | null>( null, ); const pendingNotifyRef = useRef(false); if (storeRef.current === null) { - storeRef.current = createProviderStore(snapshotValue); + storeRef.current = createProviderStore(value); } const store = storeRef.current; - if (keyByStore && typeof key === "string") { - keyByStore.set(store, key); - } useLayoutEffect(() => { if (!globalRegistry) { @@ -419,7 +349,7 @@ export default function createProvider< }, [key, store]); pendingNotifyRef.current = - store.setSnapshot(snapshotValue) || pendingNotifyRef.current; + store.setSnapshot(value) || pendingNotifyRef.current; useLayoutEffect(() => { if (!pendingNotifyRef.current) { @@ -438,13 +368,18 @@ export default function createProvider< return { [`${name}Provider`]: Provider, + [`useOptional${name}Store`]: useOptionalStoreSelector, [`use${name}Store`]: useStoreSelector, } as { [P in ProviderName as `${P}Provider`]: React.FC; + } & { + [P in ProviderName as `useOptional${P}Store`]: ResolvedOptionalProviderStoreHook< + ContextValue, + Global + >; } & { [P in ProviderName as `use${P}Store`]: ResolvedProviderStoreHook< ContextValue, - Guarded, Global >; };