fix(integration): serialize browser add inputs

- serialize extension and deep-link Add-window events
- acknowledge successful extension handling at the frontend boundary
- add regression coverage for ordered input processing
This commit is contained in:
NimBold
2026-08-12 20:20:55 +03:30
parent e9ad226b93
commit 4b43e8ed5c
4 changed files with 85 additions and 12 deletions
+26 -11
View File
@@ -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<number | null>(null);
const startupResumeStarted = useRef(false);
const startupInputReady = useRef(false);
const extensionProcessing = useRef(createSerialTaskQueue());
const frontendReadyUpdate = useRef<Promise<void>>(Promise.resolve());
const pendingStartupInputs = useRef<Array<
| { type: 'extension'; payload: ExtensionDownloadRequest }
@@ -307,6 +309,24 @@ function App() {
return update;
}, []);
const acknowledgeExtensionDownload = useCallback(async (requestId?: string) => {
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<void>) => {
return extensionProcessing.current(task);
}, []);
const schedulePostQueueAction = useCallback((action: Exclude<PostQueueAction, 'none'>) => {
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;
+45
View File
@@ -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<void>(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<void>(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']);
});
});
+13
View File
@@ -0,0 +1,13 @@
export type SerialTask = () => void | Promise<void>;
export type SerialTaskQueue = (task: SerialTask) => Promise<void>;
export const createSerialTaskQueue = (): SerialTaskQueue => {
let tail: Promise<void> = Promise.resolve();
return task => {
const next = tail.catch(() => undefined).then(task);
tail = next.catch(() => undefined);
return next;
};
};