diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8498b7e82..a8fd39af2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6563,3 +6563,14 @@ - 测试缺口的根因:原用例的夹具 `durablePerfectPixelDialog` 带着 `perfectPixelOperation`,编码了与判据相同的错误假设,结构上不可能覆盖 operation 形成之前的窗口。夹具已补上 marker 以还原真实形状,并新增「只有 marker、尚无账本」的用例,已实证:回退判据后报 `expected true to be false`。 - 影响范围:`useCanvasGenerationDialogs.ts` 的判据、`useImageCanvasGenerationWorkflow.ts` 的占位创建。不修改服务端、SpacetimeDB schema 或对外契约。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 布局 flush 不再等待封面链 + +- 缺陷:`flushProjectPersistence` 显式关掉 `queueProjectLayoutSave` 内建的 fire-and-forget 封面分支(传 `persistCover: false`),自己另起一份并在函数最后 `await coverSave`。于是每一个 `await flushProjectPersistence(...)` 的调用方都被挂在封面链后面。封面渲染要为**每个可绘制图层**取 signed URL、再用 `new Image()` 加载——那个 Image 只有 `onload` / `onerror`,**没有 timeout、没有 AbortSignal**,外层的 `try { } catch { }` 只接得住 reject、接不住「永不 settle」。一张图不 settle,生成 POST 就永远发不出去。 +- 归因:这是 2026-08-05「完美像素请求账本移出项目布局」把严格通道与普通 flush 合并成一条路径时引入的**回归**。改动前 `if (requireSuccess) { …; await strictCompletion.promise; return; }` 在封面链启动之前就返回,封面与 pre-POST 路径是结构性隔离的。图集拆分(`void flushProjectPersistence().then(() => splitSelectedIconSpritesheet(layer))`)一直走非严格路径,因此它的暴露是既有的、不是本次引入;但两者同源,一并解开。 +- 影响面分级:**必然发生**的是延迟——封面签名含未量化的 `viewport.x/y/scale`,而完美像素创建占位时 `openPlacedCanvasGenerationDialog` 会 `setViewport(centerViewportOnPlacement(...))`,所以几乎每次调用都会触发全量重渲染(逐图层取 signed URL + 加载 + 渲染 + 上传 OSS + 登记资源),这些与服务端那个 409 前置毫无关系。**可能发生**的是永久挂死,此时 `snapSelectedLayerToPerfectPixels` 的 `finally` 永不执行,图层锁与 inline 占位归属登记被永久持有;用户即使删掉占位,闸的另一半 `perfectPixelLayerIdsRef.current.has(sourceLayer.id)` 仍为真且**静默 return**,本会话内再点完美像素不会有任何反应。图集拆分没有这层脏状态——它的锁在 `splitSelectedIconSpritesheet` 函数体内才取,flush 挂住时根本没被调用,表现只是「点击无反馈」。 +- 决策(删除式修复):删掉 flush 里的 `persistCover: false` 覆盖、独立的 `const coverSave = persistProjectCoverSnapshot(...)` 与末尾的 `await coverSave`,让封面回到 `queueProjectLayoutSave` 内建的 fire-and-forget 分支。参数逐项等价(`layoutInput.viewport` 即 `coverDisplayViewport`、同一个 `refs.layersRef.current`、flush 不传 `delayMs` 故走立即分支),**封面照存,只是不再有人等它**。未采纳「给 flush 加一个跳过封面的选项」:那会把同一个结构性问题留在图集拆分身上,并且多一个需要每个调用方正确设置的开关。 +- 代价:`returnToProjects` 不再等封面就跳转。该保护本就很薄——跳转是 SPA 路由切换而非页面卸载,fire-and-forget 的 promise 在同一 JS 上下文里会跑完;真正会打断它的是浏览器关闭/硬刷新,而 `await` 在 `beforeunload` 里同样救不了。实际损失只是「点返回后立刻关标签页」这个窄窗口里封面可能没传完,而封面是缩略图、下次任意保存会重新生成。 +- 遗留(建议单开,不在本次范围):`loadProjectCoverImage` 里无 timeout / 无 AbortSignal 的 `new Image()` 本身仍是隐患,自动保存路径一样会踩。本次只是把它移出生成链的关键路径,没有消除它。 +- 影响范围:`useImageCanvasProjectPersistence.ts` 的 `flushProjectPersistence`。不改服务端、不改契约。 +- 验证方式:既有用例「flush 等待封面缓存」翻转为「flush 不等封面、但封面链照常跑完并完成上传与资源登记」;新增「封面永不 settle 时 flush 仍返回」——用永不 resolve 的 blob 模拟 `new Image()` 不 settle,并断言 `createProjectCoverSnapshotBlob` 确实被调用过以防用例空过。已实证:回退修复后新用例报 `expected 'false' to be 'true'`。运行 `npx vitest run src/components/image-editor src/components/platform-entry src/services`(101 文件 / 1241 项)、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 51b80e849..e29dd303f 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -2527,7 +2527,12 @@ describe('useImageCanvasProjectPersistence', () => { ); }); - it('flushes the latest layout and waits for the local cover cache before returning', async () => { + it('flushes the latest layout without waiting for the cover snapshot chain', async () => { + // 中文注释:flush 曾经 `await` 封面持久化,于是每个 await flush 的调用方(完美像素提交、 + // 人工重试、图集拆分)都被挂在封面链后面。封面渲染要为每个可绘制图层取 signed URL 再 + // `new Image()` 加载,而那个 Image 没有 timeout 也没有 AbortSignal——一张图不 settle, + // 生成 POST 就永远发不出去。await flush 的调用方要的只是「布局已持久化」这一个前置, + // 封面与它无关。本用例钉住:封面仍然被发起,但 flush 不等它。 const coverBlob = new Blob(['cover'], { type: 'image/webp' }); const coverCacheWrite = createDeferred(); createProjectCoverSnapshotBlobMock.mockResolvedValue(coverBlob); @@ -2567,19 +2572,52 @@ describe('useImageCanvasProjectPersistence', () => { blob: coverBlob, }); }); - expect(screen.getByTestId('flush-completed').textContent).toBe('false'); - coverCacheWrite.resolve(); + // 中文注释:封面缓存写入仍然挂着,flush 必须已经返回——这正是删除 `await coverSave` + // 要钉住的行为。 await waitFor(() => { expect(screen.getByTestId('flush-completed').textContent).toBe('true'); }); - expect(uploadEditorMediaAssetFileMock).toHaveBeenCalled(); + + // 中文注释:不等 ≠ 不做。封面链继续跑到底,上传与资源登记照常发生。 + coverCacheWrite.resolve(); + await waitFor(() => { + expect(uploadEditorMediaAssetFileMock).toHaveBeenCalled(); + }); expect(createEditorProjectResourceMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ assetKind: 'project-cover-snapshot' }), ); }); + it('returns from flush even when the cover snapshot never settles', async () => { + // 中文注释:真实渲染器里的 `new Image()` 无 timeout / 无 AbortSignal,一张图既不 load + // 也不 error 时,封面链的 Promise 永不 settle。用永不 resolve 的 blob 模拟该形状—— + // 若 flush 仍 `await` 封面,本用例会挂到超时。 + createProjectCoverSnapshotBlobMock.mockReturnValue(new Promise(() => {})); + + render(); + + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + act(() => { + screen.getByRole('button', { name: 'append server resource' }).click(); + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + await waitFor(() => { + expect(screen.getByTestId('viewport').textContent).toBe('10,5,0.5'); + }); + + act(() => { + screen.getByRole('button', { name: 'flush project persistence' }).click(); + }); + + await waitFor(() => { + expect(screen.getByTestId('flush-completed').textContent).toBe('true'); + }); + expect(createProjectCoverSnapshotBlobMock).toHaveBeenCalled(); + expect(putEditorProjectCoverCacheMock).not.toHaveBeenCalled(); + }); + it('loads and saves viewport scale with display zoom semantics', async () => { render(); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index e138e0b2a..f4e1509c8 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -1010,15 +1010,13 @@ export function useImageCanvasProjectPersistence({ canvasBackgroundColor: refs.canvasBackgroundColorRef.current, }), }; - queueProjectLayoutSave(nextProjectId, layoutInput, { - persistCover: false, - }); - - const coverSave = persistProjectCoverSnapshot( - nextProjectId, - coverDisplayViewport, - refs.layersRef.current, - ); + // 中文注释:封面走 `queueProjectLayoutSave` 内建的 fire-and-forget 分支,flush **不等** + // 它。此前 flush 显式关掉那条分支、自己起一份并在最后 `await`,于是任何 await flush 的 + // 调用方都被挂在封面链后面——而封面渲染要为每个可绘制图层取 signed URL 再 `new Image()` + // 加载,那个 Image 没有 timeout 也没有 AbortSignal,一张图不 settle 就永远不 settle。 + // 完美像素与图集拆分 await flush 只为满足一个服务端前置:占位/图层布局已经持久化。那个 + // 前置在下面的队列排干时就已满足,封面与它无关,不该挡在 POST 前面。 + queueProjectLayoutSave(nextProjectId, layoutInput); while ( activeProjectLayoutSavePromiseRef.current || pendingProjectLayoutSaveRef.current @@ -1037,14 +1035,8 @@ export function useImageCanvasProjectPersistence({ break; } } - await coverSave; }, - [ - persistProjectCoverSnapshot, - queueProjectLayoutSave, - refs, - runPendingProjectLayoutSave, - ], + [queueProjectLayoutSave, refs, runPendingProjectLayoutSave], ); const applyCreatedProjectResourceLayer = useCallback(