From 59a5f4aedba0e16ca90fed7271acb41b17d5b58d Mon Sep 17 00:00:00 2001 From: fj-sunny Date: Fri, 14 Aug 2026 14:31:03 +0800 Subject: [PATCH] fix(mobile): stabilize custom scene state transitions --- .../__tests__/useFreeChatSession.test.tsx | 16 ++++++++ .../conversation/useFreeChatSession.ts | 16 +++++++- .../scenes/SceneTrainingController.ts | 37 ++++++++++++------- .../__tests__/SceneTrainingController.test.ts | 35 ++++++++++++++++++ frontend/mobile/src/screens/ScenesScreen.tsx | 16 +++++--- 5 files changed, 99 insertions(+), 21 deletions(-) diff --git a/frontend/mobile/src/features/conversation/__tests__/useFreeChatSession.test.tsx b/frontend/mobile/src/features/conversation/__tests__/useFreeChatSession.test.tsx index d2eeecc5..a4b53734 100644 --- a/frontend/mobile/src/features/conversation/__tests__/useFreeChatSession.test.tsx +++ b/frontend/mobile/src/features/conversation/__tests__/useFreeChatSession.test.tsx @@ -1,3 +1,4 @@ +import { StrictMode } from 'react'; import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; import { Pressable, Text, View } from 'react-native'; @@ -113,6 +114,21 @@ describe('useFreeChatSession', () => { expect(controller.interrupt).toHaveBeenCalledTimes(1); }); + it('does not end and restart the same controller during StrictMode effect replay', async () => { + const controller = createController(); + const screen = await render( + + controller} /> + , + ); + + await waitFor(() => expect(controller.start).toHaveBeenCalledTimes(1)); + expect(controller.end).not.toHaveBeenCalled(); + + screen.unmount(); + await waitFor(() => expect(controller.end).toHaveBeenCalledTimes(1)); + }); + it('exposes startup errors and performs idempotent cleanup after ending', async () => { const controller = createController(); controller.start.mockRejectedValue(new Error('麦克风权限被拒绝')); diff --git a/frontend/mobile/src/features/conversation/useFreeChatSession.ts b/frontend/mobile/src/features/conversation/useFreeChatSession.ts index cfefcb8b..98b30c67 100644 --- a/frontend/mobile/src/features/conversation/useFreeChatSession.ts +++ b/frontend/mobile/src/features/conversation/useFreeChatSession.ts @@ -85,7 +85,9 @@ export function useFreeChatSession( ); const [startupError, setStartupError] = useState(null); const [elapsed, setElapsed] = useState(0); + const startPromise = useRef | null>(null); const endPromise = useRef | null>(null); + const lifecycleVersion = useRef(0); const end = useCallback(() => { if (!endPromise.current) { @@ -97,8 +99,12 @@ export function useFreeChatSession( useEffect(() => controller.subscribe(setSnapshot), [controller]); useEffect(() => { + lifecycleVersion.current += 1; let active = true; - void controller.start().catch((error: unknown) => { + if (!startPromise.current) { + startPromise.current = Promise.resolve().then(() => controller.start()); + } + void startPromise.current.catch((error: unknown) => { if (active) { setStartupError( error instanceof Error ? error.message : '实时对话启动失败', @@ -107,7 +113,13 @@ export function useFreeChatSession( }); return () => { active = false; - void end().catch(() => undefined); + lifecycleVersion.current += 1; + const cleanupVersion = lifecycleVersion.current; + queueMicrotask(() => { + if (lifecycleVersion.current === cleanupVersion) { + void end().catch(() => undefined); + } + }); }; }, [controller, end]); diff --git a/frontend/mobile/src/features/scenes/SceneTrainingController.ts b/frontend/mobile/src/features/scenes/SceneTrainingController.ts index 140f7d90..7b4a16b9 100644 --- a/frontend/mobile/src/features/scenes/SceneTrainingController.ts +++ b/frontend/mobile/src/features/scenes/SceneTrainingController.ts @@ -112,6 +112,9 @@ export class SceneTrainingController { } async next() { + if (this.snapshot.status === 'loading' || this.snapshot.status === 'scoring') { + return false; + } const scene = this.requireScene(); if (this.snapshot.stage === 'speak') return false; if (this.snapshot.index < this.snapshot.items.length - 1) { @@ -139,21 +142,27 @@ export class SceneTrainingController { } if (!this.snapshot.readingResult?.passed) return false; - const flow = await this.service.advanceStage( - scene.sceneId, - 'SENTENCE_LEARNING', - ); - if (flow.stage !== 'DIALOGUE') { - throw new Error('后端未进入场景对话阶段'); + this.update({ ...this.snapshot, status: 'loading', error: null }); + try { + const flow = await this.service.advanceStage( + scene.sceneId, + 'SENTENCE_LEARNING', + ); + if (flow.stage !== 'DIALOGUE') { + throw new Error('后端未进入场景对话阶段'); + } + this.update({ + ...this.snapshot, + status: 'ready', + stage: 'speak', + unlockedStage: 2, + readingResult: null, + }); + return true; + } catch (error) { + this.fail(error); + throw error; } - this.update({ - ...this.snapshot, - status: 'ready', - stage: 'speak', - unlockedStage: 2, - readingResult: null, - }); - return true; } previous() { diff --git a/frontend/mobile/src/features/scenes/__tests__/SceneTrainingController.test.ts b/frontend/mobile/src/features/scenes/__tests__/SceneTrainingController.test.ts index a26e9fa9..bb971750 100644 --- a/frontend/mobile/src/features/scenes/__tests__/SceneTrainingController.test.ts +++ b/frontend/mobile/src/features/scenes/__tests__/SceneTrainingController.test.ts @@ -145,6 +145,41 @@ describe('SceneTrainingController', () => { ]); }); + it('ignores a duplicate next action while a stage transition is pending', async () => { + const service = createService(); + const controller = new SceneTrainingController(service); + await controller.start(scene); + + let resolveAdvance!: (flow: { + sceneId: string; + stage: SceneFlowStage; + completed: boolean; + }) => void; + service.advanceStage.mockImplementationOnce( + () => new Promise((resolve) => { + resolveAdvance = resolve; + }), + ); + + const first = controller.next(); + await expect(controller.next()).resolves.toBe(false); + expect(service.advanceStage).toHaveBeenCalledTimes(1); + + resolveAdvance({ + sceneId: 'scene-1', + stage: 'PHRASE_LEARNING', + completed: false, + }); + await expect(first).resolves.toBe(true); + expect(controller.getSnapshot()).toEqual( + expect.objectContaining({ + status: 'ready', + learningGroup: 'phrases', + currentItem: phrase, + }), + ); + }); + it('scores the current WAV and unlocks dialogue only after a passing result', async () => { const service = createService(); const controller = new SceneTrainingController(service); diff --git a/frontend/mobile/src/screens/ScenesScreen.tsx b/frontend/mobile/src/screens/ScenesScreen.tsx index b559d55a..602a7f9f 100644 --- a/frontend/mobile/src/screens/ScenesScreen.tsx +++ b/frontend/mobile/src/screens/ScenesScreen.tsx @@ -410,6 +410,7 @@ export function Training({ id, scene, trainingController: injectedTrainingContro : readPassed; const isLastReadItem = displayedReadIndex >= displayedReadItems.length - 1; const readingResult = trainingSnapshot?.readingResult; + const trainingTransitioning = trainingSnapshot?.status === 'loading'; const completionMetrics = sceneMetricsForReport(dialogueCompletion?.evaluation); const toggleDemo = async (text: string) => { @@ -442,7 +443,7 @@ export function Training({ id, scene, trainingController: injectedTrainingContro demoActive.current = false; setDemoPlaying(false); if (scene && trainingController) { - void trainingController.next(); + void trainingController.next().catch(() => undefined); return; } if (learnIndex < learnItems.length - 1) setLearnIndex((current) => current + 1); @@ -591,7 +592,12 @@ export function Training({ id, scene, trainingController: injectedTrainingContro > - + {displayedIndex < learnItems.length - 1 ? '下一个' : displayedLearningGroup === 'words' ? '进入词组' : '进入朗读'} @@ -630,14 +636,14 @@ export function Training({ id, scene, trainingController: injectedTrainingContro { - if (scene && trainingController) void trainingController.next(); + if (scene && trainingController) void trainingController.next().catch(() => undefined); else { setUnlockedStage(2); setStage('speak'); } setRecording(false); setDemoPlaying(false); }} - style={[styles.primaryPillButton, !displayedReadPassed && styles.primaryPillDisabled]} + style={[styles.primaryPillButton, (!displayedReadPassed || trainingTransitioning) && styles.primaryPillDisabled]} > {isLastReadItem ? '进入模拟' : '下一句'}