修复素材导出并发竞态
使用同步 ref 互斥量保护集合导出和单选导出 在导出 finally 中释放互斥量并保留 state 作为忙碌 UI 新增同一事件循环连续导出的回归测试
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user