pagescene: discard objects that finish loading after a load is cancelled

Scene loads add each asset to the scene as soon as it finishes. When a
load was cancelled (superseded by a newer load, or the editor unmounted)
the loader returned after its tasks settled, leaving whatever had
finished in the scene. On unmount that leaked the objects; on a
superseded load they appeared in the newer load's scene.

A cancelled load now removes and frees the objects it created. Lights
are left alone because Scene tracks its base lights in fields a newer
load may have re-pointed at them.
This commit is contained in:
Tanish Manku
2026-10-06 17:46:29 -07:00
parent 4402f4f668
commit 83f600bd6f
2 changed files with 112 additions and 1 deletions
@@ -0,0 +1,91 @@
import * as THREE from "three";
import Scene from "../engine/scene";
import type { LoadTicket } from "../engine/save_manager";
import { StoryTellerProxyScene } from "./storyteller_proxy_scene";
vi.mock("@sparkjsdev/spark", async () => {
const { Object3D } = await import("three");
return {
SplatMesh: class extends Object3D {},
SparkRenderer: class extends Object3D {},
};
});
// The Addons barrel pulls in a lottie canvas module jsdom cannot run.
vi.mock("three/examples/jsm/Addons.js", async () => {
const { Mesh } = await import("three");
return { MMDAnimationHelper: class {}, Water: class extends Mesh {} };
});
const BOX_JSON = {
media_file_token: "Parim",
object_name: "Box",
object_user_data_name: "Box",
object_uuid: "box-uuid",
position: new THREE.Vector3(),
rotation: new THREE.Vector3(),
scale: new THREE.Vector3(1, 1, 1),
color: "#FFFFFF",
metalness: 0,
shininess: 0.5,
specular: 0,
locked: false,
visible: true,
media_file_type: "none",
user_data: { shapeKey: "Box" },
};
describe("loadFromSceneJson", () => {
it("removes and frees objects that finish after the load is cancelled", async () => {
const scene = makeScene();
const proxy = new StoryTellerProxyScene(2, scene);
const ticket = makeTicket();
const onDispose = vi.fn();
const addListener = trackFirstMeshGeometry(scene, onDispose);
const load = proxy.loadFromSceneJson([BOX_JSON] as never, "m_1", 2, ticket);
// The box is created before the loader's first await on its tasks;
// cancel while that await is pending, as a newer load or an unmount
// would.
addListener();
ticket.cancelled = true;
await load;
expect(scene.scene.getObjectByProperty("name", "Box")).toBeUndefined();
expect(onDispose).toHaveBeenCalledTimes(1);
});
it("keeps the loaded objects when the load is not cancelled", async () => {
const scene = makeScene();
const proxy = new StoryTellerProxyScene(2, scene);
await proxy.loadFromSceneJson([BOX_JSON] as never, "m_1", 2, makeTicket());
expect(scene.get_object_by_uuid("box-uuid")).toBeDefined();
});
});
function makeScene(): Scene {
return new Scene("test", "::CAM::", () => undefined, 2, {
getCameras: () => [],
getSelectedCameraId: () => "main",
fetchAsset: () => Promise.reject(new Error("no network in tests")),
getMediaUrlByToken: () => Promise.reject(new Error("no network in tests")),
});
}
function makeTicket(): LoadTicket {
return { cancelled: false, signal: new AbortController().signal };
}
// Returns a function that, once the load has created its box, subscribes
// `onDispose` to that box's geometry.
function trackFirstMeshGeometry(
scene: Scene,
onDispose: () => void,
): () => void {
return () => {
const box = scene.scene.getObjectByProperty("name", "Box") as THREE.Mesh;
box.geometry.addEventListener("dispose", onDispose);
};
}
@@ -229,7 +229,10 @@ export class StoryTellerProxyScene {
);
const settled = await Promise.allSettled(tasks);
if (ticket?.cancelled) return;
if (ticket?.cancelled) {
this.discardLoadedObjects(settled);
return;
}
// Synchronous transform-application pass. We walk results in
// original JSON order to preserve scene.children insertion
@@ -295,4 +298,21 @@ export class StoryTellerProxyScene {
this.scene._createGrid();
this.scene.updateSkybox(skybox_media_id);
}
// A cancelled load has still added whatever finished loading to the
// scene. Take those objects back out and free them, or they would leak
// and, when a newer load superseded this one, show up in its scene.
// Lights are skipped: Scene tracks its base lights in fields that a
// newer load may have re-pointed at them.
private discardLoadedObjects(
settled: PromiseSettledResult<{ obj: THREE.Object3D | undefined }>[],
) {
for (const result of settled) {
if (result.status !== "fulfilled") continue;
const { obj } = result.value;
if (!obj || obj instanceof THREE.Light) continue;
obj.removeFromParent();
this.scene.disposeObject(obj);
}
}
}