串行化画板布局自动保存
让画板布局保存共用单飞队列避免慢 PATCH 并发 覆盖慢保存期间只发送最新待保存快照 补充发布入口 429 排查与自动保存文档
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user