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:
@@ -6428,3 +6428,14 @@
|
||||
- 保留为已知缺口而非静默修正:「普通按钮不得创建第二个 operation」原文是绝对断言,但该保证只由 `existingOperation` 闸提供,而它只扫描内存 dialog 列表;占位可删之后,删掉再从源图发起会产生第二个 identity。文档改为如实描述现状并标注缺口与闭合方向(让本机账本参与防重),代码侧不在本次范围。文档的职责是描述系统实际行为,写一条做不到的保证比留一个标注清楚的缺口更糟。
|
||||
- 影响范围:仅文档。不改代码、不改测试。
|
||||
- 验证方式:`npm run check:encoding`;核对文档中不再残留「布局保存成功确认」「strict revision ACK」「从快照写入起算」等已推翻表述。
|
||||
|
||||
## 2026-08-05 legacy 内联账本一次性迁入本机;Undo 复活占位记为已知限制
|
||||
|
||||
- 背景:账本移出布局后,`hydrateCanvasGenerationDialog` 仍然认布局里的 legacy 内联快照,但**没有任何路径把它写进本机账本**;而 `serializeDialogReferences` 会在下一次保存时把内联快照剥成 `perfectPixelOperationId` 标记。先前提交声称「滚动部署期间的在途操作不会被一次性判死」只对了一半:**第一次 hydrate 活下来,第二次就变成 `failed + invalid`**,永久失去 exact retry 的 identity。
|
||||
- 决策:在 `applyProjectSnapshot` 读账本、`splitCanvasLayoutItems` 之后补一次性迁移——把带内联账本、本机却读不到、且**仍未收口**(`generating` / `pending-confirmation`)的 operation 写进本机账本。三个条件都必要:只补写缺失的(本机那份可能刚在 pre-POST flush 之后被重新锚定过,比布局里的新,不能覆盖);只补写未收口的(收口态本就不需要账本,迁移只会造出立刻被裁剪的垃圾条目)。影响范围一次性且有界,仅限部署那一刻仍在途的历史操作。
|
||||
- 未采纳:「删除占位后立即 flush 布局,压缩『被放弃的 operation 仍可能往画布插入图层』的竞态窗口」。核查后发现删除**已经**触发既有的 450ms 防抖自动保存(布局自动保存 effect 的依赖里就有 `canvasGenerationDialogs`),所以该改动只能在一个由服务端处理耗时(数秒)主导的竞态里省下 450 毫秒,代价却是让一个高频操作绕过防抖、增加 PATCH 量。收益与代价不成比例,不做。
|
||||
- 未采纳:「用户删除未收口占位后从源图重做时弹确认框」。完美像素免费,重复的最坏后果是素材库多一份;为此在常用路径上加一次确认属于给用户制造摩擦。另外账本里能用来匹配同源的只有 `request.sourceResourceId`,纯本地图层根本匹配不到——一个覆盖不全的提醒比没有提醒更容易让人误以为安全。
|
||||
- 记为已知限制而非缺陷:删除仍在处理中的占位、待原请求收口后再 `Ctrl+Z` 撤销删除,复活的占位在**当前会话内**不再被对账,会一直显示处理中;刷新即自愈,用户也可以再删一次。根因是 `observedPerfectPixelRecoveryKeysRef` 同时承担「并发保护」和「本会话已驱动过」两种语义。已推演的四种修法各有硬伤:删除瞬间剪 key 会被同一轮的 claim 检查重新标记;改成「重新出现时剪」会被 `applyProjectSnapshot` 的整批替换误触发;由删除路径显式清观察记录需要向四个删除入口铺跨 hook 通路;不把未收口 operation 放进可撤销历史则直接砍掉「误删可撤销」。为一个刷新即愈的限制付上述任一代价都不划算,留到重构该记账时一并解决。
|
||||
- 影响范围:`useImageCanvasProjectPersistence.ts` 的 `applyProjectSnapshot`。不修改服务端、SpacetimeDB schema 或对外契约。
|
||||
- 验证方式:新增「legacy 内联账本在加载后被迁入本机,且剥离内联快照后仍能凭本机账本往返回有效的 `pending-confirmation` 占位」用例,已实证:回退迁移后报 `expected +0 to be 1`。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已同步 legacy 迁移要求与 Undo 已知限制)。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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