feedback7 R22-HUD: a renderer that loses the 12 s race no longer mounts its canvas late — the attempt is aborted, a view that still comes back is destroyed, the host is no longer wiped (PR #302 by @siruimei07); golden: unchanged

Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no new dependency, no
shipped eval). The leak was real on feedback7: mountFieldView raced createFieldView against a timeout and, on failure,
removed every child of the host while the factory kept running — Pixi finished later and appended its canvas over the
simplified view (taking its clicks); the host wipe could also delete a newer view mounted by the same hook.

- ui/fieldHost.js: mountFieldView(host, { signal }) owns an AbortController; the timeout, a failed import or an invalid
  view aborts it, a view a factory returns after that is destroyed, an outer abort (useFieldView's cleanup) rejects
  without a fallback; nothing clears the host's children any more.
- render/app.js createFieldView takes the signal: the Pixi load / manifest / font waits end on abort, the synchronous
  build registers its disposables (setupCleanup) so a throw releases the app, tiles, fx, impostors, listeners and
  observers it made, the board-art and 3D-board waits moved after the view exists (aborting them is view.destroy(),
  idempotent), destroy() also removes the canvas. render/app/host.js withTimeout(p, ms, signal).
- Tests: test/ui/fieldHost-lifecycle.test.js (5, node vm, fake timers: all fail on the parent) and the opt-in real-browser
  test/render/initialization.browser.test.js (6 scenarios in headless Chrome, 5 fail on the parent: a late Pixi load
  covering the fallback — with a factory that honours the signal and one that ignores it —, art timeout, a setup
  exception, an unmount racing a newer mount); test/ui/playtest2.test.js's source pin takes the new indentation.
  fieldview / browser suites in Chrome unchanged (the two 4× throttled fps tests flake when two Chrome files run
  at once, pass alone).
- The createFieldView body stays unindented inside its new try (the PR's choice, to keep the diff reviewable).
- Docs: design/client.md §9. Golden: unchanged (client lifecycle only).
This commit is contained in:
siruimei07 authored and sganggs committed 2026-10-08 19:22:08 +08:00
1 parent 8ae891f1f3
commit 030d0a2938
7 files changed
+654 -39

No files matched your search

+61 -14
View File
@@ -132,8 +132,32 @@ function renderPref() {
return globalThis.__SP_RENDER__ === 'fallback' ? 'fallback' : 'auto';
}
function withTimeout(p, ms, what) {
return Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`${what} timed out`)), ms))]);
function abortError(signal) {
return signal?.reason ?? new DOMException('Field view initialization cancelled', 'AbortError');
}
function checkActive(signal) {
if (signal?.aborted) throw abortError(signal);
}
function withTimeout(p, ms, what, signal) {
return new Promise((resolve, reject) => {
let settled = false;
let timer;
const finish = (done, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
done(value);
};
const onAbort = () => finish(reject, abortError(signal));
signal?.addEventListener('abort', onAbort, { once: true });
timer = setTimeout(() => finish(reject, new Error(`${what} timed out`)), ms);
// Keep a rejection handler attached even when cancellation wins before this promise settles.
Promise.resolve(p).then((value) => finish(resolve, value), (err) => finish(reject, err));
if (signal?.aborted) onAbort();
});
}
/**
@@ -186,34 +210,55 @@ export function seedAssets(store) {
/**
* Create a field view in `host`: the render engine when available, else the DOM fallback.
* @param {HTMLElement} host
* @param {{ signal?: AbortSignal }} [options] Cancels a pending mount without creating a fallback.
* @returns {Promise<ReturnType<typeof guardView>>}
*/
export async function mountFieldView(host) {
export async function mountFieldView(host, { signal } = {}) {
checkActive(signal);
const pref = renderPref();
const opts = { data, assets: data.get('assets'), audio, settings: settingsStore.get(), padding: hudPadding, hud: hudBands };
if (pref !== 'fallback') {
const controller = new AbortController();
const onAbort = () => controller.abort(abortError(signal));
signal?.addEventListener('abort', onAbort, { once: true });
if (signal?.aborted) onAbort();
let engineView = null;
const dispose = (view) => { try { view?.destroy?.(); } catch { /* ignore */ } };
try {
checkActive(controller.signal);
// the shared asset store (public/js/assets.js) keeps its Spine cache across remounts (next match, reconnect)
const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import').catch(() => null);
const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import', controller.signal).catch(() => {
checkActive(controller.signal);
return null;
});
checkActive(controller.signal);
if (am?.assets && typeof am.assets.ready === 'function') {
opts.assets = am.assets;
seedAssets(am.assets);
}
const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import');
const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import', controller.signal);
checkActive(controller.signal);
if (typeof mod?.createFieldView !== 'function') throw new Error('createFieldView missing');
const view = await withTimeout(Promise.resolve(mod.createFieldView(host, opts)), LOAD_TIMEOUT_MS, 'createFieldView');
// A factory that ignores cancellation still belongs to this attempt when it eventually returns.
const pending = Promise.resolve(mod.createFieldView(host, { ...opts, signal: controller.signal })).then((view) => {
if (controller.signal.aborted) dispose(view); else engineView = view;
return view;
});
const view = await withTimeout(pending, LOAD_TIMEOUT_MS, 'createFieldView', controller.signal);
checkActive(controller.signal);
const missing = METHODS.filter((k) => typeof view?.[k] !== 'function');
if (missing.length) {
try { view?.destroy?.(); } catch { /* ignore */ }
throw new Error(`view lacks ${missing.join(', ')}`);
}
if (missing.length) throw new Error(`view lacks ${missing.join(', ')}`);
return guardView(view, 'engine');
} catch (err) {
controller.abort();
dispose(engineView);
checkActive(signal);
console.warn('[field] render engine unavailable, using the simplified view:', err?.message || err);
// anything the engine left behind in the host goes
try { while (host.firstChild) host.removeChild(host.firstChild); } catch { /* ignore */ }
} finally {
signal?.removeEventListener('abort', onAbort);
}
}
checkActive(signal);
return guardView(createFallbackView(host, { ...opts, assets: data.get('assets') }), 'fallback');
}
@@ -228,17 +273,19 @@ export function useFieldView(hostRef) {
let dead = false;
const host = hostRef.current;
if (!host) return undefined;
mountFieldView(host).then((view) => {
const controller = new AbortController();
mountFieldView(host, { signal: controller.signal }).then((view) => {
if (dead) { view.destroy(); return; }
viewRef.current = view;
globalThis.__SP_VIEW__ = view; // dev / E2E introspection (view.raw.stats?.())
setState({ view, kind: view.kind });
}, (err) => console.error('[field] mount failed', err));
}, (err) => { if (!dead && !controller.signal.aborted) console.error('[field] mount failed', err); });
const unsub = settingsStore.subscribe((s) => viewRef.current?.setSettings?.(s));
const onResize = () => viewRef.current?.resize();
window.addEventListener('resize', onResize);
return () => {
dead = true;
controller.abort();
unsub();
window.removeEventListener('resize', onResize);
// the dev hook must not keep the destroyed view — and through its host the whole detached match screen — alive