diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index d942a10f9a5..069749e84da 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -158,6 +158,7 @@ export { ɵɵpropertyInterpolate7, ɵɵpropertyInterpolate8, ɵɵpropertyInterpolateV, + ɵɵstringifyInterpolation, ɵɵProvidersFeature, ɵɵHostDirectivesFeature, ɵɵpureFunction0, diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index fffb6d342c8..102cef80893 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -94,6 +94,7 @@ export { ɵɵpropertyInterpolate7, ɵɵpropertyInterpolate8, ɵɵpropertyInterpolateV, + ɵɵstringifyInterpolation, ɵɵreference, ɵɵshallowReference, diff --git a/packages/core/src/render3/instructions/property_create.ts b/packages/core/src/render3/instructions/property_create.ts index 81567c6440e..a1825f6779c 100644 --- a/packages/core/src/render3/instructions/property_create.ts +++ b/packages/core/src/render3/instructions/property_create.ts @@ -22,6 +22,7 @@ import {HEADER_OFFSET, RENDERER} from '../interfaces/view'; import {computed} from '../reactivity/computed'; import {InputSignalNode} from '../reactivity/input_signal'; import {getCurrentTNode, getLView, getSelectedTNode, getTView, nextBindingIndex} from '../state'; +import {renderStringify} from '../util/stringify_utils'; import {getNativeByTNode} from '../util/view_utils'; import {handleUnknownPropertyError, isPropertyValid} from './element_validation'; @@ -70,13 +71,9 @@ export function ɵɵpropertyCreate( zoneTargets ??= EMPTY_ARRAY; signalInputs ??= EMPTY_ARRAY; - // If there are multiple signal targets, or any zone targets, then wrap `expr` in a computed. This - // ensures that the expression is only evaluated once, even if it has multiple consumers. Zone - // targets always use a computed as this memoizes all object/literal creation (which would - // otherwise have used pure functions). - if (zoneTargets.length > 0 || signalInputs.length > 1) { - expr = computed(expr); - } + // PERF(pk): I could avoid wrapping into computed for the case of a single binding to a signal + // based component + expr = computed(expr); lView[expressionSlot] = expr; for (const inputSignal of signalInputs) { @@ -95,7 +92,7 @@ export function ɵɵpropertyCreate( instruction: () => propertyUpdateDom(tNode.index, propName, expressionSlot, sanitizer ?? null), }); - } else if (zoneTargets?.length ?? 0 > 0) { + } else if (zoneTargets.length) { // Some binding targets were zone-based, so we need an update instruction to process them. (tView.virtualUpdate ??= []).push({ slot: expressionSlot, @@ -174,3 +171,15 @@ export function propertyUpdateInput( setNgReflectProperties(lView, element, tNode.type, targets, value); } } + +export function ɵɵstringifyInterpolation( + staticStrings: TemplateStringsArray, ...expressionValues: any[]): string { + // Build the updated content + + let content = staticStrings[0]; + for (let i = 1; i < staticStrings.length; i++) { + content += expressionValues[i - 1] + renderStringify(staticStrings[i]); + } + + return content; +} diff --git a/packages/core/test/acceptance/signal-components/dom_property_bindings.spec.ts b/packages/core/test/acceptance/signal-components/dom_property_bindings.spec.ts index 249292a18b4..25480297720 100644 --- a/packages/core/test/acceptance/signal-components/dom_property_bindings.spec.ts +++ b/packages/core/test/acceptance/signal-components/dom_property_bindings.spec.ts @@ -7,7 +7,7 @@ */ import {USE_TEMPLATE_PIPELINE} from '@angular/compiler/src/template/pipeline/switch'; -import {Component, signal} from '@angular/core'; +import {Component, signal, ɵɵdefineComponent, ɵɵelement, ɵɵpropertyCreate, ɵɵStandaloneFeature, ɵɵstringifyInterpolation} from '@angular/core'; import {TestBed} from '@angular/core/testing'; if (!USE_TEMPLATE_PIPELINE) { @@ -34,6 +34,34 @@ describe('dom property bindings in signal based components', () => { expect(div.tabIndex).toBe(5); }); + it('should not update DOM binding if a binding is not reactive', () => { + @Component({ + signals: true, + template: `
{{unrelated()}}
`, + standalone: true, + }) + class App { + _idx = 0; + nonReactive() { + return this._idx++; + } + unrelated = signal('foo'); + } + + const fixture = TestBed.createComponent(App); + const cmpInstance = fixture.componentInstance; + const div = fixture.nativeElement.firstChild; + + fixture.detectChanges(); + expect(div.tabIndex).toBe(0); + + cmpInstance.unrelated.set('bar'); + fixture.detectChanges(); + expect(div.tabIndex).toBe(0); + }); +}); + +describe('dom property interpolation in signal based components', () => { xit('should support property interpolation in DOM bindings', () => { @Component({ signals: true, @@ -48,6 +76,51 @@ describe('dom property bindings in signal based components', () => { const div = fixture.nativeElement.firstChild; fixture.detectChanges(); + expect(div.title).toBe('Hello, Angular!'); }); + + describe('hand-generated code - DELETE WHEN COMPILER CAN GENERATE INTERPOLATION', () => { + it('should support property interpolation in DOM bindings - hand generated code', () => { + class TestComponent { + name = signal('Angular'); + + static ɵfac = () => new TestComponent(); + static ɵcmp = ɵɵdefineComponent({ + type: TestComponent, + selectors: [['test-cmp']], + standalone: true, + signals: true, + features: [ɵɵStandaloneFeature], + decls: 1, + vars: 2, + consts: [[3, 'title']], + template: + (rf: any, ctx: any) => { + if ((rf & 1)) { + ɵɵelement(0, 'div', 0); + ɵɵpropertyCreate( + 1, 'title', () => ɵɵstringifyInterpolation`Hello, ${ctx.name()}!`) + } + }, + styles: [] + }); + } + + @Component({ + signals: true, + template: ``, + standalone: true, + imports: [TestComponent], + }) + class App { + } + + const fixture = TestBed.createComponent(App); + const div = fixture.nativeElement.firstChild.firstChild; + + fixture.detectChanges(); + expect(div.title).toBe('Hello, Angular!'); + }); + }); });