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

+72 -22
View File
@@ -148,14 +148,17 @@ export { releaseGl } from './app/host.js';
/**
* Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it).
* @param {HTMLElement} host
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string },
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, signal?: AbortSignal,
* padding?: object|((kind:string, size:{width:number,height:number}) => object),
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts]
*/
export async function createFieldView(host, options = {}) {
if (!host || typeof host.appendChild !== 'function') throw new TypeError('createFieldView: host element required');
const opts = options && typeof options === 'object' ? options : {};
const P = await ensurePixi();
const signal = opts.signal;
signal?.throwIfAborted();
const P = await withTimeout(ensurePixi(), null, signal);
signal?.throwIfAborted();
const assets = resolveAssets(opts.assets);
const data = makeData(opts.data);
const settings = { damageNumbers: true, quality: 'high', ...(opts.settings || {}) };
@@ -164,19 +167,28 @@ export async function createFieldView(host, options = {}) {
const want3d = boardPref !== '2d' && webgl2Available(boardPref === '3d');
// three.js (~2 MB) is fetched only when the local-art manifest lists the board atlas (in parallel with the art)
const artListed = want3d ? boardArtListed(assets).catch(() => false) : Promise.resolve(false);
const threePromise = artListed.then((ok) => (ok ? loadThree() : null));
const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null));
const threePromise = artListed.then((ok) => (ok ? loadThree() : null)).catch(() => null);
const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null)).catch(() => null);
// the manifest, and the optional local-art manifest in parallel: unit views pick an enemy's local-client model by it
// (assets.js spineEntry, DESIGN §13 — 灼热源石虫 / 炽焰源石虫); absent or slow, they draw the web models
await withTimeout(Promise.all([assets.ready ? assets.ready() : null, assets.local ? assets.local() : null]
.map((p) => Promise.resolve(p).catch(() => {}))), 4000);
.map((p) => Promise.resolve(p).catch(() => {}))), 4000, signal);
signal?.throwIfAborted();
// web fonts for the bitmap damage numbers / tier chips (never block long)
try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500); } catch { /* ignore */ }
try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500, signal); } catch { /* optional fonts */ }
signal?.throwIfAborted();
const size = () => ({ width: Math.max(1, host.clientWidth || 1), height: Math.max(1, host.clientHeight || 1) });
const dpr = () => Math.min(globalThis.devicePixelRatio || 1, QUALITY_RES[settings.quality] || 2);
const boardDpr = () => Math.min(globalThis.devicePixelRatio || 1, BOARD_RES[settings.quality] || 2);
const s0 = size();
// Construction is synchronous until the complete view can own cancellation. If setup throws,
// dispose only the resources acquired by this attempt, never another view's host contents.
const setupCleanup = [];
let destroyed = false;
let view = null;
let onAbort;
try {
const app = new P.Application({
// MSAA only where it pays: dense (DPR ≥ 1.5) screens are sharp enough without it and it would cost 4× the fill
// transparent: the 3D board canvas shows through (the 2D board paints an opaque backdrop itself)
@@ -184,6 +196,11 @@ export async function createFieldView(host, options = {}) {
resolution: dpr(), autoDensity: true, powerPreference: 'high-performance',
});
const canvas = app.view;
setupCleanup.push(() => {
releaseGl(app.renderer);
try { app.destroy(true, { children: true, texture: false, baseTexture: false }); }
finally { canvas.remove(); }
});
canvas.style.display = 'block';
canvas.style.width = '100%';
canvas.style.height = '100%';
@@ -240,7 +257,6 @@ export async function createFieldView(host, options = {}) {
for (const fn of [...set]) { try { fn(payload); } catch (err) { console.error(`[render] ${name} listener failed`, err); } }
};
let destroyed = false;
let mode = 'idle'; // 'idle' | 'prep' | 'battle'
let stageRec = null;
let cam = presetCamera('prep', { width: s0.width, height: s0.height, padding: defaultPadding('prep', s0) }, { hud: hudBands('prep', s0) });
@@ -306,6 +322,7 @@ export async function createFieldView(host, options = {}) {
surfaceLayer: (row) => tiles.surfaceLayer(row),
};
const tiles = new TileField({ ground: layers.ground, overlay: layers.overlay, props: layers.units, anim: layers.anim });
setupCleanup.push(() => tiles.destroy());
const fx = new FxSystem({
P, layers, cam: () => cam, settings, assets, heightAt, redVignette: bg.red, surfaceLayer: ctx.surfaceLayer,
timeScale: () => ctx.timeScale(),
@@ -320,10 +337,12 @@ export async function createFieldView(host, options = {}) {
return Math.max(80, Math.min(size().height * 0.4, p.y));
},
});
setupCleanup.push(() => fx.destroy());
ctx.fx = fx;
// battle devices (crates / turrets as sim units): the official crate mesh in the 3D scene, else a Pixi box
ctx.createBox = () => switchableBox({ board: () => board3d, pixi: () => tiles.createBox() });
const impostors = new ImpostorAtlas(app.renderer);
setupCleanup.push(() => impostors.destroy());
ctx.impostors = impostors;
tiles.setView(bandFor('prep'), camRect(), fieldRows('prep'));
// the real board art of the local client (optional): wait briefly so the first frame already uses it; a late
@@ -332,7 +351,6 @@ export async function createFieldView(host, options = {}) {
if (art && !destroyed) { tiles.setArt(art); tiles.project(cam, true); }
return art;
}, () => null);
await withTimeout(artPromise, 2500);
// ---- 3D board layer (render/board3d, DESIGN §15) -----------------------------------------------------------
let board3d = null; // BoardScene while the 3D board is on
@@ -340,6 +358,7 @@ export async function createFieldView(host, options = {}) {
let board3dError = null;
// lost-context recovery: the THREE module + art pack of the last 3D board, the pending retry, failures so far
const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 };
setupCleanup.push(() => { clearTimeout(recover.timer); disable3d(); });
function enable3d(THREE, pack) {
if (destroyed || board3d || !THREE || !pack) return false;
recover.THREE = THREE; recover.pack = pack;
@@ -409,13 +428,13 @@ export async function createFieldView(host, options = {}) {
if (!enable3d(recover.THREE, recover.pack)) scheduleRecover();
}, delay);
}
if (want3d) {
const ready = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
await withTimeout(ready, 6000);
}
// Attach the board only after synchronous setup completes; enable3d also guards late arrivals.
const boardReady = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
// the official soft shadow sprite replaces the procedural one once loaded (may already be cached; asked again when the
// manifest arrives late)
let shadowAsked = false;
let offShadow = null;
setupCleanup.push(() => offShadow?.());
function loadShadow() {
const shadowUrl = !shadowAsked && assets.ui ? assets.ui('battle/sprite_shadow') : null;
if (!shadowUrl) return;
@@ -428,7 +447,11 @@ export async function createFieldView(host, options = {}) {
for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t;
for (const v of penViews.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t;
};
if (t.baseTexture.valid) use(); else t.baseTexture.once('loaded', use);
if (t.baseTexture.valid) use();
else {
t.baseTexture.once('loaded', use);
offShadow = () => t.baseTexture.off('loaded', use);
}
} catch { /* optional */ }
}
loadShadow();
@@ -1206,6 +1229,17 @@ export async function createFieldView(host, options = {}) {
// the 收起 toggle of PR #149 moved the discs one button to the right) opened the bond popup instead of selecting the
// unit. As a click target that holds the finger's point the canvas wins: a tap on the board stays on the tile under it.
const onTapTarget = () => {};
const removeInput = () => {
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerup', onPointerUp);
canvas.removeEventListener('pointercancel', onPointerCancel);
canvas.removeEventListener('pointerleave', onPointerLeave);
canvas.removeEventListener('contextmenu', onContext);
canvas.removeEventListener('touchend', onTouchEnd);
canvas.removeEventListener('click', onTapTarget);
};
setupCleanup.push(removeInput);
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
@@ -1655,6 +1689,7 @@ export async function createFieldView(host, options = {}) {
let ro = null;
if (typeof ResizeObserver === 'function') {
ro = new ResizeObserver(() => resize());
setupCleanup.push(() => ro.disconnect());
ro.observe(host);
}
// a DPR change without a size change (window dragged to another monitor, zoom) must re-rasterise too; polled
@@ -1677,6 +1712,7 @@ export async function createFieldView(host, options = {}) {
leader?.view.retryAssets?.();
}
const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null;
setupCleanup.push(() => offAssets?.());
const onVisible = () => {
if (destroyed || globalThis.document?.visibilityState !== 'visible') return;
if (assets.loaded === false && typeof assets.ready === 'function') assets.ready();
@@ -1685,10 +1721,11 @@ export async function createFieldView(host, options = {}) {
leader?.view.retryAssets?.();
};
globalThis.document?.addEventListener?.('visibilitychange', onVisible);
setupCleanup.push(() => globalThis.document?.removeEventListener?.('visibilitychange', onVisible));
// ---- public API ---------------------------------------------------------------------------------------------
const view = {
view = {
setStage,
setCamera,
setPrep,
@@ -1804,14 +1841,9 @@ export async function createFieldView(host, options = {}) {
try { ro?.disconnect(); } catch { /* ignore */ }
try { offAssets?.(); } catch { /* ignore */ }
globalThis.document?.removeEventListener?.('visibilitychange', onVisible);
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerup', onPointerUp);
canvas.removeEventListener('pointercancel', onPointerCancel);
canvas.removeEventListener('pointerleave', onPointerLeave);
canvas.removeEventListener('contextmenu', onContext);
canvas.removeEventListener('touchend', onTouchEnd);
canvas.removeEventListener('click', onTapTarget);
removeInput();
offShadow?.();
signal?.removeEventListener('abort', onAbort);
app.ticker.remove(frame);
app.ticker.remove(preRender);
app.ticker.remove(postRender);
@@ -1829,6 +1861,7 @@ export async function createFieldView(host, options = {}) {
try { board3dCanvas?.remove(); } catch { /* ignore */ }
releaseGl(app.renderer);
try { app.destroy(true, { children: true, texture: false, baseTexture: false }); } catch { /* ignore */ }
canvas.remove();
},
stats() {
return {
@@ -1848,5 +1881,22 @@ export async function createFieldView(host, options = {}) {
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
},
};
// All teardown dependencies now exist. Aborting during either asset wait immediately releases
// the renderer, ticker and listeners; late shared asset results see destroyed and do nothing.
setupCleanup.length = 0;
onAbort = () => view.destroy();
signal?.addEventListener('abort', onAbort, { once: true });
signal?.throwIfAborted();
await withTimeout(artPromise, 2500, signal);
if (want3d) await withTimeout(boardReady, 6000, signal);
signal?.throwIfAborted();
return view;
} catch (err) {
if (view) view.destroy();
else {
destroyed = true;
for (const dispose of setupCleanup.reverse()) { try { dispose(); } catch { /* continue cleanup */ } }
}
throw err;
}
}
+16 -2
View File
@@ -4,8 +4,22 @@ import { createAssets, assets as defaultAssets } from '../../assets.js';
import { standInRecord } from '../../../../shared/standIn.js';
import { diyRecordOf } from '../../../../shared/diy.js';
function withTimeout(p, ms) {
return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]);
/** Wait for optional assets, or stop waiting when this view is cancelled. Shared loads keep running. */
function withTimeout(p, ms, signal) {
let timer;
let onAbort;
const stopped = new Promise((resolve, reject) => {
if (ms != null) timer = setTimeout(resolve, ms);
if (signal) {
onAbort = () => reject(signal.reason);
signal.addEventListener('abort', onAbort, { once: true });
if (signal.aborted) onAbort();
}
});
return Promise.race([p, stopped]).finally(() => {
clearTimeout(timer);
if (onAbort) signal.removeEventListener('abort', onAbort);
});
}
/** Wrap whatever the caller passed as `assets` into the store API of public/js/assets.js. */