diff --git a/packages/physics/__tests__/plugin.spec.ts b/packages/physics/__tests__/plugin.spec.ts index be84e3054a..223729bdd1 100644 --- a/packages/physics/__tests__/plugin.spec.ts +++ b/packages/physics/__tests__/plugin.spec.ts @@ -70,6 +70,36 @@ describe("rapier plugin", () => { plugin.dispose?.(ctx); }); + it("resolves a setup whose game was disposed first, installing nothing and reporting it", async () => { + await RAPIER.init(); + const plugin = rapier(); + const ctx = { physics: undefined } as unknown as ICtx, IPhysicsContext>; + const gates: Promise[] = []; + const runtime = { + fixedStep: () => 0, + holdStart: (gate: Promise) => { + gates.push(gate); + return gate; + }, + observations: { contribute: () => () => undefined, contributions: () => [] }, + rapier: null, + seed: 1, + step: 1 / 60, + tick: () => 0, + } satisfies IGamePluginRuntime; + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + plugin.setup?.(ctx, runtime); + plugin.dispose?.(ctx); + + // A reload during the Wasm load must not become a boot failure on the page. + await expect(Promise.all(gates)).resolves.toBeDefined(); + expect(ctx.physics).toBeUndefined(); + expect(runtime.rapier).toBeNull(); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/TN_PHYSICS_SETUP_SUPERSEDED/u)); + warn.mockRestore(); + }); + it("should register the actual Rapier runtime version", async () => { await RAPIER.init(); const plugin = rapier(); diff --git a/packages/physics/src/navigation/index.ts b/packages/physics/src/navigation/index.ts index ce9cead67f..2ee98d4d1c 100644 --- a/packages/physics/src/navigation/index.ts +++ b/packages/physics/src/navigation/index.ts @@ -57,13 +57,16 @@ export function recast(): NavigationPlugin { // Like rapier(), Recast initializes while the scene loads and holds `enter()` until it is up. setup: (ctx: ICtx, IPhysicsContext>, runtime) => { // rapier() leaves its readiness here (`plugin.ts` PHYSICS_READY); this bundle has no copy of it. - const physics: Promise | undefined = Reflect.get( + const physics: Promise | undefined = Reflect.get( ctx, Symbol.for("threenative.physics.ready"), ); if (physics === undefined) throw new Error("recast() requires rapier() earlier in the plugins array."); const ready = Promise.all([physics, initialize()]).then(([context]) => { + // The page that asked for navigation is gone; installing into it would leak, and rejecting + // would report a boot failure the disposed game never had. + if (context === undefined) return; const empty = emptyNavigation(); navigation = { agents: new Set(), diff --git a/packages/physics/src/plugin.ts b/packages/physics/src/plugin.ts index ceb5b1c9b7..9252ab5e7b 100644 --- a/packages/physics/src/plugin.ts +++ b/packages/physics/src/plugin.ts @@ -213,9 +213,17 @@ export function rapier(options: IPhysicsOptions = {}): PhysicsPlugin { setup: (ctx: ICtx, IPhysicsContext>, runtime) => { gameContext = ctx; const generation = ++generations; - const ready = backend.initialize().then(() => { - if (generation !== generations) - throw new Error("rapier() was disposed before it initialized."); + const ready = backend.initialize().then((): IPhysicsContext | undefined => { + // A reload or a stop raced Rapier's Wasm. The generation that asked is gone, so nothing is + // installed — and nothing rejects: with the start gate, nothing awaits `ready` any more, so + // a rejection here reached the page as a boot failure it never had (the 0.3.13 release proof + // stopped on exactly that, PRD-860). + if (generation !== generations) { + console.warn( + "TN_PHYSICS_SETUP_SUPERSEDED: rapier initialized after its game was disposed; nothing was installed.", + ); + return undefined; + } const selected = backend.createSimulation(options); simulation = selected; if (runtime !== undefined) runtime.rapier = selected.version;