fix(packages/angular): move child rendering logic to template from effect (#4519)

* fix(packages/angular): move child rendering logic to template from effect

* test(packages/angular): add unit tests on SSR hydration
This commit is contained in:
Karsa
2026-07-06 12:16:52 +02:00
committed by GitHub
parent 031b226ac6
commit 5828ecb38f
7 changed files with 324 additions and 92 deletions

View File

@@ -54,6 +54,7 @@
"@angular/core": "^21.2.17",
"@angular/forms": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"@angular/platform-server": "^21.2.17",
"@angular/router": "^21.0.0",
"@lucide/build-icons": "workspace:*",
"@lucide/helpers": "workspace:*",

View File

@@ -22,7 +22,12 @@ describe('LucideDynamicIcon', () => {
let component: LucideDynamicIcon;
let fixture: ComponentFixture<LucideDynamicIcon>;
let icon: WritableSignal<LucideIconInput | null | undefined>;
const getSvgAttribute = (attr: string) => fixture.nativeElement.getAttribute(attr);
const expectSvgClasses = (classes: string[]) => {
for (const cssClass of classes) {
expect(fixture.nativeElement.classList.contains(cssClass)).toBe(true);
}
};
const getRenderedChildren = () => Array.from(fixture.nativeElement.children) as Element[];
const testIcon: LucideIconData = {
name: 'demo',
node: [['polyline', { points: '1 1 22 22' }]],
@@ -30,11 +35,23 @@ describe('LucideDynamicIcon', () => {
const testIcon2: LucideIconData = {
name: 'demo-other',
node: [
['circle', { cx: 12, cy: 12, r: 8 }],
['circle', { cx: 12, cy: 12, r: 8, fill: 'currentColor' }],
['polyline', { points: '1 1 22 22' }],
],
aliases: ['demo-2'],
};
const supportedShapesIcon: LucideIconData = {
name: 'supported-shapes',
node: [
['path', { d: 'm1 1 2 2', fill: 'currentColor', key: 'path-key' }],
['line', { x1: 1, x2: 2, y1: 3, y2: 4, key: 'line-key' }],
['polygon', { points: '1 1 2 2 3 1', key: 'polygon-key' }],
['polyline', { points: '1 1 2 2 3 1', key: 'polyline-key' }],
['circle', { cx: 12, cy: 12, r: 8, fill: 'currentColor', key: 'circle-key' }],
['ellipse', { cx: 12, cy: 12, rx: 8, ry: 4, key: 'ellipse-key' }],
['rect', { x: 1, y: 2, width: 3, height: 4, rx: 5, ry: 6, key: 'rect-key' }],
],
};
function createComponent() {
return TestBed.createComponent(LucideDynamicIcon, {
inferTagName: true,
@@ -58,15 +75,33 @@ describe('LucideDynamicIcon', () => {
it('should render children', () => {
icon.set(testIcon2);
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toBe(
'<!--container--><circle cx="12" cy="12" r="8"></circle><polyline points="1 1 22 22"></polyline><!--ng-container-->',
const children = getRenderedChildren();
expect(children.map((child) => child.tagName.toLowerCase())).toEqual(['circle', 'polyline']);
expect(children[0].outerHTML).toBe(
'<circle cx="12" cy="12" r="8" fill="currentColor"></circle>',
);
expect(children[1].outerHTML).toBe('<polyline points="1 1 22 22"></polyline>');
});
it('should render supported SVG shapes and attributes', () => {
icon.set(supportedShapesIcon);
fixture.detectChanges();
const children = getRenderedChildren();
expect(children.map((child) => child.outerHTML)).toEqual([
'<path d="m1 1 2 2" fill="currentColor"></path>',
'<line x1="1" x2="2" y1="3" y2="4"></line>',
'<polygon points="1 1 2 2 3 1"></polygon>',
'<polyline points="1 1 2 2 3 1"></polyline>',
'<circle cx="12" cy="12" r="8" fill="currentColor"></circle>',
'<ellipse cx="12" cy="12" rx="8" ry="4"></ellipse>',
'<rect x="1" y="2" width="3" height="4" rx="5" ry="6"></rect>',
]);
});
it('should remove children on change', () => {
icon.set(null);
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toBe('<!--container--><!--ng-container-->');
expect(getRenderedChildren()).toEqual([]);
});
describe('iconInput', () => {
@@ -74,9 +109,9 @@ describe('LucideDynamicIcon', () => {
icon.set(testIcon);
fixture.detectChanges();
expect(component['icon']()).toBe(testIcon);
expect(fixture.nativeElement.innerHTML).toBe(
'<!--container--><polyline points="1 1 22 22"></polyline><!--ng-container-->',
);
expect(getRenderedChildren().map((child) => child.outerHTML)).toEqual([
'<polyline points="1 1 22 22"></polyline>',
]);
});
it('should support LucideIcon input', () => {
icon.set(LucideActivity);
@@ -97,23 +132,24 @@ describe('LucideDynamicIcon', () => {
describe('class', () => {
it('should add all classes', () => {
fixture.detectChanges();
expect(getSvgAttribute('class')).toBe('lucide lucide-demo');
expectSvgClasses(['lucide', 'lucide-demo']);
});
it('should add backwards compatible classes from aliases', () => {
icon.set(testIcon2);
fixture.detectChanges();
expect(getSvgAttribute('class')).toBe('lucide lucide-demo-other lucide-demo-2');
expectSvgClasses(['lucide', 'lucide-demo-other', 'lucide-demo-2']);
});
it('should add class icon if available', () => {
icon.set(LucideActivity);
fixture.detectChanges();
expect(getSvgAttribute('class')).toBe('lucide lucide-activity');
expectSvgClasses(['lucide', 'lucide-activity']);
});
it('should remove class on change', () => {
icon.set(null);
fixture.detectChanges();
expect(getSvgAttribute('class')).toBe('lucide');
expectSvgClasses(['lucide']);
expect(fixture.nativeElement.classList.contains('lucide-demo')).toBe(false);
});
});

View File

@@ -0,0 +1,107 @@
import { ApplicationRef, Component, destroyPlatform, Provider, Type } from '@angular/core';
import { bootstrapApplication, provideClientHydration } from '@angular/platform-browser';
import { provideServerRendering, renderApplication } from '@angular/platform-server';
import { LucideDynamicIcon } from './lucide-dynamic-icon';
import { provideLucideIcons } from './lucide-icons';
import { LucideBadgeAlert } from './icons/badge-alert';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'lucide-static-hydration-root',
template: `<svg lucideBadgeAlert [absoluteStrokeWidth]="true"></svg>`,
imports: [LucideBadgeAlert],
})
class StaticHydrationRootComponent {}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'lucide-dynamic-hydration-root',
template: `<svg [lucideIcon]="'badge-alert'"></svg>`,
imports: [LucideDynamicIcon],
})
class DynamicHydrationRootComponent {}
describe('Lucide hydration', () => {
let appRef: ApplicationRef | undefined;
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn');
});
afterEach(() => {
warnSpy.mockRestore();
appRef?.destroy();
appRef = undefined;
document.body.innerHTML = '';
delete (globalThis as { ngServerMode?: boolean }).ngServerMode;
destroyPlatform();
});
async function renderAndHydrate(
rootComponent: Type<unknown>,
selector: string,
providers: Provider[] = [],
) {
delete (globalThis as { ngServerMode?: boolean }).ngServerMode;
const appConfig = {
providers: [provideClientHydration(), ...providers],
};
const serverAppConfig = {
providers: [provideServerRendering(), provideClientHydration(), ...providers],
};
const html = await renderApplication(
(context) => bootstrapApplication(rootComponent, serverAppConfig, context),
{
document: `<!doctype html><html><head></head><body><${selector}></${selector}></body></html>`,
url: 'http://localhost/',
allowedHosts: ['localhost'],
},
);
(globalThis as { ngServerMode?: boolean }).ngServerMode = false;
const serverDocument = new DOMParser().parseFromString(html, 'text/html');
expectBadgeAlertShapeNodes(serverDocument.querySelector('svg') as SVGSVGElement);
document.head.innerHTML = serverDocument.head.innerHTML;
document.body.innerHTML = serverDocument.body.innerHTML;
appRef = await bootstrapApplication(rootComponent, appConfig);
await appRef.whenStable();
const hasMissingHydrationWarning = warnSpy.mock.calls.some((call: unknown[]) =>
String(call[0]).includes('NG0505'),
);
expect(hasMissingHydrationWarning).toBe(false);
return document.querySelector('svg') as SVGSVGElement;
}
function expectBadgeAlertShapeNodes(svg: SVGSVGElement) {
expect(Array.from(svg.children).map((child) => child.tagName.toLowerCase())).toEqual([
'path',
'line',
'line',
]);
}
it('should hydrate static icon children without duplicating SSR nodes', async () => {
const svg = await renderAndHydrate(
StaticHydrationRootComponent,
'lucide-static-hydration-root',
);
expectBadgeAlertShapeNodes(svg);
for (const child of Array.from(svg.children)) {
expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke');
}
});
it('should hydrate dynamic icon children without duplicating SSR nodes', async () => {
const svg = await renderAndHydrate(
DynamicHydrationRootComponent,
'lucide-dynamic-hydration-root',
[provideLucideIcons(LucideBadgeAlert)],
);
expectBadgeAlertShapeNodes(svg);
});
});

View File

@@ -33,6 +33,12 @@ export class LucideCircleCheck extends LucideIconBase {
protected override readonly icon = signal(LucideCircleCheck.icon);
}
@Component({
template: `<svg lucideCircleCheck class="custom-icon"></svg>`,
imports: [LucideCircleCheck],
})
class ClassHostComponent {}
describe('LucideIconBase', () => {
let component: LucideCircleCheck;
let fixture: ComponentFixture<LucideCircleCheck>;
@@ -42,6 +48,12 @@ describe('LucideIconBase', () => {
let strokeWidth: WritableSignal<string | number | undefined>;
let absoluteStrokeWidth: WritableSignal<boolean | undefined>;
const getSvgAttribute = (attr: string) => fixture.nativeElement.getAttribute(attr);
const expectSvgClasses = (classes: string[], svg: SVGElement = fixture.nativeElement) => {
for (const cssClass of classes) {
expect(svg.classList.contains(cssClass)).toBe(true);
}
};
const getRenderedChildren = () => Array.from(fixture.nativeElement.children) as Element[];
function createComponent() {
return TestBed.createComponent(LucideCircleCheck, {
inferTagName: true,
@@ -71,15 +83,25 @@ describe('LucideIconBase', () => {
it('should render children', () => {
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toBe(
'<!--container--><circle cx="12" cy="12" r="10"></circle><path d="m9 12 2 2 4-4"></path><!--ng-container-->',
);
const children = getRenderedChildren();
expect(children.map((child) => child.tagName.toLowerCase())).toEqual(['circle', 'path']);
expect(children[0].outerHTML).toBe('<circle cx="12" cy="12" r="10"></circle>');
expect(children[1].outerHTML).toBe('<path d="m9 12 2 2 4-4"></path>');
});
describe('class', () => {
it('should add all classes', () => {
fixture.detectChanges();
expect(getSvgAttribute('class')).toBe('lucide lucide-circle-check lucide-check-circle-2');
expectSvgClasses(['lucide', 'lucide-circle-check', 'lucide-check-circle-2']);
});
it('should preserve user classes', () => {
const hostFixture = TestBed.createComponent(ClassHostComponent);
hostFixture.detectChanges();
const svg = hostFixture.nativeElement.querySelector('svg');
expectSvgClasses(
['custom-icon', 'lucide', 'lucide-circle-check', 'lucide-check-circle-2'],
svg,
);
});
});
@@ -142,13 +164,15 @@ describe('LucideIconBase', () => {
});
it('should not set vector-effect on children', () => {
absoluteStrokeWidth.set(false);
for (const child of fixture.nativeElement.children) {
fixture.detectChanges();
for (const child of getRenderedChildren()) {
expect(child.getAttribute('vector-effect')).toBeNull();
}
});
it('should set vector-effect on children', () => {
absoluteStrokeWidth.set(true);
for (const child of fixture.nativeElement.children) {
fixture.detectChanges();
for (const child of getRenderedChildren()) {
expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke');
}
});
@@ -228,13 +252,15 @@ describe('LucideIconBase', () => {
});
describe('absoluteStrokeWidth', () => {
it('should use absoluteStrokeWidth from config', () => {
for (const child of fixture.nativeElement.children) {
fixture.detectChanges();
for (const child of getRenderedChildren()) {
expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke');
}
});
it('should override absoluteStrokeWidth', () => {
absoluteStrokeWidth.set(false);
for (const child of fixture.nativeElement.children) {
fixture.detectChanges();
for (const child of getRenderedChildren()) {
expect(child.getAttribute('vector-effect')).toBeNull();
}
});

View File

@@ -1,13 +1,4 @@
import {
Component,
effect,
ElementRef,
inject,
input,
Renderer2,
Signal,
viewChild,
} from '@angular/core';
import { Component, computed, inject, input, Signal } from '@angular/core';
import { LUCIDE_CONFIG } from './lucide-config';
import { LucideIconData, Nullable } from './types';
import defaultAttributes from './default-attributes';
@@ -23,6 +14,7 @@ import { lucideIconTemplate } from './lucide-icon-template';
host: {
...defaultAttributes,
class: 'lucide',
'[class]': 'iconClasses()',
'[attr.width]': 'size()',
'[attr.height]': 'size()',
'[attr.stroke]': 'color()',
@@ -32,10 +24,28 @@ import { lucideIconTemplate } from './lucide-icon-template';
})
export abstract class LucideIconBase {
protected abstract readonly icon: Signal<Nullable<LucideIconData>>;
protected readonly iconNodes = computed<LucideIconData['node']>(() => {
const node = this.icon()?.node ?? [];
if (!this.absoluteStrokeWidth()) {
return node;
}
return node.map<LucideIconData['node'][number]>(([name, attrs]) => [
name,
{
'vector-effect': 'non-scaling-stroke',
...attrs,
},
]);
});
protected readonly iconClasses = computed(() => {
const icon = this.icon();
if (!icon) {
return '';
}
const { name, aliases = [] } = icon;
return [name, ...aliases].map((item) => `lucide-${item}`).join(' ');
});
protected readonly iconConfig = inject(LUCIDE_CONFIG);
protected readonly elRef = inject(ElementRef);
protected readonly renderer = inject(Renderer2);
protected readonly contentRef = viewChild.required<ElementRef>('contentRef');
/**
* An optional accessible label for the icon.
* - If provided, it will add the title as an [`<svg:title>` element](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/title).
@@ -76,43 +86,4 @@ export abstract class LucideIconBase {
readonly absoluteStrokeWidth = input(this.iconConfig.absoluteStrokeWidth, {
transform: (value: Nullable<boolean>) => value ?? this.iconConfig.absoluteStrokeWidth,
});
constructor() {
effect((onCleanup) => {
const icon = this.icon();
if (icon) {
const absoluteStrokeWidth = this.absoluteStrokeWidth();
const { name, node, aliases = [] } = icon;
const classes = [name, ...aliases].map((item) => `lucide-${item}`);
for (const cssClass of classes) {
this.renderer.addClass(this.elRef.nativeElement, cssClass);
}
const contentRef = this.contentRef();
const refChild = contentRef.nativeElement;
const elements = node.map(([name, attrs]) => {
const element = this.renderer.createElement(name, 'http://www.w3.org/2000/svg');
if (absoluteStrokeWidth) {
this.renderer.setAttribute(element, 'vector-effect', 'non-scaling-stroke');
}
Object.entries(attrs).forEach(([name, value]) =>
this.renderer.setAttribute(
element,
name,
typeof value === 'number' ? value.toString(10) : value,
),
);
this.renderer.insertBefore(this.elRef.nativeElement, element, refChild);
return element;
});
onCleanup(() => {
elements.forEach((element) =>
this.renderer.removeChild(this.elRef.nativeElement, element),
);
for (const cssClass of classes) {
this.renderer.removeClass(this.elRef.nativeElement, cssClass);
}
});
}
});
}
}

View File

@@ -6,5 +6,66 @@ export const lucideIconTemplate = `@if (title(); as titleValue) {
<title>{{ titleValue }}</title>
}
<ng-content select="title"></ng-content>
<ng-container #contentRef></ng-container>
@for (child of iconNodes(); track child[1]['key'] ?? $index) {
@let attrs = child[1];
@switch (child[0]) {
@case ('path') {
<svg:path
[attr.d]="attrs['d']"
[attr.fill]="attrs['fill']"
[attr.vector-effect]="attrs['vector-effect']"
/>
}
@case ('line') {
<svg:line
[attr.x1]="attrs['x1']"
[attr.x2]="attrs['x2']"
[attr.y1]="attrs['y1']"
[attr.y2]="attrs['y2']"
[attr.vector-effect]="attrs['vector-effect']"
/>
}
@case ('polygon') {
<svg:polygon
[attr.points]="attrs['points']"
[attr.vector-effect]="attrs['vector-effect']"
/>
}
@case ('polyline') {
<svg:polyline
[attr.points]="attrs['points']"
[attr.vector-effect]="attrs['vector-effect']"
/>
}
@case ('circle') {
<svg:circle
[attr.cx]="attrs['cx']"
[attr.cy]="attrs['cy']"
[attr.r]="attrs['r']"
[attr.fill]="attrs['fill']"
[attr.vector-effect]="attrs['vector-effect']"
/>
}
@case ('ellipse') {
<svg:ellipse
[attr.cx]="attrs['cx']"
[attr.cy]="attrs['cy']"
[attr.rx]="attrs['rx']"
[attr.ry]="attrs['ry']"
[attr.vector-effect]="attrs['vector-effect']"
/>
}
@case ('rect') {
<svg:rect
[attr.x]="attrs['x']"
[attr.y]="attrs['y']"
[attr.width]="attrs['width']"
[attr.height]="attrs['height']"
[attr.rx]="attrs['rx']"
[attr.ry]="attrs['ry']"
[attr.vector-effect]="attrs['vector-effect']"
/>
}
}
}
<ng-content />`;