Skip to content

Commit ce324eb

Browse files
fazxesclaude
andcommitted
fix(explorer): resolve infinite re-render crash in FileExplorer
Root cause: unstable callbacks passed to useVirtualizer caused the virtualizer to re-initialize on every render, triggering state updates in an infinite loop. Changes: - Memoize getScrollElement, estimateSize, getItemKey callbacks - Memoize row interaction callbacks (handleToggle, handleOpen, etc.) - Use string-based version selectors for Zustand store state - Consolidate two useTauri() calls into one in use-file-tree.ts - Use lazy useState initialization in use-tauri.ts - Use refs for connection state to stabilize postMessage callback - Add hasAutoLoaded ref guard with queueMicrotask deferral - Fix Rust panic: use async_runtime::spawn instead of tokio::spawn 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ab9f53b commit ce324eb

4 files changed

Lines changed: 166 additions & 115 deletions

File tree

‎src-tauri/src/commands/files.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use parking_lot::Mutex;
1717
use serde::Serialize;
1818
use snowflake_core::{Error, FileEntry, FileInfo, Result};
1919
use snowflake_fs::FileWatcher;
20-
use tauri::{AppHandle, Emitter as _};
20+
use tauri::{async_runtime, AppHandle, Emitter as _};
2121
use tokio::time::sleep;
2222

2323
// ============================================
@@ -205,7 +205,7 @@ fn start_event_forwarder(app: AppHandle) {
205205
return;
206206
}
207207

