legacy 内联账本一次性迁入本机

账本移出布局后,hydrate 仍认布局里的 legacy 内联快照,但没有任何路径把它
写进本机账本;而序列化会在下一次保存时把内联快照剥成 perfectPixelOperationId
标记。先前提交声称「滚动部署期间的在途操作不会被一次性判死」只对了一半:
第一次 hydrate 活下来,第二次就变成 failed + invalid,永久失去 exact retry
的 identity。

在 applyProjectSnapshot 读账本、splitCanvasLayoutItems 之后补一次性迁移。
三个条件都必要:只补写本机缺失的(本机那份可能刚在 pre-POST flush 之后被
重新锚定过,比布局里的新);只补写未收口的(收口态本就不需要账本)。

未采纳「删除后立即 flush 压缩竞态窗口」:核查发现删除已经触发既有的 450ms
防抖自动保存,该改动只能在由服务端处理耗时(数秒)主导的竞态里省下 450 毫秒,
代价是让高频操作绕过防抖。

未采纳「重做时弹确认框」:完美像素免费,最坏是素材库多一份;在常用路径上加
确认属于制造摩擦,且账本只能按 sourceResourceId 匹配同源,纯本地图层匹配不到,
覆盖不全的提醒比没有提醒更容易让人误以为安全。

Undo 复活占位在当前会话内不再对账(刷新即自愈)记为已知限制,连同根因与四种
修法各自的硬伤一并写入专题文档与决策日志。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 09:06:28 +00:00
parent ef1807e350
commit 11a2c5afe0
4 changed files with 153 additions and 2 deletions
@@ -12,12 +12,15 @@ import type {
import {
DEFAULT_CANVAS_BACKGROUND_COLOR,
normalizeCanvasBackgroundHex,
serializeCanvasLayout,
splitCanvasLayoutItems,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
} from './ImageCanvasEditorTypes';
import { readPerfectPixelOperations } from './perfectPixelOperationStore';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import {
mergeAuthoritativeCanvasLayoutWithPendingLocalLayout,
@@ -1023,6 +1026,120 @@ describe('useImageCanvasProjectPersistence', () => {
});
});
it('migrates a legacy inline perfect-pixel ledger into local storage on load', async () => {
// 中文注释:布局内联账本是账本移出布局之前的 legacy 形状。第一次 hydrate 还能认它,但
// 下一次保存会把它剥成 perfectPixelOperationId 标记,此后再没有路径能补写本机账本——
// 不迁移的话,部署那一刻仍在途的操作会在第二次加载变成 failed + invalid,永久失去
// exact retry 的 identity。
window.localStorage.clear();
const dialogId = 'generation-dialog-legacy-inflight';
const submittedAt = Date.now();
const legacyOperation = {
version: 1 as const,
kind: 'perfect-pixel' as const,
operationId: dialogId,
taskId: `pixel-art-snap-${dialogId}`,
request: {
sourceImageSrc: 'generated-images/editor/legacy-source.png',
projectId: 'editor-project-default',
assetLabel: '源图 · 完美像素',
canvasCompletion: {
dialogId,
title: '源图 · 完美像素',
placeholder: { ...STRICT_PLACEHOLDER },
},
},
submittedAt,
reconcileUntil: submittedAt + 75_000,
};
const legacyDialogItem = {
itemType: 'generation-dialog',
layerId: `generation-dialog:${dialogId}`,
resourceId: `generation-dialog:${dialogId}`,
dialog: {
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
status: 'pending-confirmation',
composerOpen: false,
sourceLayerId: 'layer-source',
perfectPixelOperation: legacyOperation,
placeholder: { ...STRICT_PLACEHOLDER },
},
} as unknown as EditorProjectLayerSnapshot;
loadOrCreateRecentEditorProjectMock.mockResolvedValue({
projectId: 'editor-project-default',
title: '空画布项目',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [legacyDialogItem],
revision: 3,
layoutStorageVersion: 0,
updatedAt: '2026-08-05T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [legacyDialogItem],
resources: [],
updatedAt: '2026-08-05T00:00:00.000Z',
});
render(<PerfectPixelProjectPersistenceHarness />);
await waitFor(() => {
expect(screen.getByTestId('strict-project-id').textContent).toBe(
'editor-project-default',
);
});
await waitFor(() => {
expect(
readPerfectPixelOperations('user-test', 'editor-project-default').size,
).toBe(1);
});
const migrated = readPerfectPixelOperations(
'user-test',
'editor-project-default',
).get(dialogId);
expect(migrated).toEqual(legacyOperation);
// 中文注释:往返验证——布局再保存一次只剩标记,此时只有本机账本能把它救回来。
const strippedLayout = serializeCanvasLayout({
layers: [],
canvasGenerationDialogs: [
{
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
status: 'pending-confirmation',
composerOpen: false,
perfectPixelOperationId: dialogId,
perfectPixelOperation: migrated,
} as unknown as CanvasGenerationDialogState,
],
});
expect(JSON.stringify(strippedLayout)).not.toContain(
'"perfectPixelOperation"',
);
const { generationDialogs } = splitCanvasLayoutItems(
strippedLayout,
new Map(),
'user-test',
readPerfectPixelOperations('user-test', 'editor-project-default'),
);
expect(generationDialogs[0]).toMatchObject({
id: dialogId,
status: 'pending-confirmation',
});
expect(generationDialogs[0]?.perfectPixelOperation).toEqual(
legacyOperation,
);
expect(generationDialogs[0]).not.toHaveProperty(
'perfectPixelOperationInvalid',
);
});
it('persists the perfect-pixel marker without leaking the request ledger into the layout', async () => {
render(<PerfectPixelProjectPersistenceHarness />);
await waitFor(() => {
@@ -49,7 +49,10 @@ import {
firstSelectedLayerId,
normalizeCanvasSelectionIds,
} from './ImageCanvasSelectionModel';
import { readPerfectPixelOperations } from './perfectPixelOperationStore';
import {
readPerfectPixelOperations,
savePerfectPixelOperation,
} from './perfectPixelOperationStore';
type ProjectResourceOptions = {
onCreated?: (resourceId: string) => void;
@@ -1343,6 +1346,26 @@ export function useImageCanvasProjectPersistence({
currentUserId,
localPerfectPixelOperations,
);
// 中文注释:legacy 内联账本一次性迁到本机。布局里的内联快照会在下一次保存时被剥成
// `perfectPixelOperationId` 标记,此后再没有任何路径能把它补写进本机账本——不迁移
// 的话,部署那一刻仍在途的操作会在第二次加载变成 `failed + invalid`,永久失去 exact
// retry 的 identity。只补写缺失的(本机那份可能刚被重新锚定过,比布局里的新),也只
// 补写未收口的(收口态本就不需要账本)。
for (const dialog of generationDialogs) {
const operation = dialog.perfectPixelOperation;
if (
operation &&
!localPerfectPixelOperations.has(dialog.id) &&
(dialog.status === 'generating' ||
dialog.status === 'pending-confirmation')
) {
savePerfectPixelOperation(
currentUserId,
project.projectId,
operation,
);
}
}
const hydratedLayers = layerItems
.map((layer) => hydrateLayer(layer, resourcesById))
.filter((layer): layer is CanvasLayer => Boolean(layer));