Files
vpinball/lib/pinball/usePinballInstance.ts
T
valknarandClaude Sonnet 5 44504c718d Fix eject hanging the tab on ROM-based tables
Bump @valknar/vpinball-wasm to 0.3.3, which fixes a genuine infinite
busy-wait in the PinMAME plugin's Controller::Stop() under Emscripten
(no OS thread ever clears the flag it spins on) — this fired from
dispose()'s synchronous script Exit event on any ROM-based table,
hanging the tab permanently.

Sequence unmount cleanup as stop() then two animation frames then
dispose(), instead of dispose() alone, matching the consumer pattern
the engine fix expects (stop() only takes effect on its next internal
step, so disposing immediately after can still race it).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 10:27:35 +02:00

145 lines
5.2 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { LoadPinballOptions, PinballInstance } from "@valknar/vpinball-wasm";
import { hasWebGL2 } from "./webglSupport";
// Served as a plain static file, not bundled — loadPinball() internally does
// a dynamic import(`${baseUrl}/vpinball.js`) with a template-literal path
// that webpack can't statically resolve, so the whole engine must stay
// outside webpack's module graph (see scripts/copy-engine-assets.mjs).
const ENGINE_BASE_URL = "/vendor/vpinball-wasm";
export type PinballStatus = "idle" | "loading" | "ready" | "starting" | "running" | "error";
interface EngineModule {
loadPinball(options: LoadPinballOptions): Promise<PinballInstance>;
}
async function loadEngineModule(): Promise<EngineModule> {
return (await import(
/* webpackIgnore: true */ `${ENGINE_BASE_URL}/index.js`
)) as EngineModule;
}
export interface UsePinballInstanceResult {
status: PinballStatus;
/** 0-1 download progress, only meaningful while status is "loading". */
progress: number;
error: string | null;
loadTimeMs: number | null;
instanceRef: React.RefObject<PinballInstance | null>;
/** Call from a user-gesture handler (audio autoplay / fullscreen policy). */
start: () => void;
}
export interface RomSelection {
gameName: string;
romData: ArrayBuffer;
}
export function usePinballInstance(
canvasRef: React.RefObject<HTMLCanvasElement | null>,
tableData: ArrayBuffer | undefined,
rom: RomSelection | undefined,
): UsePinballInstanceResult {
// Computed once at mount (not inside the effect below) so the WebGL2-
// unsupported case is derived initial state rather than a setState call
// that an effect makes only to immediately return.
const [webgl2Supported] = useState(hasWebGL2);
const [status, setStatus] = useState<PinballStatus>(webgl2Supported ? "idle" : "error");
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(
webgl2Supported ? null : "Your browser doesn't support WebGL2, which this pinball engine requires.",
);
const [loadTimeMs, setLoadTimeMs] = useState<number | null>(null);
const instanceRef = useRef<PinballInstance | null>(null);
const disposedRef = useRef(false);
useEffect(() => {
disposedRef.current = false;
const canvas = canvasRef.current;
if (!canvas || !webgl2Supported) return;
setStatus("loading");
const startedAt = performance.now();
(async () => {
try {
const { loadPinball } = await loadEngineModule();
const instance = await loadPinball({
canvas,
baseUrl: ENGINE_BASE_URL,
tableData,
onProgress: (fraction) => {
if (!disposedRef.current) setProgress(fraction);
},
});
if (disposedRef.current) {
instance.dispose();
return;
}
if (rom) {
try {
// Must run before start() — Controller.Run() reads the ROM
// file synchronously at table-init time. A bad/mismatched ROM
// zip fails cleanly inside the engine rather than here, so this
// is defensive against loadRom() itself throwing (e.g. a
// corrupt zip), not something expected to fire routinely.
instance.loadRom(rom.gameName, rom.romData);
} catch (err) {
console.error("Failed to load ROM:", err);
}
}
instanceRef.current = instance;
setLoadTimeMs(performance.now() - startedAt);
setStatus("ready");
} catch (err) {
if (!disposedRef.current) {
setStatus("error");
setError(err instanceof Error ? err.message : "Failed to load the pinball engine.");
}
}
})();
return () => {
disposedRef.current = true;
const instance = instanceRef.current;
instanceRef.current = null;
if (instance) {
// stop() halts the engine's render loop, but only takes effect on
// its next internal step rather than immediately. Calling dispose()
// synchronously right after — freeing WASM memory before that step
// runs — is what hangs the tab. Give it a couple of frames first.
instance.stop();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
instance.dispose();
});
});
}
};
// Intentionally run once: PlayView mounts a fresh PinballCanvasImpl
// (via `key`) per table rather than swapping tableData in place.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const start = useCallback(() => {
if (!instanceRef.current) return;
setStatus("starting");
// instance.start() runs synchronously and blocks the main thread for a
// noticeable moment (heavy WASM init) — a nested rAF lets the browser
// paint the "starting" spinner from the state update above before that
// block hits, instead of the UI freezing on the old screen mid-click.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (disposedRef.current || !instanceRef.current) return;
instanceRef.current.start();
setStatus("running");
});
});
}, []);
return { status, progress, error, loadTimeMs, instanceRef, start };
}