Files
houseplan-card/test/summary-runtime-loader.test.mjs
T
Codex 9d6ac98d3a fix: attach a warm summary runtime before the first render (#506)
Every card instance attached its summary-panel runtime through
import().then(), even when the chunk had loaded long ago. The first render
therefore measured a header without summary controls; the controls arrived
a beat later, the stage shrank, the deferred refit opened a `stage-resize`
continuity candidate and the first HA tick paid three extra render passes
(Full Performance: blend stateUpdate1 50 → 130 ms, overlay 217 → 809 ms;
locally 4 performUpdate per tick instead of 1).

summary-runtime-loader.ts separates the summary code from its state: the
loaded factory is cached per page, every host builds its own runtime from
it (no shared preferences, drafts, subscriptions, timers or DOM). A warm
factory yields the runtime synchronously in connectedCallback, before the
first Lit render; a cold mount still pays one lazy import, concurrent cold
mounts share the pending import, a failed import is forgotten so the next
connection retries, and a disconnect cancels the pending attachment of that
connection. SummaryRuntimeSlot owns the per-host lifecycle so the card core
stays under its line ceiling.

Witnesses: loader unit tests (distinct instances, shared pending import,
cancelled attachment, retry after failure); demo/smoke_summary_warm_attach
(warm replacement and cold-key instance on a warm page own the runtime
before the first render, header/stage stable from the first frame, no
stage-resize, one performUpdate per geometry-neutral tick, a real viewport
resize still opens stage-resize); mutant summary-runtime-attaches-after-
first-render. smoke_summary_panel waited for `_summary` as a readiness
proxy; it now waits for the server config load, which stays asynchronous.

Local paired glow benchmarks (4× CPU throttle): blend 159.7 → 49.9 ms and
overlay 326.7 → 120.4 ms at stateUpdate1 with renders 4 → 1; the isometric
load loses the three summary-owned long tasks.

Docs screenshots: all 11 frames decode pixel-identical to the committed
ones; the manifest carries only the new source fingerprint. Initial View
ceiling recentred 299 100 → 299 600 for the +299 B loader.

Issue: #506
User-Visible: yes
2026-09-09 13:57:53 +03:00

114 lines
4.4 KiB
JavaScript
Executable File

// #506: the summary chunk's code is cached per page, its runtime is per host.
import assert from 'node:assert/strict';
import test from 'node:test';
import { SummaryRuntimeLoader } from '../test-build/summary-runtime-loader.js';
const deferred = () => {
let resolve;
let reject;
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
return { promise, resolve, reject };
};
const tick = () => new Promise((done) => setTimeout(done, 0));
class Runtime {
constructor(host) { this.host = host; this.connected = 0; }
}
test('#506 AC1: a warm factory builds a distinct runtime for every host, synchronously', async () => {
let loads = 0;
const loader = new SummaryRuntimeLoader(async () => { loads++; return (host) => new Runtime(host); });
assert.equal(loader.warm, false);
assert.equal(loader.create({ id: 'cold' }), null, 'cold code cannot be created synchronously');
await loader.ensure();
assert.equal(loader.warm, true);
const hostA = { id: 'a' };
const hostB = { id: 'b' };
const readyA = [];
const readyB = [];
loader.attach(hostA, (runtime) => readyA.push(runtime));
loader.attach(hostB, (runtime) => readyB.push(runtime));
assert.equal(readyA.length, 1, 'warm attach runs ready before returning');
assert.equal(readyB.length, 1);
assert.notEqual(readyA[0], readyB[0], 'no runtime instance is shared between hosts');
assert.equal(readyA[0].host, hostA);
assert.equal(readyB[0].host, hostB);
assert.equal(loads, 1, 'the code was loaded once for the page');
});
test('#506 AC2: concurrent cold mounts share one import but not one instance', async () => {
const pending = deferred();
let loads = 0;
const loader = new SummaryRuntimeLoader(() => { loads++; return pending.promise; });
const seen = [];
loader.attach({ id: 'a' }, (runtime) => seen.push(runtime));
loader.attach({ id: 'b' }, (runtime) => seen.push(runtime));
assert.equal(loads, 1);
assert.equal(seen.length, 0, 'nothing is attached before the import lands');
pending.resolve((host) => new Runtime(host));
await tick();
assert.equal(seen.length, 2);
assert.notEqual(seen[0], seen[1]);
assert.deepEqual(seen.map((runtime) => runtime.host.id), ['a', 'b']);
assert.equal(loader.warm, true);
});
test('#506 AC2: a cancelled attachment never reaches its host; a reconnect attaches once', async () => {
const pending = deferred();
const loader = new SummaryRuntimeLoader(() => pending.promise);
const host = { id: 'reconnecting' };
const stale = [];
const fresh = [];
const first = loader.attach(host, (runtime) => stale.push(runtime));
first.cancel(); // disconnected while the chunk was still loading
const second = loader.attach(host, (runtime) => fresh.push(runtime));
pending.resolve((h) => new Runtime(h));
await tick();
assert.deepEqual(stale, [], 'the cancelled attempt must not run its side effects');
assert.equal(fresh.length, 1, 'the live attempt attaches exactly once');
assert.equal(fresh[0].host, host);
second.cancel(); // cancelling a completed attachment is a no-op
assert.equal(fresh.length, 1);
});
test('#506 AC2: a failed import is forgotten so the next attach may retry', async () => {
const attempts = [];
const loader = new SummaryRuntimeLoader(() => {
const attempt = deferred();
attempts.push(attempt);
return attempt.promise;
});
const seen = [];
loader.attach({ id: 'first' }, (runtime) => seen.push(runtime));
attempts[0].reject(new Error('network'));
await tick();
assert.equal(seen.length, 0);
assert.equal(loader.warm, false, 'a failure is not cached as the page state');
loader.attach({ id: 'second' }, (runtime) => seen.push(runtime));
assert.equal(attempts.length, 2, 'the next attach starts a fresh import');
attempts[1].resolve((host) => new Runtime(host));
await tick();
assert.equal(seen.length, 1);
assert.equal(seen[0].host.id, 'second');
assert.equal(loader.warm, true);
});
test('#506: ensure() rejects to its caller and a reset returns the page to cold', async () => {
let fail = true;
const loader = new SummaryRuntimeLoader(async () => {
if (fail) throw new Error('chunk missing');
return (host) => new Runtime(host);
});
await assert.rejects(loader.ensure(), /chunk missing/);
fail = false;
await loader.ensure();
assert.equal(loader.warm, true);
loader.reset();
assert.equal(loader.warm, false);
assert.equal(loader.create({}), null);
});