修复:加固BGM助手前端生命周期

- Prompt 助手 scope 改为账号与项目联合绑定:hook 接收 currentUserId / projectId 两个原始值,内部生成稳定 scope key,调用方无法再传入按引用比较的对象。
- 删除 render 阶段的 scope ref 改写,scope 身份只在已提交的 React 生命周期中推进,被放弃的并发 render 不再污染迟到响应判断。
- scope 切换改用 useLayoutEffect 在 commit 阶段同步原子清理:中止旧请求、清空 active operation、reset 状态模型、清空错误与公开 dialog 状态、重建已知 dialog 集合。修复新 scope 已 commit 而 passive effect 未执行时,旧账号 / 旧项目候选被写入同 ID dialog 的竞态。
- 迟到响应判定为 stale 时先让状态模型完成 reject 再移除 active operation,不再残留 completing / simplifying 忙状态。
- Prompt 助手 client 增加 EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS = 180_000 并透传 timeoutMs,仍不启用前端重试;超时按现有失败路径恢复 idle,保留 canonical Prompt,不产生撤销快照。
- 补充定向测试:账号切换中止与清理、scope 清理早于任何 passive effect 的相位回归、等值 scope props 不误判 stale、StrictMode 双渲染、浏览器超时恢复,以及两个助手路由的超时透传与无重试断言。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 14:22:50 +00:00
parent 825b0ccf6c
commit 3fbdf210be
5 changed files with 404 additions and 29 deletions
@@ -1,7 +1,16 @@
/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react';
import { type SetStateAction, useCallback, useRef, useState } from 'react';
import {
type ReactNode,
type SetStateAction,
StrictMode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
@@ -34,7 +43,8 @@ type Deferred<T> = {
type HarnessProps = {
initialDialogs: CanvasGenerationDialogState[];
scopeKey?: string | null;
currentUserId?: string | null;
projectId?: string | null;
};
function createDeferred<T>(): Deferred<T> {
@@ -81,7 +91,26 @@ function requireDialog(
return dialog;
}
function usePromptAssistHarness({ initialDialogs, scopeKey }: HarnessProps) {
/**
* 记录同一次 commit 内的相位顺序。scope 清理必须发生在 commit 的 layout 阶段:
* 排到 passive 阶段就会留出“新 scope 已 commit、旧 operation 仍有效”的窗口,
* 而 Testing Library 的 rerender / act 会自动冲刷 passive effect,行为断言覆盖不到。
*/
function ScopePhaseProbe({ record }: { record: (phase: string) => void }) {
useLayoutEffect(() => {
record('probe-layout');
});
useEffect(() => {
record('probe-passive');
});
return null;
}
function usePromptAssistHarness({
initialDialogs,
currentUserId,
projectId,
}: HarnessProps) {
const [dialogs, setDialogs] = useState(initialDialogs);
const dialogsRef = useRef(dialogs);
dialogsRef.current = dialogs;
@@ -161,7 +190,8 @@ function usePromptAssistHarness({ initialDialogs, scopeKey }: HarnessProps) {
canvasGenerationDialogs: dialogs,
getCanvasGenerationDialogById,
updateCanvasGenerationDialogById,
scopeKey,
currentUserId,
projectId,
});
return {
@@ -203,7 +233,8 @@ describe('useImageCanvasBackgroundMusicPromptAssist', () => {
errorMessage: '旧生成错误',
}),
],
scopeKey: 'project-a',
currentUserId: 'user-a',
projectId: 'project-a',
}),
);
@@ -606,7 +637,7 @@ describe('useImageCanvasBackgroundMusicPromptAssist', () => {
expect(outcome.reason).toBe('applied');
});
it('aborts and invalidates an active request when scopeKey changes', async () => {
it('aborts and invalidates an active request when projectId changes', async () => {
const oldScopeRequest = createDeferred<PromptAssistResponse>();
const newScopeRequest = createDeferred<PromptAssistResponse>();
let signal: AbortSignal | undefined;
@@ -623,13 +654,14 @@ describe('useImageCanvasBackgroundMusicPromptAssist', () => {
.mockReturnValueOnce(newScopeRequest.promise);
const initialDialogs = [createDialog('dialog-a', ' \u0085森林冒险\u3000')];
const { result, rerender } = renderHook(
({ scopeKey }: { scopeKey: string }) =>
({ projectId }: { projectId: string }) =>
usePromptAssistHarness({
initialDialogs,
scopeKey,
currentUserId: 'user-a',
projectId,
}),
{
initialProps: { scopeKey: 'project-a' },
initialProps: { projectId: 'project-a' },
},
);
@@ -637,7 +669,7 @@ describe('useImageCanvasBackgroundMusicPromptAssist', () => {
act(() => {
action = result.current.promptAssist.completePrompt('dialog-a');
});
rerender({ scopeKey: 'project-b' });
rerender({ projectId: 'project-b' });
expect(signal?.aborted).toBe(true);
expect(result.current.promptAssist.dialogStates).toEqual({});
@@ -672,6 +704,274 @@ describe('useImageCanvasBackgroundMusicPromptAssist', () => {
);
});
it('aborts requests and clears snapshots when only currentUserId changes', async () => {
const staleRequest = createDeferred<PromptAssistResponse>();
let signal: AbortSignal | undefined;
promptAssistClientMocks.complete
.mockResolvedValueOnce(promptAssistResponse('完整的森林冒险背景音乐'))
.mockImplementationOnce(
(
_input: { currentPrompt: string },
options: { signal?: AbortSignal },
) => {
signal = options.signal;
return staleRequest.promise;
},
);
const initialDialogs = [createDialog('dialog-a', '森林冒险')];
const { result, rerender } = renderHook(
({ currentUserId }: { currentUserId: string }) =>
usePromptAssistHarness({
initialDialogs,
currentUserId,
projectId: 'project-a',
}),
{
initialProps: { currentUserId: 'user-a' },
},
);
await act(async () => {
await result.current.promptAssist.completePrompt('dialog-a');
});
expect(
result.current.promptAssist.getDialogState('dialog-a')
.undoPromptSnapshot,
).toBe('森林冒险');
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
act(() => {
action = result.current.promptAssist.completePrompt('dialog-a');
});
rerender({ currentUserId: 'user-b' });
expect(signal?.aborted).toBe(true);
expect(result.current.promptAssist.dialogStates).toEqual({});
expect(
result.current.promptAssist.getDialogState('dialog-a'),
).toMatchObject({
status: 'idle',
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
errorMessage: null,
});
const staleOutcome = await resolveAction(
staleRequest,
'旧账号的迟到结果',
action,
);
expect(staleOutcome.reason).toBe('stale');
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'完整的森林冒险背景音乐',
);
expect(result.current.promptAssist.getDialogState('dialog-a')).toMatchObject(
{
status: 'idle',
undoPromptSnapshot: null,
},
);
});
it('invalidates old-scope operations inside the commit, before any passive effect runs', async () => {
const staleRequest = createDeferred<PromptAssistResponse>();
let signal: AbortSignal | undefined;
promptAssistClientMocks.complete.mockImplementationOnce(
(
_input: { currentPrompt: string },
options: { signal?: AbortSignal },
) => {
signal = options.signal;
return staleRequest.promise;
},
);
const phases: string[] = [];
const initialDialogs = [createDialog('dialog-a', '森林冒险')];
const { result, rerender } = renderHook(
({ currentUserId }: { currentUserId: string }) =>
usePromptAssistHarness({
initialDialogs,
currentUserId,
projectId: 'project-a',
}),
{
initialProps: { currentUserId: 'user-a' },
wrapper: ({ children }: { children: ReactNode }) => (
<>
<ScopePhaseProbe record={(phase) => phases.push(phase)} />
{children}
</>
),
},
);
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
act(() => {
action = result.current.promptAssist.completePrompt('dialog-a');
});
signal?.addEventListener('abort', () => phases.push('abort'), {
once: true,
});
// 只观察 scope 切换这一次 commit 的相位顺序。
phases.length = 0;
rerender({ currentUserId: 'user-b' });
expect(phases[0]).toBe('probe-layout');
expect(phases).toContain('abort');
expect(phases.indexOf('abort')).toBeLessThan(
phases.indexOf('probe-passive'),
);
expect(signal?.aborted).toBe(true);
expect(result.current.promptAssist.dialogStates).toEqual({});
const staleOutcome = await resolveAction(
staleRequest,
'旧账号的迟到结果',
action,
);
expect(staleOutcome.reason).toBe('stale');
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'森林冒险',
);
});
it('keeps an in-flight operation valid across renders that recreate equal scope props', async () => {
const request = createDeferred<PromptAssistResponse>();
promptAssistClientMocks.complete.mockReturnValueOnce(request.promise);
const initialDialogs = [createDialog('dialog-a', '森林冒险')];
const { result, rerender } = renderHook(
({ currentUserId, projectId }: HarnessProps) =>
usePromptAssistHarness({
initialDialogs,
currentUserId,
projectId,
}),
{
initialProps: {
initialDialogs,
currentUserId: 'user-a',
projectId: 'project-a',
},
},
);
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
act(() => {
action = result.current.promptAssist.completePrompt('dialog-a');
});
// scope 身份必须按值判定:每次 render 重新构造等值 props 不得让运行中的
// operation 变成 stale。
rerender({
initialDialogs,
currentUserId: 'user-a',
projectId: 'project-a',
});
const outcome = await resolveAction(
request,
'完整的森林冒险背景音乐',
action,
);
expect(outcome.reason).toBe('applied');
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'完整的森林冒险背景音乐',
);
expect(result.current.promptAssist.getDialogState('dialog-a')).toMatchObject(
{
status: 'idle',
undoPromptSnapshot: '森林冒险',
},
);
});
it('completes without sticking in a busy state under StrictMode double rendering', async () => {
const request = createDeferred<PromptAssistResponse>();
promptAssistClientMocks.complete.mockReturnValueOnce(request.promise);
const { result } = renderHook(
() =>
usePromptAssistHarness({
initialDialogs: [createDialog('dialog-a', '森林冒险')],
currentUserId: 'user-a',
projectId: 'project-a',
}),
{ wrapper: StrictMode },
);
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
act(() => {
action = result.current.promptAssist.completePrompt('dialog-a');
});
expect(result.current.promptAssist.getDialogState('dialog-a').status).toBe(
'completing',
);
const outcome = await resolveAction(
request,
'完整的森林冒险背景音乐',
action,
);
expect(outcome.reason).toBe('applied');
expect(result.current.promptAssist.getDialogState('dialog-a')).toMatchObject(
{
status: 'idle',
operationId: null,
undoPromptSnapshot: '森林冒险',
},
);
});
it('returns to idle without an undo snapshot when the browser request times out', async () => {
const request = createDeferred<PromptAssistResponse>();
promptAssistClientMocks.complete.mockReturnValueOnce(request.promise);
const { result } = renderHook(() =>
usePromptAssistHarness({
initialDialogs: [createDialog('dialog-a', ' …森林冒险 ')],
currentUserId: 'user-a',
projectId: 'project-a',
}),
);
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
act(() => {
action = result.current.promptAssist.completePrompt('dialog-a');
});
const timeoutError = Object.assign(new Error('请求超时:180000ms'), {
name: 'TimeoutError',
});
let outcome!: Awaited<typeof action>;
await act(async () => {
request.reject(timeoutError);
outcome = await action;
});
expect(outcome).toMatchObject({
started: true,
applied: false,
reason: 'failed',
errorMessage: '请求超时:180000ms',
});
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'森林冒险',
);
expect(result.current.promptAssist.getDialogState('dialog-a')).toMatchObject(
{
status: 'idle',
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
errorMessage: '请求超时:180000ms',
},
);
});
it('aborts on unmount and discards a client response that still resolves', async () => {
const request = createDeferred<PromptAssistResponse>();
let signal: AbortSignal | undefined;
@@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
completeEditorBackgroundMusicPrompt,
@@ -78,11 +85,23 @@ function invalidPromptAssistResponseError() {
return new Error('AI 返回的背景音乐描述无效,请稍后重试');
}
/**
* Prompt 助手临时状态同时属于当前账号和当前项目,任一侧切换都必须让旧 operation
* 失效。这里把两者压成一个稳定字符串,调用方按引用比较对象时不会误判 scope 变化。
*/
function resolveBackgroundMusicPromptScopeKey(
currentUserId: string | null | undefined,
projectId: string | null | undefined,
) {
return JSON.stringify([currentUserId ?? null, projectId ?? null]);
}
export function useImageCanvasBackgroundMusicPromptAssist({
canvasGenerationDialogs,
getCanvasGenerationDialogById,
updateCanvasGenerationDialogById,
scopeKey,
currentUserId,
projectId,
}: {
canvasGenerationDialogs: CanvasGenerationDialogState[];
getCanvasGenerationDialogById: (
@@ -92,20 +111,24 @@ export function useImageCanvasBackgroundMusicPromptAssist({
dialogId: string,
updater: CanvasGenerationDialogUpdater,
) => void;
scopeKey?: string | null;
currentUserId?: string | null;
projectId?: string | null;
}) {
const modelRef = useRef(createBackgroundMusicPromptStateModel());
const activeOperationsRef = useRef(new Map<string, ActivePromptOperation>());
const errorMessagesRef = useRef(new Map<string, string>());
const knownDialogIdsRef = useRef(new Set<string>());
const scopeKeyRef = useRef(scopeKey);
const previousScopeKeyRef = useRef(scopeKey);
const scopeKey = useMemo(
() => resolveBackgroundMusicPromptScopeKey(currentUserId, projectId),
[currentUserId, projectId],
);
// 只在已提交的生命周期里推进;render 阶段改写会让被放弃的并发 render 污染
// 已提交 operation 的迟到响应判断。
const committedScopeKeyRef = useRef(scopeKey);
const [dialogStates, setDialogStates] = useState<
Record<string, BackgroundMusicPromptAssistDialogState>
>({});
scopeKeyRef.current = scopeKey;
const publishDialogState = useCallback((dialogId: string) => {
const state = modelRef.current.getDialogState(dialogId);
const nextState: BackgroundMusicPromptAssistDialogState = {
@@ -189,6 +212,26 @@ export function useImageCanvasBackgroundMusicPromptAssist({
[publishDialogState, updateDialogPrompt],
);
/**
* 迟到响应被判定为 stale 时,状态模型可能仍把这次 operation 当作当前 operation。
* 只删 Map 记录会让面板永久停在 `completing` / `simplifying`,所以这里先让模型
* 完成 reject 再放弃它;输入框保留用户当前文本,不写回任何候选。
*/
const discardStaleOperation = useCallback(
(operation: BackgroundMusicPromptOperation) => {
if (
modelRef.current.getDialogState(operation.dialogId).operationId !==
operation.operationId
) {
return;
}
modelRef.current.rejectOperation(operation);
activeOperationsRef.current.delete(operation.dialogId);
publishDialogState(operation.dialogId);
},
[publishDialogState],
);
useEffect(() => {
const currentDialogIds = new Set(
canvasGenerationDialogs
@@ -222,11 +265,19 @@ export function useImageCanvasBackgroundMusicPromptAssist({
getCanvasGenerationDialogById,
]);
useEffect(() => {
if (Object.is(previousScopeKeyRef.current, scopeKey)) {
// 必须是 layout effectpassive effect 排在 commit 之后的独立任务里,新 scope 已经
// commit 而清理尚未执行的窗口中,迟到的旧账号 / 旧项目响应会通过 operation 与 scope
// 检查,并把候选写进同 ID 的当前 dialog;随后的清理只会 reset 助手状态,不会撤销
// 已经写入的 Prompt。layout effect 在 commit 内同步执行,promise continuation 无法
// 插进这段同步代码,因此不存在该窗口;它同样只对已提交的 render 执行,被放弃的并发
// render 依旧不会推进 scope。
useLayoutEffect(() => {
if (Object.is(committedScopeKeyRef.current, scopeKey)) {
return;
}
previousScopeKeyRef.current = scopeKey;
// 账号或项目切换必须原子完成:中止旧请求、清空 operation、reset 状态模型、
// 清空错误与公开 dialog 状态,最后按新 scope 重建已知 dialog 集合。
committedScopeKeyRef.current = scopeKey;
for (const activeOperation of activeOperationsRef.current.values()) {
activeOperation.abortController?.abort();
}
@@ -300,7 +351,7 @@ export function useImageCanvasBackgroundMusicPromptAssist({
const abortController = new AbortController();
const operation = start.operation;
const operationScopeKey = scopeKeyRef.current;
const operationScopeKey = committedScopeKeyRef.current;
activeOperationsRef.current.set(dialogId, {
operation,
abortController,
@@ -326,8 +377,9 @@ export function useImageCanvasBackgroundMusicPromptAssist({
if (
activeOperation?.operation.operationId !== operation.operationId ||
!Object.is(activeOperation.scopeKey, operationScopeKey) ||
!Object.is(operationScopeKey, scopeKeyRef.current)
!Object.is(operationScopeKey, committedScopeKeyRef.current)
) {
discardStaleOperation(operation);
return {
started: true,
applied: false,
@@ -393,8 +445,9 @@ export function useImageCanvasBackgroundMusicPromptAssist({
if (
activeOperation?.operation.operationId !== operation.operationId ||
!Object.is(activeOperation.scopeKey, operationScopeKey) ||
!Object.is(operationScopeKey, scopeKeyRef.current)
!Object.is(operationScopeKey, committedScopeKeyRef.current)
) {
discardStaleOperation(operation);
return {
started: true,
applied: false,
@@ -450,6 +503,7 @@ export function useImageCanvasBackgroundMusicPromptAssist({
},
[
clearActiveOperation,
discardStaleOperation,
getCanvasGenerationDialogById,
isSubmissionLocked,
publishDialogState,
@@ -550,7 +604,7 @@ export function useImageCanvasBackgroundMusicPromptAssist({
activeOperationsRef.current.set(dialogId, {
operation: start.operation,
abortController: null,
scopeKey: scopeKeyRef.current,
scopeKey: committedScopeKeyRef.current,
});
errorMessagesRef.current.delete(dialogId);
publishDialogState(dialogId);
@@ -578,7 +632,7 @@ export function useImageCanvasBackgroundMusicPromptAssist({
}
const latestDialog = getCanvasGenerationDialogById(operation.dialogId);
if (
!Object.is(activeOperation.scopeKey, scopeKeyRef.current) ||
!Object.is(activeOperation.scopeKey, committedScopeKeyRef.current) ||
!isBackgroundMusicDialog(latestDialog) ||
latestDialog.composerOpen === false
) {
@@ -683,7 +683,8 @@ export function useImageCanvasGenerationWorkflow({
canvasGenerationDialogs,
getCanvasGenerationDialogById,
updateCanvasGenerationDialogById,
scopeKey: projectId,
currentUserId,
projectId,
});
const previousTaskCountRef = useRef(canvasGenerationDialogs.length);
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
@@ -10,6 +10,7 @@ import {
deleteEditorAssetFolder,
deleteEditorProject,
editEditorImage,
EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
generateEditorCharacterAnimation,
@@ -41,6 +42,10 @@ const editorRetryOptionsExpectation = expect.objectContaining({
retryUnsafeMethods: true,
retryableStatusCodes: expect.arrayContaining([429]),
});
// 助手请求只带有界超时:额外的 transport retry 会把一次业务语义轮放大成多次上游调用。
const promptAssistRequestOptions = {
timeoutMs: EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
};
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
@@ -1644,8 +1649,11 @@ describe('editorProjectClient', () => {
signal: undefined,
},
'AI 补全背景音乐提示词失败',
promptAssistRequestOptions,
);
expect(requestJsonMock.mock.calls[0]).toHaveLength(3);
expect(EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS).toBe(180_000);
expect(requestJsonMock.mock.calls[0]).toHaveLength(4);
expect(requestJsonMock.mock.calls[0][3]).toEqual(promptAssistRequestOptions);
});
it('simplifies a background music prompt without forwarding client-controlled limits', async () => {
@@ -1680,8 +1688,10 @@ describe('editorProjectClient', () => {
signal: undefined,
},
'简化背景音乐提示词失败',
promptAssistRequestOptions,
);
expect(requestJsonMock.mock.calls[0]).toHaveLength(3);
expect(requestJsonMock.mock.calls[0]).toHaveLength(4);
expect(requestJsonMock.mock.calls[0][3]).toEqual(promptAssistRequestOptions);
});
it.each([
@@ -1727,6 +1737,7 @@ describe('editorProjectClient', () => {
body: JSON.stringify({ currentPrompt: '森林冒险' }),
}),
fallbackMessage,
promptAssistRequestOptions,
);
},
);
@@ -1764,8 +1775,9 @@ describe('editorProjectClient', () => {
body: JSON.stringify({ currentPrompt: '森林冒险' }),
}),
fallbackMessage,
promptAssistRequestOptions,
);
expect(requestJsonMock.mock.calls[0]).toHaveLength(3);
expect(requestJsonMock.mock.calls[0]).toHaveLength(4);
},
);
@@ -31,6 +31,11 @@ const EDITOR_BACKGROUND_MUSIC_PROMPT_COMPLETION_API =
'/api/editor/audios/background-music/prompts/completions';
const EDITOR_BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_API =
'/api/editor/audios/background-music/prompts/simplifications';
/**
* 覆盖简化两个业务语义轮加单轮 transport retry 的服务端预算,同时为浏览器到 BFF 的
* 悬挂连接提供有界退出;超时后走既有失败路径恢复 idle,不写回候选。
*/
export const EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS = 180_000;
const EDITOR_GENERATION_PRICING_API = '/api/editor/generation-pricing';
const EDITOR_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
const EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES = 256 * 1024;
@@ -1235,6 +1240,9 @@ function requestEditorBackgroundMusicPromptAssist(
signal: options.signal,
},
fallbackMessage,
{
timeoutMs: EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
},
);
}