/** Maintenance Supporter — dashboard-strategy registration shim. * * WHY THIS FILE EXISTS * -------------------- * HA serves custom frontend modules via `frontend_extra_module_url`, which the * frontend loads as fire-and-forget `import("")` calls in index.html — no * ordering, no await, no completion guarantee. For a `custom:` dashboard * strategy, HA's renderer then only waits ~5 s via * `customElements.whenDefined("ll-strategy-dashboard-…")` for the element to * appear (unlike built-in strategies, which HA imports and awaits itself). * * On systems with many heavy HACS frontend plugins (card-mod, layout-card, …) * our full strategy bundle (~14 KB + a transitive graph) can lose that race: * the module is downloaded but its top-level `customElements.define` hasn't * finished executing within the 5 s window → "Timeout waiting for strategy * element ll-strategy-dashboard-maintenance-supporter to be registered". * Reproduced ~30–40 % of cold loads on a plugin-heavy live system. * * THE FIX * ------- * This shim is the `extra_module_url` instead of the full bundle. It has ZERO * static imports and does ONE thing synchronously: define a tiny wrapper * element for the dashboard strategy (and push its picker entry). Parsing a * few hundred bytes with no import graph reaches `define()` in well under a * microtask, so it wins HA's 5 s race essentially every time. The heavy * bundle is `import()`-ed lazily on first `generate()` / `getConfigElement()` * — exactly how HA's own built-in strategies defer their code. * * The heavy bundle keeps registering the editor, the section strategies, the * `ll-custom` tap handler, and the section picker entries at its own top level * (all of which only matter once a dashboard is actually being generated). */ const STRATEGY_TYPE = "maintenance-supporter"; const STRATEGY_TAG = `ll-strategy-dashboard-${STRATEGY_TYPE}`; const EDITOR_TAG = "hui-maintenance-supporter-strategy-editor"; // Absolute URL of the full strategy bundle (served at STRATEGY_URL by the // integration). The shim's only dependency, loaded on demand. const BUNDLE_URL = "/maintenance_supporter_strategy/maintenance-dashboard-strategy.js"; let _bundle: Promise | null = null; function loadBundle(): Promise { if (!_bundle) _bundle = import(/* @vite-ignore */ BUNDLE_URL); return _bundle; } // The real dashboard class, exported by the heavy bundle once it loads. We // resolve it from the registered editor's sibling export at call time. type DashboardStrategyClass = { generate(config: unknown, hass: unknown): Promise; }; async function realDashboard(): Promise { const mod = (await loadBundle()) as { MaintenanceDashboardStrategy?: DashboardStrategyClass; }; if (!mod.MaintenanceDashboardStrategy) { throw new Error( "[maintenance-supporter] strategy bundle loaded but did not export " + "MaintenanceDashboardStrategy", ); } return mod.MaintenanceDashboardStrategy; } class MaintenanceDashboardStrategyShim extends HTMLElement { static getCreateSuggestions(_hass: unknown) { return { title: "Maintenance Supporter", icon: "mdi:wrench-clock" }; } static async getConfigElement(): Promise { // Loading the bundle defines the editor element as a side effect. await loadBundle(); return document.createElement(EDITOR_TAG); } static async generate(config: unknown, hass: unknown): Promise { const real = await realDashboard(); return real.generate(config, hass); } } function defineShim(): void { // Attempt unconditionally: customElements.get() is unreliable across HA's // scoped registries (it can read true while the active dashboard registry has // no registration), so we always try and swallow the "already defined" throw. // On a fresh (cache-busted) re-import this re-registers on the now-active // registry, which is what the self-heal relies on. try { customElements.define(STRATEGY_TAG, MaintenanceDashboardStrategyShim); } catch { /* already defined in this registry — fine */ } } defineShim(); // Picker discovery (HA 2026.5+ reads window.customStrategies when the // "Add Dashboard" dialog opens). Idempotent — the heavy bundle pushes the // same entry too once it loads; whichever runs first wins, the other skips. const w = window as unknown as { customStrategies?: Array<{ type: string; strategyType: string; name: string; description?: string; documentationURL?: string; }>; }; w.customStrategies = w.customStrategies || []; if ( !w.customStrategies.some( (s) => s.type === STRATEGY_TYPE && s.strategyType === "dashboard", ) ) { w.customStrategies.push({ type: STRATEGY_TYPE, strategyType: "dashboard", name: "Maintenance Supporter", description: "Auto-generated dashboard. Group views by area, status, floor, or due date — picked from the strategy editor or YAML.", documentationURL: "https://github.com/iluebbe/maintenance_supporter#dashboard-strategy", }); } // ── Self-heal ─────────────────────────────────────────────────────────────── // Root cause (diagnosed live): HA's frontend uses a SCOPED custom-element // registry that is swapped in during boot. Our element, defined at module load, // can end up registered on a registry that the dashboard renderer no longer // uses — so `customElements.get(STRATEGY_TAG)` reads false in the active // registry and HA's 5 s whenDefined times out (console-only error, empty view). // // Verified recovery (9/9 on a live plugin-heavy system): RE-DEFINE on the // now-current registry, then bounce the view (navigate away + back) so HA's // reconnects and re-runs _fetchConfig — generate() then // finds the element and the real dashboard renders. // // Fully defensive: only acts on a Lovelace path when our element is missing // from the active registry; on a healthy load it never fires. (() => { const heal = window as unknown as { __msStrategyHealActive?: boolean }; if (heal.__msStrategyHealActive) return; // one heal loop per page session heal.__msStrategyHealActive = true; const NON_LOVELACE = /^\/(auth|config|developer-tools|profile|hassio|history|logbook|map|media-browser|energy|todo|calendar)\b/; // NOTE: customElements.get(STRATEGY_TAG) is NOT a reliable health signal — // HA's scoped registry makes it read `true` here even when the dashboard's // renderer (a different registry) failed. So we gate on the RENDERED OUTCOME: // a strategy dashboard that failed shows an essentially empty view. const ERROR_RE = /Timeout waiting for strategy element ll-strategy-(dashboard-)?maintenance-supporter/i; // The dashboard/view strategy type our generated dashboards carry. const OUR_STRATEGY = `custom:${STRATEGY_TYPE}`; // Deep querySelector through open shadow roots (HA nests the panel deeply). function findDeep(tag: string): Element | null { const stack: Array = [document.documentElement]; let scanned = 0; while (stack.length && scanned < 9000) { const node = stack.pop(); scanned++; if (!node) continue; const el = node as Element & { shadowRoot?: ShadowRoot | null }; if (el.nodeType === 1 && el.tagName && el.tagName.toLowerCase() === tag) { return el; } if (el.shadowRoot) stack.push(el.shadowRoot); const kids = (node as ParentNode).children; if (kids) for (const k of Array.from(kids)) stack.push(k); } return null; } // The view config for the path we are on (by view `path`, else numeric index, // else the first view). function currentViewCfg( cfg: { views?: Array> }, ): Record | null { const views = cfg?.views; if (!Array.isArray(views) || !views.length) return null; const seg = window.location.pathname.split("/").filter(Boolean).pop() || ""; const byPath = views.find((v) => (v as { path?: string })?.path === seg); if (byPath) return byPath; const idx = Number(seg); if (Number.isInteger(idx) && views[idx]) return views[idx]; return views[0]; } // True ONLY when the active dashboard/view is generated by our strategy. // Reads the Lovelace config off (both the processed // `config` and the original `rawConfig`, at dashboard level and current-view // level). This scopes the self-heal so it never touches a dashboard that is // not ours — the fix for issue #68, where the blank-view heuristic bounced // unrelated dashboards that happened to have < 3 cards. Defensive: any read // failure → false (never heal a view we cannot confirm is ours). function isOurStrategyView(): boolean { try { const panel = findDeep("ha-panel-lovelace") as unknown as { lovelace?: { config?: Record; rawConfig?: Record; }; } | null; const lov = panel?.lovelace; if (!lov) return false; const stratType = (s: unknown): string | undefined => (s as { type?: string } | undefined)?.type; for (const cfg of [lov.config, lov.rawConfig]) { if (!cfg) continue; if (stratType((cfg as { strategy?: unknown }).strategy) === OUR_STRATEGY) { return true; } const v = currentViewCfg( cfg as { views?: Array> }, ); if (v && stratType((v as { strategy?: unknown }).strategy) === OUR_STRATEGY) { return true; } } return false; } catch { return false; } } // Heuristic for "we are on a broken strategy view": the lovelace view exists // but is essentially empty (HA rendered the failed strategy as a blank view), // OR HA surfaced our strategy error card. Walks light + shadow DOM. function viewIsBroken(): boolean { let hasView = false; let cards = 0; let ourError = false; let hasEmptyState = false; const stack: Array = [document.documentElement]; let scanned = 0; while (stack.length && scanned < 9000) { const node = stack.pop(); scanned++; if (!node) continue; const el = node as Element & { shadowRoot?: ShadowRoot | null }; if (el.nodeType === 1 && el.tagName) { const tag = el.tagName.toLowerCase(); if (tag === "hui-view" || tag === "hui-sections-view") hasView = true; if (tag === "ha-card" || tag === "hui-card") cards++; // The zero-objects onboarding view renders exactly ONE hui-empty-state- // card. That is a SUCCESSFUL strategy render, but it trips the "< 3 // cards" blank-view heuristic below — issue #69, where the empty state // was mistaken for a broken render and the page reloaded every ~5 s. if (tag === "hui-empty-state-card") hasEmptyState = true; if (tag === "hui-error-card" && ERROR_RE.test(el.textContent || "")) { ourError = true; } } if (el.shadowRoot) stack.push(el.shadowRoot); const kids = (node as ParentNode).children; if (kids) for (const k of Array.from(kids)) stack.push(k); } // ourError (HA's error card naming OUR strategy element) is unambiguous — // act on it directly. The "blank view" signal (< 3 cards) is ambiguous on // its own (a small or still-loading dashboard looks identical), so gate it // on the active dashboard actually being our strategy. Without that gate the // self-heal bounced unrelated dashboards with few cards (issue #68). if (ourError) return true; // A rendered empty-state card means generate() ran and produced our // onboarding view — that is healthy by definition, so never bounce it. A // genuine strategy timeout shows hui-error-card or zero cards, never this // card (issue #69). if (hasEmptyState) return false; return hasView && cards < 3 && isOurStrategyView(); } // URL of THIS module, so recovery can force a fresh re-import (a cache-busted // import re-evaluates the module and re-runs customElements.define against the // registry that is active NOW — the boot-time define may have landed on a // scoped registry HA has since discarded, and a plain defineShim() call is a // no-op because customElements.get() reads stale-true across scoped registries). const SHIM_URL = "/maintenance_supporter_strategy_shim.js"; let bounces = 0; let lastBounce = 0; function recover(): void { const now = Date.now(); if (now - lastBounce < 5000 || bounces >= 3) return; // never thrash / loop lastBounce = now; bounces += 1; // 1) Force a fresh re-import so define() re-runs on the now-active registry. // (Verified live: this is what makes the element resolvable to HA's // dashboard renderer; a guarded re-define is not enough.) void import(/* @vite-ignore */ `${SHIM_URL}?heal=${now}`) .catch(() => undefined) .finally(() => { // 2) Bounce so HA re-fetches the strategy config; generate() now // resolves the element and the real dashboard renders. const back = window.location.pathname + window.location.search; history.pushState(null, "", "/lovelace"); window.dispatchEvent(new CustomEvent("location-changed")); window.setTimeout(() => { history.pushState(null, "", back); window.dispatchEvent(new CustomEvent("location-changed")); }, 200); }); } function watch(): void { if (NON_LOVELACE.test(window.location.pathname)) return; let ticks = 0; const startedAt = Date.now(); const id = window.setInterval(() => { ticks++; try { // Give HA past its ~5 s strategy timeout before judging the outcome. if (Date.now() - startedAt < 6000) return; if (NON_LOVELACE.test(window.location.pathname)) { window.clearInterval(id); return; } if (viewIsBroken()) { recover(); } else { window.clearInterval(id); // healthy / recovered } if (ticks >= 30) window.clearInterval(id); // ~15 s safety cap } catch { window.clearInterval(id); } }, 500); } try { if (document.readyState === "loading") { window.addEventListener("DOMContentLoaded", watch); } else { watch(); } window.addEventListener("location-changed", () => { if (!NON_LOVELACE.test(window.location.pathname)) watch(); }); } catch { /* best-effort; never block registration */ } })(); export {};