perf(card): a floor switch stops re-querying the same subtrees (#694)

A floor switch replaces the whole stage, so the card's pointer-hover
MutationObserver receives hundreds of records whose targets are the same
few containers. Each record re-ran `matches` and a `.devlayer` subtree
`querySelector` on its target, and kept doing so after the device layer
had already been found. The batch logic moves to `deviceLayerMutated` in
device-hit-owner.ts: a node is checked at most once per batch, the first
hit ends the checks, and every added node still goes through
`_syncPointerHoverSubtree` in record order. The card shrinks by 12 lines.

The View stair layer read the card's `_model` getter once more for every
navigable stair; the getter rebuilds the config fingerprint on each read.
`renderLayer` now reads it once.

`languageRenderGate` wrote `lang` on the host on every render. It now
writes it only when the value differs (language switch, English fallback,
a foreign value); an unchanged value is left alone.

No behaviour changes: DOM, tooltips and pixels are the same. Unit tests
count subtree queries per node, `_model` reads per render and `lang`
writes; one mutant per change restores the old behaviour.

Issue: #694
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-01 00:22:26 +00:00
committed by claude[bot]
parent 153fa7a680
commit 6f17b6cd4c
9 changed files with 246 additions and 20 deletions
+70 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DeviceHitIndex, DevicePointerOwnerLatch, deviceHitScrollSources,
DeviceHitIndex, DevicePointerOwnerLatch, deviceHitScrollSources, deviceLayerMutated,
observeDeviceHitGeometryScroll, pointInDeviceCapsule, resolveDeviceHitOwner,
} from '../test-build/device-hit-owner.js';
@@ -130,3 +130,72 @@ test('#613 scroll observation crosses shadow hosts and tears down exactly once',
assert.equal(invalidations, 4);
reconnect();
});
// #694: an instrumented element counts every selector query run against it.
// `device` answers `.devlayer, .devlayer *`; `holdsDevice` answers the subtree
// query for `.devlayer` below it.
const probeNode = (name, { device = false, holdsDevice = false } = {}) => ({
name,
nodeType: 1,
queries: 0,
matches(selector) {
assert.equal(selector, '.devlayer, .devlayer *');
this.queries += 1;
return device;
},
querySelector(selector) {
assert.equal(selector, '.devlayer');
this.queries += 1;
return holdsDevice ? {} : null;
},
});
const record = (target, addedNodes = [], removedNodes = []) => ({ target, addedNodes, removedNodes });
test('#694 AC1 a batch into one subtree queries each node at most once', () => {
const stage = probeNode('stage');
const children = Array.from({ length: 40 }, (_, index) => probeNode(`child-${index}`));
const text = { nodeType: 3, name: 'text' };
const records = [
...children.map((child) => record(stage, [child])),
...children.map((child) => record(child)),
record(stage, [text], [children[0]]),
];
const synced = [];
assert.equal(deviceLayerMutated(records, (node) => synced.push(node.name)), false);
// matches + querySelector: one check is two queries, never more.
assert.equal(stage.queries, 2, 'the shared record target is checked once per batch');
for (const child of children) assert.equal(child.queries, 2, `${child.name} is checked once`);
assert.deepEqual(synced, [...children.map((child) => child.name), 'text'],
'every added node still reaches the pointer-hover sync, in record order');
});
test('#694 AC1 the first device-layer hit ends the queries, not the hover sync', () => {
const stage = probeNode('stage');
const devlayer = probeNode('devlayer', { device: true });
const later = Array.from({ length: 12 }, (_, index) => probeNode(`later-${index}`));
const added = later.map((_, index) => probeNode(`added-${index}`));
const records = [
record(stage),
record(devlayer),
...later.map((target, index) => record(target, [added[index]], [probeNode('gone')])),
];
const synced = [];
assert.equal(deviceLayerMutated(records, (node) => synced.push(node.name)), true);
assert.equal(stage.queries, 2);
assert.equal(devlayer.queries, 1, 'a match needs no subtree query');
for (const node of [...later, ...added]) {
assert.equal(node.queries, 0, `${node.name} is not queried after the first hit`);
}
assert.deepEqual(synced, added.map((node) => node.name),
'added nodes after the hit still reach the pointer-hover sync');
});
test('#694 AC1 a device layer is still found in targets, added and removed nodes', () => {
const plain = () => probeNode('plain');
const none = () => {};
assert.equal(deviceLayerMutated([record(plain(), [plain()], [plain()])], none), false);
assert.equal(deviceLayerMutated([record(plain()), record(probeNode('dev', { device: true }))], none), true);
assert.equal(deviceLayerMutated([record(plain(), [probeNode('floor', { holdsDevice: true })])], none), true);
assert.equal(deviceLayerMutated([record(plain(), [], [probeNode('old', { holdsDevice: true })])], none), true);
assert.equal(deviceLayerMutated([record({ nodeType: 3 }, [{ nodeType: 3 }])], none), false);
});
+38
View File
@@ -130,6 +130,44 @@ test('locale render gate exposes the fallback language to assistive technology',
assert.equal(host.attrs.get('lang'), 'en');
});
// #694: a real element answers getAttribute; count only the `lang` writes.
class AttributeHost extends FakeHost {
langWrites = [];
getAttribute(name) { return this.attrs.has(name) ? this.attrs.get(name) : null; }
setAttribute(name, value) {
if (name === 'lang') this.langWrites.push(value);
super.setAttribute(name, value);
}
}
test('#694 AC3 the locale render gate writes lang only when the value changes', async () => {
const runtime = new LanguageRuntime([
{ code: 'en', dictionary: {} },
{ code: 'de', dictionary: {} },
{ code: 'fr', loadDictionary: async () => { throw new Error('offline'); } },
], 'build', () => {});
const host = new AttributeHost();
const renders = (code, times = 3) => {
for (let index = 0; index < times; index += 1) languageRenderGate(host, runtime, code);
};
renders('en');
assert.deepEqual(host.langWrites, ['en'], 'repeated renders keep the attribute that is already right');
renders('de');
assert.deepEqual(host.langWrites, ['en', 'de'], 'a language switch writes the new language once');
assert.equal(languageRenderGate(host, runtime, 'fr'), 'warm');
await runtime.ensure('fr');
renders('fr');
assert.deepEqual(host.langWrites, ['en', 'de', 'en'], 'the English fallback is written once');
assert.equal(host.attrs.get('lang'), 'en');
renders('de');
renders(null);
assert.deepEqual(host.langWrites, ['en', 'de', 'en', 'de'], 'no language code writes nothing');
host.attrs.set('lang', 'ru');
renders('de');
assert.deepEqual(host.langWrites, ['en', 'de', 'en', 'de', 'de'], 'a foreign value is corrected');
assert.equal(host.attrs.get('lang'), 'de');
});
test('the production runtime is the tested class, not a handwritten twin (#354)', async () => {
const registry = await import('../test-build/i18n/registry.js');
assert.ok(
+61
View File
@@ -29,6 +29,7 @@ import {
stairPhysicalSizeCm,
stairTargetState,
} from '../test-build/stairs-editor-model.js';
import { StairViewRuntime } from '../test-build/stairs-view.js';
const straight = (extra = {}) => ({
id: 'straight', kind: 'straight', x: 0.5, y: 0.5, angle: 0,
@@ -397,3 +398,63 @@ test('#693 курсор move над телом лестницы — только
const view = readFileSync(new URL('../src/stairs-view.ts', import.meta.url), 'utf8');
assert.match(view, /<g class="hp-stair hp-stair-view /, 'слой View помечает свои лестницы');
});
// #694: a View-layer host whose `_model` getter counts its reads, as the card's
// getter fingerprints the whole config on each one.
const FLOORS = [
{ id: 'ground', title: 'Ground floor' },
{ id: 'upper', title: 'Upper floor' },
{ id: 'attic', title: 'Attic' },
];
const stairViewHost = (stairs, { mode = 'view', fixedFloor = false } = {}) => ({
reads: 0,
tips: [],
get _model() { this.reads += 1; return FLOORS; },
_mode: mode,
_curSpaceCfg: { stairs },
_space: 'ground',
_hasFixedFloor: fixedFloor,
_suppressClick: false,
_cellCm: 5,
_gridPitch: 1,
_decorStyle: { color: '#607d8b', opacity: 1 },
_tabClick() {},
_t: (key, vars) => (vars ? `${key}:${vars.title}` : key),
_showTip(event, title, meta) { this.tips.push([title, meta]); },
_clearPointerHover() {},
});
/** The value bound right after `attribute=` in a lit template. */
const boundValue = (template, attribute) => {
const index = template.strings.findIndex((part) => part.trimEnd().endsWith(`${attribute}=`));
assert.ok(index >= 0, `the stair template binds ${attribute}`);
return template.values[index];
};
test('#694 AC2 the View stair layer reads the card model once per render at any stair count', () => {
const targets = ['upper', 'attic', 'ground', null, 'gone'];
const titles = { upper: 'Upper floor', attic: 'Attic' };
for (const count of [0, 1, 2, 7, 40]) {
const stairs = Array.from({ length: count }, (_, index) => straight({
id: `stair-${index}`, x: 0.1 + index * 0.02, target_space_id: targets[index % targets.length],
}));
const host = stairViewHost(stairs);
const layer = new StairViewRuntime(host).renderLayer();
assert.equal(host.reads, 1, `${count} stairs: one read of _model per render`);
const items = layer.values[0];
assert.equal(items.length, count);
for (const item of items) boundValue(item, '@pointerenter')({});
assert.deepEqual(host.tips, stairs.flatMap((stair) => (
titles[stair.target_space_id] ? [[`stairs.tooltip_navigate:${titles[stair.target_space_id]}`, '']] : []
)), `${count} stairs: each link still names its target floor, the rest name none`);
assert.deepEqual(items.map((item) => boundValue(item, 'data-target-state')),
stairs.map((stair) => ({ upper: 'active', attic: 'active', ground: 'self', gone: 'deleted' })[
stair.target_space_id] ?? 'missing'));
}
for (const options of [{ fixedFloor: true }, { mode: 'plan' }]) {
const host = stairViewHost([straight({ id: 'a' }), straight({ id: 'b', target_space_id: 'attic' })], options);
const layer = new StairViewRuntime(host).renderLayer();
for (const item of layer.values[0]) boundValue(item, '@pointerenter')({});
assert.equal(host.reads, 1, `${JSON.stringify(options)}: one read`);
assert.deepEqual(host.tips, [], `${JSON.stringify(options)}: no stair is a link, none announces a floor`);
}
});