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:
1 parent
8ae891f1f3
commit
030d0a2938
7 files changed
+654
-39
No files matched your search
@@ -0,0 +1,343 @@
|
||||
// Real renderer startup and cancellation, without downloaded art.
|
||||
// RENDER_E2E=1 node --test test/render/initialization.browser.test.js
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const skip = process.env.RENDER_E2E === '1' && existsSync(CHROME) ? false : 'set RENDER_E2E=1 (needs Chrome)';
|
||||
const HTML = `<!doctype html><html><head><link rel="stylesheet" href="/css/theme.css"><link rel="stylesheet" href="/css/screens/game.css"></head>
|
||||
<body style="margin:0"><div id="host" style="position:relative;width:1000px;height:650px"></div><div id="hooks"></div></body></html>`;
|
||||
|
||||
// These wrappers keep the real browser/Pixi behavior and record resources owned by each view.
|
||||
function trackBrowserResources() {
|
||||
const state = window.__initialization = { apps: [], observers: [], listeners: [], calls: 0, settled: 0 };
|
||||
const timers = new Map();
|
||||
const set = window.setTimeout.bind(window), clear = window.clearTimeout.bind(window);
|
||||
window.setTimeout = (fn, ms, ...args) => {
|
||||
const id = set(() => { timers.delete(id); fn(...args); }, ms);
|
||||
if (ms === 12000) timers.set(id, () => fn(...args));
|
||||
return id;
|
||||
};
|
||||
window.clearTimeout = (id) => { timers.delete(id); clear(id); };
|
||||
state.expire = () => {
|
||||
for (const [id, fn] of [...timers]) { clear(id); timers.delete(id); fn(); }
|
||||
};
|
||||
const NativeObserver = window.ResizeObserver;
|
||||
window.ResizeObserver = class extends NativeObserver {
|
||||
constructor(fn) { super(fn); this.targets = new Set(); state.observers.push(this); }
|
||||
observe(target, ...args) { this.targets.add(target); return super.observe(target, ...args); }
|
||||
unobserve(target) { this.targets.delete(target); return super.unobserve(target); }
|
||||
disconnect() { this.targets.clear(); return super.disconnect(); }
|
||||
};
|
||||
const add = EventTarget.prototype.addEventListener, remove = EventTarget.prototype.removeEventListener;
|
||||
const tracked = (target, type) => target instanceof HTMLCanvasElement ||
|
||||
(target === document && type === 'visibilitychange') || (target === window && type === 'resize');
|
||||
const capture = (opts) => typeof opts === 'boolean' ? opts : !!opts?.capture;
|
||||
EventTarget.prototype.addEventListener = function (type, fn, opts) {
|
||||
if (tracked(this, type) && !state.listeners.some((r) => r.target === this && r.type === type && r.fn === fn && r.capture === capture(opts))) {
|
||||
state.listeners.push({ target: this, type, fn, capture: capture(opts) });
|
||||
}
|
||||
return add.call(this, type, fn, opts);
|
||||
};
|
||||
EventTarget.prototype.removeEventListener = function (type, fn, opts) {
|
||||
state.listeners = state.listeners.filter((r) => !(r.target === this && r.type === type && r.fn === fn && r.capture === capture(opts)));
|
||||
return remove.call(this, type, fn, opts);
|
||||
};
|
||||
state.snapshot = () => ({
|
||||
apps: state.apps.map(({ app, ticker, canvas }) => ({ destroyed: app.renderer === null, ticking: ticker.started, attached: canvas.isConnected })),
|
||||
observers: state.observers.filter((r) => r.targets.size).length,
|
||||
listeners: state.listeners.map((r) => r.type),
|
||||
canvases: document.querySelectorAll('#host canvas').length,
|
||||
fallbacks: document.querySelectorAll('#host .ff').length,
|
||||
});
|
||||
}
|
||||
|
||||
function trackPixiApplications() {
|
||||
const Application = PIXI.Application;
|
||||
PIXI.Application = class extends Application {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
window.__initialization.apps.push({ app: this, ticker: this.ticker, canvas: this.view });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const FACTORY_WRAPPER = `export * from '/js/render/app.js?implementation';
|
||||
import { createFieldView as create } from '/js/render/app.js?implementation';
|
||||
export async function createFieldView(...args) {
|
||||
window.__initialization.calls++;
|
||||
if (window.__initialization.ignoreSignal) args[1] = { ...args[1], signal: undefined };
|
||||
try { return await create(...args); }
|
||||
finally { window.__initialization.settled++; }
|
||||
}`;
|
||||
|
||||
describe('field initialization in headless Chrome', { skip }, () => {
|
||||
let srv, browser, pixi;
|
||||
before(async () => {
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] });
|
||||
pixi = readFileSync(path.join(ROOT, 'public/vendor/pixi.min.js'), 'utf8') + `\n;(${trackPixiApplications.toString()})();`;
|
||||
});
|
||||
after(async () => { await browser?.close(); await srv?.close(); });
|
||||
|
||||
async function open(t, { holdPixi = false } = {}) {
|
||||
const page = await browser.newPage();
|
||||
t.after(() => page.close());
|
||||
const errors = [];
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
await page.setViewport({ width: 1100, height: 750 });
|
||||
await page.evaluateOnNewDocument(trackBrowserResources);
|
||||
await page.setRequestInterception(true);
|
||||
let releasePixi;
|
||||
const requested = new Promise((resolve) => {
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname === '/initialization-test') return request.respond({ status: 200, contentType: 'text/html', body: HTML });
|
||||
if (url.pathname === '/js/render/app.js' && !url.search) return request.respond({ status: 200, contentType: 'text/javascript', body: FACTORY_WRAPPER });
|
||||
if (url.pathname === '/vendor/pixi.min.js') {
|
||||
releasePixi = () => request.respond({ status: 200, contentType: 'text/javascript', body: pixi });
|
||||
resolve();
|
||||
if (!holdPixi) return releasePixi();
|
||||
return;
|
||||
}
|
||||
return request.continue();
|
||||
});
|
||||
});
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/initialization-test?board=2d`);
|
||||
// Empty manifests prevent optional local art requests while preserving the real asset store.
|
||||
await page.evaluate(async () => {
|
||||
const { assets } = await import('/js/assets.js');
|
||||
assets.seed({ version: 1, chars: {}, enemies: {}, tokens: {}, items: {} });
|
||||
assets.seedLocal({ version: 1, files: {} });
|
||||
const { data } = await import('/js/data.js');
|
||||
await data.load('stages');
|
||||
window.__initialization.stage = data.list('stages')[0];
|
||||
window.__initialization.host = document.getElementById('host');
|
||||
});
|
||||
return { page, errors, requested, release: () => releasePixi() };
|
||||
}
|
||||
|
||||
async function start(page) {
|
||||
await page.evaluate(async () => {
|
||||
const { mountFieldView } = await import('/js/ui/fieldHost.js');
|
||||
const s = window.__initialization;
|
||||
s.mount = mountFieldView(s.host).then((view) => {
|
||||
s.view = view;
|
||||
view.setStage(s.stage);
|
||||
view.setPrep({ board: [], hand: [], temp: [] }, { editable: true });
|
||||
s.clicks = [];
|
||||
view.on('tileClick', (click) => s.clicks.push([click.row, click.col]));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function pixiFinished(page) {
|
||||
await page.evaluate(async () => {
|
||||
const { ensurePixi } = await import('/js/render/app/pixi.js');
|
||||
await ensurePixi();
|
||||
await new Promise(requestAnimationFrame);
|
||||
});
|
||||
}
|
||||
|
||||
async function clickFallback(page) {
|
||||
await page.waitForSelector('#host .ff-tile--floor');
|
||||
const tile = await page.$eval('#host .ff-tile--floor', (element) => {
|
||||
const r = element.getBoundingClientRect();
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2, row: +element.dataset.row, col: +element.dataset.col };
|
||||
});
|
||||
await page.mouse.click(tile.x, tile.y);
|
||||
return [tile.row, tile.col];
|
||||
}
|
||||
|
||||
test('successful startup owns one running app and destroy releases its browser resources', async (t) => {
|
||||
const { page, errors } = await open(t);
|
||||
await start(page);
|
||||
await page.waitForFunction(() => window.__initialization.view?.kind === 'engine');
|
||||
const mounted = await page.evaluate(() => window.__initialization.snapshot());
|
||||
assert.equal(mounted.canvases, 1);
|
||||
assert.deepEqual(mounted.apps, [{ destroyed: false, ticking: true, attached: true }]);
|
||||
assert.equal(mounted.observers, 1);
|
||||
assert.ok(mounted.listeners.includes('pointerdown'));
|
||||
assert.ok(mounted.listeners.includes('visibilitychange'));
|
||||
const destroyed = await page.evaluate(() => {
|
||||
const s = window.__initialization;
|
||||
s.view.destroy(); s.view.destroy();
|
||||
return s.snapshot();
|
||||
});
|
||||
assert.deepEqual(destroyed, { apps: [{ destroyed: true, ticking: false, attached: false }], observers: 0, listeners: [], canvases: 0, fallbacks: 0 });
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
for (const ignoreSignal of [false, true]) test(`a timed-out Pixi load cannot cover the clickable fallback (${ignoreSignal ? 'legacy factory ignores cancellation' : 'cancellable factory'})`, async (t) => {
|
||||
const { page, errors, requested, release } = await open(t, { holdPixi: true });
|
||||
await page.evaluate((value) => { window.__initialization.ignoreSignal = value; }, ignoreSignal);
|
||||
await start(page);
|
||||
await requested;
|
||||
await page.evaluate(() => window.__initialization.expire());
|
||||
await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback');
|
||||
const target = await clickFallback(page);
|
||||
assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]);
|
||||
await release();
|
||||
await pixiFinished(page);
|
||||
await page.waitForFunction(() => window.__initialization.settled === 1);
|
||||
await clickFallback(page);
|
||||
const late = await page.evaluate(() => ({ ...window.__initialization.snapshot(), clicks: window.__initialization.clicks }));
|
||||
assert.deepEqual(late.clicks, [target, target], 'real pointer input still reaches the fallback after Pixi finishes');
|
||||
assert.equal(late.canvases, 0, 'the abandoned engine must not append an orphan canvas');
|
||||
assert.equal(late.fallbacks, 1);
|
||||
assert.ok(late.apps.every((app) => app.destroyed && !app.ticking && !app.attached));
|
||||
assert.deepEqual(late.listeners, []);
|
||||
assert.equal(late.observers, 1, 'only the fallback observes the host');
|
||||
if (!ignoreSignal) {
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
await page.screenshot({ path: path.join(OUT, 'render-initialization-fallback.png') });
|
||||
}
|
||||
const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); });
|
||||
assert.equal(destroyed.observers, 0);
|
||||
assert.equal(destroyed.canvases, 0);
|
||||
assert.equal(destroyed.fallbacks, 0);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test('timeout during board-art startup immediately releases the allocated app and permits a fresh engine', async (t) => {
|
||||
const { page, errors } = await open(t);
|
||||
await page.evaluate(async () => {
|
||||
const { assets } = await import('/js/assets.js');
|
||||
const s = window.__initialization, local = assets.local.bind(assets);
|
||||
assets.local = () => {
|
||||
if (!s.apps.length) return local();
|
||||
assets.local = local;
|
||||
s.artBlocked = true;
|
||||
return new Promise((resolve) => { s.releaseArt = async () => resolve(await local()); });
|
||||
};
|
||||
});
|
||||
await start(page);
|
||||
await page.waitForFunction(() => window.__initialization.artBlocked);
|
||||
const allocated = await page.evaluate(() => window.__initialization.snapshot());
|
||||
assert.deepEqual(allocated.apps, [{ destroyed: false, ticking: true, attached: true }]);
|
||||
await page.evaluate(() => window.__initialization.expire());
|
||||
await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback');
|
||||
const cancelled = await page.evaluate(() => window.__initialization.snapshot());
|
||||
assert.deepEqual(cancelled.apps, [{ destroyed: true, ticking: false, attached: false }], 'cancellation frees the real renderer before the delayed art resolves');
|
||||
assert.equal(cancelled.canvases, 0);
|
||||
assert.equal(cancelled.observers, 1);
|
||||
assert.deepEqual(cancelled.listeners, []);
|
||||
const target = await clickFallback(page);
|
||||
assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]);
|
||||
await page.evaluate(async () => {
|
||||
const s = window.__initialization;
|
||||
await s.releaseArt();
|
||||
s.view.destroy();
|
||||
s.view = null;
|
||||
});
|
||||
await page.waitForFunction(() => window.__initialization.settled === 1);
|
||||
await start(page);
|
||||
await page.waitForFunction(() => window.__initialization.view?.kind === 'engine');
|
||||
const replacement = await page.evaluate(async () => {
|
||||
await new Promise(requestAnimationFrame);
|
||||
const s = window.__initialization, app = s.view.raw.debug.app;
|
||||
app.renderer.render(app.stage);
|
||||
return s.snapshot();
|
||||
});
|
||||
assert.deepEqual(replacement.apps, [
|
||||
{ destroyed: true, ticking: false, attached: false },
|
||||
{ destroyed: false, ticking: true, attached: true },
|
||||
], 'a replacement engine can render with the surviving shared caches');
|
||||
assert.equal(replacement.canvases, 1);
|
||||
const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); });
|
||||
assert.ok(destroyed.apps.every((app) => app.destroyed && !app.ticking && !app.attached));
|
||||
assert.equal(destroyed.observers, 0);
|
||||
assert.deepEqual(destroyed.listeners, []);
|
||||
assert.equal(destroyed.canvases, 0);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test('a setup exception releases partial resources without removing another host child', async (t) => {
|
||||
const { page, errors } = await open(t);
|
||||
await page.evaluate(async () => {
|
||||
const { assets } = await import('/js/assets.js');
|
||||
const s = window.__initialization, subscribe = assets.onChange.bind(assets);
|
||||
s.sibling = document.createElement('span');
|
||||
s.sibling.textContent = 'Existing host content';
|
||||
s.host.appendChild(s.sibling);
|
||||
// Fail after input and resize resources exist, while the real view is still being built.
|
||||
assets.onChange = (fn) => {
|
||||
if (s.observers.some((observer) => observer.targets.has(s.host))) {
|
||||
assets.onChange = subscribe;
|
||||
throw new Error('asset subscription unavailable');
|
||||
}
|
||||
return subscribe(fn);
|
||||
};
|
||||
});
|
||||
await start(page);
|
||||
await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback');
|
||||
const failed = await page.evaluate(() => ({ ...window.__initialization.snapshot(), sibling: window.__initialization.sibling.isConnected }));
|
||||
assert.deepEqual(failed.apps, [{ destroyed: true, ticking: false, attached: false }]);
|
||||
assert.equal(failed.sibling, true, 'failed initialization only cleans up its own resources');
|
||||
assert.equal(failed.canvases, 0);
|
||||
assert.equal(failed.observers, 1);
|
||||
assert.deepEqual(failed.listeners, []);
|
||||
const target = await clickFallback(page);
|
||||
assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]);
|
||||
const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); });
|
||||
assert.equal(destroyed.observers, 0);
|
||||
assert.equal(destroyed.fallbacks, 0);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test('unmount during startup cannot erase a newer hook mount on the same host', async (t) => {
|
||||
const { page, errors, requested, release } = await open(t, { holdPixi: true });
|
||||
await page.evaluate(async () => {
|
||||
const { h, render } = await import('/vendor/preact.module.js');
|
||||
const { useFieldView } = await import('/js/ui/fieldHost.js');
|
||||
const s = window.__initialization;
|
||||
function Field() { s.hook = useFieldView({ current: s.host }); return null; }
|
||||
s.renderHook = (key) => render(key ? h(Field, { key }) : null, document.getElementById('hooks'));
|
||||
s.renderHook('first');
|
||||
});
|
||||
await requested;
|
||||
await page.evaluate(() => {
|
||||
const s = window.__initialization;
|
||||
s.renderHook(null);
|
||||
window.__SP_RENDER__ = 'fallback';
|
||||
s.renderHook('second');
|
||||
});
|
||||
await page.waitForFunction(() => window.__initialization.hook?.kind === 'fallback');
|
||||
await page.evaluate(() => {
|
||||
const s = window.__initialization;
|
||||
s.current = s.hook.view;
|
||||
s.current.setStage(s.stage);
|
||||
s.current.setPrep({ board: [], hand: [], temp: [] }, { editable: true });
|
||||
s.clicks = [];
|
||||
s.current.on('tileClick', (click) => s.clicks.push([click.row, click.col]));
|
||||
s.expire();
|
||||
});
|
||||
await release();
|
||||
await pixiFinished(page);
|
||||
await page.waitForFunction(() => window.__initialization.settled === 1);
|
||||
const late = await page.evaluate(() => ({ ...window.__initialization.snapshot(), current: window.__SP_VIEW__ === window.__initialization.current }));
|
||||
assert.equal(late.fallbacks, 1, 'the old mount must not clear the newer view from the shared host');
|
||||
assert.equal(late.canvases, 0);
|
||||
assert.equal(late.current, true);
|
||||
assert.ok(late.apps.every((app) => app.destroyed && !app.ticking && !app.attached));
|
||||
const target = await clickFallback(page);
|
||||
assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]);
|
||||
const destroyed = await page.evaluate(() => {
|
||||
window.__initialization.renderHook(null);
|
||||
return { ...window.__initialization.snapshot(), current: window.__SP_VIEW__ };
|
||||
});
|
||||
assert.equal(destroyed.current, null);
|
||||
assert.equal(destroyed.observers, 0);
|
||||
assert.equal(destroyed.canvases, 0);
|
||||
assert.equal(destroyed.fallbacks, 0);
|
||||
assert.deepEqual(destroyed.listeners, []);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
// Field-host ownership under cancellation and timeout. Browser tests cover the actual renderer; this fixture runs
|
||||
// the production orchestration with controlled imports and timers, without a DOM or GPU dependency.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { getEventListeners } from 'node:events';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const sourcePath = new URL('../../public/js/ui/fieldHost.js', import.meta.url);
|
||||
const source = readFileSync(sourcePath, 'utf8')
|
||||
.replace(/^import .*;$/gm, '').replace(/^export /gm, '')
|
||||
.replace("import('../assets.js')", 'importAssets()')
|
||||
.replace("import('../render/app.js')", 'importApp()');
|
||||
const METHODS = ['setStage', 'setCamera', 'setPrep', 'enterBattle', 'pushSnapshot', 'pushEvents', 'highlightTiles', 'on', 'resize'];
|
||||
const flush = async () => { for (let i = 0; i < 16; i++) await Promise.resolve(); };
|
||||
|
||||
function fixture() {
|
||||
const timers = new Map(), requests = [], fallbacks = [], warnings = [];
|
||||
let timerId = 0;
|
||||
const unrelated = { name: 'unrelated' };
|
||||
const host = {
|
||||
children: [unrelated],
|
||||
get firstChild() { return this.children[0] || null; },
|
||||
appendChild(node) { this.children.push(node); },
|
||||
removeChild(node) { const i = this.children.indexOf(node); if (i >= 0) this.children.splice(i, 1); },
|
||||
};
|
||||
function view(name, valid = true) {
|
||||
const node = { name };
|
||||
host.appendChild(node);
|
||||
return {
|
||||
...(valid ? Object.fromEntries(METHODS.map((k) => [k, () => {}])) : {}),
|
||||
node, destroyCalls: 0,
|
||||
destroy() { this.destroyCalls++; host.removeChild(node); },
|
||||
};
|
||||
}
|
||||
const context = vm.createContext({
|
||||
AbortController, DOMException, URLSearchParams, Promise,
|
||||
console: { warn: (...args) => warnings.push(args) },
|
||||
audio: {}, data: { get() { return null; } }, settingsStore: { get() { return {}; } },
|
||||
setTimeout(fn) { timers.set(++timerId, fn); return timerId; },
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
importAssets: async () => null,
|
||||
importApp: async () => ({
|
||||
createFieldView(_host, opts) {
|
||||
// Deliberately ignore cancellation: the host must still dispose a factory's late return value.
|
||||
return new Promise((resolve, reject) => requests.push({ resolve, reject, signal: opts.signal }));
|
||||
},
|
||||
}),
|
||||
createFallbackView() {
|
||||
const fallback = view('fallback');
|
||||
fallbacks.push({ view: fallback, rendererAborted: requests.at(-1)?.signal?.aborted });
|
||||
return fallback;
|
||||
},
|
||||
});
|
||||
vm.runInContext(source + '\nglobalThis.mount = mountFieldView;', context, { filename: sourcePath.pathname });
|
||||
return {
|
||||
host, unrelated, timers, requests, fallbacks, warnings, view,
|
||||
mount: (signal) => context.mount(host, { signal }),
|
||||
timeout() { const fn = [...timers.values()].at(-1); assert.ok(fn, 'initialization has a deadline'); fn(); },
|
||||
};
|
||||
}
|
||||
|
||||
function settled(promise) {
|
||||
const result = { state: 'pending', value: null };
|
||||
promise.then((value) => { result.state = 'fulfilled'; result.value = value; }, (error) => { result.state = 'rejected'; result.value = error; });
|
||||
return result;
|
||||
}
|
||||
|
||||
test('successful initialization releases deadlines and cancellation listeners', async () => {
|
||||
const f = fixture(), controller = new AbortController();
|
||||
const pending = f.mount(controller.signal);
|
||||
await flush();
|
||||
const raw = f.view('engine');
|
||||
f.requests[0].resolve(raw);
|
||||
const mounted = await pending;
|
||||
assert.equal(mounted.kind, 'engine');
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
|
||||
assert.equal(getEventListeners(f.requests[0].signal, 'abort').length, 0);
|
||||
mounted.destroy();
|
||||
assert.equal(raw.destroyCalls, 1);
|
||||
});
|
||||
|
||||
test('cancelling a pending mount rejects without fallback and disposes a late result', async () => {
|
||||
const f = fixture(), controller = new AbortController();
|
||||
const result = settled(f.mount(controller.signal));
|
||||
await flush();
|
||||
controller.abort();
|
||||
await flush();
|
||||
assert.equal(result.state, 'rejected');
|
||||
assert.equal(result.value.name, 'AbortError');
|
||||
assert.equal(f.fallbacks.length, 0);
|
||||
assert.equal(f.warnings.length, 0);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
|
||||
const late = f.view('late');
|
||||
f.requests[0].resolve(late);
|
||||
await flush();
|
||||
assert.equal(late.destroyCalls, 1);
|
||||
assert.deepEqual(f.host.children, [f.unrelated]);
|
||||
});
|
||||
|
||||
test('cancellation between factory resolution and mount continuation disposes the resolved view', async () => {
|
||||
const f = fixture(), controller = new AbortController();
|
||||
const result = settled(f.mount(controller.signal));
|
||||
await flush();
|
||||
const raw = f.view('settling');
|
||||
f.requests[0].resolve(raw);
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
await flush();
|
||||
assert.equal(result.state, 'rejected');
|
||||
assert.equal(result.value.name, 'AbortError');
|
||||
assert.equal(raw.destroyCalls, 1);
|
||||
assert.equal(f.fallbacks.length, 0);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.deepEqual(f.host.children, [f.unrelated]);
|
||||
});
|
||||
|
||||
test('an invalid renderer API is disposed and aborted before fallback mounts', async () => {
|
||||
const f = fixture();
|
||||
const pending = f.mount();
|
||||
await flush();
|
||||
const invalid = f.view('invalid', false);
|
||||
f.requests[0].resolve(invalid);
|
||||
const fallback = await pending;
|
||||
assert.equal(fallback.kind, 'fallback');
|
||||
assert.equal(invalid.destroyCalls, 1);
|
||||
assert.equal(f.fallbacks[0].rendererAborted, true);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.deepEqual(f.host.children, [f.unrelated, f.fallbacks[0].view.node]);
|
||||
fallback.destroy();
|
||||
});
|
||||
|
||||
test('a timed-out factory returning late cannot remove a replacement view or unrelated children', async () => {
|
||||
const f = fixture();
|
||||
const first = f.mount();
|
||||
await flush();
|
||||
f.timeout();
|
||||
const fallback = await first;
|
||||
assert.equal(fallback.kind, 'fallback');
|
||||
fallback.destroy();
|
||||
const replacementPending = f.mount();
|
||||
await flush();
|
||||
const replacement = f.view('replacement');
|
||||
f.requests[1].resolve(replacement);
|
||||
const mounted = await replacementPending;
|
||||
const late = f.view('late');
|
||||
f.requests[0].resolve(late);
|
||||
await flush();
|
||||
assert.equal(late.destroyCalls, 1);
|
||||
assert.equal(replacement.destroyCalls, 0);
|
||||
assert.equal(f.fallbacks[0].rendererAborted, true);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.deepEqual(f.host.children, [f.unrelated, replacement.node]);
|
||||
mounted.destroy();
|
||||
});
|
||||
@@ -126,7 +126,7 @@ describe('8: the detail card never covers the selected unit\'s underframe', () =
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const app = readFileSync(new URL('../../public/js/render/app.js', import.meta.url), 'utf8');
|
||||
assert.match(app, /\n {2}canvas\.addEventListener\('click', onTapTarget\);\n/, 'registered with the other canvas listeners');
|
||||
assert.match(app, /\n {6}canvas\.removeEventListener\('click', onTapTarget\);\n/, 'dropped on destroy');
|
||||
assert.match(app, /\n +canvas\.removeEventListener\('click', onTapTarget\);\n/, 'dropped on destroy');
|
||||
assert.match(app, /const onTapTarget = \(\) => \{\};/, 'a no-op: the press itself stays with the pointer events');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user