Files
advanced-camera-card/src/card-controller/templates/index.ts
T
Dermot Duffy ea251ca988 perf(bundle): lazy-load the nunjucks template engine (#2535)
Closes #2531.

## Summary

The full `nunjucks` templating engine (~226KB) plus `ha-nunjucks`
(~46KB) — roughly **272KB, ~13% of the eager entry chunk** — was
statically imported and downloaded by every card on initial load, even
though templates only apply when a config value contains a `{{ … }}` /
`{% … %}` delimiter. Most cards use no templates and never need the
engine.

This defers the engine behind a dynamic `import('ha-nunjucks/dist')` so
it ships in a separate, on-demand chunk instead of the eager
`card-*.js`.

## Approach

The render path (`TemplateRenderer.renderRecursively`) is **kept
synchronous** — it is called from many synchronous hot paths
(condition/trigger evaluators, picture-elements rendering, actions,
folder matchers), and making it async would be a large, high-risk
refactor of the evaluation core.

Instead:

- **New `src/card-controller/templates/engine.ts`** — a module-level
singleton lazy loader (`loadTemplateEngine()` / `getTemplateEngine()`)
shared across all `TemplateRenderer` instances, plus a
`containsTemplate()` delimiter helper.
- **Delimiter gating** — strings without a delimiter never touch the
engine (the overwhelming majority of renders).
- **Pre-warm at config time** — because every template string originates
in the config, a new mandatory `TEMPLATE_ENGINE` initialization aspect
loads the engine before first render whenever the config contains a
delimiter. This **guarantees no raw `{{ … }}` flash**: content/condition
rendering is blocked until the engine is present for template-using
cards. Cards without templates never load it.

## Result

- nunjucks + ha-nunjucks move out of the eager chunk into a separate
chunk fetched only when a card actually uses templates.
- No change to the synchronous public render API; condition/trigger
evaluation core untouched.

## Tests

- New `engine.ts` loader coverage (concurrent load, cached reuse,
not-loaded fallback).
- The 11 existing test files that render real (delimiter-bearing)
templates declare their dependency explicitly via
`beforeAll(loadTemplateEngine)` — no global/implicit setup hook.
- Full suite green (4764 tests), lint and ts-prune clean, per-file 100%
coverage maintained for the affected directories.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_016ykWemdkZrgywvC71pHc6c

---
_Generated by [Claude
Code](https://claude.ai/code/session_016ykWemdkZrgywvC71pHc6c)_
2026-06-30 17:45:13 -07:00

179 lines
5.9 KiB
TypeScript

import type { renderTemplate } from 'ha-nunjucks/dist';
import type { ConditionState } from '../../condition-trigger/conditions/types';
import type { TriggerData } from '../../condition-trigger/triggers/types';
import type { HomeAssistant } from '../../ha/types';
import { isRecord } from '../../utils/basic';
import type { TemplateACCNamespace, TemplateMediaData } from './types';
type RenderTemplate = typeof renderTemplate;
interface TemplateContext {
acc: TemplateACCNamespace;
// The HA-native top-level `trigger`, set only when a trigger fired.
trigger?: TriggerData;
}
interface TemplateRenderOptions {
conditionState?: ConditionState;
triggerData?: TriggerData;
mediaData?: TemplateMediaData;
}
// The template-rendering methods that callers depend on. Callers (e.g.
// condition/trigger code) are kept independent of CardController via this
// interface.
export interface TemplateRenderer {
// Whether the renderer has finished loading. Synchronous callers that may run
// before loading completes (condition/trigger evaluation) check this and
// defer rather than rendering a template against an absent renderer.
isLoaded(): boolean;
renderRecursively(
hass: HomeAssistant,
data: unknown,
options?: TemplateRenderOptions,
): unknown;
renderRecursivelyAsType<T>(
hass: HomeAssistant,
data: T,
options?: TemplateRenderOptions,
): T;
}
// Renders nunjucks templates for the card. The renderer itself (`ha-nunjucks`,
// ~272KB) is large and most cards never use a template, so it is imported on
// demand the first time a template needs rendering (see `loadRenderer`).
export class TemplateManager implements TemplateRenderer {
private _renderer: RenderTemplate | null = null;
/**
* Whether any string anywhere in a given piece of data is a template.
*/
public static dataContainsTemplate(data: unknown): boolean {
return TemplateManager._containsTemplate(JSON.stringify(data) ?? '');
}
/**
* Load the renderer (the first time) and remember it. Repeat calls return
* immediately once loaded; a failed load is not cached, so a later call
* retries. Concurrent calls share a single load via the module cache.
*/
public async loadRenderer(): Promise<void> {
if (this._renderer) {
return;
}
const module = await import('ha-nunjucks/dist');
this._renderer = module.renderTemplate;
}
public isLoaded(): boolean {
return !!this._renderer;
}
public renderRecursively = (
hass: HomeAssistant,
data: unknown,
options?: TemplateRenderOptions,
): unknown => {
return this._renderTemplateRecursively(
hass,
data,
this._generateTemplateContext(options),
);
};
// Structure-preserving variant of `renderRecursively`: arrays, records, and
// primitives keep their shape (only string leaves are rendered), so the
// caller's type is asserted back unchanged. Callers whose template renders to
// a *different* type than its input (e.g. a string that yields a boolean)
// must use `renderRecursively` and narrow the `unknown` result at runtime.
public renderRecursivelyAsType = <T>(
hass: HomeAssistant,
data: T,
options?: TemplateRenderOptions,
): T => this.renderRecursively(hass, data, options) as T;
private _generateTemplateContext(
options?: TemplateRenderOptions,
): TemplateContext | undefined {
if (
!options?.conditionState?.camera &&
!options?.conditionState?.view &&
!options?.conditionState?.config &&
!options?.triggerData &&
!options?.mediaData
) {
return;
}
const acc: TemplateACCNamespace = {
...(options?.conditionState?.camera && { camera: options.conditionState.camera }),
...(options?.conditionState?.view && { view: options.conditionState.view }),
...(options?.conditionState?.config && { config: options.conditionState.config }),
...(options?.mediaData && { media: options.mediaData }),
};
return {
acc,
...(options?.triggerData && { trigger: options.triggerData }),
};
}
private _renderTemplateRecursively(
hass: HomeAssistant,
data: unknown,
templateContext?: TemplateContext,
): unknown {
if (typeof data === 'string') {
if (!TemplateManager._containsTemplate(data)) {
return data;
}
if (!this._renderer) {
// A defensive guard that should not be reached: the renderer is loaded
// during mandatory initialization before the view, triggers, or actions
// render, and the condition/trigger evaluators that can run earlier
// check `isLoaded()` first and defer rather than calling in here.
this.loadRenderer().catch(() => {});
return data;
}
return this._renderer(
// ha-nunjucks has a more complete model of the Home Assistant object, but
// does not export it as a type.
hass as unknown as Parameters<RenderTemplate>[0],
data,
templateContext,
);
} else if (Array.isArray(data)) {
return data.map((item) =>
this._renderTemplateRecursively(hass, item, templateContext),
);
} else if (isRecord(data)) {
const result = {};
for (const key in data) {
result[key] = this._renderTemplateRecursively(hass, data[key], templateContext);
}
return result;
}
return data;
}
/**
* Whether a string contains a nunjucks template that needs rendering. It does
* only if it has a matching pair of markers (`{{ … }}` or `{% … %}`); this is
* the same check `ha-nunjucks` makes, so a string without them renders the
* same whether or not the renderer has loaded -- which is what lets a card
* that uses no templates avoid loading the renderer at all.
*/
private static _containsTemplate(value: string): boolean {
return (
(value.includes('{{') && value.includes('}}')) ||
(value.includes('{%') && value.includes('%}'))
);
}
}