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)_
This commit is contained in:
committed by
dermotduffy
parent
b33c034810
commit
ea251ca988
@@ -894,4 +894,33 @@ describe('ConfigManager', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasTemplate', () => {
|
||||
it('should report true when the config contains a template', () => {
|
||||
const { manager } = createConfigManagerTestSetup();
|
||||
|
||||
manager.setConfig({
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [TEST_CAMERAS.OFFICE],
|
||||
view: {
|
||||
actions: {
|
||||
tap_action: { action: 'navigate', navigation_path: '{{ acc.camera }}' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(manager.hasTemplate()).toBe(true);
|
||||
});
|
||||
|
||||
it('should report false when the config contains no template', () => {
|
||||
const { manager } = createConfigManagerTestSetup();
|
||||
|
||||
manager.setConfig({
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [TEST_CAMERAS.OFFICE],
|
||||
});
|
||||
|
||||
expect(manager.hasTemplate()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,23 @@ import { assert, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AutomationsManager } from '../../../src/card-controller/automations-manager';
|
||||
import { setRemoteControlEntityFromConfig } from '../../../src/card-controller/config/load-control-entities';
|
||||
import { TemplateRenderer } from '../../../src/card-controller/templates/index';
|
||||
import type { CardController } from '../../../src/card-controller/controller';
|
||||
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
|
||||
import {
|
||||
GENERATED_ACTION,
|
||||
type ActionGenerator,
|
||||
type GeneratedActionConfig,
|
||||
} from '../../../src/config/schema/actions/custom/generated-action';
|
||||
import {
|
||||
INTERNAL_CALLBACK_ACTION,
|
||||
type InternalCallbackActionConfig,
|
||||
} from '../../../src/config/schema/actions/custom/internal';
|
||||
import { isAdvancedCameraCardCustomAction } from '../../../src/utils/action';
|
||||
import type { ActionConfig } from '../../../src/config/schema/actions/types';
|
||||
import {
|
||||
createCameraAction,
|
||||
isAdvancedCameraCardCustomAction,
|
||||
} from '../../../src/utils/action';
|
||||
import { arrayify } from '../../../src/utils/basic';
|
||||
import {
|
||||
createCardAPI,
|
||||
createConfig,
|
||||
@@ -18,6 +28,10 @@ import {
|
||||
createView,
|
||||
} from '../../test-utils';
|
||||
|
||||
const isGeneratedAction = (action: ActionConfig): action is GeneratedActionConfig =>
|
||||
'advanced_camera_card_action' in action &&
|
||||
action.advanced_camera_card_action === GENERATED_ACTION;
|
||||
|
||||
describe('setRemoteControlEntityFromConfig', () => {
|
||||
it('without control entity', () => {
|
||||
const api = createCardAPI();
|
||||
@@ -94,8 +108,8 @@ describe('setRemoteControlEntityFromConfig', () => {
|
||||
actions: [
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'camera_select',
|
||||
camera: '{{ trigger.to_state.state }}',
|
||||
advanced_camera_card_action: '__GENERATED_ACTION__',
|
||||
generator: expect.any(Function),
|
||||
},
|
||||
],
|
||||
triggers: [
|
||||
@@ -161,8 +175,8 @@ describe('setRemoteControlEntityFromConfig', () => {
|
||||
actions: [
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'camera_select',
|
||||
camera: '{{ hass.states["input_select.camera"].state }}',
|
||||
advanced_camera_card_action: '__GENERATED_ACTION__',
|
||||
generator: expect.any(Function),
|
||||
},
|
||||
],
|
||||
triggers: [
|
||||
@@ -176,8 +190,8 @@ describe('setRemoteControlEntityFromConfig', () => {
|
||||
actions: [
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'camera_select',
|
||||
camera: '{{ trigger.to_state.state }}',
|
||||
advanced_camera_card_action: '__GENERATED_ACTION__',
|
||||
generator: expect.any(Function),
|
||||
},
|
||||
],
|
||||
triggers: [
|
||||
@@ -623,8 +637,8 @@ describe('setRemoteControlEntityFromConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('should render the firing trigger into the camera action', () => {
|
||||
it('should resolve the new entity state through the trigger template', () => {
|
||||
describe('should wire the firing trigger into the camera action', () => {
|
||||
it('should carry the new entity state to the camera-select action', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
@@ -638,10 +652,12 @@ describe('setRemoteControlEntityFromConfig', () => {
|
||||
);
|
||||
|
||||
// Capture the automations the loader registers, then drive them through a
|
||||
// real AutomationsManager so the state trigger actually fires and its
|
||||
// `{{ trigger.to_state.state }}` template is rendered end to end (the
|
||||
// other tests only string-match the literal, so a context mis-wire would
|
||||
// silently break remote control).
|
||||
// real AutomationsManager so the state trigger actually fires. The
|
||||
// registered action is a generated action whose generator reads the
|
||||
// firing trigger's `to_state` to pick the camera, so this verifies that
|
||||
// the generator and the exact triggering value are wired through together
|
||||
// (the other tests only string-match the registered action shape, so a
|
||||
// context mis-wire would silently break remote control).
|
||||
setRemoteControlEntityFromConfig(api);
|
||||
const automations = vi.mocked(api.getAutomationsManager().addAutomations).mock
|
||||
.calls[0][0];
|
||||
@@ -670,16 +686,92 @@ describe('setRemoteControlEntityFromConfig', () => {
|
||||
.mock.calls.at(-1)?.[0];
|
||||
assert(captured);
|
||||
|
||||
const rendered = new TemplateRenderer().renderRecursively(hass, captured.actions, {
|
||||
triggerData: captured.triggerData,
|
||||
});
|
||||
expect(rendered).toEqual([
|
||||
expect(captured.actions).toEqual([
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'camera_select',
|
||||
camera: 'camera.two',
|
||||
advanced_camera_card_action: '__GENERATED_ACTION__',
|
||||
generator: expect.any(Function),
|
||||
},
|
||||
]);
|
||||
expect(captured.triggerData?.platform).toBe('state');
|
||||
expect(captured.triggerData?.entity_id).toBe('input_select.camera');
|
||||
expect(captured.triggerData?.to_state?.state).toBe('camera.two');
|
||||
|
||||
// Running the generated action's generator with the firing trigger
|
||||
// selects the camera named by the new state.
|
||||
const generated = arrayify(captured.actions)[0];
|
||||
assert(isGeneratedAction(generated));
|
||||
expect(generated.generator({ api, triggerData: captured.triggerData })).toEqual({
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'camera_select',
|
||||
camera: 'camera.two',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should generate a camera-select action only when a camera is named', () => {
|
||||
// Register the automations for a given camera priority, then pull the
|
||||
// generator off the generated action on the named trigger's automation.
|
||||
const getGenerator = (
|
||||
api: CardController,
|
||||
cameraPriority: 'card' | 'entity',
|
||||
triggerName: string,
|
||||
): ActionGenerator => {
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
remote_control: {
|
||||
entities: {
|
||||
camera: 'input_select.camera',
|
||||
camera_priority: cameraPriority,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
setRemoteControlEntityFromConfig(api);
|
||||
const automations = vi.mocked(api.getAutomationsManager().addAutomations).mock
|
||||
.calls[0][0];
|
||||
const automation = automations.find((candidate) =>
|
||||
candidate.triggers.some((trigger) => trigger.trigger === triggerName),
|
||||
);
|
||||
assert(automation);
|
||||
const action = arrayify(automation.actions)[0];
|
||||
assert(isGeneratedAction(action));
|
||||
return action.generator;
|
||||
};
|
||||
|
||||
it('should select the camera named by the entity state on initialize', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
|
||||
createHASS({
|
||||
'input_select.camera': createStateEntity({ state: 'camera.kitchen' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getGenerator(api, 'entity', 'initialized')({ api })).toEqual(
|
||||
createCameraAction('camera.kitchen'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate nothing on initialize when the entity has no state', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS({}));
|
||||
|
||||
expect(getGenerator(api, 'entity', 'initialized')({ api })).toBeNull();
|
||||
});
|
||||
|
||||
it('should generate nothing for a state trigger that carries no to_state', () => {
|
||||
const api = createCardAPI();
|
||||
|
||||
expect(
|
||||
getGenerator(
|
||||
api,
|
||||
'card',
|
||||
'state',
|
||||
)({
|
||||
api,
|
||||
triggerData: { platform: 'state', entity_id: 'input_select.camera' },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,11 @@ import { OverridesManager } from '../../../src/card-controller/config/overrides-
|
||||
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
|
||||
import type { AdvancedCameraCardConfig } from '../../../src/config/schema/types';
|
||||
import { AdvancedCameraCardError } from '../../../src/types';
|
||||
import { createConfig } from '../../test-utils';
|
||||
import { createConfig, createMockTemplateRenderer } from '../../test-utils';
|
||||
|
||||
describe('OverridesManager', () => {
|
||||
const templateManager = createMockTemplateRenderer();
|
||||
|
||||
it('should add overrides', () => {
|
||||
const config = createConfig({
|
||||
overrides: [
|
||||
@@ -25,7 +27,7 @@ describe('OverridesManager', () => {
|
||||
});
|
||||
|
||||
const manager = new OverridesManager(vi.fn());
|
||||
manager.set(new ConditionStateManager(), config.overrides);
|
||||
manager.set(new ConditionStateManager(), templateManager, config.overrides);
|
||||
|
||||
expect(manager.hasOverrides()).toBe(true);
|
||||
});
|
||||
@@ -49,11 +51,11 @@ describe('OverridesManager', () => {
|
||||
|
||||
const stateManager = new ConditionStateManager();
|
||||
const manager = new OverridesManager(vi.fn());
|
||||
manager.set(stateManager, config.overrides);
|
||||
manager.set(stateManager, templateManager, config.overrides);
|
||||
|
||||
expect(manager.getConfig(config).menu?.style).toBe('hidden');
|
||||
|
||||
manager.set(stateManager, []);
|
||||
manager.set(stateManager, templateManager, []);
|
||||
|
||||
stateManager.setState({ fullscreen: true });
|
||||
expect(manager.getConfig(config).menu?.style).toBe('hidden');
|
||||
@@ -79,7 +81,7 @@ describe('OverridesManager', () => {
|
||||
});
|
||||
|
||||
const manager = new OverridesManager(vi.fn());
|
||||
manager.set(new ConditionStateManager(), config.overrides);
|
||||
manager.set(new ConditionStateManager(), templateManager, config.overrides);
|
||||
|
||||
expect(manager.getConfig(config)).toBe(config);
|
||||
});
|
||||
@@ -104,7 +106,7 @@ describe('OverridesManager', () => {
|
||||
const callback = vi.fn();
|
||||
const stateManager = new ConditionStateManager();
|
||||
const manager = new OverridesManager(callback);
|
||||
manager.set(stateManager, config.overrides);
|
||||
manager.set(stateManager, templateManager, config.overrides);
|
||||
|
||||
expect(manager.getConfig(config).menu?.style).toBe('hidden');
|
||||
|
||||
@@ -139,7 +141,7 @@ describe('OverridesManager', () => {
|
||||
stateManager.setState({ fullscreen: true });
|
||||
|
||||
const manager = new OverridesManager(vi.fn());
|
||||
manager.set(stateManager, config.overrides);
|
||||
manager.set(stateManager, templateManager, config.overrides);
|
||||
|
||||
const overriddenConfig = manager.getConfig(config);
|
||||
|
||||
@@ -174,7 +176,7 @@ describe('OverridesManager', () => {
|
||||
stateManager.setState({ fullscreen: true });
|
||||
|
||||
const manager = new OverridesManager(vi.fn());
|
||||
manager.set(stateManager, config.overrides);
|
||||
manager.set(stateManager, templateManager, config.overrides);
|
||||
|
||||
const overriddenConfig = manager.getConfig(config);
|
||||
|
||||
@@ -205,7 +207,7 @@ describe('OverridesManager', () => {
|
||||
stateManager.setState({ fullscreen: true });
|
||||
|
||||
const manager = new OverridesManager(vi.fn());
|
||||
manager.set(stateManager, config.overrides);
|
||||
manager.set(stateManager, templateManager, config.overrides);
|
||||
|
||||
const overriddenConfig = manager.getConfig(config);
|
||||
|
||||
@@ -240,7 +242,7 @@ describe('OverridesManager', () => {
|
||||
stateManager.setState({ fullscreen: true });
|
||||
|
||||
const manager = new OverridesManager(vi.fn());
|
||||
manager.set(stateManager, config.overrides);
|
||||
manager.set(stateManager, templateManager, config.overrides);
|
||||
|
||||
const overriddenConfig = manager.getConfig(config);
|
||||
|
||||
@@ -276,7 +278,7 @@ describe('OverridesManager', () => {
|
||||
stateManager.setState({ fullscreen: true });
|
||||
|
||||
const manager = new OverridesManager(vi.fn());
|
||||
manager.set(stateManager, config.overrides);
|
||||
manager.set(stateManager, templateManager, config.overrides);
|
||||
|
||||
const overriddenConfig = manager.getConfig(config);
|
||||
|
||||
@@ -310,7 +312,7 @@ describe('OverridesManager', () => {
|
||||
stateManager.setState({ fullscreen: true });
|
||||
|
||||
const manager = new OverridesManager(vi.fn());
|
||||
manager.set(stateManager, config.overrides);
|
||||
manager.set(stateManager, templateManager, config.overrides);
|
||||
|
||||
const overriddenConfig = manager.getConfig(config);
|
||||
|
||||
@@ -341,7 +343,7 @@ describe('OverridesManager', () => {
|
||||
stateManager.setState({ fullscreen: true });
|
||||
|
||||
const manager = new OverridesManager(vi.fn());
|
||||
manager.set(stateManager, config.overrides);
|
||||
manager.set(stateManager, templateManager, config.overrides);
|
||||
|
||||
let thrown: unknown = null;
|
||||
try {
|
||||
@@ -442,7 +444,7 @@ describe('OverridesManager', () => {
|
||||
|
||||
const stateManager = new ConditionStateManager();
|
||||
const manager = new OverridesManager(vi.fn());
|
||||
manager.set(stateManager, config.overrides);
|
||||
manager.set(stateManager, templateManager, config.overrides);
|
||||
|
||||
expect(manager.getConfig(config).live.controls.thumbnails.mode).toBe('right');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user