From a17ec689a36105dddf8568eac9fa605bf91492f5 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 29 Apr 2026 06:36:39 +0200 Subject: [PATCH] fix: tolerate stale vault watcher listeners --- src/hooks/useVaultWatcher.test.ts | 41 +++++++++++++++++++++++++++++++ src/hooks/useVaultWatcher.ts | 11 ++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/hooks/useVaultWatcher.test.ts b/src/hooks/useVaultWatcher.test.ts index d67aea68..4ffde38d 100644 --- a/src/hooks/useVaultWatcher.test.ts +++ b/src/hooks/useVaultWatcher.test.ts @@ -138,10 +138,51 @@ describe('useVaultWatcher', () => { unmount() + await settleWatcherSubscription() expect(mocks.unlisten).toHaveBeenCalledOnce() expect(mocks.invoke).toHaveBeenCalledWith('stop_vault_watcher') }) + it('swallows stale native watcher unlisten failures and still stops the watcher', async () => { + mocks.unlisten.mockImplementationOnce(() => { + throw new TypeError("undefined is not an object (evaluating 'listeners[eventId].handlerId')") + }) + + const { unmount } = renderHook(() => useVaultWatcher({ + vaultPath: '/vault', + onVaultChanged: vi.fn(), + })) + + await settleWatcherSubscription() + + expect(() => unmount()).not.toThrow() + await settleWatcherSubscription() + expect(mocks.unlisten).toHaveBeenCalledOnce() + expect(mocks.invoke).toHaveBeenCalledWith('stop_vault_watcher') + }) + + it('keeps listener replacement stable when vault paths churn', async () => { + mocks.unlisten.mockImplementationOnce(() => { + throw new TypeError("undefined is not an object (evaluating 'listeners[eventId].handlerId')") + }) + + const { rerender } = renderHook( + ({ vaultPath }) => useVaultWatcher({ vaultPath, onVaultChanged: vi.fn() }), + { initialProps: { vaultPath: '/vault-a' } }, + ) + + await settleWatcherSubscription() + + expect(() => rerender({ vaultPath: '/vault-b' })).not.toThrow() + await settleWatcherSubscription() + + expect(mocks.listen).toHaveBeenCalledTimes(2) + expect(mocks.unlisten).toHaveBeenCalledOnce() + expect(mocks.invoke).toHaveBeenCalledWith('start_vault_watcher', { path: '/vault-a' }) + expect(mocks.invoke).toHaveBeenCalledWith('start_vault_watcher', { path: '/vault-b' }) + expect(mocks.invoke).toHaveBeenCalledWith('stop_vault_watcher') + }) + it('batches changed paths for the active vault', async () => { const onVaultChanged = vi.fn() renderHook(() => useVaultWatcher({ vaultPath: '/vault', onVaultChanged })) diff --git a/src/hooks/useVaultWatcher.ts b/src/hooks/useVaultWatcher.ts index 62b24916..40c8ad49 100644 --- a/src/hooks/useVaultWatcher.ts +++ b/src/hooks/useVaultWatcher.ts @@ -200,6 +200,12 @@ function handleWatcherEvent({ } } +function cleanupNativeWatcherListener(unlisten: UnlistenFn): void { + void Promise.resolve() + .then(unlisten) + .catch(() => {}) +} + function usePendingVaultRefresh({ vaultPathRef, onVaultChanged, @@ -263,7 +269,6 @@ function useNativeVaultWatcher({ enqueueChangedPaths: (paths: WatchPath[]) => void clearPendingRefresh: () => void }) { - useEffect(() => { const root = normalizeWatchPath(vaultPath) if (!root || !isTauri()) return @@ -275,7 +280,7 @@ function useNativeVaultWatcher({ handleWatcherEvent({ event, root, enqueueChangedPaths }) }).then((nextUnlisten) => { if (cancelled) { - nextUnlisten() + cleanupNativeWatcherListener(nextUnlisten) } else { unlisten = nextUnlisten } @@ -290,7 +295,7 @@ function useNativeVaultWatcher({ return () => { cancelled = true clearPendingRefresh() - unlisten?.() + if (unlisten) cleanupNativeWatcherListener(unlisten) void invoke('stop_vault_watcher').catch(() => {}) } }, [vaultPath, enqueueChangedPaths, clearPendingRefresh])