Skip to content

Commit

Permalink
fix(platform-browser): prepend baseHref to sourceMappingURL in CS…
Browse files Browse the repository at this point in the history
…S 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
  • Loading branch information
alan-agius4 authored and alxhub committed Jan 29, 2025
1 parent cf90542 commit 6b09716
Show file tree
Hide file tree
Showing 2 changed files with 161 additions and 6 deletions.
74 changes: 68 additions & 6 deletions packages/platform-browser/src/dom/dom_renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export const NAMESPACE_URIS: {[ns: string]: string} = {
};

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

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

/**
* Prepends a baseHref to the `sourceMappingURL` within the provided CSS content.
* If the `sourceMappingURL` contains an inline (encoded) map, the function skips processing.
*
* @note For inline stylesheets, the `sourceMappingURL` is relative to the page's origin
* and not the provided baseHref. This function is needed as when accessing the page with a URL
* containing two or more segments.
* For example, if the baseHref is set to `/`, and you visit a URL like `http://localhost/foo/bar`,
* the map would be requested from `http://localhost/foo/bar/comp.css.map` instead of what you'd expect,
* which is `http://localhost/comp.css.map`. This behavior is corrected by modifying the `sourceMappingURL`
* to ensure external source maps are loaded relative to the baseHref.
*
* @param baseHref - The base URL to prepend to the `sourceMappingURL`.
* @param styles - An array of CSS content strings, each potentially containing a `sourceMappingURL`.
* @returns The updated array of CSS content strings with modified `sourceMappingURL` values,
* or the original content if no modification is needed.
*/
export function addBaseHrefToCssSourceMap(baseHref: string, styles: string[]): string[] {
if (!baseHref) {
return styles;
}

const absoluteBaseHrefUrl = new URL(baseHref, 'http://localhost');

return styles.map((cssContent) => {
if (!cssContent.includes('sourceMappingURL=')) {
return cssContent;
}

return cssContent.replace(SOURCEMAP_URL_REGEXP, (_, sourceMapUrl) => {
if (
sourceMapUrl[0] === '/' ||
sourceMapUrl.startsWith('data:') ||
PROTOCOL_REGEXP.test(sourceMapUrl)
) {
return `/*# sourceMappingURL=${sourceMapUrl} */`;
}

const {pathname: resolvedSourceMapUrl} = new URL(sourceMapUrl, absoluteBaseHrefUrl);

return `/*# sourceMappingURL=${resolvedSourceMapUrl} */`;
});
});
}

@Injectable()
export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
private readonly rendererByCompId = new Map<
Expand Down Expand Up @@ -145,6 +193,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
const sharedStylesHost = this.sharedStylesHost;
const removeStylesOnCompDestroy = this.removeStylesOnCompDestroy;
const platformIsServer = this.platformIsServer;
const tracingService = this.tracingService;

switch (type.encapsulation) {
case ViewEncapsulation.Emulated:
Expand All @@ -157,7 +206,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
doc,
ngZone,
platformIsServer,
this.tracingService,
tracingService,
);
break;
case ViewEncapsulation.ShadowDom:
Expand All @@ -170,7 +219,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
ngZone,
this.nonce,
platformIsServer,
this.tracingService,
tracingService,
);
default:
renderer = new NoneEncapsulationDomRenderer(
Expand All @@ -181,7 +230,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
doc,
ngZone,
platformIsServer,
this.tracingService,
tracingService,
);
break;
}
Expand Down Expand Up @@ -449,9 +498,15 @@ class ShadowDomRenderer extends DefaultDomRenderer2 {
) {
super(eventManager, doc, ngZone, platformIsServer, tracingService);
this.shadowRoot = (hostEl as any).attachShadow({mode: 'open'});

this.sharedStylesHost.addHost(this.shadowRoot);
const styles = shimStylesContent(component.id, component.styles);
let styles = component.styles;
if (ngDevMode) {
// We only do this in development, as for production users should not add CSS sourcemaps to components.
const baseHref = getDOM().getBaseHref(doc) ?? '';
styles = addBaseHrefToCssSourceMap(baseHref, styles);
}

styles = shimStylesContent(component.id, styles);

for (const style of styles) {
const styleEl = document.createElement('style');
Expand Down Expand Up @@ -520,7 +575,14 @@ class NoneEncapsulationDomRenderer extends DefaultDomRenderer2 {
compId?: string,
) {
super(eventManager, doc, ngZone, platformIsServer, tracingService);
this.styles = compId ? shimStylesContent(compId, component.styles) : component.styles;
let styles = component.styles;
if (ngDevMode) {
// We only do this in development, as for production users should not add CSS sourcemaps to components.
const baseHref = getDOM().getBaseHref(doc) ?? '';
styles = addBaseHrefToCssSourceMap(baseHref, styles);
}

this.styles = compId ? shimStylesContent(compId, styles) : styles;
this.styleUrls = component.getExternalStyles?.(compId);
}

Expand Down
93 changes: 93 additions & 0 deletions packages/platform-browser/test/dom/dom_renderer_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {Component, Renderer2, ViewEncapsulation} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {By} from '@angular/platform-browser/src/dom/debug/by';
import {
addBaseHrefToCssSourceMap,
NAMESPACE_URIS,
REMOVE_STYLES_ON_COMPONENT_DESTROY,
} from '@angular/platform-browser/src/dom/dom_renderer';
Expand Down Expand Up @@ -268,6 +269,89 @@ describe('DefaultDomRendererV2', () => {
}
});
});

