From 89755f274d9bf817be6b1f3765d0ea52836284fe Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 24 Mar 2026 17:41:30 +0100 Subject: [PATCH] Refactor lovelace view lifecycle to avoid unnecessary DOM rebuilds (#30101) * Refactor lovelace view lifecycle to avoid unnecessary DOM rebuilds - Remove `force` flag from `hui-root` that was clearing the entire view cache and destroying all cached view DOM on any config change. Views now receive updated lovelace in place and handle config changes internally. - Add `_cleanupViewCache` to remove stale cache entries when views are added, removed, or reordered. - Remove `@ll-rebuild` handler from `hui-root`. Cards and badges already handle `ll-rebuild` via their `hui-card`/`hui-badge` wrappers. Sections now always stop propagation and rebuild locally. - Add `deepEqual` guard in `hui-view._setConfig` and `hui-section._initializeConfig` to skip re-rendering when strategy regeneration produces an identical config. - Simplify `hui-view` refresh flow: remove `_refreshConfig`, `_rendered` flag, `strategy-config-changed` event, and connected/disconnected callbacks. Registry changes now debounce directly into `_initializeConfig`. - Fix `isStrategy` check in `hui-view._initializeConfig` to use the raw config (before strategy expansion) rather than the generated config. Co-Authored-By: Claude Sonnet 4.6 * Remove unused type * Improve viewCache cleanup * clean up * Handle custom view re-creation * Fix custom view loading --------- Co-authored-by: Claude Sonnet 4.6 --- src/panels/lovelace/hui-root.ts | 58 +++++-------- src/panels/lovelace/sections/hui-section.ts | 6 ++ src/panels/lovelace/types.ts | 1 - src/panels/lovelace/views/hui-view.ts | 94 +++++++-------------- 4 files changed, 59 insertions(+), 100 deletions(-) diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index bddd5888ce..10566b1f4b 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -35,7 +35,6 @@ import { extractSearchParamsObject, removeSearchParam, } from "../../common/url/search-params"; -import { debounce } from "../../common/util/debounce"; import { afterNextRender } from "../../common/util/render-status"; import "../../components/ha-button"; import "../../components/ha-dropdown"; @@ -157,7 +156,7 @@ class HUIRoot extends LitElement { private _configChangedByUndo = false; - private _viewCache?: Record; + private _viewCache: Record = {}; private _viewScrollPositions: Record = {}; @@ -171,23 +170,10 @@ class HUIRoot extends LitElement { }), }); - private _debouncedConfigChanged: () => void; - private _conversation = memoizeOne((_components) => isComponentLoaded(this.hass, "conversation") ); - constructor() { - super(); - // The view can trigger a re-render when it knows that certain - // web components have been loaded. - this._debouncedConfigChanged = debounce( - () => this._selectView(this._curView, true), - 100, - false - ); - } - private _renderActionItems(): TemplateResult { const result: TemplateResult[] = []; @@ -633,7 +619,6 @@ class HUIRoot extends LitElement { .hass=${this.hass} .theme=${curViewConfig?.theme} id="view" - @ll-rebuild=${this._debouncedConfigChanged} > @@ -770,7 +755,6 @@ class HUIRoot extends LitElement { } let newSelectView; - let force = false; let viewPath: string | undefined = this.route!.path.split("/")[1]; viewPath = viewPath ? decodeURI(viewPath) : undefined; @@ -802,9 +786,8 @@ class HUIRoot extends LitElement { | Lovelace | undefined; - if (!oldLovelace || oldLovelace.config !== this.lovelace!.config) { - // On config change, recreate the current view from scratch. - force = true; + if (oldLovelace && oldLovelace.config !== this.lovelace!.config) { + this._cleanupViewCache(); } if (!oldLovelace || oldLovelace.editMode !== this.lovelace!.editMode) { @@ -823,15 +806,12 @@ class HUIRoot extends LitElement { } } - if (!force && huiView) { + if (huiView) { huiView.lovelace = this.lovelace!; } } - if (newSelectView !== undefined || force) { - if (force && newSelectView === undefined) { - newSelectView = this._curView; - } + if (newSelectView !== undefined) { // Will allow for ripples to start rendering afterNextRender(() => { if (changedProperties.has("route")) { @@ -843,7 +823,7 @@ class HUIRoot extends LitElement { scrollTo({ behavior: "auto", top: position }) ); } - this._selectView(newSelectView, force); + this._selectView(newSelectView); }); } } @@ -1166,8 +1146,19 @@ class HUIRoot extends LitElement { } } - private _selectView(viewIndex: HUIRoot["_curView"], force: boolean): void { - if (!force && this._curView === viewIndex) { + private _cleanupViewCache(): void { + // Keep only the currently displayed view to avoid UI flash. + // All other cached views are cleared and will be recreated on next visit. + const currentView = + this._curView != null ? this._viewCache[this._curView] : undefined; + this._viewCache = {}; + if (currentView && this._curView != null) { + this._viewCache[this._curView] = currentView; + } + } + + private _selectView(viewIndex: HUIRoot["_curView"]): void { + if (this._curView === viewIndex) { return; } @@ -1180,11 +1171,6 @@ class HUIRoot extends LitElement { this._curView = viewIndex; - if (force) { - this._viewCache = {}; - this._viewScrollPositions = {}; - } - // Recreate a new element to clear the applied themes. const root = this._viewRoot; @@ -1212,12 +1198,12 @@ class HUIRoot extends LitElement { return; } - if (!force && this._viewCache![viewIndex]) { - view = this._viewCache![viewIndex]; + if (this._viewCache[viewIndex]) { + view = this._viewCache[viewIndex]; } else { view = document.createElement("hui-view"); view.index = viewIndex; - this._viewCache![viewIndex] = view; + this._viewCache[viewIndex] = view; } view.lovelace = this.lovelace; diff --git a/src/panels/lovelace/sections/hui-section.ts b/src/panels/lovelace/sections/hui-section.ts index e0e572baff..de3a589005 100644 --- a/src/panels/lovelace/sections/hui-section.ts +++ b/src/panels/lovelace/sections/hui-section.ts @@ -3,6 +3,7 @@ import type { PropertyValues } from "lit"; import { ReactiveElement } from "lit"; import { customElement, property, state } from "lit/decorators"; import { storage } from "../../../common/decorators/storage"; +import { deepEqual } from "../../../common/util/deep-equal"; import { fireEvent } from "../../../common/dom/fire_event"; import "../../../components/ha-svg-icon"; import type { LovelaceSectionElement } from "../../../data/lovelace"; @@ -165,6 +166,11 @@ export class HuiSection extends ConditionalListenerMixin( ...sectionConfig, type: sectionConfig.type || DEFAULT_SECTION_LAYOUT, }; + + if (isStrategy && deepEqual(sectionConfig, this._config)) { + return; + } + this._config = sectionConfig; // Create a new layout element if necessary. diff --git a/src/panels/lovelace/types.ts b/src/panels/lovelace/types.ts index a69c2aeaf1..8dacd951ea 100644 --- a/src/panels/lovelace/types.ts +++ b/src/panels/lovelace/types.ts @@ -24,7 +24,6 @@ declare global { interface HASSDomEvents { "ll-rebuild": Record; "ll-upgrade": Record; - "ll-badge-rebuild": Record; } } diff --git a/src/panels/lovelace/views/hui-view.ts b/src/panels/lovelace/views/hui-view.ts index 6c93cb2124..41d655c945 100644 --- a/src/panels/lovelace/views/hui-view.ts +++ b/src/panels/lovelace/views/hui-view.ts @@ -3,7 +3,7 @@ import type { PropertyValues } from "lit"; import { ReactiveElement } from "lit"; import { customElement, property, state } from "lit/decorators"; import { storage } from "../../../common/decorators/storage"; -import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event"; +import type { HASSDomEvent } from "../../../common/dom/fire_event"; import { debounce } from "../../../common/util/debounce"; import { deepEqual } from "../../../common/util/deep-equal"; import "../../../components/entity/ha-state-label-badge"; @@ -90,9 +90,7 @@ export class HUIView extends ReactiveElement { private _layoutElement?: LovelaceViewElement; - private _layoutElementConfig?: LovelaceViewConfig; - - private _rendered = false; + private _config?: LovelaceViewConfig; @storage({ key: "dashboardCardClipboard", @@ -139,11 +137,8 @@ export class HUIView extends ReactiveElement { element.addEventListener( "ll-rebuild", (ev: Event) => { - // In edit mode let it go to hui-root and rebuild whole view. - if (!this.lovelace!.editMode) { - ev.stopPropagation(); - this._rebuildSection(element, sectionConfig); - } + ev.stopPropagation(); + this._rebuildSection(element, sectionConfig); }, { once: true } ); @@ -154,18 +149,6 @@ export class HUIView extends ReactiveElement { return this; } - connectedCallback(): void { - super.connectedCallback(); - this.updateComplete.then(() => { - this._rendered = true; - }); - } - - disconnectedCallback(): void { - super.disconnectedCallback(); - this._rendered = false; - } - public willUpdate(changedProperties: PropertyValues): void { super.willUpdate(changedProperties); @@ -201,51 +184,22 @@ export class HUIView extends ReactiveElement { const viewConfig = this.lovelace.config.views[this.index]; if (oldHass && this.hass && this.lovelace && isStrategyView(viewConfig)) { if ( - oldHass.entities !== this.hass.entities || - oldHass.devices !== this.hass.devices || - oldHass.areas !== this.hass.areas || - oldHass.floors !== this.hass.floors + this.hass.config.state === "RUNNING" && + (oldHass.entities !== this.hass.entities || + oldHass.devices !== this.hass.devices || + oldHass.areas !== this.hass.areas || + oldHass.floors !== this.hass.floors) ) { - if (this.hass.config.state === "RUNNING") { - // If the page is not rendered yet, we can force the refresh - if (this._rendered) { - this._debounceRefreshConfig(false); - } else { - this._refreshConfig(true); - } - } + this._debounceRefreshConfig(); } } } private _debounceRefreshConfig = debounce( - (force: boolean) => this._refreshConfig(force), + () => this._initializeConfig(), 200 ); - private _refreshConfig = async (force: boolean) => { - if (!this.hass || !this.lovelace) { - return; - } - const viewConfig = this.lovelace.config.views[this.index]; - - if (!isStrategyView(viewConfig)) { - return; - } - - const oldConfig = this._layoutElementConfig; - const newConfig = await this._generateConfig(viewConfig); - - // Don't ask if the config is the same - if (!deepEqual(newConfig, oldConfig)) { - if (force) { - this._setConfig(newConfig, true); - } else { - fireEvent(this, "strategy-config-changed"); - } - } - }; - protected update(changedProperties: PropertyValues) { super.update(changedProperties); @@ -321,18 +275,22 @@ export class HUIView extends ReactiveElement { }; } - private async _setConfig( - viewConfig: LovelaceViewConfig, - isStrategy: boolean - ) { + private _setConfig(viewConfig: LovelaceViewConfig, isStrategy: boolean) { + if (isStrategy && deepEqual(viewConfig, this._config)) { + return; + } + + this._config = viewConfig; + // Create a new layout element if necessary. let addLayoutElement = false; if (!this._layoutElement || this._layoutElementType !== viewConfig.type) { addLayoutElement = true; this._createLayoutElement(viewConfig); + } else { + this._layoutElement.setConfig(viewConfig); } - this._layoutElementConfig = viewConfig; this._createBadges(viewConfig); this._createCards(viewConfig); this._createSections(viewConfig); @@ -355,9 +313,9 @@ export class HUIView extends ReactiveElement { private async _initializeConfig() { const rawConfig = this.lovelace.config.views[this.index]; + const isStrategy = isStrategyView(rawConfig); const viewConfig = await this._generateConfig(rawConfig); - const isStrategy = isStrategyView(viewConfig); this._setConfig(viewConfig, isStrategy); } @@ -365,6 +323,16 @@ export class HUIView extends ReactiveElement { private _createLayoutElement(config: LovelaceViewConfig): void { this._layoutElement = createViewElement(config) as LovelaceViewElement; this._layoutElementType = config.type; + this._layoutElement.addEventListener( + "ll-rebuild", + (ev: Event) => { + ev.stopPropagation(); + // Force recreation of the layout element + this._layoutElementType = undefined; + this._initializeConfig(); + }, + { once: true } + ); this._layoutElement.addEventListener("ll-create-card", (ev) => { showCreateCardDialog(this, { lovelaceConfig: this.lovelace.config,