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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user