it('should update an external sourceMappingURL by prepending the baseHref as a prefix', () => {
document.head.innerHTML = `<base href="/base/" />`;
TestBed.resetTestingModule();
TestBed.configureTestingModule({
declarations: [CmpEncapsulationNoneWithSourceMap],
});

const fixture = TestBed.createComponent(CmpEncapsulationNoneWithSourceMap);
fixture.detectChanges();

expect(document.head.querySelector('style')?.textContent).toContain(
'/*# sourceMappingURL=/base/cmp-none.css.map */',
);

document.head.innerHTML = '';
});
});

describe('addBaseHrefToCssSourceMap', () => {
it('should return the original styles if baseHref is empty', () => {
const styles = ['body { color: red; }'];
const result = addBaseHrefToCssSourceMap('', styles);
expect(result).toEqual(styles);
});

it('should skip styles that do not contain a sourceMappingURL', () => {
const styles = ['body { color: red; }', 'h1 { font-size: 2rem; }'];
const result = addBaseHrefToCssSourceMap('/base/', styles);
expect(result).toEqual(styles);
});

it('should not modify inline (encoded) sourceMappingURL maps', () => {
const styles = ['/*# sourceMappingURL=data:application/json;base64,xyz */'];
const result = addBaseHrefToCssSourceMap('/base/', styles);
expect(result).toEqual(styles);
});

it('should prepend baseHref to external sourceMappingURL', () => {
const styles = ['/*# sourceMappingURL=style.css */'];
const result = addBaseHrefToCssSourceMap('/base/', styles);
expect(result).toEqual(['/*# sourceMappingURL=/base/style.css */']);
});

it('should handle baseHref with a trailing slash correctly', () => {
const styles = ['/*# sourceMappingURL=style.css */'];
const result = addBaseHrefToCssSourceMap('/base/', styles);
expect(result).toEqual(['/*# sourceMappingURL=/base/style.css */']);
});

it('should handle baseHref without a trailing slash correctly', () => {
const styles = ['/*# sourceMappingURL=style.css */'];
const result = addBaseHrefToCssSourceMap('/base', styles);
expect(result).toEqual(['/*# sourceMappingURL=/style.css */']);
});

it('should not duplicate slashes in the final URL', () => {
const styles = ['/*# sourceMappingURL=./style.css */'];
const result = addBaseHrefToCssSourceMap('/base/', styles);
expect(result).toEqual(['/*# sourceMappingURL=/base/style.css */']);
});

it('should not add base href to sourceMappingURL that is absolute', () => {
const styles = ['/*# sourceMappingURL=http://example.com/style.css */'];
const result = addBaseHrefToCssSourceMap('/base/', styles);
expect(result).toEqual(['/*# sourceMappingURL=http://example.com/style.css */']);
});

it('should process multiple styles and handle each case correctly', () => {
const styles = [
'/*# sourceMappingURL=style1.css */',
'/*# sourceMappingURL=data:application/json;base64,xyz */',
'h1 { font-size: 2rem; }',
'/*# sourceMappingURL=style2.css */',
];
const result = addBaseHrefToCssSourceMap('/base/', styles);
expect(result).toEqual([
'/*# sourceMappingURL=/base/style1.css */',
'/*# sourceMappingURL=data:application/json;base64,xyz */',
'h1 { font-size: 2rem; }',
'/*# sourceMappingURL=/base/style2.css */',
]);
});
});

async function styleCount(
Expand Down Expand Up @@ -309,6 +393,15 @@ class CmpEncapsulationEmulated {}
})
class CmpEncapsulationNone {}

@Component({
selector: 'cmp-none',
template: `<div class="none"></div>`,
styles: [`.none { color: lime; }\n/*# sourceMappingURL=cmp-none.css.map */`],
encapsulation: ViewEncapsulation.None,
standalone: false,
})
class CmpEncapsulationNoneWithSourceMap {}

@Component({
selector: 'cmp-shadow',
template: `<div class="shadow"></div><cmp-emulated></cmp-emulated><cmp-none></cmp-none>`,
Expand Down

0 comments on commit 6b09716

Please sign in to comment.