Skip to content

Commit 1828a84

Browse files
alan-agius4alxhub
authored andcommitted
fix(platform-browser): prepend baseHref to sourceMappingURL in CSS content (#59730)
Implemented functionality to prepend the baseHref to `sourceMappingURL` in CSS content. Added handling to ensure external sourcemaps are loaded relative to the baseHref. Corrected sourcemap URL behavior when accessing pages with multi-segment URLs (e.g., `/foo/bar`). Ensured that when the baseHref is set to `/`, maps are requested from the correct path (e.g., `http://localhost/comp.css.map` instead of `http://localhost/foo/bar/comp.css.map`). Closes #59729 PR Close #59730
1 parent 544b9ee commit 1828a84

File tree

2 files changed

+161
-6
lines changed

2 files changed

+161
-6
lines changed

packages/platform-browser/src/dom/dom_renderer.ts

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export const NAMESPACE_URIS: {[ns: string]: string} = {
4343
};
4444

4545
const COMPONENT_REGEX = /%COMP%/g;
46+
const SOURCEMAP_URL_REGEXP = /\/\*#\s*sourceMappingURL=(.+?)\s*\*\//;
47+
const PROTOCOL_REGEXP = /^https?:/;
4648

4749
export const COMPONENT_VARIABLE = '%COMP%';
4850
export const HOST_ATTR = `_nghost-${COMPONENT_VARIABLE}`;
@@ -80,6 +82,52 @@ export function shimStylesContent(compId: string, styles: string[]): string[] {
8082
return styles.map((s) => s.replace(COMPONENT_REGEX, compId));
8183
}
8284

85+
/**
86+
* Prepends a baseHref to the `sourceMappingURL` within the provided CSS content.
87+
* If the `sourceMappingURL` contains an inline (encoded) map, the function skips processing.
88+
*
89+
* @note For inline stylesheets, the `sourceMappingURL` is relative to the page's origin
90+
* and not the provided baseHref. This function is needed as when accessing the page with a URL
91+
* containing two or more segments.
92+
* For example, if the baseHref is set to `/`, and you visit a URL like `http://localhost/foo/bar`,
93+
* the map would be requested from `http://localhost/foo/bar/comp.css.map` instead of what you'd expect,
94+
* which is `http://localhost/comp.css.map`. This behavior is corrected by modifying the `sourceMappingURL`
95+
* to ensure external source maps are loaded relative to the baseHref.
96+
*
97+
98+
* @param baseHref - The base URL to prepend to the `sourceMappingURL`.
99+
* @param styles - An array of CSS content strings, each potentially containing a `sourceMappingURL`.
100+
* @returns The updated array of CSS content strings with modified `sourceMappingURL` values,
101+
* or the original content if no modification is needed.
102+
*/
103+
export function addBaseHrefToCssSourceMap(baseHref: string, styles: string[]): string[] {
104+
if (!baseHref) {
105+
return styles;
106+
}
107+
108+
const absoluteBaseHrefUrl = new URL(baseHref, 'http://localhost');
109+
110+
return styles.map((cssContent) => {
111+
if (!cssContent.includes('sourceMappingURL=')) {
112+
return cssContent;
113+
}
114+
115+
return cssContent.replace(SOURCEMAP_URL_REGEXP, (_, sourceMapUrl) => {
116+
if (
117+
sourceMapUrl[0] === '/' ||
118+
sourceMapUrl.startsWith('data:') ||
119+
PROTOCOL_REGEXP.test(sourceMapUrl)
120+
) {
121+
return `/*# sourceMappingURL=${sourceMapUrl} */`;
122+
}
123+
124+
const {pathname: resolvedSourceMapUrl} = new URL(sourceMapUrl, absoluteBaseHrefUrl);
125+
126+
return `/*# sourceMappingURL=${resolvedSourceMapUrl} */`;
127+
});
128+
});
129+
}
130+
83131
@Injectable()
84132
export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
85133
private readonly rendererByCompId = new Map<
@@ -145,6 +193,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
145193
const sharedStylesHost = this.sharedStylesHost;
146194
const removeStylesOnCompDestroy = this.removeStylesOnCompDestroy;
147195
const platformIsServer = this.platformIsServer;
196+
const tracingService = this.tracingService;
148197

149198
switch (type.encapsulation) {
150199
case ViewEncapsulation.Emulated:
@@ -157,7 +206,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
157206
doc,
158207
ngZone,
159208
platformIsServer,
160-
this.tracingService,
209+
tracingService,
161210
);
162211
break;
163212
case ViewEncapsulation.ShadowDom:
@@ -170,7 +219,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
170219
ngZone,
171220
this.nonce,
172221
platformIsServer,
173-
this.tracingService,
222+
tracingService,
174223
);
175224
default:
176225
renderer = new NoneEncapsulationDomRenderer(
@@ -181,7 +230,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
181230
doc,
182231
ngZone,
183232
platformIsServer,
184-
this.tracingService,
233+
tracingService,
185234
);
186235
break;
187236
}
@@ -449,9 +498,15 @@ class ShadowDomRenderer extends DefaultDomRenderer2 {
449498
) {
450499
super(eventManager, doc, ngZone, platformIsServer, tracingService);
451500
this.shadowRoot = (hostEl as any).attachShadow({mode: 'open'});
452-
453501
this.sharedStylesHost.addHost(this.shadowRoot);
454-
const styles = shimStylesContent(component.id, component.styles);
502+
let styles = component.styles;
503+
if (ngDevMode) {
504+
// We only do this in development, as for production users should not add CSS sourcemaps to components.
505+
const baseHref = getDOM().getBaseHref(doc) ?? '';
506+
styles = addBaseHrefToCssSourceMap(baseHref, styles);
507+
}
508+
509+
styles = shimStylesContent(component.id, styles);
455510

456511
for (const style of styles) {
457512
const styleEl = document.createElement('style');
@@ -520,7 +575,14 @@ class NoneEncapsulationDomRenderer extends DefaultDomRenderer2 {
520575
compId?: string,
521576
) {
522577
super(eventManager, doc, ngZone, platformIsServer, tracingService);
523-
this.styles = compId ? shimStylesContent(compId, component.styles) : component.styles;
578+
let styles = component.styles;
579+
if (ngDevMode) {
580+
// We only do this in development, as for production users should not add CSS sourcemaps to components.
581+
const baseHref = getDOM().getBaseHref(doc) ?? '';
582+
styles = addBaseHrefToCssSourceMap(baseHref, styles);
583+
}
584+
585+
this.styles = compId ? shimStylesContent(compId, styles) : styles;
524586
this.styleUrls = component.getExternalStyles?.(compId);
525587
}
526588

packages/platform-browser/test/dom/dom_renderer_spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {Component, Renderer2, ViewEncapsulation} from '@angular/core';
99
import {ComponentFixture, TestBed} from '@angular/core/testing';
1010
import {By} from '@angular/platform-browser/src/dom/debug/by';
1111
import {
12+
addBaseHrefToCssSourceMap,
1213
NAMESPACE_URIS,
1314
REMOVE_STYLES_ON_COMPONENT_DESTROY,
1415
} from '@angular/platform-browser/src/dom/dom_renderer';
@@ -268,6 +269,89 @@ describe('DefaultDomRendererV2', () => {
268269
}
269270
});
270271
});
272+
273+
it('should update an external sourceMappingURL by prepending the baseHref as a prefix', () => {
274+
document.head.innerHTML = `<base href="/base/" />`;
275+
TestBed.resetTestingModule();
276+
TestBed.configureTestingModule({
277+
declarations: [CmpEncapsulationNoneWithSourceMap],
278+
});
279+
280+
const fixture = TestBed.createComponent(CmpEncapsulationNoneWithSourceMap);
281+
fixture.detectChanges();
282+
283+
expect(document.head.querySelector('style')?.textContent).toContain(
284+
'/*# sourceMappingURL=/base/cmp-none.css.map */',
285+
);
286+
287+
document.head.innerHTML = '';
288+
});
289+
});
290+
291+
describe('addBaseHrefToCssSourceMap', () => {
292+
it('should return the original styles if baseHref is empty', () => {
293+
const styles = ['body { color: red; }'];
294+
const result = addBaseHrefToCssSourceMap('', styles);
295+
expect(result).toEqual(styles);
296+
});
297+
298+
it('should skip styles that do not contain a sourceMappingURL', () => {
299+
const styles = ['body { color: red; }', 'h1 { font-size: 2rem; }'];
300+
const result = addBaseHrefToCssSourceMap('/base/', styles);
301+
expect(result).toEqual(styles);
302+
});
303+
304+
it('should not modify inline (encoded) sourceMappingURL maps', () => {
305+
const styles = ['/*# sourceMappingURL=data:application/json;base64,xyz */'];
306+
const result = addBaseHrefToCssSourceMap('/base/', styles);
307+
expect(result).toEqual(styles);
308+
});
309+
310+
it('should prepend baseHref to external sourceMappingURL', () => {
311+
const styles = ['/*# sourceMappingURL=style.css */'];
312+
const result = addBaseHrefToCssSourceMap('/base/', styles);
313+
expect(result).toEqual(['/*# sourceMappingURL=/base/style.css */']);
314+
});
315+
316+
it('should handle baseHref with a trailing slash correctly', () => {
317+
const styles = ['/*# sourceMappingURL=style.css */'];
318+
const result = addBaseHrefToCssSourceMap('/base/', styles);
319+
expect(result).toEqual(['/*# sourceMappingURL=/base/style.css */']);
320+
});
321+
322+
it('should handle baseHref without a trailing slash correctly', () => {
323+
const styles = ['/*# sourceMappingURL=style.css */'];
324+
const result = addBaseHrefToCssSourceMap('/base', styles);
325+
expect(result).toEqual(['/*# sourceMappingURL=/style.css */']);
326+
});
327+
328+
it('should not duplicate slashes in the final URL', () => {
329+
const styles = ['/*# sourceMappingURL=./style.css */'];
330+
const result = addBaseHrefToCssSourceMap('/base/', styles);
331+
expect(result).toEqual(['/*# sourceMappingURL=/base/style.css */']);
332+
});
333+
334+
it('should not add base href to sourceMappingURL that is absolute', () => {
335+
const styles = ['/*# sourceMappingURL=http://example.com/style.css */'];
336+
const result = addBaseHrefToCssSourceMap('/base/', styles);
337+
expect(result).toEqual(['/*# sourceMappingURL=http://example.com/style.css */']);
338+
});
339+
340+
it('should process multiple styles and handle each case correctly', () => {
341+
const styles = [
342+
'/*# sourceMappingURL=style1.css */',
343+
'/*# sourceMappingURL=data:application/json;base64,xyz */',
344+
'h1 { font-size: 2rem; }',
345+
'/*# sourceMappingURL=style2.css */',
346+
];
347+
const result = addBaseHrefToCssSourceMap('/base/', styles);
348+
expect(result).toEqual([
349+
'/*# sourceMappingURL=/base/style1.css */',
350+
'/*# sourceMappingURL=data:application/json;base64,xyz */',
351+
'h1 { font-size: 2rem; }',
352+
'/*# sourceMappingURL=/base/style2.css */',
353+
]);
354+
});
271355
});
272356

273357
async function styleCount(
@@ -309,6 +393,15 @@ class CmpEncapsulationEmulated {}
309393
})
310394
class CmpEncapsulationNone {}
311395

396+
@Component({
397+
selector: 'cmp-none',
398+
template: `<div class="none"></div>`,
399+
styles: [`.none { color: lime; }\n/*# sourceMappingURL=cmp-none.css.map */`],
400+
encapsulation: ViewEncapsulation.None,
401+
standalone: false,
402+
})
403+
class CmpEncapsulationNoneWithSourceMap {}
404+
312405
@Component({
313406
selector: 'cmp-shadow',
314407
template: `<div class="shadow"></div><cmp-emulated></cmp-emulated><cmp-none></cmp-none>`,

0 commit comments

Comments
 (0)