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