Add cache for markdown card and markdown element (#24217)

* Add cache for markdown card and markdown element

* Rename to expiration

* Only use cache logic for markdown card

* Add tests

* Improve tests
pull/24278/head
Paul Bottein 2025-02-17 09:01:44 +01:00 committed by GitHub
parent 00d0cb7afa
commit 3ee3cfa6cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 141 additions and 3 deletions

View File

@ -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",

View File

@ -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<string>(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),

View File

@ -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}
></ha-markdown-element>`;
}

View File

@ -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<RenderTemplateResult>(1000);
@customElement("hui-markdown-card")
export class HuiMarkdownCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> {
@ -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}
<ha-card .header=${this._config.title}>
<ha-markdown
cache
breaks
class=${classMap({
"no-header": !this._config.title,

24
src/util/cache-manager.ts Normal file
View File

@ -0,0 +1,24 @@
export class CacheManager<T> {
constructor(expiration?: number) {
this._expiration = expiration;
}
private _expiration?: number;
private _cache = new Map<string, T>();
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);
}
}

View File

@ -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<string>(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<string>(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<string>();
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");
});
});