Skip to content

Commit d560cac

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

2 files changed

Lines changed: 213 additions & 12 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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+
function renderComponent(url: string = MOCK_SVG_URL, attrs?: Record<string, string>) {
45+
const fixture = TestBed.createComponent(TestSvgComponent)
46+
47+
fixture.componentInstance.data.set({ url, attrs })
48+
TestBed.tick()
49+
50+
return {
51+
fixture,
52+
hostElement: fixture.nativeElement as HTMLElement,
53+
}
54+
}
55+
56+
describe('SvgBaseDirective', () => {
57+
test('fetches SVG from the registry and inserts it into the DOM', () => {
58+
const { mockRegistry } = setup()
59+
const svg = createSvgElement()
60+
mockRegistry.getFromUrl.mockResolvedValue(svg)
61+
62+
const { hostElement } = renderComponent()
63+
64+
expect(mockRegistry.getFromUrl).toHaveBeenCalledWith(MOCK_SVG_URL)
65+
expect(hostElement.contains(svg)).toBe(true)
66+
})
67+
68+
test('applies provided attrs to the SVG element', () => {
69+
const { mockRegistry } = setup()
70+
const svg = createSvgElement()
71+
mockRegistry.getFromUrl.mockResolvedValue(svg)
72+
73+
const { hostElement } = renderComponent(MOCK_SVG_URL, { 'data-test': 'true', class: 'my-icon' })
74+
75+
expect(svg.getAttribute('data-test')).toBe('true')
76+
expect(svg.getAttribute('class')).toBe('my-icon')
77+
expect(hostElement.contains(svg)).toBe(true)
78+
})
79+
80+
test('does not modify SVG attributes when no attrs are provided', () => {
81+
const { mockRegistry } = setup()
82+
const svg = createSvgElement()
83+
const originalAttributes = Array.from(svg.attributes).map((a) => ({ name: a.name, value: a.value }))
84+
mockRegistry.getFromUrl.mockResolvedValue(svg)
85+
86+
const { hostElement } = renderComponent()
87+
88+
expect(Array.from(svg.attributes).map((a) => ({ name: a.name, value: a.value }))).toEqual(originalAttributes)
89+
expect(hostElement.contains(svg)).toBe(true)
90+
})
91+
92+
test('allows to update the SVG when the url signal changes', () => {
93+
const { mockRegistry } = setup()
94+
const svgFirst = createSvgElement()
95+
const svgSecond = createSvgElement()
96+
97+
mockRegistry.getFromUrl.mockResolvedValueOnce(svgFirst).mockResolvedValueOnce(svgSecond)
98+
99+
const { fixture, hostElement } = renderComponent()
100+
101+
expect(hostElement.contains(svgFirst)).toBe(true)
102+
103+
fixture.componentInstance.data.set({ url: '/assets/other.svg' })
104+
TestBed.tick()
105+
106+
expect(hostElement.contains(svgSecond)).toBe(true)
107+
})
108+
109+
test('clears existing DOM content before inserting the new SVG on signal change', () => {
110+
const { mockRegistry } = setup()
111+
const svgFirst = createSvgElement()
112+
const svgSecond = createSvgElement()
113+
mockRegistry.getFromUrl.mockResolvedValueOnce(svgFirst).mockResolvedValueOnce(svgSecond)
114+
115+
const { fixture, hostElement } = renderComponent()
116+
117+
expect(hostElement.contains(svgFirst)).toBe(true)
118+
119+
fixture.componentInstance.data.set({ url: '/assets/other.svg' })
120+
TestBed.tick()
121+
122+
expect(hostElement.contains(svgFirst)).toBe(false)
123+
expect(hostElement.contains(svgSecond)).toBe(true)
124+
expect(hostElement.querySelectorAll('svg').length).toBe(1)
125+
})
126+
127+
test('does not insert SVG when the signal changes before the first request resolved', () => {
128+
const { mockRegistry } = setup()
129+
130+
let resolveFirst!: (svg: SVGElement) => void
131+
const firstPromise = new Promise<SVGElement>((resolve) => (resolveFirst = resolve))
132+
133+
const firstSvg = createSvgElement()
134+
const secondSvg = createSvgElement()
135+
136+
mockRegistry.getFromUrl
137+
.mockReturnValueOnce(firstPromise) // first call returns a promise that we can control
138+
.mockResolvedValueOnce(secondSvg) // second call resolves immediately
139+
140+
const { fixture, hostElement } = renderComponent()
141+
142+
expect(mockRegistry.getFromUrl).toHaveBeenCalledTimes(1)
143+
144+
// Change signal before firstPromise resolves
145+
fixture.componentInstance.data.set({ url: '/assets/other.svg' })
146+
TestBed.tick()
147+
148+
// At this point, the second svg should be rendered.
149+
expect(mockRegistry.getFromUrl).toHaveBeenCalledTimes(2)
150+
expect(hostElement.contains(secondSvg)).toBe(true)
151+
152+
// Now resolve the first promise; it should have no effect in the DOM anymore.
153+
resolveFirst(firstSvg)
154+
TestBed.tick()
155+
expect(hostElement.contains(secondSvg)).toBe(true)
156+
})
157+
158+
test('logs a debug info for network errors (HttpErrorResponse with status 0)', () => {
159+
const { mockLogger, mockRegistry } = setup()
160+
161+
const networkError = new HttpErrorResponse({ status: 0, url: MOCK_SVG_URL })
162+
mockRegistry.getFromUrl.mockRejectedValue(networkError)
163+
164+
renderComponent()
165+
166+
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining(MOCK_SVG_URL), networkError)
167+
expect(mockLogger.error).not.toHaveBeenCalled()
168+
})
169+
170+
test('logs an error for HttpErrorResponse with a non-zero status', () => {
171+
const { mockLogger, mockRegistry } = setup()
172+
173+
const notFoundErrorResponse = new HttpErrorResponse({ status: 404, url: MOCK_SVG_URL })
174+
mockRegistry.getFromUrl.mockRejectedValue(notFoundErrorResponse)
175+
176+
renderComponent()
177+
178+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining(MOCK_SVG_URL), notFoundErrorResponse)
179+
})
180+
181+
test('logs an error for non-HTTP errors', () => {
182+
const { mockLogger, mockRegistry } = setup()
183+
184+
const genericError = new Error('Something went wrong')
185+
mockRegistry.getFromUrl.mockRejectedValue(genericError)
186+
187+
renderComponent()
188+
189+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining(MOCK_SVG_URL), genericError)
190+
})
191+
})

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)