Skip to content

Commit aa0032b

Browse files
committed
feat(svg): enhance SvgBaseDirective to prevent race conditions
1 parent 4c985eb commit aa0032b

2 files changed

Lines changed: 224 additions & 12 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { HttpErrorResponse } from '@angular/common/http'
2+
import { Component, inject, signal } from '@angular/core'
3+
import { TestBed } from '@angular/core/testing'
4+
import { Logger } from '@shiftcode/logger'
5+
import { describe, expect, test, vi } from 'vitest'
6+
7+
import { SvgBaseDirective } from './svg-base.directive'
8+
import { SvgRegistry } from './svg-registry.service'
9+
10+
const MOCK_SVG_URL = '/assets/test.svg'
11+
12+
@Component({ selector: 'sc-test-svg-base', template: '', standalone: true })
13+
class TestSvgComponent extends SvgBaseDirective {
14+
readonly data = signal<{ url: string; attrs?: Record<string, string> }>({ url: MOCK_SVG_URL })
15+
protected readonly logger = inject(Logger)
16+
}
17+
18+
function createSvgElement(): SVGElement {
19+
const div = document.createElement('div')
20+
div.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" data-ts="${Date.now()}"><path d="M0 0"/></svg>`
21+
return div.querySelector('svg') as SVGElement
22+
}
23+
24+
function setup() {
25+
const mockLogger = {
26+
debug: vi.fn(),
27+
info: vi.fn(),
28+
warn: vi.fn(),
29+
error: vi.fn(),
30+
} as unknown as Logger
31+
32+
const mockRegistry = { getFromUrl: vi.fn<() => Promise<SVGElement>>() }
33+
34+
TestBed.configureTestingModule({
35+
providers: [
36+
{ provide: Logger, useValue: mockLogger },
37+
{ provide: SvgRegistry, useValue: mockRegistry },
38+
],
39+
})
40+
41+
return { mockLogger, mockRegistry }
42+
}
43+
44+
async function flushMicrotasks(count = 2): Promise<void> {
45+
for (let i = 0; i < count; i++) {
46+
await Promise.resolve()
47+
}
48+
}
49+
50+
async function renderComponent(url: string = MOCK_SVG_URL, attrs?: Record<string, string>) {
51+
const fixture = TestBed.createComponent(TestSvgComponent)
52+
53+
fixture.componentInstance.data.set({ url, attrs })
54+
TestBed.tick()
55+
await flushMicrotasks()
56+
57+
return {
58+
fixture,
59+
hostElement: fixture.nativeElement as HTMLElement,
60+
}
61+
}
62+
63+
describe('SvgBaseDirective', () => {
64+
test('fetches SVG from the registry and inserts it into the DOM', async () => {
65+
const { mockRegistry } = setup()
66+
const svg = createSvgElement()
67+
mockRegistry.getFromUrl.mockResolvedValue(svg)
68+
69+
const { hostElement } = await renderComponent()
70+
71+
expect(mockRegistry.getFromUrl).toHaveBeenCalledWith(MOCK_SVG_URL)
72+
expect(hostElement.contains(svg)).toBe(true)
73+
})
74+
75+
test('applies provided attrs to the SVG element', async () => {
76+
const { mockRegistry } = setup()
77+
const svg = createSvgElement()
78+
mockRegistry.getFromUrl.mockResolvedValue(svg)
79+
80+
const { hostElement } = await renderComponent(MOCK_SVG_URL, { 'data-test': 'true', class: 'my-icon' })
81+
82+
expect(svg.getAttribute('data-test')).toBe('true')
83+
expect(svg.getAttribute('class')).toBe('my-icon')
84+
expect(hostElement.contains(svg)).toBe(true)
85+
})
86+
87+
test('does not modify SVG attributes when no attrs are provided', async () => {
88+
const { mockRegistry } = setup()
89+
const svg = createSvgElement()
90+
const originalAttributes = Array.from(svg.attributes).map((a) => ({ name: a.name, value: a.value }))
91+
mockRegistry.getFromUrl.mockResolvedValue(svg)
92+
93+
const { hostElement } = await renderComponent()
94+
95+
expect(Array.from(svg.attributes).map((a) => ({ name: a.name, value: a.value }))).toEqual(originalAttributes)
96+
expect(hostElement.contains(svg)).toBe(true)
97+
})
98+
99+
test('allows to update the SVG when the url signal changes', async () => {
100+
const { mockRegistry } = setup()
101+
const svgFirst = createSvgElement()
102+
const svgSecond = createSvgElement()
103+
104+
mockRegistry.getFromUrl.mockResolvedValueOnce(svgFirst).mockResolvedValueOnce(svgSecond)
105+
106+
const { fixture, hostElement } = await renderComponent()
107+
108+
expect(hostElement.contains(svgFirst)).toBe(true)
109+
110+
fixture.componentInstance.data.set({ url: '/assets/other.svg' })
111+
TestBed.tick()
112+
await flushMicrotasks()
113+
114+
expect(hostElement.contains(svgSecond)).toBe(true)
115+
})
116+
117+
test('clears existing DOM content before inserting the new SVG on signal change', async () => {
118+
const { mockRegistry } = setup()
119+
const svgFirst = createSvgElement()
120+
const svgSecond = createSvgElement()
121+
mockRegistry.getFromUrl.mockResolvedValueOnce(svgFirst).mockResolvedValueOnce(svgSecond)
122+
123+
const { fixture, hostElement } = await renderComponent()
124+
125+
expect(hostElement.contains(svgFirst)).toBe(true)
126+
127+
fixture.componentInstance.data.set({ url: '/assets/other.svg' })
128+
TestBed.tick()
129+
await flushMicrotasks()
130+
131+
expect(hostElement.contains(svgFirst)).toBe(false)
132+
expect(hostElement.contains(svgSecond)).toBe(true)
133+
expect(hostElement.querySelectorAll('svg').length).toBe(1)
134+
})
135+
136+
test('does not insert SVG when the signal changes before the first request resolved', async () => {
137+
const { mockRegistry } = setup()
138+
139+
let resolveFirst!: (svg: SVGElement) => void
140+
const firstPromise = new Promise<SVGElement>((resolve) => (resolveFirst = resolve))
141+
142+
const firstSvg = createSvgElement()
143+
const secondSvg = createSvgElement()
144+
145+
mockRegistry.getFromUrl
146+
.mockReturnValueOnce(firstPromise) // first call returns a promise that we can control
147+
.mockResolvedValueOnce(secondSvg) // second call resolves immediately
148+
149+
const { fixture, hostElement } = await renderComponent()
150+
151+
expect(mockRegistry.getFromUrl).toHaveBeenCalledTimes(1)
152+
153+
// Change signal before firstPromise resolves
154+
fixture.componentInstance.data.set({ url: '/assets/other.svg' })
155+
TestBed.tick()
156+
await flushMicrotasks()
157+
158+
// At this point, the second svg should be rendered.
159+
expect(mockRegistry.getFromUrl).toHaveBeenCalledTimes(2)
160+
expect(hostElement.contains(secondSvg)).toBe(true)
161+
162+
// Now resolve the first promise; it should have no effect in the DOM anymore.
163+
resolveFirst(firstSvg)
164+
TestBed.tick()
165+
await flushMicrotasks()
166+
expect(hostElement.contains(secondSvg)).toBe(true)
167+
})
168+
169+
test('logs a debug info for network errors (HttpErrorResponse with status 0)', async () => {
170+
const { mockLogger, mockRegistry } = setup()
171+
172+
const networkError = new HttpErrorResponse({ status: 0, url: MOCK_SVG_URL })
173+
mockRegistry.getFromUrl.mockRejectedValue(networkError)
174+
175+
await renderComponent()
176+
177+
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining(MOCK_SVG_URL), networkError)
178+
expect(mockLogger.error).not.toHaveBeenCalled()
179+
})
180+
181+
test('logs an error for HttpErrorResponse with a non-zero status', async () => {
182+
const { mockLogger, mockRegistry } = setup()
183+
184+
const notFoundErrorResponse = new HttpErrorResponse({ status: 404, url: MOCK_SVG_URL })
185+
mockRegistry.getFromUrl.mockRejectedValue(notFoundErrorResponse)
186+
187+
await renderComponent()
188+
189+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining(MOCK_SVG_URL), notFoundErrorResponse)
190+
})
191+
192+
test('logs an error for non-HTTP errors', async () => {
193+
const { mockLogger, mockRegistry } = setup()
194+
195+
const genericError = new Error('Something went wrong')
196+
mockRegistry.getFromUrl.mockRejectedValue(genericError)
197+
198+
await renderComponent()
199+
200+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining(MOCK_SVG_URL), genericError)
201+
})
202+
})

