mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 18:59:36 +00:00
fix(properties): harden resume lifecycle
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
PROPERTIES_WINDOW_ACTION_RESULT,
|
||||
PROPERTIES_WINDOW_REMOVED,
|
||||
PROPERTIES_WINDOW_SNAPSHOT,
|
||||
attachAsyncPropertiesListener,
|
||||
getPropertiesLifecycleAction,
|
||||
sendPropertiesActionRequest,
|
||||
sendPropertiesReady,
|
||||
@@ -190,7 +191,7 @@ export const PropertiesWindowApp = () => {
|
||||
const id = await invoke('get_properties_window_download_id');
|
||||
if (cancelled) return;
|
||||
setDownloadId(id);
|
||||
unlistenSnapshot = await listen<PropertiesSnapshotEvent>(PROPERTIES_WINDOW_SNAPSHOT, async event => {
|
||||
const snapshotListener = await listen<PropertiesSnapshotEvent>(PROPERTIES_WINDOW_SNAPSHOT, async event => {
|
||||
if (event.payload.windowLabel !== windowLabel
|
||||
|| event.payload.downloadId !== id
|
||||
|| event.payload.sessionId !== sessionId) return;
|
||||
@@ -220,7 +221,12 @@ export const PropertiesWindowApp = () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
unlistenResult = await listen<PropertiesActionResult>(PROPERTIES_WINDOW_ACTION_RESULT, event => {
|
||||
if (cancelled) {
|
||||
snapshotListener();
|
||||
return;
|
||||
}
|
||||
unlistenSnapshot = snapshotListener;
|
||||
const resultListener = await listen<PropertiesActionResult>(PROPERTIES_WINDOW_ACTION_RESULT, event => {
|
||||
if (event.payload.windowLabel !== windowLabel
|
||||
|| event.payload.downloadId !== id
|
||||
|| event.payload.sessionId !== sessionId) return;
|
||||
@@ -248,7 +254,12 @@ export const PropertiesWindowApp = () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
unlistenRemoved = await listen<{ windowLabel: string; downloadId: string }>(PROPERTIES_WINDOW_REMOVED, event => {
|
||||
if (cancelled) {
|
||||
resultListener();
|
||||
return;
|
||||
}
|
||||
unlistenResult = resultListener;
|
||||
const removedListener = await listen<{ windowLabel: string; downloadId: string }>(PROPERTIES_WINDOW_REMOVED, event => {
|
||||
if (event.payload.windowLabel === windowLabel && event.payload.downloadId === id) {
|
||||
if (readyRetryTimer !== undefined) {
|
||||
window.clearInterval(readyRetryTimer);
|
||||
@@ -258,6 +269,11 @@ export const PropertiesWindowApp = () => {
|
||||
setNotice(t($ => $.downloadTable.noDownloads));
|
||||
}
|
||||
});
|
||||
if (cancelled) {
|
||||
removedListener();
|
||||
return;
|
||||
}
|
||||
unlistenRemoved = removedListener;
|
||||
await sendPropertiesReady(sessionId);
|
||||
if (cancelled) return;
|
||||
// Tauri event listeners are registered asynchronously. If the main
|
||||
@@ -315,12 +331,16 @@ export const PropertiesWindowApp = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
let disposed = false;
|
||||
let unlisten: UnlistenFn | undefined;
|
||||
void currentWindow.onCloseRequested(event => {
|
||||
attachAsyncPropertiesListener(currentWindow.onCloseRequested(event => {
|
||||
event.preventDefault();
|
||||
setClosePrompt(true);
|
||||
}).then(value => { unlisten = value; });
|
||||
return () => unlisten?.();
|
||||
}), () => disposed, value => { unlisten = value; });
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [currentWindow, isDirty]);
|
||||
|
||||
const requestAction = useCallback(async (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
PROPERTIES_WINDOW_CLOSED,
|
||||
PROPERTIES_WINDOW_READY,
|
||||
applySecretPatch,
|
||||
attachAsyncPropertiesListener,
|
||||
beginExclusivePropertiesAction,
|
||||
createFrameCoalescer,
|
||||
enqueuePropertiesAction,
|
||||
@@ -176,8 +177,10 @@ export const PropertiesWindowBridgeHost = () => {
|
||||
};
|
||||
|
||||
const handleReady = async (payload: PropertiesWindowReady) => {
|
||||
if (disposed) return;
|
||||
try {
|
||||
await invoke('validate_properties_window_request', payload);
|
||||
if (disposed) return;
|
||||
const item = useDownloadStore.getState().downloads.find(download => download.id === payload.downloadId);
|
||||
if (!item) {
|
||||
await sendPropertiesRemoved(payload.windowLabel, payload.downloadId);
|
||||
@@ -192,12 +195,14 @@ export const PropertiesWindowBridgeHost = () => {
|
||||
};
|
||||
|
||||
const processAction = async (request: PropertiesActionRequest) => {
|
||||
if (disposed) return;
|
||||
let ok = false;
|
||||
let error: string | undefined;
|
||||
const actionKey = `${request.windowLabel}:${request.downloadId}`;
|
||||
let releaseAction: (() => void) | undefined;
|
||||
try {
|
||||
await invoke('validate_properties_window_request', request);
|
||||
if (disposed) return;
|
||||
const registration = windows.get(request.windowLabel);
|
||||
if (!registration
|
||||
|| registration.downloadId !== request.downloadId
|
||||
@@ -302,6 +307,7 @@ export const PropertiesWindowBridgeHost = () => {
|
||||
} finally {
|
||||
releaseAction?.();
|
||||
}
|
||||
if (disposed) return;
|
||||
if (ok) {
|
||||
try {
|
||||
await sendFor(request.windowLabel, request.downloadId);
|
||||
@@ -325,12 +331,14 @@ export const PropertiesWindowBridgeHost = () => {
|
||||
};
|
||||
|
||||
const handleAction = async (request: PropertiesActionRequest) => {
|
||||
if (disposed) return;
|
||||
const actionKey = `${request.windowLabel}:${request.downloadId}`;
|
||||
try {
|
||||
// The native command validates the caller, download binding, and
|
||||
// renderer session. If a ready event is delayed or lost, this valid
|
||||
// action can also establish the main-window registration.
|
||||
await invoke('validate_properties_window_request', request);
|
||||
if (disposed) return;
|
||||
synchronizeRegistration(request.windowLabel, request.downloadId, request.sessionId);
|
||||
} catch {
|
||||
// Stale renderer actions are deliberately ignored. The current child
|
||||
@@ -349,15 +357,32 @@ export const PropertiesWindowBridgeHost = () => {
|
||||
await enqueuePropertiesAction(actionChains, actionKey, () => processAction(request));
|
||||
};
|
||||
|
||||
void listen<PropertiesWindowReady>(PROPERTIES_WINDOW_READY, event => void handleReady(event.payload)).then(value => { unlistenReady = value; });
|
||||
void listen<PropertiesActionRequest>(PROPERTIES_WINDOW_ACTION_REQUEST, event => void handleAction(event.payload)).then(value => { unlistenAction = value; });
|
||||
void listen<string>(PROPERTIES_WINDOW_CLOSED, event => {
|
||||
const registration = windows.get(event.payload);
|
||||
windows.delete(event.payload);
|
||||
snapshotRevisions.delete(event.payload);
|
||||
snapshotCoalescer.cancel(event.payload);
|
||||
if (registration) actionsInFlight.delete(`${event.payload}:${registration.downloadId}`);
|
||||
}).then(value => { unlistenClosed = value; });
|
||||
attachAsyncPropertiesListener(
|
||||
listen<PropertiesWindowReady>(PROPERTIES_WINDOW_READY, event => {
|
||||
if (!disposed) void handleReady(event.payload);
|
||||
}),
|
||||
() => disposed,
|
||||
value => { unlistenReady = value; },
|
||||
);
|
||||
attachAsyncPropertiesListener(
|
||||
listen<PropertiesActionRequest>(PROPERTIES_WINDOW_ACTION_REQUEST, event => {
|
||||
if (!disposed) void handleAction(event.payload);
|
||||
}),
|
||||
() => disposed,
|
||||
value => { unlistenAction = value; },
|
||||
);
|
||||
attachAsyncPropertiesListener(
|
||||
listen<string>(PROPERTIES_WINDOW_CLOSED, event => {
|
||||
if (disposed) return;
|
||||
const registration = windows.get(event.payload);
|
||||
windows.delete(event.payload);
|
||||
snapshotRevisions.delete(event.payload);
|
||||
snapshotCoalescer.cancel(event.payload);
|
||||
if (registration) actionsInFlight.delete(`${event.payload}:${registration.downloadId}`);
|
||||
}),
|
||||
() => disposed,
|
||||
value => { unlistenClosed = value; },
|
||||
);
|
||||
|
||||
const unsubscribeStore = useDownloadStore.subscribe((state, previous) => {
|
||||
for (const [windowLabel, registration] of windows) {
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock('@tauri-apps/api/event', () => ({
|
||||
|
||||
import {
|
||||
applySecretPatch,
|
||||
attachAsyncPropertiesListener,
|
||||
beginExclusivePropertiesAction,
|
||||
createFrameCoalescer,
|
||||
enqueuePropertiesAction,
|
||||
@@ -130,6 +131,7 @@ describe('Properties window bridge', () => {
|
||||
|
||||
it('derives truthful lifecycle commands from the current status', () => {
|
||||
expect(getPropertiesLifecycleAction('downloading')).toBe('pause');
|
||||
expect(getPropertiesLifecycleAction('queued')).toBe('pause');
|
||||
expect(getPropertiesLifecycleAction('retrying')).toBe('pause');
|
||||
expect(getPropertiesLifecycleAction('paused')).toBe('resume');
|
||||
expect(getPropertiesLifecycleAction('ready')).toBe('start');
|
||||
@@ -255,4 +257,39 @@ describe('Properties window bridge', () => {
|
||||
coalescer.cancelAll();
|
||||
expect(frames.size).toBe(0);
|
||||
});
|
||||
|
||||
it('unlistens a Tauri listener that resolves after bridge cleanup', async () => {
|
||||
let resolveListener!: (unlisten: () => void) => void;
|
||||
const listener = new Promise<() => void>(resolve => { resolveListener = resolve; });
|
||||
let disposed = true;
|
||||
let assigned = false;
|
||||
let unlistened = false;
|
||||
|
||||
attachAsyncPropertiesListener(
|
||||
listener,
|
||||
() => disposed,
|
||||
() => { assigned = true; },
|
||||
);
|
||||
resolveListener(() => { unlistened = true; });
|
||||
await listener;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(assigned).toBe(false);
|
||||
expect(unlistened).toBe(true);
|
||||
});
|
||||
|
||||
it('assigns a live Tauri listener while the bridge is mounted', async () => {
|
||||
let resolveListener!: (unlisten: () => void) => void;
|
||||
const listener = new Promise<() => void>(resolve => { resolveListener = resolve; });
|
||||
const unlisten = vi.fn();
|
||||
let assigned: (() => void) | undefined;
|
||||
|
||||
attachAsyncPropertiesListener(listener, () => false, value => { assigned = value; });
|
||||
resolveListener(unlisten);
|
||||
await listener;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(assigned).toBe(unlisten);
|
||||
expect(unlisten).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { emitTo } from '@tauri-apps/api/event';
|
||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
|
||||
import type { DownloadStatus } from './bindings/DownloadStatus';
|
||||
import type { DownloadItem } from './store/useDownloadStore';
|
||||
@@ -303,6 +304,25 @@ export const createFrameCoalescer = (
|
||||
};
|
||||
};
|
||||
|
||||
// Tauri listener registration is asynchronous. React StrictMode can unmount
|
||||
// an effect before `listen()` resolves; in that case assigning the late
|
||||
// unlisten callback after cleanup leaks a second bridge listener. A leaked
|
||||
// Properties host can process one click twice, observe the queued state from
|
||||
// the first action, and turn the intended resume into an immediate pause.
|
||||
export const attachAsyncPropertiesListener = <T extends UnlistenFn>(
|
||||
listener: Promise<T>,
|
||||
isDisposed: () => boolean,
|
||||
assign: (unlisten: T) => void,
|
||||
): void => {
|
||||
void listener.then(unlisten => {
|
||||
if (isDisposed()) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
assign(unlisten);
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
export const openPropertiesWindow = (downloadId: string): Promise<string> =>
|
||||
invoke('open_download_properties_window', { id: downloadId });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user