diff --git a/example/package.json b/example/package.json
index 29956e3..f26a351 100644
--- a/example/package.json
+++ b/example/package.json
@@ -12,6 +12,7 @@
"dependencies": {
"@expo/metro-runtime": "^6.1.2",
"@gorhom/bottom-sheet": "^5.2.8",
+ "@react-native-clipboard/clipboard": "^1.16.3",
"expo": "^54.0.31",
"expo-dev-client": "~6.0.13",
"expo-linking": "~8.0.11",
diff --git a/example/src/App.tsx b/example/src/App.tsx
index f0b5996..3da00ff 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -7,6 +7,7 @@ import {
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
+import { BottomSheetDebugMonitor } from './components/BottomSheetDebugMonitor';
import { UserProvider } from './context/UserContext';
import { HomeScreen } from './screens';
import { PersistentWithPortalSheet, ScannerSheet } from './sheets';
@@ -34,6 +35,8 @@ export default function App() {
+ {/* Debug Monitor */}
+
diff --git a/example/src/components/BottomSheetDebugMonitor.tsx b/example/src/components/BottomSheetDebugMonitor.tsx
new file mode 100644
index 0000000..ccb1e64
--- /dev/null
+++ b/example/src/components/BottomSheetDebugMonitor.tsx
@@ -0,0 +1,665 @@
+import { useState, useRef, useEffect } from 'react';
+import {
+ View,
+ Text,
+ Modal,
+ ScrollView,
+ Pressable,
+ StyleSheet,
+ PanResponder,
+ Animated,
+ Alert,
+} from 'react-native';
+import Clipboard from '@react-native-clipboard/clipboard';
+import {
+ useBottomSheetStore,
+ __getAllAnimatedIndexes,
+} from 'react-native-bottom-sheet-stack';
+
+interface LogEntry {
+ timestamp: number;
+ type:
+ | 'open'
+ | 'close'
+ | 'status'
+ | 'animatedIndex'
+ | 'error'
+ | 'cleanup'
+ | 'valueChange';
+ sheetId: string;
+ message: string;
+ value?: unknown;
+}
+
+const MAX_LOGS = 200;
+const logHistory: LogEntry[] = [];
+
+// Track previous animatedIndex values for change detection
+const previousValues = new Map();
+
+// Track cleanup calls
+let cleanupCount = 0;
+let ensureCount = 0;
+
+export function addDebugLog(
+ type: LogEntry['type'],
+ sheetId: string,
+ message: string,
+ value?: unknown
+) {
+ logHistory.unshift({
+ timestamp: Date.now(),
+ type,
+ sheetId,
+ message,
+ value,
+ });
+ if (logHistory.length > MAX_LOGS) {
+ logHistory.pop();
+ }
+}
+
+// Functions to be called from animatedRegistry to track cleanup/ensure
+export function trackCleanup(sheetId: string) {
+ cleanupCount++;
+ addDebugLog('cleanup', sheetId, `Cleanup called (total: ${cleanupCount})`);
+}
+
+export function trackEnsure(sheetId: string, value: number) {
+ ensureCount++;
+ addDebugLog(
+ 'animatedIndex',
+ sheetId,
+ `Ensure called (total: ${ensureCount})`,
+ { initialValue: value }
+ );
+}
+
+// Poll all animatedIndex values and log changes
+function pollAnimatedIndexValues() {
+ const registry = __getAllAnimatedIndexes();
+
+ registry.forEach((animatedIndex, key) => {
+ const currentValue = animatedIndex.value;
+ const previousValue = previousValues.get(key);
+
+ // Log if value changed significantly (more than 0.01)
+ if (
+ previousValue === undefined ||
+ Math.abs(currentValue - previousValue) > 0.01
+ ) {
+ // Especially log if value went to 0 or very close to 0
+ if (
+ currentValue >= -0.1 &&
+ currentValue <= 0.1 &&
+ (previousValue === undefined ||
+ previousValue < -0.5 ||
+ previousValue > 0.5)
+ ) {
+ addDebugLog(
+ 'valueChange',
+ key,
+ `⚠️ VALUE JUMPED TO ~0! ${previousValue?.toFixed(4) ?? 'N/A'} -> ${currentValue.toFixed(4)}`
+ );
+ } else if (previousValue !== undefined) {
+ addDebugLog(
+ 'valueChange',
+ key,
+ `Value: ${previousValue.toFixed(4)} -> ${currentValue.toFixed(4)}`
+ );
+ }
+ previousValues.set(key, currentValue);
+ }
+ });
+
+ // Check for keys that were removed
+ previousValues.forEach((_, key) => {
+ if (!registry.has(key)) {
+ addDebugLog('cleanup', key, `Key removed from registry`);
+ previousValues.delete(key);
+ }
+ });
+}
+
+export function BottomSheetDebugMonitor() {
+ const [modalVisible, setModalVisible] = useState(false);
+ const [refreshKey, setRefreshKey] = useState(0);
+ const { sheetsById, stackOrder } = useBottomSheetStore();
+
+ const pan = useRef(new Animated.ValueXY({ x: 20, y: 100 })).current;
+
+ const panResponder = useRef(
+ PanResponder.create({
+ onStartShouldSetPanResponder: () => true,
+ onMoveShouldSetPanResponder: () => true,
+ onPanResponderGrant: () => {
+ pan.setOffset({
+ x: (pan.x as any)._value,
+ y: (pan.y as any)._value,
+ });
+ },
+ onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], {
+ useNativeDriver: false,
+ }),
+ onPanResponderRelease: () => {
+ pan.flattenOffset();
+ },
+ })
+ ).current;
+
+ // Poll animatedIndex values every 50ms to catch rapid changes
+ useEffect(() => {
+ const interval = setInterval(pollAnimatedIndexValues, 50);
+ return () => clearInterval(interval);
+ }, []);
+
+ // Subscribe to store changes to log them
+ useEffect(() => {
+ const unsubscribe = useBottomSheetStore.subscribe(
+ (state) => ({
+ sheetsById: state.sheetsById,
+ stackOrder: state.stackOrder,
+ }),
+ (current, previous) => {
+ // Detect new sheets
+ Object.keys(current.sheetsById).forEach((id) => {
+ const currentSheet = current.sheetsById[id];
+ const previousSheet = previous.sheetsById[id];
+
+ if (!previousSheet && currentSheet) {
+ addDebugLog(
+ 'open',
+ id,
+ `Sheet created with status: ${currentSheet.status}`
+ );
+ } else if (
+ previousSheet &&
+ currentSheet &&
+ previousSheet.status !== currentSheet.status
+ ) {
+ addDebugLog(
+ 'status',
+ id,
+ `Status: ${previousSheet.status} -> ${currentSheet.status}`
+ );
+ }
+ });
+
+ // Detect removed sheets
+ Object.keys(previous.sheetsById).forEach((id) => {
+ if (!current.sheetsById[id]) {
+ addDebugLog('close', id, 'Sheet removed from store');
+ }
+ });
+
+ // Detect stack changes
+ if (
+ JSON.stringify(current.stackOrder) !==
+ JSON.stringify(previous.stackOrder)
+ ) {
+ addDebugLog(
+ 'status',
+ 'stack',
+ `Stack: [${current.stackOrder.join(', ')}]`
+ );
+ }
+ }
+ );
+
+ return unsubscribe;
+ }, []);
+
+ // Auto refresh when modal is open
+ useEffect(() => {
+ if (modalVisible) {
+ const interval = setInterval(() => setRefreshKey((k) => k + 1), 500);
+ return () => clearInterval(interval);
+ }
+ return undefined;
+ }, [modalVisible]);
+
+ const sheetCount = Object.keys(sheetsById).length;
+ const activeCount = stackOrder.length;
+
+ const generateStatsText = () => {
+ const registry = __getAllAnimatedIndexes();
+ const registryEntries = Array.from(registry.entries());
+
+ let text = '=== BOTTOM SHEET DEBUG STATS ===\n\n';
+ text += `Timestamp: ${new Date().toISOString()}\n\n`;
+
+ // Counters
+ text += '--- COUNTERS ---\n';
+ text += `ensureAnimatedIndex calls: ${ensureCount}\n`;
+ text += `cleanupAnimatedIndex calls: ${cleanupCount}\n`;
+ text += `Registry size: ${registryEntries.length}\n`;
+ text += `Store sheets: ${Object.keys(sheetsById).length}\n`;
+ text += `Stack size: ${stackOrder.length}\n\n`;
+
+ // Stack Order
+ text += '--- STACK ORDER ---\n';
+ text += `[${stackOrder.join(', ') || 'empty'}]\n\n`;
+
+ // Sheets in Store with key matching
+ text += '--- SHEETS IN STORE ---\n';
+ Object.entries(sheetsById).forEach(([id, sheet]) => {
+ // Calculate the registry key that SHOULD be used
+ const expectedKey = sheet.portalSession
+ ? `${id}-${sheet.portalSession}`
+ : id;
+ const animatedIndexByExpectedKey = registry.get(expectedKey);
+ const animatedIndexById = registry.get(id);
+
+ text += `\n[${id}]\n`;
+ text += ` status: ${sheet.status}\n`;
+ text += ` usePortal: ${sheet.usePortal}\n`;
+ text += ` keepMounted: ${sheet.keepMounted}\n`;
+ text += ` portalSession: ${sheet.portalSession}\n`;
+ text += ` inStack: ${stackOrder.includes(id)}\n`;
+ text += ` expectedRegistryKey: ${expectedKey}\n`;
+ text += ` animatedIndex (by expectedKey): ${animatedIndexByExpectedKey ? animatedIndexByExpectedKey.value.toFixed(6) : 'N/A'}\n`;
+ text += ` animatedIndex (by id only): ${animatedIndexById ? animatedIndexById.value.toFixed(6) : 'N/A'}\n`;
+ text += ` KEY MATCH: ${animatedIndexByExpectedKey ? 'YES' : 'NO - MISMATCH!'}\n`;
+ });
+
+ // AnimatedIndex Registry
+ text += '\n--- ANIMATED INDEX REGISTRY ---\n';
+ text += `Total entries: ${registryEntries.length}\n`;
+ if (registryEntries.length === 0) {
+ text += 'Empty\n';
+ } else {
+ registryEntries.forEach(([key, animatedIndex]) => {
+ // Parse the key to find base id and session
+ const parts = key.split('-');
+ const possibleSession = parts[parts.length - 1];
+ const isSessionKey = !isNaN(Number(possibleSession));
+ const baseId = isSessionKey ? parts.slice(0, -1).join('-') : key;
+
+ const sheetInStore = sheetsById[baseId];
+ const expectedByStore = sheetInStore?.portalSession
+ ? `${baseId}-${sheetInStore.portalSession}`
+ : baseId;
+
+ text += `\n[${key}]\n`;
+ text += ` value: ${animatedIndex.value.toFixed(6)}\n`;
+ text += ` baseId: ${baseId}\n`;
+ text += ` isSessionKey: ${isSessionKey}\n`;
+ text += ` sheetInStore: ${sheetInStore ? 'YES' : 'NO'}\n`;
+ if (sheetInStore) {
+ text += ` store.portalSession: ${sheetInStore.portalSession}\n`;
+ text += ` expectedKeyByStore: ${expectedByStore}\n`;
+ text += ` KEYS MATCH: ${key === expectedByStore ? 'YES' : 'NO - STALE KEY!'}\n`;
+ } else {
+ text += ` STATUS: ORPHAN (no sheet in store)\n`;
+ }
+ });
+ }
+
+ // Log History (last 50, filtered to show important events)
+ text += '\n--- LOG HISTORY (last 50) ---\n';
+ logHistory.slice(0, 50).forEach((log) => {
+ const time = new Date(log.timestamp).toLocaleTimeString('en-US', {
+ hour12: false,
+ });
+ const ms = log.timestamp % 1000;
+ text += `${time}.${ms.toString().padStart(3, '0')} [${log.type}] [${log.sheetId}] ${log.message}`;
+ if (log.value !== undefined) {
+ text += ` | ${JSON.stringify(log.value)}`;
+ }
+ text += '\n';
+ });
+
+ return text;
+ };
+
+ const copyStats = () => {
+ const text = generateStatsText();
+ Clipboard.setString(text);
+ Alert.alert('Copied!', 'Stats copied to clipboard');
+ };
+
+ return (
+ <>
+ {/* Floating Badge */}
+
+ setModalVisible(true)}
+ style={styles.badgeContent}
+ >
+ BS
+
+ {activeCount}/{sheetCount}
+
+
+
+
+ {/* Debug Modal */}
+ setModalVisible(false)}
+ >
+
+
+ Bottom Sheet Debug
+
+
+ Copy
+
+ setModalVisible(false)}
+ style={styles.closeButton}
+ >
+ Close
+
+
+
+
+
+ {/* Current State */}
+
+ Current State
+ Stack Order:
+
+ [{stackOrder.join(', ') || 'empty'}]
+
+
+ Sheets ({sheetCount}):
+ {Object.entries(sheetsById).map(([id, sheet]) => {
+ const animatedIndexRegistry = __getAllAnimatedIndexes();
+ const animatedIndex = animatedIndexRegistry.get(id);
+ return (
+
+ {id}
+
+ status: {sheet.status} | portal: {String(sheet.usePortal)}{' '}
+ | keepMounted: {String(sheet.keepMounted)}
+
+
+ portalSession: {sheet.portalSession} | inStack:{' '}
+ {String(stackOrder.includes(id))}
+
+
+ animatedIndex:{' '}
+ {animatedIndex ? animatedIndex.value.toFixed(4) : 'N/A'}
+ {animatedIndex ? ` (in registry)` : ' (NOT in registry!)'}
+
+
+ );
+ })}
+
+
+ {/* AnimatedIndex Registry */}
+
+ AnimatedIndex Registry
+ {(() => {
+ const registry = __getAllAnimatedIndexes();
+ const entries = Array.from(registry.entries());
+ if (entries.length === 0) {
+ return Empty;
+ }
+ return entries.map(([id, animatedIndex]) => (
+
+ {id}
+
+ value: {animatedIndex.value.toFixed(4)}
+
+
+ {sheetsById[id] ? 'in store' : 'ORPHAN (not in store!)'}
+
+
+ ));
+ })()}
+
+
+ {/* Log History */}
+
+
+ Log History ({logHistory.length})
+
+ {logHistory.map((log, index) => (
+
+
+ {new Date(log.timestamp).toLocaleTimeString()}
+
+ [{log.sheetId}]
+ {log.message}
+ {log.value !== undefined && (
+
+ {JSON.stringify(log.value)}
+
+ )}
+
+ ))}
+
+
+
+
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ floatingBadge: {
+ position: 'absolute',
+ zIndex: 9999,
+ },
+ badgeContent: {
+ backgroundColor: '#007AFF',
+ borderRadius: 25,
+ width: 50,
+ height: 50,
+ justifyContent: 'center',
+ alignItems: 'center',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.25,
+ shadowRadius: 4,
+ elevation: 5,
+ },
+ badgeText: {
+ color: 'white',
+ fontSize: 12,
+ fontWeight: 'bold',
+ },
+ badgeCount: {
+ color: 'white',
+ fontSize: 10,
+ },
+ modalContainer: {
+ flex: 1,
+ backgroundColor: '#1a1a1a',
+ },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: 16,
+ borderBottomWidth: 1,
+ borderBottomColor: '#333',
+ },
+ headerTitle: {
+ color: 'white',
+ fontSize: 18,
+ fontWeight: 'bold',
+ },
+ headerButtons: {
+ flexDirection: 'row',
+ gap: 16,
+ },
+ copyButton: {
+ backgroundColor: '#4CAF50',
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ borderRadius: 6,
+ },
+ copyButtonText: {
+ color: 'white',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ closeButton: {
+ padding: 8,
+ },
+ closeButtonText: {
+ color: '#007AFF',
+ fontSize: 16,
+ },
+ content: {
+ flex: 1,
+ padding: 16,
+ },
+ section: {
+ marginBottom: 24,
+ },
+ sectionTitle: {
+ color: '#007AFF',
+ fontSize: 16,
+ fontWeight: 'bold',
+ marginBottom: 12,
+ },
+ label: {
+ color: '#888',
+ fontSize: 12,
+ marginTop: 8,
+ },
+ value: {
+ color: 'white',
+ fontSize: 14,
+ fontFamily: 'monospace',
+ },
+ sheetItem: {
+ backgroundColor: '#2a2a2a',
+ padding: 12,
+ borderRadius: 8,
+ marginTop: 8,
+ },
+ sheetId: {
+ color: '#4CAF50',
+ fontSize: 14,
+ fontWeight: 'bold',
+ fontFamily: 'monospace',
+ },
+ sheetDetails: {
+ color: '#ccc',
+ fontSize: 12,
+ fontFamily: 'monospace',
+ marginTop: 4,
+ },
+ logItem: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ padding: 8,
+ borderRadius: 4,
+ marginTop: 4,
+ backgroundColor: '#2a2a2a',
+ },
+ log_open: {
+ borderLeftWidth: 3,
+ borderLeftColor: '#4CAF50',
+ },
+ log_close: {
+ borderLeftWidth: 3,
+ borderLeftColor: '#f44336',
+ },
+ log_status: {
+ borderLeftWidth: 3,
+ borderLeftColor: '#2196F3',
+ },
+ log_animatedIndex: {
+ borderLeftWidth: 3,
+ borderLeftColor: '#FF9800',
+ },
+ log_error: {
+ borderLeftWidth: 3,
+ borderLeftColor: '#f44336',
+ backgroundColor: '#3a2a2a',
+ },
+ log_cleanup: {
+ borderLeftWidth: 3,
+ borderLeftColor: '#9C27B0',
+ },
+ log_valueChange: {
+ borderLeftWidth: 3,
+ borderLeftColor: '#E91E63',
+ backgroundColor: '#3a2a3a',
+ },
+ logTime: {
+ color: '#666',
+ fontSize: 10,
+ fontFamily: 'monospace',
+ marginRight: 8,
+ },
+ logSheet: {
+ color: '#4CAF50',
+ fontSize: 12,
+ fontFamily: 'monospace',
+ marginRight: 8,
+ },
+ logMessage: {
+ color: 'white',
+ fontSize: 12,
+ fontFamily: 'monospace',
+ flex: 1,
+ },
+ logValue: {
+ color: '#FF9800',
+ fontSize: 10,
+ fontFamily: 'monospace',
+ width: '100%',
+ marginTop: 4,
+ },
+ animatedIndexValue: {
+ color: '#FF9800',
+ },
+ registryItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: '#2a2a2a',
+ padding: 8,
+ borderRadius: 4,
+ marginTop: 4,
+ },
+ registryId: {
+ color: '#4CAF50',
+ fontSize: 12,
+ fontFamily: 'monospace',
+ flex: 1,
+ },
+ registryValue: {
+ color: '#FF9800',
+ fontSize: 12,
+ fontFamily: 'monospace',
+ marginRight: 8,
+ },
+ registryStatus: {
+ fontSize: 10,
+ fontFamily: 'monospace',
+ },
+ registryInStore: {
+ color: '#4CAF50',
+ },
+ registryOrphan: {
+ color: '#f44336',
+ fontWeight: 'bold',
+ },
+});
diff --git a/src/BottomSheetBackdrop.tsx b/src/BottomSheetBackdrop.tsx
index 788ce97..c4237b8 100644
--- a/src/BottomSheetBackdrop.tsx
+++ b/src/BottomSheetBackdrop.tsx
@@ -1,3 +1,4 @@
+import { useEffect, useState } from 'react';
import { Pressable, StyleSheet } from 'react-native';
import Animated, {
Extrapolation,
@@ -5,17 +6,27 @@ import Animated, {
useAnimatedStyle,
} from 'react-native-reanimated';
import { getAnimatedIndex } from './animatedRegistry';
+import { useStartClosing } from './store';
interface BottomSheetBackdropProps {
sheetId: string;
- onPress?: () => void;
}
-export function BottomSheetBackdrop({
- sheetId,
- onPress,
-}: BottomSheetBackdropProps) {
+export function BottomSheetBackdrop({ sheetId }: BottomSheetBackdropProps) {
const animatedIndex = getAnimatedIndex(sheetId);
+ const startClosing = useStartClosing();
+
+ if (!animatedIndex) {
+ throw new Error('animatedIndex must be defined in BottomSheetBackdrop');
+ }
+
+ const [initialized, setInitialized] = useState(false);
+
+ useEffect(() => {
+ setTimeout(() => {
+ setInitialized(true);
+ }, 64);
+ }, []);
const animatedStyle = useAnimatedStyle(() => {
const opacity = interpolate(
@@ -28,12 +39,14 @@ export function BottomSheetBackdrop({
return { opacity };
});
+ if (!initialized) {
+ return null;
+ }
+
return (
{
- onPress?.();
- }}
+ onPress={() => startClosing(sheetId)}
>
contextAnimatedIndex.value,
+ (value) => {
+ externalAnimatedIndex?.set(value);
+ }
+ );
- const animatedIndex = externalAnimatedIndex ?? getAnimatedIndex(id);
const { handleAnimate, handleChange, handleClose } =
createSheetEventHandlers(id);
const wrappedOnAnimate: BottomSheetProps['onAnimate'] = (
- fromIndex: number,
- toIndex: number,
- fromPosition: number,
- toPosition: number
+ fromIndex,
+ toIndex,
+ fromPosition,
+ toPosition
) => {
handleAnimate(fromIndex, toIndex);
onAnimate?.(fromIndex, toIndex, fromPosition, toPosition);
};
const wrappedOnChange: BottomSheetProps['onChange'] = (
- index: number,
- position: number,
+ index,
+ position,
type
) => {
handleChange(index);
@@ -80,7 +92,7 @@ export const BottomSheetManaged = React.forwardRef<
ref={ref}
{...props}
index={defaultIndex}
- animatedIndex={animatedIndex}
+ animatedIndex={contextAnimatedIndex}
onChange={wrappedOnChange}
onClose={wrappedOnClose}
onAnimate={wrappedOnAnimate}
diff --git a/src/BottomSheetPersistent.tsx b/src/BottomSheetPersistent.tsx
index 36b93b5..18bec16 100644
--- a/src/BottomSheetPersistent.tsx
+++ b/src/BottomSheetPersistent.tsx
@@ -1,6 +1,8 @@
import type { BottomSheetMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
import React, { useEffect, useRef } from 'react';
+import { Portal } from 'react-native-teleport';
+import { BottomSheetContext } from './BottomSheet.context';
import {
useMount,
useSheetExists,
@@ -13,8 +15,6 @@ import { BottomSheetRefContext } from './BottomSheetRef.context';
import type { BottomSheetPortalId } from './portal.types';
import { setSheetRef } from './refsMap';
import { useEvent } from './useEvent';
-import { Portal } from 'react-native-teleport';
-import { BottomSheetContext } from './BottomSheet.context';
interface BottomSheetPersistentProps {
id: BottomSheetPortalId;
diff --git a/src/BottomSheetPortal.tsx b/src/BottomSheetPortal.tsx
index abc7998..60fd0ff 100644
--- a/src/BottomSheetPortal.tsx
+++ b/src/BottomSheetPortal.tsx
@@ -4,9 +4,9 @@ import React from 'react';
import { Portal } from 'react-native-teleport';
import { BottomSheetContext } from './BottomSheet.context';
+import { useSheetPortalSession } from './bottomSheet.store';
import { BottomSheetDefaultIndexContext } from './BottomSheetDefaultIndex.context';
import { BottomSheetRefContext } from './BottomSheetRef.context';
-import { useSheetPortalSession } from './bottomSheet.store';
import type { BottomSheetPortalId } from './portal.types';
import { getSheetRef } from './refsMap';
@@ -23,8 +23,10 @@ export function BottomSheetPortal({ id, children }: BottomSheetPortalProps) {
return null;
}
+ const portalName = `bottomsheet-${id}-${portalSession}`;
+
return (
-
+
diff --git a/src/QueueItem.tsx b/src/QueueItem.tsx
index 516f4aa..8fffc97 100644
--- a/src/QueueItem.tsx
+++ b/src/QueueItem.tsx
@@ -1,17 +1,16 @@
-import { memo, useEffect } from 'react';
+import { memo, useEffect, type PropsWithChildren } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated from 'react-native-reanimated';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { PortalHost } from 'react-native-teleport';
-import { cleanupAnimatedIndex } from './animatedRegistry';
+import { cleanupAnimatedIndex, getAnimatedIndex } from './animatedRegistry';
import { BottomSheetContext } from './BottomSheet.context';
import {
useSheetContent,
- useSheetUsePortal,
useSheetKeepMounted,
useSheetPortalSession,
- useStartClosing,
+ useSheetUsePortal,
} from './bottomSheet.store';
import { BottomSheetBackdrop } from './BottomSheetBackdrop';
import { cleanupSheetRef } from './refsMap';
@@ -32,10 +31,10 @@ export const QueueItem = memo(function QueueItem({
const usePortal = useSheetUsePortal(id);
const keepMounted = useSheetKeepMounted(id);
const portalSession = useSheetPortalSession(id);
- const startClosing = useStartClosing();
const { width, height } = useSafeAreaFrame();
- const scaleStyle = useSheetScaleAnimatedStyle(id);
+
+ const animatedIndex = getAnimatedIndex(id);
useEffect(() => {
return () => {
@@ -47,6 +46,10 @@ export const QueueItem = memo(function QueueItem({
const backdropZIndex = stackIndex * 2;
const contentZIndex = stackIndex * 2 + 1;
+ if (!animatedIndex) {
+ return null;
+ }
+
return (
<>
{isActive && (
@@ -54,19 +57,11 @@ export const QueueItem = memo(function QueueItem({
style={[StyleSheet.absoluteFillObject, { zIndex: backdropZIndex }]}
pointerEvents="box-none"
>
- startClosing(id)} />
+
)}
-
+
{usePortal ? (
)}
-
+
>
);
});
-const styles = StyleSheet.create({
- container: {},
-});
+const ScaleWrapper = ({
+ id,
+ zIndex,
+ children,
+}: PropsWithChildren<{
+ id: string;
+ zIndex: number;
+}>) => {
+ const scaleStyle = useSheetScaleAnimatedStyle(id);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/animatedRegistry.ts b/src/animatedRegistry.ts
index 91b3e4d..28bc1b4 100644
--- a/src/animatedRegistry.ts
+++ b/src/animatedRegistry.ts
@@ -2,18 +2,26 @@ import { makeMutable, type SharedValue } from 'react-native-reanimated';
/**
* Registry for shared animated values per sheet.
- * This allows backdrop to access the animatedIndex from the bottom sheet.
+ * AnimatedIndex is created eagerly in store actions (open/mount)
+ * before any component renders, ensuring it's always available.
*/
const animatedIndexRegistry = new Map>();
-export function getAnimatedIndex(sheetId: string): SharedValue {
- let animatedIndex = animatedIndexRegistry.get(sheetId);
-
- if (!animatedIndex) {
- animatedIndex = makeMutable(-1);
- animatedIndexRegistry.set(sheetId, animatedIndex);
+export function ensureAnimatedIndex(sheetId: string): SharedValue {
+ const existing = animatedIndexRegistry.get(sheetId);
+ if (existing) {
+ return existing;
}
+ const animatedIndex = makeMutable(-1);
+ animatedIndexRegistry.set(sheetId, animatedIndex);
+ return animatedIndex;
+}
+
+export function getAnimatedIndex(
+ sheetId: string
+): SharedValue | undefined {
+ const animatedIndex = animatedIndexRegistry.get(sheetId);
return animatedIndex;
}
@@ -28,3 +36,11 @@ export function cleanupAnimatedIndex(sheetId: string): void {
export function __resetAnimatedIndexes(): void {
animatedIndexRegistry.clear();
}
+
+/**
+ * Get all animated indexes for debugging.
+ * @internal
+ */
+export function __getAllAnimatedIndexes(): Map> {
+ return animatedIndexRegistry;
+}
diff --git a/src/bottomSheetCoordinator.ts b/src/bottomSheetCoordinator.ts
index 81bc501..16b3ff8 100644
--- a/src/bottomSheetCoordinator.ts
+++ b/src/bottomSheetCoordinator.ts
@@ -45,7 +45,10 @@ export function createSheetEventHandlers(sheetId: string) {
const state = useBottomSheetStore.getState();
const currentStatus = state.sheetsById[sheetId]?.status;
- if (toIndex === -1 && currentStatus === 'open') {
+ if (
+ toIndex === -1 &&
+ (currentStatus === 'open' || currentStatus === 'opening')
+ ) {
startClosing(sheetId);
}
};
diff --git a/src/index.tsx b/src/index.tsx
index 5fae241..f7a5659 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -35,7 +35,12 @@ export type {
BottomSheetPortalParams,
} from './portal.types';
+export { useBottomSheetStore } from './bottomSheet.store';
+
// Testing utilities (internal use)
export { __resetSheetRefs } from './refsMap';
-export { __resetAnimatedIndexes } from './animatedRegistry';
+export {
+ __resetAnimatedIndexes,
+ __getAllAnimatedIndexes,
+} from './animatedRegistry';
export { __resetPortalSessions } from './portalSessionRegistry';
diff --git a/src/store/store.ts b/src/store/store.ts
index bbcd1a6..eab4654 100644
--- a/src/store/store.ts
+++ b/src/store/store.ts
@@ -7,10 +7,10 @@ import {
getTopSheetId,
isActivatableKeepMounted,
isHidden,
- isOpening,
removeFromStack,
updateSheet,
} from './helpers';
+import { ensureAnimatedIndex } from '../animatedRegistry';
import { getNextPortalSession } from '../portalSessionRegistry';
import type { BottomSheetState, BottomSheetStore } from './types';
@@ -27,6 +27,13 @@ export const useBottomSheetStore = create(
return state;
}
+ const hasOpeningInGroup = Object.values(state.sheetsById).some(
+ (s) => s.groupId === sheet.groupId && s.status === 'opening'
+ );
+ if (hasOpeningInGroup) {
+ return state;
+ }
+
const updatedSheetsById = applyModeToTopSheet(
state.sheetsById,
state.stackOrder,
@@ -39,6 +46,8 @@ export const useBottomSheetStore = create(
? getNextPortalSession(sheet.id)
: undefined;
+ ensureAnimatedIndex(sheet.id);
+
const newSheet: BottomSheetState = existingSheet
? {
...existingSheet,
@@ -69,7 +78,7 @@ export const useBottomSheetStore = create(
startClosing: (id) =>
set((state) => {
const sheet = state.sheetsById[id];
- if (!sheet || isHidden(sheet) || isOpening(sheet)) return state;
+ if (!sheet || isHidden(sheet)) return state;
let updatedSheetsById = updateSheet(state.sheetsById, id, {
status: 'closing',
@@ -146,6 +155,8 @@ export const useBottomSheetStore = create(
set((state) => {
if (state.sheetsById[sheet.id]) return state;
+ ensureAnimatedIndex(sheet.id);
+
// For portal-based persistent sheets, set initial portalSession
// This session will be reused across open/close cycles
const portalSession = sheet.usePortal
diff --git a/src/useTracePropChanges.ts b/src/useTracePropChanges.ts
new file mode 100644
index 0000000..0056100
--- /dev/null
+++ b/src/useTracePropChanges.ts
@@ -0,0 +1,27 @@
+import { useEffect, useRef } from 'react';
+
+export function useTracePropChanges(
+ componentName: string,
+ props: Record
+) {
+ const prevProps = useRef(props);
+
+ useEffect(() => {
+ const allKeys = Object.keys({ ...props, ...prevProps.current });
+ allKeys.forEach((key) => {
+ if (prevProps.current[key] !== props[key]) {
+ console.log(`[${componentName}] Prop '${key}' changed.`);
+ if (
+ typeof props[key] === 'object' ||
+ typeof props[key] === 'function'
+ ) {
+ console.log(
+ `[${componentName}] New instance detected for prop '${key}'.`
+ );
+ }
+ }
+ });
+
+ prevProps.current = props;
+ });
+}
diff --git a/yarn.lock b/yarn.lock
index 6c0678b..62fa9cd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3521,6 +3521,23 @@ __metadata:
languageName: node
linkType: hard
+"@react-native-clipboard/clipboard@npm:^1.16.3":
+ version: 1.16.3
+ resolution: "@react-native-clipboard/clipboard@npm:1.16.3"
+ peerDependencies:
+ react: ">= 16.9.0"
+ react-native: ">= 0.61.5"
+ react-native-macos: ">= 0.61.0"
+ react-native-windows: ">= 0.61.0"
+ peerDependenciesMeta:
+ react-native-macos:
+ optional: true
+ react-native-windows:
+ optional: true
+ checksum: 3d9944fc2c4acbecf917e752cc36ec92c4dcdb590c94c81c78c24df9ddd4b02448e252eb39e0949000b01046cfdfe2b03e1676c5e3ac0fe7eb3bf6b649970c27
+ languageName: node
+ linkType: hard
+
"@react-native/assets-registry@npm:0.81.5":
version: 0.81.5
resolution: "@react-native/assets-registry@npm:0.81.5"
@@ -12110,6 +12127,7 @@ __metadata:
"@babel/core": ^7.28.5
"@expo/metro-runtime": ^6.1.2
"@gorhom/bottom-sheet": ^5.2.8
+ "@react-native-clipboard/clipboard": ^1.16.3
expo: ^54.0.31
expo-dev-client: ~6.0.13
expo-linking: ~8.0.11