libs/components/src/lib/svg/svg-base.directive.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,31 +15,35 @@ export abstract class SvgBaseDirective {
1515
protected readonly svgRegistry = inject(SvgRegistry)
1616

1717
protected abstract readonly logger: Logger
18-
protected abstract data: Signal<{ url: string; attrs?: Record<string, string> }>
18+
protected abstract readonly data: Signal<{ url: string; attrs?: Record<string, string> }>
1919

2020
constructor() {
21-
effect(() => {
21+
effect((onCleanup) => {
2222
const { url, attrs } = this.data()
23-
this.getAndSet(url, attrs)
23+
const abortController = new AbortController()
24+
this.getAndSet(url, attrs, abortController.signal)
25+
onCleanup(() => abortController.abort())
2426
})
2527
}
2628

27-
private getAndSet(url: string, attrs?: Record<string, string>) {
29+
private getAndSet(url: string, attrs: Record<string, string> | undefined, abortSignal: AbortSignal) {
30+
// due to the caching in SvgRegistry we cannot simply abort the fetching of the svg.
31+
// but we ensure that we do not set the svg element if the abort signal has been triggered in the meantime.
2832
this.svgRegistry
2933
.getFromUrl(url)
30-
.then(this.getSvgModifyFn(attrs))
31-
.then(this.setSvgElement)
34+
.then(this.getSvgModifierFn(attrs))
35+
.then(this.getSvgSetterFn(abortSignal))
3236
.catch((err: any) => {
3337
if (err instanceof HttpErrorResponse && err.status === 0) {
3438
// in case of no internet or a timeout log a warning, we can not do anything about that
35-
this.logger.warn(`Error retrieving icon for path ${url}, due to no network`, err)
39+
this.logger.debug(`Error retrieving icon for path ${url}, due to no network`, err)
3640
} else {
3741
this.logger.error(`Error retrieving icon for path ${url}`, err)
3842
}
3943
})
4044
}
4145

42-
private readonly getSvgModifyFn = (attrs?: Record<string, string>) => {
46+
private getSvgModifierFn(attrs?: Record<string, string>) {
4347
const attrsEntries = attrs ? Object.entries(attrs) : []
4448
if (attrsEntries.length === 0) {
4549
return (svg: SVGElement): SVGElement => svg
@@ -53,9 +57,15 @@ export abstract class SvgBaseDirective {
5357
}
5458
}
5559

56-
private readonly setSvgElement = (svg: SVGElement | null) => {
57-
// Remove existing child nodes and add the new SVG element.
58-
this.elRef.nativeElement.innerHTML = ''
59-
this.renderer.appendChild(this.elRef.nativeElement, svg)
60+
private getSvgSetterFn = (abortSignal: AbortSignal) => {
61+
return (svg: SVGElement) => {
62+
if (abortSignal.aborted) {
63+
this.logger.debug('Aborting setSvgElement due to abort signal')
64+
return
65+
}
66+
// Remove existing child nodes and add the new SVG element.
67+
this.elRef.nativeElement.innerHTML = ''
68+
this.renderer.appendChild(this.elRef.nativeElement, svg)
69+
}
6070
}
6171
}

0 commit comments

Comments
 (0)