Skip to content

Commit df9ec74

Browse files
crisbetojosephperrott
authored andcommitted
fix(icon): not taking current path after initialization (#13641)
1 parent e4bf74a commit df9ec74

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)