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 ? '进入模拟' : '下一句'}