diff --git a/Extensions/Browser b/Extensions/Browser index 64a8c3c..f08ebb5 160000 --- a/Extensions/Browser +++ b/Extensions/Browser @@ -1 +1 @@ -Subproject commit 64a8c3c5a2c35b6621ac4f8e10d8a915253af8c8 +Subproject commit f08ebb5d0f7e9e2f6e62aa488e78443b3ad4a0eb diff --git a/src/App.tsx b/src/App.tsx index cf4e33d..f1d352e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,6 +43,7 @@ import { synchronizeDocumentAppearance } from './utils/documentAppearance'; import { createMainWindowSizePersistence } from './utils/mainWindowState'; import type { MainWindowSize } from './bindings/MainWindowSize'; import { beginSchedulerControl, isSchedulerControlCurrent } from './utils/schedulerControl'; +import { createSerialTaskQueue } from './utils/serialTaskQueue'; const loadSettingsView = () => import('./components/SettingsView'); const loadSchedulerView = () => import('./components/SchedulerView'); @@ -254,6 +255,7 @@ function App() { const pendingPostActionTimer = useRef(null); const startupResumeStarted = useRef(false); const startupInputReady = useRef(false); + const extensionProcessing = useRef(createSerialTaskQueue()); const frontendReadyUpdate = useRef>(Promise.resolve()); const pendingStartupInputs = useRef { + if (!requestId) return; + try { + await invoke('ack_extension_download', { requestId }); + } catch (error) { + console.error('Failed to acknowledge browser extension download:', error); + } + }, []); + + const processExtensionDownload = useCallback(async (payload: ExtensionDownloadRequest) => { + await useDownloadStore.getState().handleExtensionDownload(payload); + await acknowledgeExtensionDownload(payload.request_id); + }, [acknowledgeExtensionDownload]); + + const enqueueAddInput = useCallback((task: () => void | Promise) => { + return extensionProcessing.current(task); + }, []); + const schedulePostQueueAction = useCallback((action: Exclude) => { clearPendingPostActionTimer(); @@ -646,16 +666,11 @@ function App() { } }); unlistenExtension = await listen('extension-add-download', (event) => { - if (event.payload.request_id) { - void invoke('ack_extension_download', { requestId: event.payload.request_id }).catch(error => { - console.error('Failed to acknowledge browser extension download:', error); - }); - } if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) { pendingStartupInputs.current.push({ type: 'extension', payload: event.payload }); return; } - useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => { + enqueueAddInput(() => processExtensionDownload(event.payload)).catch(error => { console.error('Failed to handle browser extension download:', error); }); }); @@ -664,7 +679,7 @@ function App() { pendingStartupInputs.current.push({ type: 'deep-link', payload: event.payload }); return; } - useDownloadStore.getState().openAddModalWithUrls(event.payload); + enqueueAddInput(() => useDownloadStore.getState().openAddModalWithUrls(event.payload)); }); cleanupListeners = disposeListeners; @@ -718,7 +733,7 @@ function App() { mainWindowSizePersistence.dispose(); disposePersistence(); }; - }, [addToast, queueFrontendReadyUpdate]); + }, [addToast, enqueueAddInput, processExtensionDownload, queueFrontendReadyUpdate]); useEffect(() => { if (!coreReady) return; @@ -740,14 +755,14 @@ function App() { const pendingInputs = pendingStartupInputs.current.splice(0); for (const input of pendingInputs) { if (input.type === 'extension') { - useDownloadStore.getState().handleExtensionDownload(input.payload).catch(error => { + enqueueAddInput(() => processExtensionDownload(input.payload)).catch(error => { console.error('Failed to handle queued browser extension download:', error); }); } else { - useDownloadStore.getState().openAddModalWithUrls(input.payload); + enqueueAddInput(() => useDownloadStore.getState().openAddModalWithUrls(input.payload)); } } - }, [coreReady, showKeychainModal]); + }, [coreReady, enqueueAddInput, processExtensionDownload, showKeychainModal]); useEffect(() => { if (!coreReady || showKeychainModal || startupResumeStarted.current) return; diff --git a/src/utils/serialTaskQueue.test.ts b/src/utils/serialTaskQueue.test.ts new file mode 100644 index 0000000..6d52107 --- /dev/null +++ b/src/utils/serialTaskQueue.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { createSerialTaskQueue } from './serialTaskQueue'; + +describe('createSerialTaskQueue', () => { + it('runs tasks in enqueue order and waits for each previous task', async () => { + const queue = createSerialTaskQueue(); + const events: string[] = []; + let releaseFirst!: () => void; + const firstReleased = new Promise(resolve => { + releaseFirst = resolve; + }); + + const first = queue(async () => { + events.push('first-start'); + await firstReleased; + events.push('first-end'); + }); + const second = queue(async () => { + events.push('second'); + }); + + await new Promise(resolve => setTimeout(resolve, 0)); + expect(events).toEqual(['first-start']); + releaseFirst(); + await Promise.all([first, second]); + expect(events).toEqual(['first-start', 'first-end', 'second']); + }); + + it('continues with later tasks after a failed task', async () => { + const queue = createSerialTaskQueue(); + const events: string[] = []; + + const failed = queue(async () => { + events.push('failed'); + throw new Error('expected failure'); + }); + const continued = queue(async () => { + events.push('continued'); + }); + + await expect(failed).rejects.toThrow('expected failure'); + await expect(continued).resolves.toBeUndefined(); + expect(events).toEqual(['failed', 'continued']); + }); +}); diff --git a/src/utils/serialTaskQueue.ts b/src/utils/serialTaskQueue.ts new file mode 100644 index 0000000..641ffd2 --- /dev/null +++ b/src/utils/serialTaskQueue.ts @@ -0,0 +1,13 @@ +export type SerialTask = () => void | Promise; + +export type SerialTaskQueue = (task: SerialTask) => Promise; + +export const createSerialTaskQueue = (): SerialTaskQueue => { + let tail: Promise = Promise.resolve(); + + return task => { + const next = tail.catch(() => undefined).then(task); + tail = next.catch(() => undefined); + return next; + }; +};