mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user