Files
2026-07-08 10:43:39 -04:00

349 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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("<url>")` 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 ~3040 % 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<unknown> | null = null;
function loadBundle(): Promise<unknown> {
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<unknown>;
};
async function realDashboard(): Promise<DashboardStrategyClass> {
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<HTMLElement> {
// 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<unknown> {
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
// <hui-panel-lovelace> 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<Element | ShadowRoot> = [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<string, unknown>> },
): Record<string, unknown> | 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 <ha-panel-lovelace> (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<string, unknown>;
rawConfig?: Record<string, unknown>;
};
} | 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<Record<string, unknown>> },
);
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<Element | ShadowRoot> = [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 {};