Browse Source

fix(scene): defer destructive teardown until transition succeeds

Made-with: Cursor
master
npmrun 2 weeks ago
parent
commit
5e3c13ce1a
  1. 34
      src/scene/SceneManager.ts
  2. 113
      tests/kernel/transition-transaction.test.ts

34
src/scene/SceneManager.ts

@ -138,6 +138,7 @@ class SceneManager {
let previousMountedBefore = false; let previousMountedBefore = false;
let targetVisibleBefore = true; let targetVisibleBefore = true;
let targetMountedBefore = false; let targetMountedBefore = false;
let shouldFinalizePreviousNormal = false;
const transaction = new TransitionTransaction({ const transaction = new TransitionTransaction({
prepare: () => { prepare: () => {
@ -153,19 +154,9 @@ class SceneManager {
previous.stage.visible = false; previous.stage.visible = false;
previous.stage._isHolderLast = true; previous.stage._isHolderLast = true;
} else { } else {
// 销毁显示节点并卸载,但保留场景注册,便于之后再次 changeScene("welcome") 等 // 延后销毁:若目标场景中途失败,可回滚到原有效舞台节点,避免空容器伪恢复
const oldStage = previous.stage; previous.stage.visible = false;
oldStage.destroy({ children: true }); shouldFinalizePreviousNormal = true;
this.game.stage.removeChild(oldStage);
if (previous._assetsLoaded) {
await previous.unLoadBundle?.();
previous._assetsLoaded = false;
}
await previous.onUnLoad?.();
const nextStage = new Container();
nextStage.label = previous.name;
(previous as { stage: Container }).stage = nextStage;
previous._layoutDone = false;
} }
} else if (previous.type === SceneType.Resident) { } else if (previous.type === SceneType.Resident) {
previous.stage.visible = false; previous.stage.visible = false;
@ -202,6 +193,23 @@ class SceneManager {
// 触发回调 // 触发回调
await this.emitStageChange(target, previous); await this.emitStageChange(target, previous);
if (shouldFinalizePreviousNormal) {
// 到这里说明切换已成功,再执行破坏性清理
const oldStage = previous.stage;
this.game.stage.removeChild(oldStage);
oldStage.destroy({ children: true });
if (previous._assetsLoaded) {
await previous.unLoadBundle?.();
previous._assetsLoaded = false;
}
await previous.onUnLoad?.();
const nextStage = new Container();
nextStage.label = previous.name;
(previous as { stage: Container }).stage = nextStage;
previous._layoutDone = false;
}
logger.debug(`SceneManager: changed from "${previous.name}" to "${name}"`); logger.debug(`SceneManager: changed from "${previous.name}" to "${name}"`);
}, },
rollback: async () => { rollback: async () => {

113
tests/kernel/transition-transaction.test.ts

@ -1,5 +1,74 @@
import { describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { TransitionTransaction } from "@/scene/TransitionTransaction"; import { TransitionTransaction } from "@/scene/TransitionTransaction";
import { SceneType } from "@/enums/SceneType";
import type { IBaseScene } from "@/scene/types";
class MockContainer {
visible = true;
label = "";
_isHolderLast?: boolean;
children: MockContainer[] = [];
addChild(child: MockContainer): MockContainer {
this.children.push(child);
return child;
}
removeChild(child: MockContainer): MockContainer {
this.children = this.children.filter((item) => item !== child);
return child;
}
destroy(options?: { children?: boolean }): void {
if (options?.children) {
this.children = [];
}
}
}
vi.mock("pixi.js", () => ({
Container: MockContainer,
}));
vi.mock("@/core/Game", () => {
class MockGame {
private static instance: MockGame;
readonly stage = new MockContainer();
static getInstance(): MockGame {
if (!MockGame.instance) {
MockGame.instance = new MockGame();
}
return MockGame.instance;
}
}
return { default: MockGame };
});
function createScene(
name: string,
type: SceneType = SceneType.Normal,
overrides?: Partial<IBaseScene>
): IBaseScene {
const stage = new MockContainer();
stage.label = name;
return {
name,
type,
stage: stage as never,
_assetsLoaded: true,
_layoutDone: true,
changeScene: () => {},
loadBundle: async () => {},
unLoadBundle: async () => {},
layout: async () => {},
onLoad: async () => {},
onUnLoad: async () => {},
...overrides,
};
}
describe("TransitionTransaction", () => { describe("TransitionTransaction", () => {
it("rolls back when prepare fails and never commits", async () => { it("rolls back when prepare fails and never commits", async () => {
@ -42,3 +111,45 @@ describe("TransitionTransaction", () => {
expect(rollback).toHaveBeenCalledTimes(1); expect(rollback).toHaveBeenCalledTimes(1);
}); });
}); });
describe("SceneManager rollback safety", () => {
beforeEach(async () => {
vi.resetModules();
const module = await import("@/scene/SceneManager");
(module.default as unknown as { instance?: unknown }).instance = undefined;
});
it("keeps previous stage content intact when target load fails", async () => {
const module = await import("@/scene/SceneManager");
const SceneManager = module.default;
const manager = SceneManager.getInstance();
const previous = createScene("previous");
const previousContent = new MockContainer();
(previous.stage as unknown as MockContainer).addChild(previousContent);
const target = createScene("target", SceneType.Normal, {
onLoad: async () => {
throw new Error("target onLoad failed");
},
});
manager.registerScene(previous);
manager.registerScene(target);
await manager.initScene("previous");
await expect(manager.changeScene("target")).rejects.toThrowError(
"target onLoad failed"
);
const stage = manager["game"].stage as unknown as MockContainer;
expect(manager.currentScene).toBe(previous);
expect(previous.stage).not.toBeUndefined();
expect((previous.stage as unknown as MockContainer).children).toContain(
previousContent
);
expect((previous.stage as unknown as MockContainer).visible).toBe(true);
expect(stage.children.includes(previous.stage as never)).toBe(true);
expect(stage.children.includes(target.stage as never)).toBe(false);
});
});

Loading…
Cancel
Save