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
+28
View File
@@ -91,6 +91,34 @@ export function observeDeviceHitGeometryScroll(
};
}
type DeviceLayerMutation = Pick<MutationRecord, 'target' | 'addedNodes' | 'removedNodes'>;
/**
* One batch of the card's pointer-hover MutationObserver: every added node goes
* through `syncAdded`, and the result says whether the device layer changed.
* A floor switch delivers hundreds of records into the same subtrees (#694),
* so a node is queried at most once per batch and the first hit ends the queries.
*/
export function deviceLayerMutated(
records: Iterable<DeviceLayerMutation>,
syncAdded: (node: Node) => void,
): boolean {
const checked = new Set<Node>();
const inDeviceLayer = (node: Node): boolean => {
if (node.nodeType !== 1 || checked.has(node)) return false;
checked.add(node);
const element = node as Element;
return element.matches('.devlayer, .devlayer *') || !!element.querySelector?.('.devlayer');
};
let changed = false;
for (const record of records) {
for (const node of record.addedNodes) syncAdded(node);
if (!changed && (inDeviceLayer(record.target)
|| [...record.addedNodes, ...record.removedNodes].some(inDeviceLayer))) changed = true;
}
return changed;
}
const EPSILON = 1e-7;
const distanceSquared = (a: DeviceHitPoint, b: DeviceHitPoint): number => {
+3 -15
View File
@@ -71,7 +71,7 @@ import {
type FixedFloorSelection, type InitialSpaceSelection,
} from './initial-load';
import { TouchGestureClickGuard } from './touch-gesture-click-guard';
import { DeviceHitController, observeDeviceHitGeometryScroll } from './device-hit-owner';
import { DeviceHitController, deviceLayerMutated, observeDeviceHitGeometryScroll } from './device-hit-owner';
import { selectActiveSpaceModel, selectSpaceModelById } from './space-model-selection';
import { roomTempRangeFromDraft, type SpaceDialogState } from './space-dialog';
import { mdiHomeCityOutline } from '@mdi/js';
@@ -2542,21 +2542,9 @@ export class HouseplanCard extends LitElement {
const PointerHoverObserver = this.ownerDocument.defaultView?.MutationObserver;
if (PointerHoverObserver) {
this._pointerHoverObserver = new PointerHoverObserver((records) => {
let deviceGeometryChanged = false;
for (const record of records) {
for (const node of record.addedNodes) this._syncPointerHoverSubtree(node);
const inDeviceLayer = (node: Node): boolean => {
if (node.nodeType !== Node.ELEMENT_NODE) return false;
const element = node as Element;
return element.matches('.devlayer, .devlayer *')
|| !!element.querySelector?.('.devlayer');
};
if (inDeviceLayer(record.target)
|| [...record.addedNodes, ...record.removedNodes].some(inDeviceLayer)) {
deviceGeometryChanged = true;
}
if (deviceLayerMutated(records, (node) => this._syncPointerHoverSubtree(node))) {
this._invalidateDeviceHitGeometry();
}
if (deviceGeometryChanged) this._invalidateDeviceHitGeometry();
});
this._pointerHoverObserver.observe(this.renderRoot, {
childList: true,
+5 -1
View File
@@ -95,6 +95,7 @@ interface LanguageHostElement {
isConnected: boolean;
requestUpdate(): void;
setAttribute(name: string, value: string): void;
getAttribute?(name: string): string | null;
removeAttribute(name: string): void;
}
@@ -114,7 +115,10 @@ export function languageRenderGate(
host.removeAttribute('aria-busy');
}
if (code) {
host.setAttribute('lang', state === 'fallback' ? 'en' : code);
// #694: `lang` is inherited; a write, even of the same value, can
// invalidate style for the whole shadow tree. Write it only on change.
const lang = state === 'fallback' ? 'en' : code;
if (host.getAttribute?.('lang') !== lang) host.setAttribute('lang', lang);
committedHosts.add(host);
}
return 'ready';
+5 -2
View File
@@ -61,7 +61,10 @@ export class StairViewRuntime {
}
public renderLayer(): TemplateResult {
const spaceIds = new Set(this.owner._model.map((item) => item.id));
// #694: the card's `_model` getter fingerprints the whole config on every
// read; read it once per layer, not once more per navigable stair.
const model = this.owner._model;
const spaceIds = new Set(model.map((item) => item.id));
const interactive = this.owner._mode === 'view';
const items = this.stairs.map((stair) => {
const geometry = cachedStairRenderGeometry(stair, this.owner._cellCm);
@@ -73,7 +76,7 @@ export class StairViewRuntime {
// #676 К8: the tooltip has exactly the link's condition — `active` — so a
// missing, self, deleted or fixed-floor target never announces a floor.
const targetTitle = active
? this.owner._model.find((item) => item.id === stair.target_space_id)?.title ?? ''
? model.find((item) => item.id === stair.target_space_id)?.title ?? ''
: '';
const tip = (event: PointerEvent): void => {
if (!active) return;