修复素材导出并发竞态

使用同步 ref 互斥量保护集合导出和单选导出

在导出 finally 中释放互斥量并保留 state 作为忙碌 UI

新增同一事件循环连续导出的回归测试
This commit is contained in:
2026-07-27 16:37:08 +08:00
parent ea1813fcc4
commit 3b4b6cac85
2 changed files with 64 additions and 5 deletions
@@ -65,6 +65,15 @@ function ExportWorkflowHarness({ layers }: { layers: CanvasLayer[] }) {
>
</button>
<button
type="button"
onClick={() => {
void workflow.exportCanvasAssets();
void workflow.exportCanvasAssets();
}}
>
</button>
<button
type="button"
onClick={() => workflow.exportLayerImage(layers[0] ?? null)}
@@ -163,6 +172,51 @@ describe('useImageCanvasAssetExportWorkflow', () => {
}
});
it('prevents concurrent collection exports before React re-renders', async () => {
const originalFetch = globalThis.fetch;
let resolveFetch: (response: Response) => void = () => {};
const fetchMock = vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
);
globalThis.fetch = fetchMock as typeof fetch;
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:mutex-export'),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
try {
render(
<ExportWorkflowHarness
layers={[createLayer('slow-export', { src: '/slow-export.png' })]}
/>,
);
fireEvent.click(
screen.getByRole('button', { name: '连续导出画布素材' }),
);
expect(fetchMock).toHaveBeenCalledTimes(1);
resolveFetch(new Response(new Blob(['image'], { type: 'image/png' })));
await waitFor(() => {
expect(screen.getByTestId('status').textContent).toBe(
'success:画布素材已导出',
);
});
} finally {
globalThis.fetch = originalFetch;
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('exports canvas assets into a zip with metadata and failed image records', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url: string) => {
@@ -1,5 +1,5 @@
import JSZip from 'jszip';
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import type {
CanvasAssetExportImage,
@@ -273,6 +273,7 @@ export function useImageCanvasAssetExportWorkflow({
const [assetExportStatus, setAssetExportStatus] =
useState<AssetExportStatus | null>(null);
const [isExportingAssets, setIsExportingAssets] = useState(false);
const isExportingAssetsRef = useRef(false);
const exportLayerImage = useCallback(
async (
@@ -347,7 +348,7 @@ export function useImageCanvasAssetExportWorkflow({
emptyMessage: string;
successMessage: string;
}) => {
if (isExportingAssets) {
if (isExportingAssetsRef.current) {
return;
}
const exportableLayers = targetLayers
@@ -361,6 +362,7 @@ export function useImageCanvasAssetExportWorkflow({
return;
}
isExportingAssetsRef.current = true;
setIsExportingAssets(true);
setAssetExportStatus(null);
@@ -609,10 +611,11 @@ export function useImageCanvasAssetExportWorkflow({
message: '素材导出失败',
});
} finally {
isExportingAssetsRef.current = false;
setIsExportingAssets(false);
}
},
[isExportingAssets, projectTitle],
[projectTitle],
);
const exportCanvasAssets = useCallback(
@@ -628,7 +631,7 @@ export function useImageCanvasAssetExportWorkflow({
const exportSelectedAssets = useCallback(
async (selectedLayers: CanvasLayer[]) => {
if (isExportingAssets) {
if (isExportingAssetsRef.current) {
return;
}
const exportableLayers = selectedLayers.filter(
@@ -642,11 +645,13 @@ export function useImageCanvasAssetExportWorkflow({
return;
}
if (exportableLayers.length === 1) {
isExportingAssetsRef.current = true;
setIsExportingAssets(true);
setAssetExportStatus(null);
try {
await exportLayerImage(exportableLayers[0] ?? null);
} finally {
isExportingAssetsRef.current = false;
setIsExportingAssets(false);
}
return;
@@ -658,7 +663,7 @@ export function useImageCanvasAssetExportWorkflow({
successMessage: '选中素材已导出',
});
},
[exportAssetCollection, exportLayerImage, isExportingAssets],
[exportAssetCollection, exportLayerImage],
);
return {