208-
let _handle = tokio::spawn(async move {
208+
let _handle = async_runtime::spawn(async move {
209209
log::debug!("File watcher event forwarder started");
210210

211211
loop {

‎src/components/layout/file-explorer.tsx‎

Lines changed: 67 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -110,30 +110,65 @@ export const FileExplorer: FC<FileExplorerProps> = ({ collapsed = false }) => {
110110
retryFolder,
111111
} = useFileTree();
112112

113-
// Get tree state for flattening - these selectors are stable (Immer)
114-
const treeNodes = useFileStore((s) => s.treeNodes);
115-
const expandedFolders = useFileStore((s) => s.expandedFolders);
116-
// Get action directly from store (not via selector to avoid new reference each render)
117-
const selectTreePath = useFileStore.getState().selectTreePath;
118-
119-
// Flatten tree for virtualization
120-
// Recalculates when tree structure or expansion state changes
121-
const flatItems = useMemo(
122-
() => flattenTree(rootChildren, treeNodes, expandedFolders),
123-
[rootChildren, treeNodes, expandedFolders]
124-
);
113+
// Use stable selectors to trigger re-render when tree structure changes
114+
const treeNodesVersion = useFileStore((s) => Object.keys(s.treeNodes).join(','));
115+
const expandedFoldersVersion = useFileStore((s) => Array.from(s.expandedFolders).join(','));
116+
const selectTreePath = useFileStore((s) => s.selectTreePath);
125117

126-
// Virtualizer setup
118+
// Stable ref for scroll container - must be defined before useVirtualizer
127119
const parentRef = useRef<HTMLDivElement>(null);
128-
const virtualizer = useVirtualizer({
120+
121+
// Memoize flat items with stable dependencies
122+
const flatItems = useMemo(() => {
123+
void treeNodesVersion;
124+
void expandedFoldersVersion;
125+
const state = useFileStore.getState();
126+
return flattenTree(rootChildren, state.treeNodes, state.expandedFolders);
127+
}, [rootChildren, treeNodesVersion, expandedFoldersVersion]);
128+
129+
// CRITICAL: Memoize callbacks passed to useVirtualizer to prevent infinite loops
130+
const getScrollElement = useCallback(() => parentRef.current, []);
131+
const estimateSize = useCallback(() => ROW_HEIGHT, []);
132+
const getItemKey = useCallback((index: number) => flatItems[index]?.path ?? index, [flatItems]);
133+
134+
// Initialize virtualizer with stable callbacks
135+
const rowVirtualizer = useVirtualizer({
129136
count: flatItems.length,
130-
getScrollElement: () => parentRef.current,
131-
estimateSize: () => ROW_HEIGHT,
137+
getScrollElement,
138+
estimateSize,
132139
overscan: OVERSCAN,
133-
// Maintain scroll position when items change
134-
getItemKey: (index) => flatItems[index]?.path ?? index,
140+
getItemKey,
135141
});
136142

143+
// Memoize row interaction callbacks
144+
const handleToggle = useCallback(
145+
(path: string): void => {
146+
toggleFolder(path);
147+
},
148+
[toggleFolder]
149+
);
150+
151+
const handleOpen = useCallback(
152+
(path: string): void => {
153+
openFile(path);
154+
},
155+
[openFile]
156+
);
157+
158+
const handleSelect = useCallback(
159+
(path: string | null): void => {
160+
selectTreePath(path);
161+
},
162+
[selectTreePath]
163+
);
164+
165+
const handleRetry = useCallback(
166+
(path: string): void => {
167+
retryFolder(path);
168+
},
169+
[retryFolder]
170+
);
171+
137172
const handleOpenQuickSearch = useCallback((): void => {
138173
window.dispatchEvent(new CustomEvent('openCommandPalette'));
139174
}, []);
@@ -142,7 +177,8 @@ export const FileExplorer: FC<FileExplorerProps> = ({ collapsed = false }) => {
142177
return null;
143178
}
144179

145-
const virtualItems = virtualizer.getVirtualItems();
180+
// Get virtual items once per render
181+
const virtualItems = rowVirtualizer.getVirtualItems();
146182

147183
return (
148184
<div className="flex flex-col h-full">
@@ -191,38 +227,39 @@ export const FileExplorer: FC<FileExplorerProps> = ({ collapsed = false }) => {
191227
Retry
192228
</button>
193229
</div>
194-
) : isRootLoading && rootChildren.length === 0 ? (
230+
) : isRootLoading && flatItems.length === 0 ? (
195231
<div className="flex items-center justify-center py-8 text-muted-foreground">
196232
<Loader2 className="h-4 w-4 animate-spin mr-2" />
197233
<span className="text-sm">Loading...</span>
198234
</div>
199-
) : rootChildren.length === 0 ? (
235+
) : flatItems.length === 0 ? (
200236
<div className="flex items-center justify-center py-8 text-muted-foreground">
201237
<span className="text-sm">No files found</span>
202238
</div>
203239
) : (
204240
<div
205241
style={{
206-
height: virtualizer.getTotalSize(),
242+
height: rowVirtualizer.getTotalSize(),
207243
width: '100%',
208244
position: 'relative',
209245
}}
210246
>
211-
{virtualItems.map((virtualRow) => {
212-
const item = flatItems[virtualRow.index];
247+
{virtualItems.map((virtualItem) => {
248+
const item = flatItems[virtualItem.index];
213249
if (!item) return null;
250+
214251
return (
215252
<FileTreeRow
216253
key={item.path}
217254
path={item.path}
218255
depth={item.depth}
219256
node={item.node}
220-
top={virtualRow.start}
221-
height={virtualRow.size}
222-
onToggle={toggleFolder}
223-
onOpen={openFile}
224-
onSelect={selectTreePath}
225-
onRetry={retryFolder}
257+
top={virtualItem.start}
258+
height={virtualItem.size}
259+
onToggle={handleToggle}
260+
onOpen={handleOpen}
261+
onSelect={handleSelect}
262+
onRetry={handleRetry}
226263
/>
227264
);
228265
})}

‎src/hooks/use-file-tree.ts‎

Lines changed: 74 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -81,18 +81,6 @@ const EMPTY_CHILDREN: readonly FileNode[] = [];
8181
export function useFileTree(options: UseFileTreeOptions = {}): UseFileTreeResult {
8282
const { autoLoad = true, debug = false } = options;
8383

84-
const { postMessage } = useTauri();
85-
86-
// Get only what we need from store (minimizes re-renders)
87-
const rootPath = useFileStore((s) => s.rootPath);
88-
const rootChildren = useFileStore((s) =>
89-
s.rootPath ? (s.treeNodes[s.rootPath] ?? EMPTY_CHILDREN) : EMPTY_CHILDREN
90-
);
91-
const isRootLoading = useFileStore(
92-
(s) => s.loadingPaths.has('__root__') || s.loadingPaths.has(s.rootPath ?? '')
93-
);
94-
const rootError = useFileStore((s) => s.errorPaths.get(s.rootPath ?? '__root__') ?? null);
95-
9684
// Track pending requests: path -> { uuid, timeoutId }
9785
const pendingRequests = useRef(
9886
new Map<string, { uuid: string; timeoutId: ReturnType<typeof setTimeout> }>()
@@ -101,54 +89,10 @@ export function useFileTree(options: UseFileTreeOptions = {}): UseFileTreeResult
10189
// Track previous treeNodes for detecting cleared cache
10290
const prevTreeNodesRef = useRef<Set<string>>(new Set());
10391

104-
// Request children for a path
105-
const requestChildren = useCallback(
106-
(path?: string): void => {
107-
const store = useFileStore.getState();
108-
const requestPath = path ?? store.rootPath ?? '';
109-
110-
// Don't request if already pending
111-
if (pendingRequests.current.has(requestPath)) {
112-
if (debug) {
113-
console.warn('[useFileTree] Skipping duplicate request for:', requestPath);
114-
}
115-
return;
116-
}
117-
118-
const uuid = crypto.randomUUID();
119-
const loadingKey = requestPath || '__root__';
92+
// Guard to ensure autoLoad only runs once per mount
93+
const hasAutoLoaded = useRef(false);
12094

121-
// Set timeout for request
122-
const timeoutId = setTimeout(() => {
123-
const pending = pendingRequests.current.get(requestPath);
124-
if (pending?.uuid === uuid) {
125-
pendingRequests.current.delete(requestPath);
126-
useFileStore.getState().setLoading(loadingKey, false);
127-
useFileStore.getState().setError(loadingKey, 'Request timed out');
128-
console.error('[useFileTree] Request timed out for:', requestPath);
129-
}
130-
}, REQUEST_TIMEOUT_MS);
131-
132-
pendingRequests.current.set(requestPath, { uuid, timeoutId });
133-
134-
// Clear any previous error and set loading
135-
useFileStore.getState().clearError(loadingKey);
136-
useFileStore.getState().setLoading(loadingKey, true);
137-
138-
if (debug) {
139-
console.warn('[useFileTree] Requesting children for:', requestPath || '(root)');
140-
}
141-
142-
postMessage({
143-
type: 'file:tree:request',
144-
uuid,
145-
path: path,
146-
});
147-
},
148-
[postMessage, debug]
149-
);
150-
151-
// Handle incoming messages
95+
// Handle incoming messages - defined before useTauri so we can pass it in
15296
const handleMessage = useCallback(
15397
(message: ExtensionMessage): void => {
15498
switch (message.type) {
@@ -281,22 +225,85 @@ export function useFileTree(options: UseFileTreeOptions = {}): UseFileTreeResult
281225
[debug]
282226
);
283227

284-
// Set up message listener
285-
useTauri({ onMessage: handleMessage });
228+
// Single useTauri call - consolidates message handling and postMessage
229+
const { postMessage } = useTauri({ onMessage: handleMessage });
286230

287-
// Request root children on mount (if autoLoad enabled)
288-
useEffect(() => {
289-
if (autoLoad) {
231+
// Get only what we need from store (minimizes re-renders)
232+
const rootPath = useFileStore((s) => s.rootPath);
233+
const rootChildren = useFileStore((s) =>
234+
s.rootPath ? (s.treeNodes[s.rootPath] ?? EMPTY_CHILDREN) : EMPTY_CHILDREN
235+
);
236+
const isRootLoading = useFileStore(
237+
(s) => s.loadingPaths.has('__root__') || s.loadingPaths.has(s.rootPath ?? '')
238+
);
239+
const rootError = useFileStore((s) => s.errorPaths.get(s.rootPath ?? '__root__') ?? null);
240+
241+
// Request children for a path
242+
const requestChildren = useCallback(
243+
(path?: string): void => {
290244
const store = useFileStore.getState();
291-
if (Object.keys(store.treeNodes).length === 0) {
292-
requestChildren();
245+
const requestPath = path ?? store.rootPath ?? '';
246+
247+
// Don't request if already pending
248+
if (pendingRequests.current.has(requestPath)) {
249+
if (debug) {
250+
console.warn('[useFileTree] Skipping duplicate request for:', requestPath);
251+
}
252+
return;
293253
}
254+
255+
const uuid = crypto.randomUUID();
256+
const loadingKey = requestPath || '__root__';
257+
258+
// Set timeout for request
259+
const timeoutId = setTimeout(() => {
260+
const pending = pendingRequests.current.get(requestPath);
261+
if (pending?.uuid === uuid) {
262+
pendingRequests.current.delete(requestPath);
263+
useFileStore.getState().setLoading(loadingKey, false);
264+
useFileStore.getState().setError(loadingKey, 'Request timed out');
265+
console.error('[useFileTree] Request timed out for:', requestPath);
266+
}
267+
}, REQUEST_TIMEOUT_MS);
268+
269+
pendingRequests.current.set(requestPath, { uuid, timeoutId });
270+
271+
// Clear any previous error and set loading
272+
useFileStore.getState().clearError(loadingKey);
273+
useFileStore.getState().setLoading(loadingKey, true);
274+
275+
if (debug) {
276+
console.warn('[useFileTree] Requesting children for:', requestPath || '(root)');
277+
}
278+
279+
postMessage({
280+
type: 'file:tree:request',
281+
uuid,
282+
path: path,
283+
});
284+
},
285+
[postMessage, debug]
286+
);
287+
288+
// Request root children on mount (if autoLoad enabled)
289+
// Uses a ref guard to ensure this only runs once per mount
290+
// Deferred via queueMicrotask to avoid synchronous re-render during commit
291+
useEffect(() => {
292+
if (autoLoad && !hasAutoLoaded.current) {
293+
hasAutoLoaded.current = true;
294+
queueMicrotask(() => {
295+
const store = useFileStore.getState();
296+
if (Object.keys(store.treeNodes).length === 0) {
297+
requestChildren();
298+
}
299+
});
294300
}
295301
}, [autoLoad, requestChildren]);
296302

297303
// Subscribe to treeNodes keys for detecting cleared folders
304+
// Use Array.from instead of spread to handle Immer proxies more reliably
298305
const treeNodeKeys = useFileStore((s) => Object.keys(s.treeNodes).join(','));
299-
const expandedFoldersList = useFileStore((s) => [...s.expandedFolders].join(','));
306+
const expandedFoldersList = useFileStore((s) => Array.from(s.expandedFolders).join(','));
300307
const currentRootPath = useFileStore((s) => s.rootPath);
301308

302309
// Re-fetch expanded folders when their children are cleared (e.g., by file watcher)

0 commit comments

Comments
 (0)