+
{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
>;
};