Skip to content

Commit 365a674

Browse files
crisbetojosephperrott
authored andcommitted
fix(icon): not taking current path after initialization
When resolving the current path while prefixing `url()` references, we use a provider to get the current path so we can account for server-side rendering and to be able to stub it out during tests. Since the provider is an object with a regular `pathname` property, the path will only be resolved once the first time an icon is resolved and then it'll be cached. This means that all subsequent icons will take an incorrect path. Related to #13628.
1 parent 8cfaeea commit 365a674

File tree

2 files changed

+142
-15
lines changed

2 files changed

+142
-15
lines changed

src/lib/icon/icon.spec.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,11 @@ function verifyPathChildElement(element: Element, attributeValue: string): void
3939

4040

4141
describe('MatIcon', () => {
42+
let fakePath: string;
4243

4344
beforeEach(async(() => {
45+
fakePath = '/fake-path';
46+
4447
TestBed.configureTestingModule({
4548
imports: [HttpClientTestingModule, MatIconModule],
4649
declarations: [
@@ -55,7 +58,7 @@ describe('MatIcon', () => {
5558
],
5659
providers: [{
5760
provide: MAT_ICON_LOCATION,
58-
useValue: {pathname: '/fake-path'}
61+
useValue: {getPathname: () => fakePath}
5962
}]
6063
});
6164

@@ -608,6 +611,63 @@ describe('MatIcon', () => {
608611
tick();
609612
}));
610613

614+
it('should use latest path when prefixing the `url()` references', fakeAsync(() => {
615+
iconRegistry.addSvgIconLiteral('fido', trustHtml(`
616+
<svg>
617+
<filter id="blur">
618+
<feGaussianBlur in="SourceGraphic" stdDeviation="5" />
619+
</filter>
620+
621+
<circle cx="170" cy="60" r="50" fill="green" filter="url('#blur')" />
622+
</svg>
623+
`));
624+
625+
let fixture = TestBed.createComponent(IconFromSvgName);
626+
fixture.componentInstance.iconName = 'fido';
627+
fixture.detectChanges();
628+
let circle = fixture.nativeElement.querySelector('mat-icon svg circle');
629+
630+
expect(circle.getAttribute('filter')).toMatch(/^url\(['"]?\/fake-path#blur['"]?\)$/);
631+
tick();
632+
fixture.destroy();
633+
634+
fakePath = '/another-fake-path';
635+
fixture = TestBed.createComponent(IconFromSvgName);
636+
fixture.componentInstance.iconName = 'fido';
637+
fixture.detectChanges();
638+
circle = fixture.nativeElement.querySelector('mat-icon svg circle');
639+
640+
expect(circle.getAttribute('filter')).toMatch(/^url\(['"]?\/another-fake-path#blur['"]?\)$/);
641+
tick();
642+
}));
643+
644+
it('should update the `url()` references when the path changes', fakeAsync(() => {
645+
iconRegistry.addSvgIconLiteral('fido', trustHtml(`
646+
<svg>
647+
<filter id="blur">
648+
<feGaussianBlur in="SourceGraphic" stdDeviation="5" />
649+
</filter>
650+
651+
<circle cx="170" cy="60" r="50" fill="green" filter="url('#blur')" />
652+
</svg>
653+
`));
654+
655+
const fixture = TestBed.createComponent(IconFromSvgName);
656+
fixture.componentInstance.iconName = 'fido';
657+
fixture.detectChanges();
658+
const circle = fixture.nativeElement.querySelector('mat-icon svg circle');
659+
660+
// We use a regex to match here, rather than the exact value, because different browsers
661+
// return different quotes through `getAttribute`, while some even omit the quotes altogether.
662+
expect(circle.getAttribute('filter')).toMatch(/^url\(['"]?\/fake-path#blur['"]?\)$/);
663+
tick();
664+
665+
fakePath = '/different-path';
666+
fixture.detectChanges();
667+
668+
expect(circle.getAttribute('filter')).toMatch(/^url\(['"]?\/different-path#blur['"]?\)$/);
669+
}));
670+
611671
});
612672

613673
describe('custom fonts', () => {

src/lib/icon/icon.ts

Lines changed: 81 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
InjectionToken,
2222
inject,
2323
Inject,
24+
OnDestroy,
25+
AfterViewChecked,
2426
} from '@angular/core';
2527
import {DOCUMENT} from '@angular/common';
2628
import {CanColor, CanColorCtor, mixinColor} from '@angular/material/core';
@@ -51,14 +53,18 @@ export const MAT_ICON_LOCATION = new InjectionToken<MatIconLocation>('mat-icon-l
5153
* @docs-private
5254
*/
5355
export interface MatIconLocation {
54-
pathname: string;
56+
getPathname: () => string;
5557
}
5658

5759
/** @docs-private */
5860
export function MAT_ICON_LOCATION_FACTORY(): MatIconLocation {
5961
const _document = inject(DOCUMENT);
60-
const pathname = (_document && _document.location && _document.location.pathname) || '';
61-
return {pathname};
62+
63+
return {
64+
// Note that this needs to be a function, rather than a property, because Angular
65+
// will only resolve it once, but we want the current path on each call.
66+
getPathname: () => (_document && _document.location && _document.location.pathname) || ''
67+
};
6268
}
6369

6470

@@ -126,7 +132,9 @@ const funcIriPattern = /^url\(['"]?#(.*?)['"]?\)$/;
126132
encapsulation: ViewEncapsulation.None,
127133
changeDetection: ChangeDetectionStrategy.OnPush,
128134
})
129-
export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, CanColor {
135+
export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, AfterViewChecked,
136+
CanColor, OnDestroy {
137+
130138
/**
131139
* Whether the icon should be inlined, automatically sizing the icon to match the font size of
132140
* the element the icon is contained in.
@@ -162,6 +170,12 @@ export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, Can
162170
private _previousFontSetClass: string;
163171
private _previousFontIconClass: string;
164172

173+
/** Keeps track of the current page path. */
174+
private _previousPath?: string;
175+
176+
/** Keeps track of the elements and attributes that we've prefixed with the current path. */
177+
private _elementsWithExternalReferences?: Map<Element, {name: string, value: string}[]>;
178+
165179
constructor(
166180
elementRef: ElementRef<HTMLElement>,
167181
private _iconRegistry: MatIconRegistry,
@@ -233,6 +247,31 @@ export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, Can
233247
}
234248
}
235249

250+
ngAfterViewChecked() {
251+
const cachedElements = this._elementsWithExternalReferences;
252+
253+
if (cachedElements && this._location && cachedElements.size) {
254+
const newPath = this._location.getPathname();
255+
256+
// We need to check whether the URL has changed on each change detection since
257+
// the browser doesn't have an API that will let us react on link clicks and
258+
// we can't depend on the Angular router. The references need to be updated,
259+
// because while most browsers don't care whether the URL is correct after
260+
// the first render, Safari will break if the user navigates to a different
261+
// page and the SVG isn't re-rendered.
262+
if (newPath !== this._previousPath) {
263+
this._previousPath = newPath;
264+
this._prependPathToReferences(newPath);
265+
}
266+
}
267+
}
268+
269+
ngOnDestroy() {
270+
if (this._elementsWithExternalReferences) {
271+
this._elementsWithExternalReferences.clear();
272+
}
273+
}
274+
236275
private _usingFontIcon(): boolean {
237276
return !this.svgIcon;
238277
}
@@ -251,14 +290,24 @@ export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, Can
251290

252291
// Note: we do this fix here, rather than the icon registry, because the
253292
// references have to point to the URL at the time that the icon was created.
254-
this._prependCurrentPathToReferences(svg);
293+
if (this._location) {
294+
const path = this._location.getPathname();
295+
this._previousPath = path;
296+
this._cacheChildrenWithExternalReferences(svg);
297+
this._prependPathToReferences(path);
298+
}
299+
255300
this._elementRef.nativeElement.appendChild(svg);
256301
}
257302

258303
private _clearSvgElement() {
259304
const layoutElement: HTMLElement = this._elementRef.nativeElement;
260305
let childCount = layoutElement.childNodes.length;
261306

307+
if (this._elementsWithExternalReferences) {
308+
this._elementsWithExternalReferences.clear();
309+
}
310+
262311
// Remove existing non-element child nodes and SVGs, and add the new SVG element. Note that
263312
// we can't use innerHTML, because IE will throw if the element has a data binding.
264313
while (childCount--) {
@@ -317,24 +366,42 @@ export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, Can
317366
* reference. This is required because WebKit browsers require references to be prefixed with
318367
* the current path, if the page has a `base` tag.
319368
*/
320-
private _prependCurrentPathToReferences(element: SVGElement) {
321-
// @breaking-change 8.0.0 Remove this null check once `_location` parameter is required.
322-
if (!this._location) {
323-
return;
369+
private _prependPathToReferences(path: string) {
370+
const elements = this._elementsWithExternalReferences;
371+
372+
if (elements) {
373+
elements.forEach((attrs, element) => {
374+
attrs.forEach(attr => {
375+
element.setAttribute(attr.name, `url('${path}#${attr.value}')`);
376+
});
377+
});
324378
}
379+
}
325380

381+
/**
382+
* Caches the children of an SVG element that have `url()`
383+
* references that we need to prefix with the current path.
384+
*/
385+
private _cacheChildrenWithExternalReferences(element: SVGElement) {
326386
const elementsWithFuncIri = element.querySelectorAll(funcIriAttributeSelector);
327-
const path = this._location.pathname ? this._location.pathname.split('#')[0] : '';
387+
const elements = this._elementsWithExternalReferences =
388+
this._elementsWithExternalReferences || new Map();
328389

329390
for (let i = 0; i < elementsWithFuncIri.length; i++) {
330391
funcIriAttributes.forEach(attr => {
331-
const value = elementsWithFuncIri[i].getAttribute(attr);
392+
const elementWithReference = elementsWithFuncIri[i];
393+
const value = elementWithReference.getAttribute(attr);
332394
const match = value ? value.match(funcIriPattern) : null;
333395

334396
if (match) {
335-
// Note the quotes inside the `url()`. They're important, because URLs pointing to named
336-
// router outlets can contain parentheses which will break if they aren't quoted.
337-
elementsWithFuncIri[i].setAttribute(attr, `url('${path}#${match[1]}')`);
397+
let attributes = elements.get(elementWithReference);
398+
399+
if (!attributes) {
400+
attributes = [];
401+
elements.set(elementWithReference, attributes);
402+
}
403+
404+
attributes!.push({name: attr, value: match[1]});
338405
}
339406
});
340407
}

0 commit comments

Comments
 (0)