diff --git a/package.json b/package.json index cd9afdf16f..328b4eb9c7 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "marked": "15.0.7", "memoize-one": "6.0.0", "node-vibrant": "4.0.3", + "object-hash": "3.0.0", "punycode": "2.3.1", "qr-scanner": "1.4.2", "qrcode": "1.5.4", @@ -215,7 +216,6 @@ "lodash.merge": "4.6.2", "lodash.template": "4.5.0", "map-stream": "0.0.7", - "object-hash": "3.0.0", "pinst": "3.0.0", "prettier": "3.5.1", "rspack-manifest-plugin": "5.0.3", diff --git a/src/components/ha-markdown-element.ts b/src/components/ha-markdown-element.ts index 128bb220bd..1c642df869 100644 --- a/src/components/ha-markdown-element.ts +++ b/src/components/ha-markdown-element.ts @@ -1,7 +1,12 @@ +import type { PropertyValues } from "lit"; import { ReactiveElement } from "lit"; import { customElement, property } from "lit/decorators"; +import hash from "object-hash"; import { fireEvent } from "../common/dom/fire_event"; import { renderMarkdown } from "../resources/render-markdown"; +import { CacheManager } from "../util/cache-manager"; + +const markdownCache = new CacheManager(1000); const _gitHubMarkdownAlerts = { reType: @@ -26,6 +31,16 @@ class HaMarkdownElement extends ReactiveElement { @property({ type: Boolean, attribute: "lazy-images" }) public lazyImages = false; + @property({ type: Boolean }) public cache = false; + + public disconnectedCallback() { + super.disconnectedCallback(); + if (this.cache) { + const key = this._computeCacheKey(); + markdownCache.set(key, this.innerHTML); + } + } + protected createRenderRoot() { return this; } @@ -37,6 +52,24 @@ class HaMarkdownElement extends ReactiveElement { } } + protected willUpdate(_changedProperties: PropertyValues): void { + if (!this.innerHTML && this.cache) { + const key = this._computeCacheKey(); + if (markdownCache.has(key)) { + this.innerHTML = markdownCache.get(key)!; + this._resize(); + } + } + } + + private _computeCacheKey() { + return hash({ + content: this.content, + allowSvg: this.allowSvg, + breaks: this.breaks, + }); + } + private async _render() { this.innerHTML = await renderMarkdown( String(this.content), diff --git a/src/components/ha-markdown.ts b/src/components/ha-markdown.ts index 8570260a93..14d26fa2cd 100644 --- a/src/components/ha-markdown.ts +++ b/src/components/ha-markdown.ts @@ -13,6 +13,8 @@ export class HaMarkdown extends LitElement { @property({ type: Boolean, attribute: "lazy-images" }) public lazyImages = false; + @property({ type: Boolean }) public cache = false; + protected render() { if (!this.content) { return nothing; @@ -23,6 +25,7 @@ export class HaMarkdown extends LitElement { .allowSvg=${this.allowSvg} .breaks=${this.breaks} .lazyImages=${this.lazyImages} + .cache=${this.cache} >`; } diff --git a/src/panels/lovelace/cards/hui-markdown-card.ts b/src/panels/lovelace/cards/hui-markdown-card.ts index 7bc53af091..77cb23f734 100644 --- a/src/panels/lovelace/cards/hui-markdown-card.ts +++ b/src/panels/lovelace/cards/hui-markdown-card.ts @@ -3,17 +3,21 @@ import type { PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; -import { fireEvent } from "../../../common/dom/fire_event"; +import hash from "object-hash"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-alert"; import "../../../components/ha-card"; import "../../../components/ha-markdown"; -import "../../../components/ha-alert"; import type { RenderTemplateResult } from "../../../data/ws-templates"; import { subscribeRenderTemplate } from "../../../data/ws-templates"; import type { HomeAssistant } from "../../../types"; +import { CacheManager } from "../../../util/cache-manager"; import type { LovelaceCard, LovelaceCardEditor } from "../types"; import type { MarkdownCardConfig } from "./types"; +const templateCache = new CacheManager(1000); + @customElement("hui-markdown-card") export class HuiMarkdownCard extends LitElement implements LovelaceCard { public static async getConfigElement(): Promise { @@ -68,9 +72,32 @@ export class HuiMarkdownCard extends LitElement implements LovelaceCard { this._tryConnect(); } + private _computeCacheKey() { + return hash(this._config); + } + public disconnectedCallback() { super.disconnectedCallback(); this._tryDisconnect(); + + if (this._config && this._templateResult) { + const key = this._computeCacheKey(); + templateCache.set(key, this._templateResult); + } + } + + protected willUpdate(_changedProperties: PropertyValues): void { + super.willUpdate(_changedProperties); + if (!this._config) { + return; + } + + if (!this._templateResult) { + const key = this._computeCacheKey(); + if (templateCache.has(key)) { + this._templateResult = templateCache.get(key); + } + } } protected render() { @@ -87,6 +114,7 @@ export class HuiMarkdownCard extends LitElement implements LovelaceCard { : nothing} { + constructor(expiration?: number) { + this._expiration = expiration; + } + + private _expiration?: number; + + private _cache = new Map(); + + public get(key: string): T | undefined { + return this._cache.get(key); + } + + public set(key: string, value: T): void { + this._cache.set(key, value); + if (this._expiration) { + window.setTimeout(() => this._cache.delete(key), this._expiration); + } + } + + public has(key: string): boolean { + return this._cache.has(key); + } +} diff --git a/test/util/cache-manager.test.ts b/test/util/cache-manager.test.ts new file mode 100644 index 0000000000..7f0b61ba8b --- /dev/null +++ b/test/util/cache-manager.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { CacheManager } from "../../src/util/cache-manager"; + +const savedSetTimeout = setTimeout; + +describe("cache-manager", () => { + beforeEach(() => { + vi.useFakeTimers(); + window.setTimeout = setTimeout; + }); + afterEach(() => { + vi.useRealTimers(); + window.setTimeout = savedSetTimeout; + }); + it("should return value before expiration", async () => { + const cacheManager = new CacheManager(1000); + cacheManager.set("key", "value"); + + expect(cacheManager.has("key")).toBe(true); + expect(cacheManager.get("key")).toBe("value"); + + vi.advanceTimersByTime(500); + expect(cacheManager.has("key")).toBe(true); + expect(cacheManager.get("key")).toBe("value"); + }); + + it("should not return value after expiration", async () => { + const cacheManager = new CacheManager(1000); + cacheManager.set("key", "value"); + + expect(cacheManager.has("key")).toBe(true); + expect(cacheManager.get("key")).toBe("value"); + + vi.advanceTimersByTime(2000); + expect(cacheManager.has("key")).toBe(false); + expect(cacheManager.get("key")).toBe(undefined); + }); + + it("should always return value if no expiration", async () => { + const cacheManager = new CacheManager(); + cacheManager.set("key", "value"); + + expect(cacheManager.has("key")).toBe(true); + expect(cacheManager.get("key")).toBe("value"); + + vi.advanceTimersByTime(10000000000000000000000); + expect(cacheManager.has("key")).toBe(true); + expect(cacheManager.get("key")).toBe("value"); + }); +});