feat(compiler): Support css var namespacing in properties (#68846)

Adds support for namespacing css variables in style properties. Behaves
as you'd expect following the implementation for stylesheets generally.

This change also moves the error message into a util function since we
now need to produce the same error in three places.

PR Close #68846
This commit is contained in:
Matthew Beck
2026-05-20 16:12:16 -07:00
committed by leonsenft
parent af5e4e1131
commit 8c8b2f7783
10 changed files with 164 additions and 35 deletions
@@ -168,7 +168,7 @@ export const platformBrowser: (extraProviders?: StaticProvider[]) => PlatformRef
export function provideClientHydration(...features: HydrationFeature<HydrationFeatureKind>[]): EnvironmentProviders;
// @public
export function provideCssVarNamespacing(namespace: string): EnvironmentProviders;
export function provideCssVarNamespacing(namespace?: string): EnvironmentProviders;
// @public
export function provideProtractorTestingSupport(options?: {
@@ -3,6 +3,6 @@ hostAttrs: [2, "--camel-case", "foo", "--kebab-case", "foo"],
…
hostBindings: function MyDirective_HostBindings(rf, ctx) {
if (rf & 2) {
i0.ɵɵstyleProp("--camelCase", ctx.value)("--kebab-case", ctx.value);
i0.ɵɵstyleProp("--%NS%camelCase", ctx.value)("--%NS%kebab-case", ctx.value);
}
}
@@ -4,6 +4,6 @@
if (rf & 1) {
i0.ɵɵelement(0, "div", 0);
} if (rf & 2) {
i0.ɵɵstyleProp("--camelCase", ctx.value)("--kebab-case", ctx.value);
i0.ɵɵstyleProp("--%NS%camelCase", ctx.value)("--%NS%kebab-case", ctx.value);
}
}
+11 -16
View File
@@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import * as chars from './chars';
import {namespaceCssVariable} from './util';
/**
* The following set contains all keywords that can be used in the animation css shorthand
@@ -1048,25 +1049,19 @@ const _cssVariableRe = /(var\(\s*)?(--(?:[a-zA-Z0-9_-]|[^\x00-\x7F])+)(\s*:)?/g;
*/
export function namespaceCssVariables(cssText: string): string {
return cssText.replace(_cssVariableRe, (match, leadingVar, varName, trailingColon) => {
// Check for a leading `var(` or trailing `:` to approximate whether we're operating on a
// real CSS variable, not another piece of syntax that resembles it. For example, this
// guards against:
// - `.foo--bar {}`
// - `/* --foo */`
// - `p { content: "--foo" }`
// - `[data---bar] {}`
// - `[data-status=foo--bar] {}`
// etc.
if (!leadingVar && !trailingColon) {
return match;
}
if (varName.startsWith('--global-') && !varName.startsWith('--global--')) {
throw new Error(
`CSS variable "${varName}" has a single hyphen after "--global". ` +
`Use two hyphens ("--global--${varName.substring('--global-'.length)}") to opt-out of namespacing.`,
);
}
let result;
if (varName.startsWith('--global--')) {
result = `--${varName.substring('--global--'.length)}`;
} else {
result = `--%NS%${varName.substring('--'.length)}`;
}
return (leadingVar || '') + result + (trailingColon || '');
return (leadingVar ?? '') + namespaceCssVariable(varName) + (trailingColon ?? '');
});
}
@@ -9,6 +9,7 @@
import * as ir from '../../ir';
import type {CompilationJob} from '../compilation';
import {namespaceCssVariable} from '../../../../util';
const STYLE_DOT = 'style.';
const CLASS_DOT = 'class.';
@@ -40,6 +41,8 @@ export function parseHostStyleProperties(job: CompilationJob): void {
if (!isCssCustomProperty(op.name)) {
op.name = hyphenate(op.name);
} else {
op.name = namespaceCssVariable(op.name);
}
const {property, suffix} = parseProperty(op.name);
@@ -36,7 +36,7 @@ import {InterpolatedAttributeToken, InterpolatedTextToken} from '../ml_parser/to
import {ParseError, ParseErrorLevel, ParseSourceSpan} from '../parse_util';
import {ElementSchemaRegistry} from '../schema/element_schema_registry';
import {CssSelector} from '../directive_matching';
import {splitAtColon, splitAtPeriod} from '../util';
import {namespaceCssVariable, splitAtColon, splitAtPeriod} from '../util';
import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../template/pipeline/src/namespaces';
const PROPERTY_PARTS_SEPARATOR = '.';
@@ -594,7 +594,16 @@ export class BindingParser {
securityContexts = [SecurityContext.NONE];
} else if (parts[0] == STYLE_PREFIX) {
unit = parts.length > 2 ? parts[2] : null;
boundPropertyName = parts[1];
const boundName = parts[1];
if (!boundName.startsWith('--')) {
boundPropertyName = boundName;
} else {
try {
boundPropertyName = namespaceCssVariable(boundName);
} catch (e) {
this._reportError((e as Error).message, boundProp.sourceSpan);
}
}
bindingType = BindingType.Style;
securityContexts = [SecurityContext.STYLE];
} else if (parts[0] == ANIMATE_PREFIX) {
+22
View File
@@ -153,3 +153,25 @@ export function getJitStandaloneDefaultForVersion(version: string): boolean {
// All other Angular versions (v19+) default to true.
return true;
}
/**
* Namespaces a CSS variable name and validates its syntax.
*
* @param varName The CSS variable name starting with `--`.
* @throws An Error if the CSS variable is invalid (e.g. has a single hyphen after "--global").
* @returns The namespaced CSS variable name.
*/
export function namespaceCssVariable(varName: string): string {
if (varName.startsWith('--global-') && !varName.startsWith('--global--')) {
throw new Error(
`CSS variable "${varName}" has a single hyphen after "--global". ` +
`Use two hyphens ("--global--${varName.substring('--global-'.length)}") to opt-out of namespacing.`,
);
}
if (varName.startsWith('--global--')) {
return '--' + varName.substring('--global--'.length);
} else {
return '--%NS%' + varName.substring('--'.length);
}
}
@@ -78,20 +78,23 @@ export const REMOVE_STYLES_ON_COMPONENT_DESTROY = new InjectionToken<boolean>(
*
* Typically set via {@link provideCssVarNamespacing}.
*/
export const CSS_VAR_NAMESPACE = new InjectionToken<string>('CSS_VAR_NAMESPACE');
export const CSS_VAR_NAMESPACE = new InjectionToken<string>(
typeof ngDevMode !== 'undefined' && ngDevMode ? 'CSS_VAR_NAMESPACE' : '',
);
/**
* Configures the application to use the given namespace for all CSS variables.
*
* @param namespace The prefix string to use as a namespace. This is typically the `APP_ID`
* followed by a separator, such as 'my-app_'.
* @param namespace The prefix string to use as a namespace. If not provided, it defaults
* to the `APP_ID`. An underscore is appended unconditionally.
* @publicApi
*/
export function provideCssVarNamespacing(namespace: string): EnvironmentProviders {
export function provideCssVarNamespacing(namespace?: string): EnvironmentProviders {
return makeEnvironmentProviders([
{
provide: CSS_VAR_NAMESPACE,
useValue: namespace,
useFactory: (appId: string) => `${namespace ?? appId}_`,
deps: [APP_ID],
},
]);
}
@@ -177,7 +180,13 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
@Inject(CSS_VAR_NAMESPACE) @Optional() cssVarNamespace: string | null = null,
) {
this.cssVarNamespace = cssVarNamespace ?? '';
this.defaultRenderer = new DefaultDomRenderer2(eventManager, doc, ngZone, this.tracingService);
this.defaultRenderer = new DefaultDomRenderer2(
eventManager,
doc,
ngZone,
this.tracingService,
this.cssVarNamespace,
);
}
createRenderer(element: any, type: RendererType2 | null): Renderer2 {
@@ -304,6 +313,7 @@ class DefaultDomRenderer2 implements Renderer2 {
private readonly doc: Document,
protected readonly ngZone: NgZone,
private readonly tracingService: TracingService<TracingSnapshot> | null,
private readonly cssVarNamespace: string = '',
) {}
destroy(): void {}
@@ -412,7 +422,11 @@ class DefaultDomRenderer2 implements Renderer2 {
}
setStyle(el: any, style: string, value: any, flags: RendererStyleFlags2): void {
if (flags & (RendererStyleFlags2.DashCase | RendererStyleFlags2.Important)) {
const isVariable = style.startsWith('--');
if (isVariable) {
style = style.replace('%NS%', this.cssVarNamespace);
}
if (isVariable || flags & (RendererStyleFlags2.DashCase | RendererStyleFlags2.Important)) {
el.style.setProperty(style, value, flags & RendererStyleFlags2.Important ? 'important' : '');
} else {
el.style[style] = value;
@@ -420,7 +434,11 @@ class DefaultDomRenderer2 implements Renderer2 {
}
removeStyle(el: any, style: string, flags: RendererStyleFlags2): void {
if (flags & RendererStyleFlags2.DashCase) {
const isVariable = style.startsWith('--');
if (isVariable) {
style = style.replace('%NS%', this.cssVarNamespace);
}
if (isVariable || flags & RendererStyleFlags2.DashCase) {
// removeProperty has no effect when used on camelCased properties.
el.style.removeProperty(style);
} else {
@@ -538,7 +556,7 @@ class ShadowDomRenderer extends DefaultDomRenderer2 {
cssVarNamespace: string,
private sharedStylesHost?: SharedStylesHost,
) {
super(eventManager, doc, ngZone, tracingService);
super(eventManager, doc, ngZone, tracingService, cssVarNamespace);
this.shadowRoot = (hostEl as any).attachShadow({mode: 'open'});
// SharedStylesHost is used to add styles to the shadow root by ShadowDom.
@@ -628,7 +646,7 @@ class NoneEncapsulationDomRenderer extends DefaultDomRenderer2 {
cssVarNamespace: string,
compId?: string,
) {
super(eventManager, doc, ngZone, tracingService);
super(eventManager, doc, ngZone, tracingService, cssVarNamespace);
let styles = component.styles;
if (ngDevMode) {
// We only do this in development, as for production users should not add CSS sourcemaps to components.
@@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {APP_ID} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {CssVarNamespacer} from '../../src/dom/css_var_namespacer';
@@ -14,7 +15,7 @@ import {provideCssVarNamespacing} from '../../src/dom/dom_renderer';
describe('CssVarNamespacer', () => {
it('should namespace variables when `CSS_VAR_NAMESPACE` is provided', () => {
TestBed.configureTestingModule({
providers: [CssVarNamespacer, provideCssVarNamespacing('test-app_')],
providers: [CssVarNamespacer, provideCssVarNamespacing('test-app')],
});
const namespacer = TestBed.inject(CssVarNamespacer);
@@ -22,6 +23,20 @@ describe('CssVarNamespacer', () => {
expect(namespacer.namespace('--my-var')).toBe('--test-app_my-var');
});
it('should fallback to `APP_ID` with an underscore when no namespace is provided to `provideCssVarNamespacing`', () => {
TestBed.configureTestingModule({
providers: [
CssVarNamespacer,
{provide: APP_ID, useValue: 'custom-app'},
provideCssVarNamespacing(),
],
});
const namespacer = TestBed.inject(CssVarNamespacer);
expect(namespacer.namespace('--my-var')).toBe('--custom-app_my-var');
});
it('should not namespace variables when `CSS_VAR_NAMESPACE` is not provided', () => {
TestBed.configureTestingModule({
providers: [CssVarNamespacer],
@@ -361,7 +361,7 @@ describe('DefaultDomRendererV2', () => {
TestBed.configureTestingModule({
imports: [CmpNamespaceEmulated],
providers: [provideCssVarNamespacing('my-namespace_')],
providers: [provideCssVarNamespacing('my-namespace')],
});
const fixture = TestBed.createComponent(CmpNamespaceEmulated);
fixture.detectChanges();
@@ -384,7 +384,7 @@ describe('DefaultDomRendererV2', () => {
TestBed.configureTestingModule({
imports: [CmpNamespaceNone],
providers: [provideCssVarNamespacing('my-namespace_')],
providers: [provideCssVarNamespacing('my-namespace')],
});
const fixture = TestBed.createComponent(CmpNamespaceNone);
fixture.detectChanges();
@@ -407,7 +407,7 @@ describe('DefaultDomRendererV2', () => {
TestBed.configureTestingModule({
imports: [CmpNamespaceShadow],
providers: [provideCssVarNamespacing('my-namespace_')],
providers: [provideCssVarNamespacing('my-namespace')],
});
const fixture = TestBed.createComponent(CmpNamespaceShadow);
fixture.detectChanges();
@@ -498,6 +498,73 @@ describe('DefaultDomRendererV2', () => {
expect(css).toContain('var(--foo)');
});
});
describe('style property bindings namespacing', () => {
it('should namespace style property bindings starting with `--`', () => {
@Component({
selector: 'cmp-style-prop-namespace',
template: `<div [style.--foo]="'blue'"></div>`,
standalone: true,
})
class CmpStylePropNamespace {}
TestBed.configureTestingModule({
imports: [CmpStylePropNamespace],
providers: [provideCssVarNamespacing('my-namespace')],
});
const fixture = TestBed.createComponent(CmpStylePropNamespace);
fixture.detectChanges();
const div = fixture.nativeElement.querySelector('div');
expect(div.style.getPropertyValue('--my-namespace_foo')).toBe('blue');
expect(div.style.getPropertyValue('--foo')).toBe('');
});
it('should throw an error if style property binding starts with `--global-` with a single hyphen', () => {
@Component({
selector: 'cmp-style-prop-error',
template: `<div [style.--global-foo]="'blue'"></div>`,
standalone: true,
})
class CmpStylePropError {}
expect(() => {
TestBed.configureTestingModule({
imports: [CmpStylePropError],
providers: [provideCssVarNamespacing('my-namespace')],
});
}).toThrowError(/CSS variable "--global-foo" has a single hyphen after "--global"/);
});
it('should namespace styles set via Renderer2.setStyle/removeStyle', () => {
@Component({
selector: 'cmp-renderer-set-style',
template: '',
standalone: true,
})
class CmpRendererSetStyle {
constructor(public renderer: Renderer2) {}
}
TestBed.configureTestingModule({
imports: [CmpRendererSetStyle],
providers: [provideCssVarNamespacing('my-namespace')],
});
const fixture = TestBed.createComponent(CmpRendererSetStyle);
const comp = fixture.componentInstance;
const div = document.createElement('div');
comp.renderer.setStyle(div, '--%NS%foo', 'blue');
expect(div.style.getPropertyValue('--my-namespace_foo')).toBe('blue');
comp.renderer.setStyle(div, '--bar', 'red');
expect(div.style.getPropertyValue('--bar')).toBe('red');
expect(div.style.getPropertyValue('--my-namespace_bar')).toBe('');
comp.renderer.removeStyle(div, '--%NS%foo');
expect(div.style.getPropertyValue('--my-namespace_foo')).toBe('');
});
});
});
});