串行化画板布局自动保存

让画板布局保存共用单飞队列避免慢 PATCH 并发

覆盖慢保存期间只发送最新待保存快照

补充发布入口 429 排查与自动保存文档
This commit is contained in:
2026-06-23 15:16:50 +08:00
parent 0579741f64
commit 47bdc73b93
4 changed files with 216 additions and 20 deletions
@@ -232,6 +232,26 @@ function ProjectPersistenceHarness({
>
move generation
</button>
<button
type="button"
onClick={() => {
setGenerationDialogs((currentDialogs) =>
currentDialogs.map((dialog) =>
dialog.id === 'generation-dialog-1'
? {
...dialog,
prompt: '第二次移动后的生成器',
placeholder: dialog.placeholder
? { ...dialog.placeholder, x: 120 }
: dialog.placeholder,
}
: dialog,
),
);
}}
>
move generation again
</button>
</div>
);
}
@@ -528,6 +548,113 @@ describe('useImageCanvasProjectPersistence', () => {
vi.useRealTimers();
});
it('serializes project layout saves and only sends the latest pending snapshot while a save is running', async () => {
const savedDialog: CanvasGenerationDialogState = {
id: 'generation-dialog-1',
mode: 'generate',
prompt: '刷新后继续生成',
status: 'generating',
composerOpen: false,
placeholder: {
x: 42,
y: 56,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
};
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-default',
title: '空画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
itemType: 'generation-dialog',
layerId: 'generation-dialog:generation-dialog-1',
resourceId: 'generation-dialog:generation-dialog-1',
dialog: savedDialog,
},
],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
const saveResolvers: Array<(value: unknown) => void> = [];
saveEditorProjectLayoutMock.mockImplementation(
() =>
new Promise((resolve) => {
saveResolvers.push(resolve);
}),
);
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
vi.useFakeTimers();
act(() => {
screen.getByRole('button', { name: 'move generation' }).click();
});
act(() => {
vi.advanceTimersByTime(451);
});
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
expect(saveEditorProjectLayoutMock).toHaveBeenLastCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
itemType: 'generation-dialog',
dialog: expect.objectContaining({
id: 'generation-dialog-1',
prompt: '移动后的生成器',
placeholder: expect.objectContaining({ x: 88 }),
}),
}),
]),
}),
);
act(() => {
screen.getByRole('button', { name: 'move generation again' }).click();
});
act(() => {
vi.advanceTimersByTime(451);
});
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
await act(async () => {
saveResolvers[0]?.({
projectId: 'editor-project-default',
title: '空画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:01.000Z',
});
await Promise.resolve();
});
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2);
expect(saveEditorProjectLayoutMock).toHaveBeenLastCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
itemType: 'generation-dialog',
dialog: expect.objectContaining({
id: 'generation-dialog-1',
prompt: '第二次移动后的生成器',
placeholder: expect.objectContaining({ x: 120 }),
}),
}),
]),
}),
);
vi.useRealTimers();
});
it('saves generated layers together with their generator snapshots', async () => {
const savedDialog: CanvasGenerationDialogState = {
id: 'generation-dialog-1',
@@ -32,6 +32,11 @@ type PendingProjectResourceLayer = {
options: ProjectResourceOptions;
};
type PendingProjectLayoutSave = {
projectId: string;
input: Parameters<typeof saveEditorProjectLayout>[1];
};
type ImageCanvasProjectPersistenceRefs = {
layersRef: RefObject<CanvasLayer[]>;
viewportRef: RefObject<CanvasViewport>;
@@ -90,6 +95,9 @@ export function useImageCanvasProjectPersistence({
const pendingProjectResourceLayersRef = useRef<PendingProjectResourceLayer[]>(
[],
);
const pendingProjectLayoutSaveRef =
useRef<PendingProjectLayoutSave | null>(null);
const isProjectLayoutSaveRunningRef = useRef(false);
const saveTimerRef = useRef<number | null>(null);
const [projectId, setProjectId] = useState<string | null>(null);
const [isProjectReady, setIsProjectReady] = useState(false);
@@ -103,6 +111,68 @@ export function useImageCanvasProjectPersistence({
restoreCanvasGenerationDialogs,
} = setters;
const runPendingProjectLayoutSave = useCallback(
function runPendingProjectLayoutSave() {
if (isProjectLayoutSaveRunningRef.current) {
return;
}
const pendingSave = pendingProjectLayoutSaveRef.current;
if (!pendingSave) {
return;
}
pendingProjectLayoutSaveRef.current = null;
isProjectLayoutSaveRunningRef.current = true;
Promise.resolve(
saveEditorProjectLayout(pendingSave.projectId, pendingSave.input),
)
.catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
})
.finally(() => {
isProjectLayoutSaveRunningRef.current = false;
if (
pendingProjectLayoutSaveRef.current &&
saveTimerRef.current === null
) {
runPendingProjectLayoutSave();
}
});
},
[openEditorLoginModal],
);
const queueProjectLayoutSave = useCallback(
(
nextProjectId: string,
input: Parameters<typeof saveEditorProjectLayout>[1],
options: { delayMs?: number } = {},
) => {
pendingProjectLayoutSaveRef.current = {
projectId: nextProjectId,
input,
};
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
const delayMs = options.delayMs ?? 0;
if (delayMs > 0) {
saveTimerRef.current = window.setTimeout(() => {
saveTimerRef.current = null;
runPendingProjectLayoutSave();
}, delayMs);
return;
}
runPendingProjectLayoutSave();
},
[runPendingProjectLayoutSave],
);
const createProjectResourceForLayer = useCallback(
(layer: CanvasLayer, options: ProjectResourceOptions = {}) => {
const readyProjectId = projectIdRef.current;
@@ -154,17 +224,13 @@ export function useImageCanvasProjectPersistence({
refs.layersRef.current = nextLayers;
setLayers(nextLayers);
if (nextLayers.length) {
void saveEditorProjectLayout(readyProjectId, {
queueProjectLayoutSave(readyProjectId, {
viewport: viewportToCanvasDisplayViewport(refs.viewportRef.current),
layers: serializeCanvasLayout({
layers: nextLayers,
canvasGenerationDialogs:
refs.canvasGenerationDialogsRef.current,
}),
}).catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
});
}
})
@@ -174,7 +240,7 @@ export function useImageCanvasProjectPersistence({
}
});
},
[openEditorLoginModal, refs, setLayers],
[openEditorLoginModal, queueProjectLayoutSave, refs, setLayers],
);
const applyProjectSnapshot = useCallback(
@@ -309,35 +375,30 @@ export function useImageCanvasProjectPersistence({
if (!projectId || !isProjectReady) {
return undefined;
}
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = window.setTimeout(() => {
saveEditorProjectLayout(projectId, {
queueProjectLayoutSave(
projectId,
{
viewport: viewportToCanvasDisplayViewport(viewport),
layers: serializeCanvasLayout({
layers,
canvasGenerationDialogs,
}),
}).catch((error: unknown) => {
if (isEditorAuthError(error)) {
openEditorLoginModal();
}
});
}, 450);
},
{ delayMs: 450 },
);
return () => {
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}, [
isProjectReady,
canvasGenerationDialogs,
layers,
openEditorLoginModal,
projectId,
queueProjectLayoutSave,
refs,
viewport,
]);