From 8c8b2f77831236b23ca99ccb62e6197ff43b400d Mon Sep 17 00:00:00 2001 From: Matthew Beck Date: Wed, 20 May 2026 16:12:16 -0700 Subject: [PATCH] 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 --- .../public-api/platform-browser/index.api.md | 2 +- .../host_bindings/css_custom_properties.js | 2 +- .../style_bindings/css_custom_properties.js | 2 +- packages/compiler/src/shadow_css.ts | 27 +++---- .../src/phases/host_style_property_parsing.ts | 3 + .../src/template_parser/binding_parser.ts | 13 +++- packages/compiler/src/util.ts | 22 ++++++ .../platform-browser/src/dom/dom_renderer.ts | 38 +++++++--- .../test/dom/css_var_namespacer_spec.ts | 17 ++++- .../test/dom/dom_renderer_spec.ts | 73 ++++++++++++++++++- 10 files changed, 164 insertions(+), 35 deletions(-) diff --git a/goldens/public-api/platform-browser/index.api.md b/goldens/public-api/platform-browser/index.api.md index a511fc7ec94..e98386fb7a8 100644 --- a/goldens/public-api/platform-browser/index.api.md +++ b/goldens/public-api/platform-browser/index.api.md @@ -168,7 +168,7 @@ export const platformBrowser: (extraProviders?: StaticProvider[]) => PlatformRef export function provideClientHydration(...features: HydrationFeature[]): EnvironmentProviders; // @public -export function provideCssVarNamespacing(namespace: string): EnvironmentProviders; +export function provideCssVarNamespacing(namespace?: string): EnvironmentProviders; // @public export function provideProtractorTestingSupport(options?: { diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/host_bindings/css_custom_properties.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/host_bindings/css_custom_properties.js index c6f5c0691d5..fb952a07a28 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/host_bindings/css_custom_properties.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/host_bindings/css_custom_properties.js @@ -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); } } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/style_bindings/css_custom_properties.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/style_bindings/css_custom_properties.js index f6ae98875ae..09e233d21b2 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/style_bindings/css_custom_properties.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/style_bindings/css_custom_properties.js @@ -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); } } diff --git a/packages/compiler/src/shadow_css.ts b/packages/compiler/src/shadow_css.ts index 9b48245459c..be229811f9f 100644 --- a/packages/compiler/src/shadow_css.ts +++ b/packages/compiler/src/shadow_css.ts @@ -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 ?? ''); }); } diff --git a/packages/compiler/src/template/pipeline/src/phases/host_style_property_parsing.ts b/packages/compiler/src/template/pipeline/src/phases/host_style_property_parsing.ts index e353ee779a0..093ef17c96f 100644 --- a/packages/compiler/src/template/pipeline/src/phases/host_style_property_parsing.ts +++ b/packages/compiler/src/template/pipeline/src/phases/host_style_property_parsing.ts @@ -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); diff --git a/packages/compiler/src/template_parser/binding_parser.ts b/packages/compiler/src/template_parser/binding_parser.ts index 35c461b218f..92c630ce844 100644 --- a/packages/compiler/src/template_parser/binding_parser.ts +++ b/packages/compiler/src/template_parser/binding_parser.ts @@ -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) { diff --git a/packages/compiler/src/util.ts b/packages/compiler/src/util.ts index 25c8ae86044..afaf2bdbe19 100644 --- a/packages/compiler/src/util.ts +++ b/packages/compiler/src/util.ts @@ -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); + } +} diff --git a/packages/platform-browser/src/dom/dom_renderer.ts b/packages/platform-browser/src/dom/dom_renderer.ts index 2eefd32834e..5ec9e23be95 100644 --- a/packages/platform-browser/src/dom/dom_renderer.ts +++ b/packages/platform-browser/src/dom/dom_renderer.ts @@ -78,20 +78,23 @@ export const REMOVE_STYLES_ON_COMPONENT_DESTROY = new InjectionToken( * * Typically set via {@link provideCssVarNamespacing}. */ -export const CSS_VAR_NAMESPACE = new InjectionToken('CSS_VAR_NAMESPACE'); +export const CSS_VAR_NAMESPACE = new InjectionToken( + 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 | 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. diff --git a/packages/platform-browser/test/dom/css_var_namespacer_spec.ts b/packages/platform-browser/test/dom/css_var_namespacer_spec.ts index 2da85b35e8c..1077256a3fe 100644 --- a/packages/platform-browser/test/dom/css_var_namespacer_spec.ts +++ b/packages/platform-browser/test/dom/css_var_namespacer_spec.ts @@ -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], diff --git a/packages/platform-browser/test/dom/dom_renderer_spec.ts b/packages/platform-browser/test/dom/dom_renderer_spec.ts index ea1bb99691c..06ad4aeb11c 100644 --- a/packages/platform-browser/test/dom/dom_renderer_spec.ts +++ b/packages/platform-browser/test/dom/dom_renderer_spec.ts @@ -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: `
`, + 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: `
`, + 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(''); + }); + }); }); });