From db1682f567a4c9fce220f7e732b7af7c02df3452 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 26 Feb 2025 11:52:01 +0100 Subject: [PATCH 01/10] refactor(core): move createComponent tests into a separate file Moves the tests for `createComponent` into their own file since the `component_spec.ts` was a bit too generic and was accumulating all sorts of tests. --- .../core/test/acceptance/component_spec.ts | 238 -------- .../test/acceptance/create_component_spec.ts | 578 ++++++++++++++++++ 2 files changed, 578 insertions(+), 238 deletions(-) create mode 100644 packages/core/test/acceptance/create_component_spec.ts diff --git a/packages/core/test/acceptance/component_spec.ts b/packages/core/test/acceptance/component_spec.ts index 80bbbeca2467..470d9bf45026 100644 --- a/packages/core/test/acceptance/component_spec.ts +++ b/packages/core/test/acceptance/component_spec.ts @@ -12,13 +12,10 @@ import { Component, ComponentRef, createComponent, - createEnvironmentInjector, Directive, ElementRef, - EmbeddedViewRef, EnvironmentInjector, forwardRef, - inject, Injectable, InjectionToken, Injector, @@ -28,7 +25,6 @@ import { OnDestroy, reflectComponentType, Renderer2, - Type, ViewChild, ViewContainerRef, ViewEncapsulation, @@ -36,7 +32,6 @@ import { ɵsetDocument, ɵɵdefineComponent, } from '@angular/core'; -import {stringifyForError} from '@angular/core/src/render3/util/stringify_utils'; import {TestBed} from '@angular/core/testing'; import {expect} from '@angular/platform-browser/testing/src/matchers'; @@ -798,239 +793,6 @@ describe('component', () => { expect(targetEl.innerHTML).toContain('DynamicComponent Content'); }); - describe('createComponent', () => { - it('should create an instance of a standalone component', () => { - @Component({ - template: 'Hello {{ name }}!', - }) - class StandaloneComponent { - name = 'Angular'; - } - - const hostElement = document.createElement('div'); - const environmentInjector = TestBed.inject(EnvironmentInjector); - const componentRef = createComponent(StandaloneComponent, {hostElement, environmentInjector}); - - componentRef.changeDetectorRef.detectChanges(); - expect(hostElement.textContent).toBe('Hello Angular!'); - - // Verify basic change detection works. - componentRef.instance.name = 'ZoneJS'; - componentRef.changeDetectorRef.detectChanges(); - expect(hostElement.textContent).toBe('Hello ZoneJS!'); - componentRef.destroy(); - }); - - it('should create an instance of an NgModule-based component', () => { - @Component({ - template: 'Hello {{ name }}!', - standalone: false, - }) - class NgModuleBasedComponent { - name = 'Angular'; - } - - @NgModule({ - declarations: [NgModuleBasedComponent], - }) - class AppModule {} - - const hostElement = document.createElement('div'); - const environmentInjector = TestBed.inject(EnvironmentInjector); - const componentRef = createComponent(NgModuleBasedComponent, { - hostElement, - environmentInjector, - }); - - componentRef.changeDetectorRef.detectChanges(); - expect(hostElement.textContent).toBe('Hello Angular!'); - - // Verify basic change detection works. - componentRef.instance.name = 'ZoneJS'; - componentRef.changeDetectorRef.detectChanges(); - expect(hostElement.textContent).toBe('Hello ZoneJS!'); - }); - - it('should render projected content', () => { - @Component({ - template: ` - | - | - - `, - }) - class StandaloneComponent {} - - // Helper method to create a `

` element - const p = (content: string): Element => { - const element = document.createElement('p'); - element.innerHTML = content; - return element; - }; - const hostElement = document.createElement('div'); - const environmentInjector = TestBed.inject(EnvironmentInjector); - const projectableNodes = [[p('1')], [p('2')], [p('3')]]; - const componentRef = createComponent(StandaloneComponent, { - hostElement, - environmentInjector, - projectableNodes, - }); - - componentRef.changeDetectorRef.detectChanges(); - expect(hostElement.innerHTML.replace(/\s*/g, '')).toBe('

1

|

2

|

3

'); - componentRef.destroy(); - }); - - it('should be able to inject tokens from EnvironmentInjector', () => { - const A = new InjectionToken('A'); - @Component({ - template: 'Token: {{ a }}', - }) - class StandaloneComponent { - a = inject(A); - } - - const hostElement = document.createElement('div'); - const parentInjector = TestBed.inject(EnvironmentInjector); - const providers = [{provide: A, useValue: 'EnvironmentInjector(A)'}]; - const environmentInjector = createEnvironmentInjector(providers, parentInjector); - const componentRef = createComponent(StandaloneComponent, {hostElement, environmentInjector}); - componentRef.changeDetectorRef.detectChanges(); - - expect(hostElement.textContent).toBe('Token: EnvironmentInjector(A)'); - componentRef.destroy(); - }); - - it('should be able to use NodeInjector from the node hierarchy', () => { - const A = new InjectionToken('A'); - const B = new InjectionToken('B'); - @Component({ - template: '{{ a }} and {{ b }}', - }) - class ChildStandaloneComponent { - a = inject(A); - b = inject(B); - } - - @Component({ - template: 'Tokens:
', - providers: [{provide: A, useValue: 'ElementInjector(A)'}], - }) - class RootStandaloneComponent { - @ViewChild('target', {read: ElementRef}) target!: ElementRef; - constructor(private injector: Injector) {} - - createChildComponent() { - const hostElement = this.target.nativeElement; - const parentInjector = this.injector.get(EnvironmentInjector); - const providers = [ - {provide: A, useValue: 'EnvironmentInjector(A)'}, - {provide: B, useValue: 'EnvironmentInjector(B)'}, - ]; - const environmentInjector = createEnvironmentInjector(providers, parentInjector); - const childComponentRef = createComponent(ChildStandaloneComponent, { - hostElement, - elementInjector: this.injector, - environmentInjector, - }); - childComponentRef.changeDetectorRef.detectChanges(); - } - } - - const fixture = TestBed.createComponent(RootStandaloneComponent); - fixture.detectChanges(); - - fixture.componentInstance.createChildComponent(); - - const rootEl = fixture.nativeElement; - - // Token A is coming from the Element Injector, token B - from the Environment Injector. - expect(rootEl.textContent).toBe('Tokens: ElementInjector(A) and EnvironmentInjector(B)'); - }); - - it('should create a host element if none provided', () => { - const selector = 'standalone-comp'; - @Component({ - selector, - template: 'Hello {{ name }}!', - }) - class StandaloneComponent { - name = 'Angular'; - } - - const environmentInjector = TestBed.inject(EnvironmentInjector); - const componentRef = createComponent(StandaloneComponent, {environmentInjector}); - componentRef.changeDetectorRef.detectChanges(); - - const hostElement = (componentRef.hostView as EmbeddedViewRef) - .rootNodes[0]; - - // A host element that matches component's selector. - expect(hostElement.tagName.toLowerCase()).toBe(selector); - - expect(hostElement.textContent).toBe('Hello Angular!'); - componentRef.destroy(); - }); - - it( - 'should fall-back to use a `div` as a host element if none provided ' + - 'and element selector does not have a tag name', - () => { - @Component({ - selector: '.some-class', - template: 'Hello {{ name }}!', - }) - class StandaloneComponent { - name = 'Angular'; - } - - const environmentInjector = TestBed.inject(EnvironmentInjector); - const componentRef = createComponent(StandaloneComponent, {environmentInjector}); - componentRef.changeDetectorRef.detectChanges(); - - const hostElement = (componentRef.hostView as EmbeddedViewRef) - .rootNodes[0]; - - // A host element has the `div` tag name, since component's selector doesn't contain - // tag name information (only a class name). - expect(hostElement.tagName.toLowerCase()).toBe('div'); - - expect(hostElement.textContent).toBe('Hello Angular!'); - componentRef.destroy(); - }, - ); - - describe('error checking', () => { - it('should throw when provided class is not a component', () => { - class NotAComponent {} - - @Directive() - class ADirective {} - - @Injectable() - class AnInjectiable {} - - const errorFor = (type: Type): string => - `NG0906: The ${stringifyForError(type)} is not an Angular component, ` + - `make sure it has the \`@Component\` decorator.`; - const hostElement = document.createElement('div'); - const environmentInjector = TestBed.inject(EnvironmentInjector); - - expect(() => - createComponent(NotAComponent, {hostElement, environmentInjector}), - ).toThrowError(errorFor(NotAComponent)); - - expect(() => createComponent(ADirective, {hostElement, environmentInjector})).toThrowError( - errorFor(ADirective), - ); - - expect(() => - createComponent(AnInjectiable, {hostElement, environmentInjector}), - ).toThrowError(errorFor(AnInjectiable)); - }); - }); - }); - describe('reflectComponentType', () => { it('should create an ComponentMirror for a standalone component', () => { function transformFn() {} diff --git a/packages/core/test/acceptance/create_component_spec.ts b/packages/core/test/acceptance/create_component_spec.ts new file mode 100644 index 000000000000..d61ce6568c13 --- /dev/null +++ b/packages/core/test/acceptance/create_component_spec.ts @@ -0,0 +1,578 @@ +/*! + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + Component, + createComponent, + createEnvironmentInjector, + Directive, + ElementRef, + EmbeddedViewRef, + EnvironmentInjector, + inject, + Injectable, + InjectionToken, + Injector, + Input, + NgModule, + OnDestroy, + Type, + ViewChild, +} from '@angular/core'; +import {stringifyForError} from '@angular/core/src/render3/util/stringify_utils'; +import {TestBed} from '@angular/core/testing'; + +describe('createComponent', () => { + it('should create an instance of a standalone component', () => { + @Component({ + template: 'Hello {{ name }}!', + }) + class StandaloneComponent { + name = 'Angular'; + } + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const componentRef = createComponent(StandaloneComponent, {hostElement, environmentInjector}); + + componentRef.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('Hello Angular!'); + + // Verify basic change detection works. + componentRef.instance.name = 'ZoneJS'; + componentRef.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('Hello ZoneJS!'); + componentRef.destroy(); + }); + + it('should create an instance of an NgModule-based component', () => { + @Component({ + template: 'Hello {{ name }}!', + standalone: false, + }) + class NgModuleBasedComponent { + name = 'Angular'; + } + + @NgModule({ + declarations: [NgModuleBasedComponent], + }) + class AppModule {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const componentRef = createComponent(NgModuleBasedComponent, { + hostElement, + environmentInjector, + }); + + componentRef.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('Hello Angular!'); + + // Verify basic change detection works. + componentRef.instance.name = 'ZoneJS'; + componentRef.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('Hello ZoneJS!'); + }); + + it('should render projected content', () => { + @Component({ + template: ` + | + | + + `, + }) + class StandaloneComponent {} + + // Helper method to create a `

` element + const p = (content: string): Element => { + const element = document.createElement('p'); + element.innerHTML = content; + return element; + }; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const projectableNodes = [[p('1')], [p('2')], [p('3')]]; + const componentRef = createComponent(StandaloneComponent, { + hostElement, + environmentInjector, + projectableNodes, + }); + + componentRef.changeDetectorRef.detectChanges(); + expect(hostElement.innerHTML.replace(/\s*/g, '')).toBe('

1

|

2

|

3

'); + componentRef.destroy(); + }); + + it('should be able to inject tokens from EnvironmentInjector', () => { + const A = new InjectionToken('A'); + @Component({ + template: 'Token: {{ a }}', + }) + class StandaloneComponent { + a = inject(A); + } + + const hostElement = document.createElement('div'); + const parentInjector = TestBed.inject(EnvironmentInjector); + const providers = [{provide: A, useValue: 'EnvironmentInjector(A)'}]; + const environmentInjector = createEnvironmentInjector(providers, parentInjector); + const componentRef = createComponent(StandaloneComponent, {hostElement, environmentInjector}); + componentRef.changeDetectorRef.detectChanges(); + + expect(hostElement.textContent).toBe('Token: EnvironmentInjector(A)'); + componentRef.destroy(); + }); + + it('should be able to use NodeInjector from the node hierarchy', () => { + const A = new InjectionToken('A'); + const B = new InjectionToken('B'); + @Component({ + template: '{{ a }} and {{ b }}', + }) + class ChildStandaloneComponent { + a = inject(A); + b = inject(B); + } + + @Component({ + template: 'Tokens:
', + providers: [{provide: A, useValue: 'ElementInjector(A)'}], + }) + class RootStandaloneComponent { + @ViewChild('target', {read: ElementRef}) target!: ElementRef; + constructor(private injector: Injector) {} + + createChildComponent() { + const hostElement = this.target.nativeElement; + const parentInjector = this.injector.get(EnvironmentInjector); + const providers = [ + {provide: A, useValue: 'EnvironmentInjector(A)'}, + {provide: B, useValue: 'EnvironmentInjector(B)'}, + ]; + const environmentInjector = createEnvironmentInjector(providers, parentInjector); + const childComponentRef = createComponent(ChildStandaloneComponent, { + hostElement, + elementInjector: this.injector, + environmentInjector, + }); + childComponentRef.changeDetectorRef.detectChanges(); + } + } + + const fixture = TestBed.createComponent(RootStandaloneComponent); + fixture.detectChanges(); + + fixture.componentInstance.createChildComponent(); + + const rootEl = fixture.nativeElement; + + // Token A is coming from the Element Injector, token B - from the Environment Injector. + expect(rootEl.textContent).toBe('Tokens: ElementInjector(A) and EnvironmentInjector(B)'); + }); + + it('should create a host element if none provided', () => { + const selector = 'standalone-comp'; + @Component({ + selector, + template: 'Hello {{ name }}!', + }) + class StandaloneComponent { + name = 'Angular'; + } + + const environmentInjector = TestBed.inject(EnvironmentInjector); + const componentRef = createComponent(StandaloneComponent, {environmentInjector}); + componentRef.changeDetectorRef.detectChanges(); + + const hostElement = (componentRef.hostView as EmbeddedViewRef) + .rootNodes[0]; + + // A host element that matches component's selector. + expect(hostElement.tagName.toLowerCase()).toBe(selector); + + expect(hostElement.textContent).toBe('Hello Angular!'); + componentRef.destroy(); + }); + + it( + 'should fall-back to use a `div` as a host element if none provided ' + + 'and element selector does not have a tag name', + () => { + @Component({ + selector: '.some-class', + template: 'Hello {{ name }}!', + }) + class StandaloneComponent { + name = 'Angular'; + } + + const environmentInjector = TestBed.inject(EnvironmentInjector); + const componentRef = createComponent(StandaloneComponent, {environmentInjector}); + componentRef.changeDetectorRef.detectChanges(); + + const hostElement = (componentRef.hostView as EmbeddedViewRef) + .rootNodes[0]; + + // A host element has the `div` tag name, since component's selector doesn't contain + // tag name information (only a class name). + expect(hostElement.tagName.toLowerCase()).toBe('div'); + + expect(hostElement.textContent).toBe('Hello Angular!'); + componentRef.destroy(); + }, + ); + + describe('attaching directives to root component', () => { + it('should be able to attach directives when creating a component', () => { + const logs: string[] = []; + + @Directive({ + host: { + 'class': 'class-1', + 'attr-one': 'one', + }, + }) + class Dir1 { + constructor() { + logs.push('Dir1'); + } + } + + @Directive({ + host: { + 'class': 'class-2', + 'attr-two': 'two', + }, + }) + class Dir2 { + constructor() { + logs.push('Dir2'); + } + } + + @Component({ + template: '', + host: { + 'class': 'host', + 'attr-three': 'host', + }, + }) + class HostComponent { + constructor() { + logs.push('HostComponent'); + } + } + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir1, Dir2], + }); + + expect(logs).toEqual(['HostComponent', 'Dir1', 'Dir2']); + expect(hostElement.className).toBe('host class-1 class-2'); + expect(hostElement.getAttribute('attr-one')).toBe('one'); + expect(hostElement.getAttribute('attr-two')).toBe('two'); + expect(hostElement.getAttribute('attr-three')).toBe('host'); + }); + + it('should support setting the value of a directive using setInput', () => { + let dirInstance: Dir; + + @Directive({}) + class Dir { + @Input() value: number | null = null; + + constructor() { + dirInstance = this; + } + } + + @Component({template: ''}) + class HostComponent {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir], + }); + + expect(dirInstance!.value).toBe(null); + + ref.setInput('value', 1); + expect(dirInstance!.value).toBe(1); + + ref.setInput('value', 2); + expect(dirInstance!.value).toBe(2); + }); + + it('should execute host directives in the correct order', () => { + const logs: string[] = []; + + @Directive({}) + class Chain1_3 { + constructor() { + logs.push('Chain1 - level 3'); + } + } + + @Directive({ + hostDirectives: [Chain1_3], + }) + class Chain1_2 { + constructor() { + logs.push('Chain1 - level 2'); + } + } + + @Directive({ + hostDirectives: [Chain1_2], + }) + class Chain1 { + constructor() { + logs.push('Chain1 - level 1'); + } + } + + @Directive({}) + class Chain2_2 { + constructor() { + logs.push('Chain2 - level 2'); + } + } + + @Directive({ + hostDirectives: [Chain2_2], + }) + class Chain2 { + constructor() { + logs.push('Chain2 - level 1'); + } + } + + @Directive() + class Chain3_2 { + constructor() { + logs.push('Chain3 - level 2'); + } + } + + @Directive({hostDirectives: [Chain3_2]}) + class Chain3 { + constructor() { + logs.push('Chain3 - level 1'); + } + } + + @Component({ + selector: 'my-comp', + template: '', + hostDirectives: [Chain1, Chain2, Chain3], + }) + class HostComponent { + constructor() { + logs.push('HostComponent'); + } + } + + @Directive() + class Dir1 { + constructor() { + logs.push('Dir1'); + } + } + + @Directive({}) + class Dir2 { + constructor() { + logs.push('Dir2'); + } + } + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir1, Dir2], + }); + + expect(logs).toEqual([ + 'Chain1 - level 3', + 'Chain1 - level 2', + 'Chain1 - level 1', + 'Chain2 - level 2', + 'Chain2 - level 1', + 'Chain3 - level 2', + 'Chain3 - level 1', + 'HostComponent', + 'Dir1', + 'Dir2', + ]); + }); + + it('should destroy the attached directives when the component ref is destroyed', () => { + const logs: string[] = []; + + @Directive({}) + class Dir1 implements OnDestroy { + ngOnDestroy() { + logs.push('Dir1'); + } + } + + @Directive({}) + class Dir2 implements OnDestroy { + ngOnDestroy() { + logs.push('Dir2'); + } + } + + @Component({template: ''}) + class HostComponent implements OnDestroy { + ngOnDestroy() { + logs.push('HostComponent'); + } + } + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir1, Dir2], + }); + + ref.destroy(); + expect(logs).toEqual(['HostComponent', 'Dir1', 'Dir2']); + }); + + it('should be able to inject the attached directive', () => { + let createdInstance: Dir | undefined; + let injectedInstance: Dir | undefined; + + @Directive({}) + class Dir { + constructor() { + createdInstance = this; + } + } + + @Component({template: ''}) + class HostComponent { + constructor() { + injectedInstance = inject(Dir); + } + } + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir], + }); + + expect(createdInstance).toBeTruthy(); + expect(injectedInstance).toBeTruthy(); + expect(createdInstance).toBe(injectedInstance); + }); + + it('should throw if the same directive is attached multiple times', () => { + @Directive({}) + class Dir {} + + @Component({template: ''}) + class HostComponent {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => { + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir, Dir], + }); + }).toThrowError(/Directive Dir matches multiple times on the same element/); + }); + + it('should throw if a non-directive class is attached', () => { + class NotADir {} + + @Component({template: ''}) + class HostComponent {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => { + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [NotADir], + }); + }).toThrowError(/Class NotADir is not a directive/); + }); + + it('should throw if a component class is attached', () => { + @Component({template: '', standalone: true}) + class NotADir {} + + @Component({template: ''}) + class HostComponent {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => { + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [NotADir], + }); + }).toThrowError(/Class NotADir is not a directive/); + }); + }); + + describe('error checking', () => { + it('should throw when provided class is not a component', () => { + class NotAComponent {} + + @Directive() + class ADirective {} + + @Injectable() + class AnInjectiable {} + + const errorFor = (type: Type): string => + `NG0906: The ${stringifyForError(type)} is not an Angular component, ` + + `make sure it has the \`@Component\` decorator.`; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => createComponent(NotAComponent, {hostElement, environmentInjector})).toThrowError( + errorFor(NotAComponent), + ); + + expect(() => createComponent(ADirective, {hostElement, environmentInjector})).toThrowError( + errorFor(ADirective), + ); + + expect(() => createComponent(AnInjectiable, {hostElement, environmentInjector})).toThrowError( + errorFor(AnInjectiable), + ); + }); + }); +}); From a1c0f24ae0f50247b38d16967b54a0f3c8ed513f Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 26 Feb 2025 09:33:31 +0100 Subject: [PATCH 02/10] refactor(core): move duplicate directive check outside of host directives The check that verifies that there are no duplicates in the directives array was only running after host directive matching since that was the only case when it can happen. After the upcoming changes that won't be the case anymore so these changes move it always run after directive matching. I also did some additional cleanup by adding comments and by not lazily initializing the `allDirectiveDefs` array when matching host directives. The array is guaranteed to be defined since earlier in the function we verify that there's at least one def with host directives. --- packages/core/src/render3/view/directives.ts | 22 ++++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/core/src/render3/view/directives.ts b/packages/core/src/render3/view/directives.ts index 177730ed5e30..eac0c7d92f84 100644 --- a/packages/core/src/render3/view/directives.ts +++ b/packages/core/src/render3/view/directives.ts @@ -94,6 +94,8 @@ export function resolveDirectives( [directiveDefs, hostDirectiveDefs, hostDirectiveRanges] = hostDirectiveResolution; } + ngDevMode && assertNoDuplicateDirectives(directiveDefs); + initializeDirectives( tView, lView, @@ -135,9 +137,12 @@ function resolveHostDirectives(matches: DirectiveDef[]): HostDirectiveR let componentDef: ComponentDef | null = null; let hasHostDirectives = false; + // Having host directives is the less common scenario. Make an initial + // validation pass so we don't allocate memory unnecessarily. for (let i = 0; i < matches.length; i++) { const def = matches[i]; + // Given that we may need this further down, we can resolve it already while validating. if (i === 0 && isComponentDef(def)) { componentDef = def; } @@ -148,11 +153,12 @@ function resolveHostDirectives(matches: DirectiveDef[]): HostDirectiveR } } + // If there's at least one def with host directive, we can't bail out of this function. if (!hasHostDirectives) { return null; } - let allDirectiveDefs: DirectiveDef[] | null = null; + const allDirectiveDefs: DirectiveDef[] = []; let hostDirectiveDefs: HostDirectiveDefs | null = null; let hostDirectiveRanges: HostDirectiveRanges | null = null; @@ -168,7 +174,6 @@ function resolveHostDirectives(matches: DirectiveDef[]): HostDirectiveR // 4. Selector-matched dir for (const def of matches) { if (def.findHostDirectiveDefs !== null) { - allDirectiveDefs ??= []; hostDirectiveDefs ??= new Map(); hostDirectiveRanges ??= new Map(); resolveHostDirectivesForDef(def, allDirectiveDefs, hostDirectiveRanges, hostDirectiveDefs); @@ -176,18 +181,17 @@ function resolveHostDirectives(matches: DirectiveDef[]): HostDirectiveR // Component definition needs to be pushed early to maintain the correct ordering. if (def === componentDef) { - allDirectiveDefs ??= []; allDirectiveDefs.push(def); } } - if (allDirectiveDefs !== null) { - allDirectiveDefs.push(...(componentDef === null ? matches : matches.slice(1))); - ngDevMode && assertNoDuplicateDirectives(allDirectiveDefs); - return [allDirectiveDefs, hostDirectiveDefs, hostDirectiveRanges]; + if (componentDef === null) { + allDirectiveDefs.push(...matches); + } else { + allDirectiveDefs.push(...matches.slice(1)); } - return null; + return [allDirectiveDefs, hostDirectiveDefs, hostDirectiveRanges]; } function resolveHostDirectivesForDef( @@ -642,7 +646,7 @@ function initTNodeFlags(tNode: TNode, index: number, numberOfDirectives: number) tNode.providerIndexes = index; } -export function assertNoDuplicateDirectives(directives: DirectiveDef[]): void { +function assertNoDuplicateDirectives(directives: DirectiveDef[]): void { // The array needs at least two elements in order to have duplicates. if (directives.length < 2) { return; From bfbb9ef737bb8da66a03d4eea072ee88e75d7c00 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 26 Feb 2025 13:01:13 +0100 Subject: [PATCH 03/10] refactor(core): add assertion parameter to getDirectiveDef Some upcoming functionality won't work if we can't retrieve a directive definition from a class. These changes add a `throwIfNotFound` to `getDirectiveDef`, similar to `getNgModuleDef`, to avoid duplication in such cases. --- packages/core/src/render3/def_getters.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/core/src/render3/def_getters.ts b/packages/core/src/render3/def_getters.ts index 29095d6d384f..3b10d10d50bd 100644 --- a/packages/core/src/render3/def_getters.ts +++ b/packages/core/src/render3/def_getters.ts @@ -12,11 +12,11 @@ import {stringify} from '../util/stringify'; import {NG_COMP_DEF, NG_DIR_DEF, NG_MOD_DEF, NG_PIPE_DEF} from './fields'; import type {ComponentDef, DirectiveDef, PipeDef} from './interfaces/definition'; -export function getNgModuleDef(type: any, throwNotFound: true): NgModuleDef; +export function getNgModuleDef(type: any, throwIfNotFound: true): NgModuleDef; export function getNgModuleDef(type: any): NgModuleDef | null; -export function getNgModuleDef(type: any, throwNotFound?: boolean): NgModuleDef | null { +export function getNgModuleDef(type: any, throwIfNotFound?: boolean): NgModuleDef | null { const ngModuleDef = type[NG_MOD_DEF] || null; - if (!ngModuleDef && throwNotFound === true) { + if (!ngModuleDef && throwIfNotFound === true) { throw new Error(`Type ${stringify(type)} does not have 'ɵmod' property.`); } return ngModuleDef; @@ -32,8 +32,14 @@ export function getComponentDef(type: any): ComponentDef | null { return type[NG_COMP_DEF] || null; } -export function getDirectiveDef(type: any): DirectiveDef | null { - return type[NG_DIR_DEF] || null; +export function getDirectiveDef(type: any, throwIfNotFound: true): DirectiveDef; +export function getDirectiveDef(type: any): DirectiveDef | null; +export function getDirectiveDef(type: any, throwIfNotFound?: boolean): DirectiveDef | null { + const def = type[NG_DIR_DEF] || null; + if (!def && throwIfNotFound === true) { + throw new Error(`Type ${stringify(type)} does not have 'ɵdir' property.`); + } + return def; } export function getPipeDef(type: any): PipeDef | null { From 7ec2a8e3f94b9a214fc9b45723ee9162d84ac163 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 26 Feb 2025 10:50:45 +0100 Subject: [PATCH 04/10] feat(core): add the ability to apply directives to dynamically-created components Updates `createComponent`, `ViewContainerRef.createComponent` and `ComponentFactory.create` to allow the user to specify directives that should be applied when creating the component. --- goldens/public-api/core/index.api.md | 6 +- packages/core/src/linker/component_factory.ts | 1 + .../core/src/linker/view_container_ref.ts | 11 +++ packages/core/src/render3/component.ts | 3 + packages/core/src/render3/component_ref.ts | 25 ++++++- .../test/acceptance/create_component_spec.ts | 62 ++++++++++++++++- .../acceptance/view_container_ref_spec.ts | 69 +++++++++++++++++++ .../hello_world/bundle.golden_symbols.json | 2 + 8 files changed, 172 insertions(+), 7 deletions(-) diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index 0e8496ac8a73..2e8b83250d96 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -295,7 +295,7 @@ export interface ComponentDecorator { // @public @deprecated export abstract class ComponentFactory { abstract get componentType(): Type; - abstract create(injector: Injector, projectableNodes?: any[][], rootSelectorOrNode?: string | any, environmentInjector?: EnvironmentInjector | NgModuleRef): ComponentRef; + abstract create(injector: Injector, projectableNodes?: any[][], rootSelectorOrNode?: string | any, environmentInjector?: EnvironmentInjector | NgModuleRef, directives?: Type[]): ComponentRef; abstract get inputs(): { propName: string; templateName: string; @@ -454,6 +454,7 @@ export function createComponent(component: Type, options: { hostElement?: Element; elementInjector?: Injector; projectableNodes?: Node[][]; + directives?: Type[]; }): ComponentRef; // @public @@ -1980,9 +1981,10 @@ export abstract class ViewContainerRef { ngModuleRef?: NgModuleRef; environmentInjector?: EnvironmentInjector | NgModuleRef; projectableNodes?: Node[][]; + directives?: Type[]; }): ComponentRef; // @deprecated - abstract createComponent(componentFactory: ComponentFactory, index?: number, injector?: Injector, projectableNodes?: any[][], environmentInjector?: EnvironmentInjector | NgModuleRef): ComponentRef; + abstract createComponent(componentFactory: ComponentFactory, index?: number, injector?: Injector, projectableNodes?: any[][], environmentInjector?: EnvironmentInjector | NgModuleRef, directives?: Type[]): ComponentRef; abstract createEmbeddedView(templateRef: TemplateRef, context?: C, options?: { index?: number; injector?: Injector; diff --git a/packages/core/src/linker/component_factory.ts b/packages/core/src/linker/component_factory.ts index b3eae21cc7cb..80c2769a9fc8 100644 --- a/packages/core/src/linker/component_factory.ts +++ b/packages/core/src/linker/component_factory.ts @@ -122,5 +122,6 @@ export abstract class ComponentFactory { projectableNodes?: any[][], rootSelectorOrNode?: string | any, environmentInjector?: EnvironmentInjector | NgModuleRef, + directives?: Type[], ): ComponentRef; } diff --git a/packages/core/src/linker/view_container_ref.ts b/packages/core/src/linker/view_container_ref.ts index 5a9f74da421c..53c0fef4e63b 100644 --- a/packages/core/src/linker/view_container_ref.ts +++ b/packages/core/src/linker/view_container_ref.ts @@ -225,6 +225,7 @@ export abstract class ViewContainerRef { * replace the `ngModuleRef` parameter. * * projectableNodes: list of DOM nodes that should be projected through * [``](api/core/ng-content) of the new component instance. + * * directives: Directives that should be applied to the component. * * @returns The new `ComponentRef` which contains the component instance and the host view. */ @@ -236,6 +237,7 @@ export abstract class ViewContainerRef { ngModuleRef?: NgModuleRef; environmentInjector?: EnvironmentInjector | NgModuleRef; projectableNodes?: Node[][]; + directives?: Type[]; }, ): ComponentRef; @@ -250,6 +252,7 @@ export abstract class ViewContainerRef { * [``](api/core/ng-content) of the new component instance. * @param ngModuleRef An instance of the NgModuleRef that represent an NgModule. * This information is used to retrieve corresponding NgModule injector. + * @param directives Directives that should be applied to the component. * * @returns The new `ComponentRef` which contains the component instance and the host view. * @@ -263,6 +266,7 @@ export abstract class ViewContainerRef { injector?: Injector, projectableNodes?: any[][], environmentInjector?: EnvironmentInjector | NgModuleRef, + directives?: Type[], ): ComponentRef; /** @@ -426,6 +430,7 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { injector?: Injector; projectableNodes?: Node[][]; ngModuleRef?: NgModuleRef; + directives?: Type[]; }, ): ComponentRef; /** @@ -439,6 +444,7 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { injector?: Injector | undefined, projectableNodes?: any[][] | undefined, environmentInjector?: EnvironmentInjector | NgModuleRef | undefined, + directives?: Type[], ): ComponentRef; override createComponent( componentFactoryOrType: ComponentFactory | Type, @@ -451,10 +457,12 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { ngModuleRef?: NgModuleRef; environmentInjector?: EnvironmentInjector | NgModuleRef; projectableNodes?: Node[][]; + directives?: Type[]; }, injector?: Injector | undefined, projectableNodes?: any[][] | undefined, environmentInjector?: EnvironmentInjector | NgModuleRef | undefined, + directives?: Type[], ): ComponentRef { const isComponentFactory = componentFactoryOrType && !isType(componentFactoryOrType); let index: number | undefined; @@ -499,6 +507,7 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { ngModuleRef?: NgModuleRef; environmentInjector?: EnvironmentInjector | NgModuleRef; projectableNodes?: Node[][]; + directives?: Type[]; }; if (ngDevMode && options.environmentInjector && options.ngModuleRef) { throwError( @@ -509,6 +518,7 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { injector = options.injector; projectableNodes = options.projectableNodes; environmentInjector = options.environmentInjector || options.ngModuleRef; + directives = options.directives; } const componentFactory: ComponentFactory = isComponentFactory @@ -553,6 +563,7 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { projectableNodes, rNode, environmentInjector, + directives, ); this.insertImpl( componentRef.hostView, diff --git a/packages/core/src/render3/component.ts b/packages/core/src/render3/component.ts index c8111097deaf..a8d50fc3ed6d 100644 --- a/packages/core/src/render3/component.ts +++ b/packages/core/src/render3/component.ts @@ -73,6 +73,7 @@ import {assertComponentDef} from './errors'; * `[[element1, element2]]`: projects `element1` and `element2` into the same ``. * `[[element1, element2], [element3]]`: projects `element1` and `element2` into one ``, * and `element3` into a separate ``. + * * `directives` (optional): Directives that should be applied to the component. * @returns ComponentRef instance that represents a given Component. * * @publicApi @@ -84,6 +85,7 @@ export function createComponent( hostElement?: Element; elementInjector?: Injector; projectableNodes?: Node[][]; + directives?: Type[]; }, ): ComponentRef { ngDevMode && assertComponentDef(component); @@ -95,6 +97,7 @@ export function createComponent( options.projectableNodes, options.hostElement, options.environmentInjector, + options.directives, ); } diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index b5e60d328da0..859185b4b668 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -29,7 +29,7 @@ import {Sanitizer} from '../sanitization/sanitizer'; import {assertComponentType} from './assert'; import {attachPatchData} from './context_discovery'; -import {getComponentDef} from './def_getters'; +import {getComponentDef, getDirectiveDef} from './def_getters'; import {depsTracker} from './deps_tracker/deps_tracker'; import {NodeInjector} from './di'; import {reportUnknownPropertyError} from './instructions/element_validation'; @@ -231,6 +231,7 @@ export class ComponentFactory extends AbstractComponentFactory { projectableNodes?: any[][] | undefined, rootSelectorOrNode?: any, environmentInjector?: NgModuleRef | EnvironmentInjector | undefined, + directives?: Type[], ): AbstractComponentRef { profiler(ProfilerEvent.DynamicComponentStart); @@ -289,6 +290,24 @@ export class ComponentFactory extends AbstractComponentFactory { retrieveHydrationInfo(hostElement, rootViewInjector, true /* isRootView */), ); + const directivesToApply: DirectiveDef[] = [this.componentDef]; + + if (directives) { + for (const directiveType of directives) { + const directiveDef = getDirectiveDef(directiveType, true); + + if (ngDevMode && !directiveDef.standalone) { + throw new RuntimeError( + RuntimeErrorCode.TYPE_IS_NOT_STANDALONE, + `The ${stringifyForError(directiveType)} directive must be standalone in ` + + `order to be applied to a dynamically-created component.`, + ); + } + + directivesToApply.push(directiveDef); + } + } + rootLView[HEADER_OFFSET] = hostElement; // rootView is the parent when bootstrapping @@ -306,14 +325,14 @@ export class ComponentFactory extends AbstractComponentFactory { rootTView, rootLView, '#host', - () => [this.componentDef], + () => directivesToApply, true, 0, ); // ---- element instruction - // TODO(crisbeto): in practice `hostRNode` should always be defined, but there are some + // TODO(crisbeto): in practice `hostElement` should always be defined, but there are some // tests where the renderer is mocked out and `undefined` is returned. We should update the // tests so that this check can be removed. if (hostElement) { diff --git a/packages/core/test/acceptance/create_component_spec.ts b/packages/core/test/acceptance/create_component_spec.ts index d61ce6568c13..3002c25cfef5 100644 --- a/packages/core/test/acceptance/create_component_spec.ts +++ b/packages/core/test/acceptance/create_component_spec.ts @@ -489,6 +489,43 @@ describe('createComponent', () => { expect(createdInstance).toBe(injectedInstance); }); + it('should write to the inputs of the attached directives using setInput', () => { + let dirInstance!: Dir; + + @Directive() + class Dir { + @Input() someInput = 0; + + constructor() { + dirInstance = this; + } + } + + @Component({template: ''}) + class HostComponent { + @Input() someInput = 0; + } + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir], + }); + + expect(dirInstance.someInput).toBe(0); + expect(ref.instance.someInput).toBe(0); + + ref.setInput('someInput', 1); + expect(dirInstance.someInput).toBe(1); + expect(ref.instance.someInput).toBe(1); + + ref.setInput('someInput', 2); + expect(dirInstance.someInput).toBe(2); + expect(ref.instance.someInput).toBe(2); + }); + it('should throw if the same directive is attached multiple times', () => { @Directive({}) class Dir {} @@ -523,7 +560,7 @@ describe('createComponent', () => { environmentInjector, directives: [NotADir], }); - }).toThrowError(/Class NotADir is not a directive/); + }).toThrowError(/Type NotADir does not have 'ɵdir' property/); }); it('should throw if a component class is attached', () => { @@ -542,7 +579,28 @@ describe('createComponent', () => { environmentInjector, directives: [NotADir], }); - }).toThrowError(/Class NotADir is not a directive/); + }).toThrowError(/Type NotADir does not have 'ɵdir' property/); + }); + + it('should throw if attached directive is not standalone', () => { + @Directive({standalone: false}) + class Dir {} + + @Component({template: ''}) + class HostComponent {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => { + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir], + }); + }).toThrowError( + /The Dir directive must be standalone in order to be applied to a dynamically-created component/, + ); }); }); diff --git a/packages/core/test/acceptance/view_container_ref_spec.ts b/packages/core/test/acceptance/view_container_ref_spec.ts index a17d6f5011c7..9693db95e7c7 100644 --- a/packages/core/test/acceptance/view_container_ref_spec.ts +++ b/packages/core/test/acceptance/view_container_ref_spec.ts @@ -1790,6 +1790,75 @@ describe('ViewContainerRef', () => { ); }); + it('should support attaching directives when creating the component', () => { + const logs: string[] = []; + + @Directive({ + selector: '[dir-one]', + host: { + 'class': 'class-1', + 'attr-one': 'one', + }, + }) + class Dir1 { + constructor() { + logs.push('Dir1'); + } + } + + @Directive({ + selector: 'dir-two', + host: { + 'class': 'class-2', + 'attr-two': 'two', + }, + }) + class Dir2 { + constructor() { + logs.push('Dir2'); + } + } + + @Component({ + selector: 'host-component', + template: '', + standalone: false, + host: { + 'class': 'host', + 'attr-three': 'host', + }, + }) + class HostComponent { + constructor() { + logs.push('HostComponent'); + } + } + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + declarations: [EmbeddedViewInsertionComp, VCRefDirective, HostComponent], + }); + const fixture = TestBed.createComponent(EmbeddedViewInsertionComp); + const vcRefDir = fixture.debugElement + .query(By.directive(VCRefDirective)) + .injector.get(VCRefDirective); + fixture.detectChanges(); + + expect(getElementHtml(fixture.nativeElement)).toEqual('

'); + + vcRefDir.vcref.createComponent(HostComponent, { + index: 0, + directives: [Dir1, Dir2], + }); + fixture.detectChanges(); + + expect(logs).toEqual(['HostComponent', 'Dir1', 'Dir2']); + expect(getElementHtml(fixture.nativeElement)).toEqual( + '

', + ); + }); + describe('`options` argument handling', () => { it('should work correctly when an empty object is provided', () => { fixture.componentInstance.viewContainerRef.createComponent(ChildA, {}); diff --git a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json index c1f7ddafefa6..fc688c9ba2c6 100644 --- a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json @@ -48,6 +48,7 @@ "LOCALE_ID2", "NEW_LINE", "NG_COMP_DEF", + "NG_DIR_DEF", "NG_ELEMENT_ID", "NG_ENV_ID", "NG_FACTORY_DEF", @@ -197,6 +198,7 @@ "getCurrentTNode", "getCurrentTNodePlaceholderOk", "getDeclarationTNode", + "getDirectiveDef", "getFactoryDef", "getFirstLContainer", "getInitialLViewFlagsFromDef", From 65188d5a93aa2ce3ca961aec9181b749aa3bf747 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 26 Feb 2025 12:47:19 +0100 Subject: [PATCH 05/10] refactor(core): set up underlying input binding symbols Sets up the symbols used to power the upcoming `inputBinding` functionality. I also fixed that `setDirectiveInput` was incorrectly only allowing strings for the `value` parameter. --- goldens/public-api/core/errors.api.md | 4 + goldens/public-api/core/index.api.md | 3 + packages/core/src/core.ts | 1 + packages/core/src/errors.ts | 2 + packages/core/src/render3/dynamic_bindings.ts | 112 ++++++++++++++++++ .../core/src/render3/instructions/shared.ts | 2 +- 6 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/render3/dynamic_bindings.ts diff --git a/goldens/public-api/core/errors.api.md b/goldens/public-api/core/errors.api.md index 4e63687cdd00..4f575ac92e86 100644 --- a/goldens/public-api/core/errors.api.md +++ b/goldens/public-api/core/errors.api.md @@ -63,6 +63,8 @@ export const enum RuntimeErrorCode { // (undocumented) INJECTOR_ALREADY_DESTROYED = 205, // (undocumented) + INVALID_BINDING_TARGET = 316, + // (undocumented) INVALID_DIFFER_INPUT = 900, // (undocumented) INVALID_EVENT_BINDING = 306, @@ -109,6 +111,8 @@ export const enum RuntimeErrorCode { // (undocumented) MULTIPLE_PLATFORMS = 400, // (undocumented) + NO_BINDING_TARGET = 315, + // (undocumented) NO_SUPPORTING_DIFFER_FACTORY = 901, // (undocumented) OUTPUT_REF_DESTROYED = 953, diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index 2e8b83250d96..847e8f2529fd 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -995,6 +995,9 @@ export const Input: InputDecorator; // @public export const input: InputFunction; +// @public +export function inputBinding(publicName: string, value: () => unknown): Binding; + // @public (undocumented) export interface InputDecorator { (arg?: string | Input): any; diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index e801bc009c34..684268c85c6e 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -112,6 +112,7 @@ export { afterNextRender, ɵFirstAvailable, } from './render3/after_render/hooks'; +export {inputBinding} from './render3/dynamic_bindings'; export {ApplicationConfig, mergeApplicationConfig} from './application/application_config'; export {makeStateKey, StateKey, TransferState} from './transfer_state'; export {booleanAttribute, numberAttribute} from './util/coercion'; diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index f762556a0173..ff71b1d956a4 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -60,6 +60,8 @@ export const enum RuntimeErrorCode { HOST_DIRECTIVE_CONFLICTING_ALIAS = 312, MULTIPLE_MATCHING_PIPES = 313, UNINITIALIZED_LET_ACCESS = 314, + NO_BINDING_TARGET = 315, + INVALID_BINDING_TARGET = 316, // Bootstrap Errors MULTIPLE_PLATFORMS = 400, diff --git a/packages/core/src/render3/dynamic_bindings.ts b/packages/core/src/render3/dynamic_bindings.ts new file mode 100644 index 000000000000..75513e6c904a --- /dev/null +++ b/packages/core/src/render3/dynamic_bindings.ts @@ -0,0 +1,112 @@ +/*! + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {RuntimeError, RuntimeErrorCode} from '../errors'; +import {Type} from '../interface/type'; +import {bindingUpdated} from './bindings'; +import {setDirectiveInput, storePropertyBindingMetadata} from './instructions/shared'; +import {DirectiveDef} from './interfaces/definition'; +import {getLView, getSelectedTNode, getTView, nextBindingIndex} from './state'; + +/** Symbol used to store and retrieve metadata about a binding. */ +export const BINDING = /* @__PURE__ */ Symbol('BINDING'); + +/** + * A dynamically-defined binding targeting. + * For example, `inputBinding('value', () => 123)` creates an input binding. + */ +export interface Binding { + readonly [BINDING]: { + readonly kind: string; + readonly requiredVars: number; + }; + + /** Target to which to apply the binding. */ + readonly target?: unknown; + + /** Callback that will be invoked during creation. */ + create?(): void; + + /** Callback that will be invoked during updates. */ + update?(): void; +} + +/** + * Represents a dynamically-created directive with bindings targeting it specifically. + */ +export interface DirectiveWithBindings { + /** Directive type that should be created. */ + type: Type; + + /** Bindings that should be applied to the specific directive. */ + bindings: Binding[]; +} + +// This is constant between all the bindings so we can reuse the object. +const INPUT_BINDING_METADATA: Binding[typeof BINDING] = {kind: 'input', requiredVars: 1}; + +class InputBinding implements Binding { + readonly target!: DirectiveDef; + readonly [BINDING] = INPUT_BINDING_METADATA; + + constructor( + private readonly publicName: string, + private readonly value: () => unknown, + ) {} + + update(): void { + const lView = getLView(); + const bindingIndex = nextBindingIndex(); + const value = this.value(); + if (bindingUpdated(lView, bindingIndex, value)) { + const tView = getTView(); + const tNode = getSelectedTNode(); + + if (!this.target && ngDevMode) { + throw new RuntimeError( + RuntimeErrorCode.NO_BINDING_TARGET, + `Input binding to property "${this.publicName}" does not have a target.`, + ); + } + + const hasSet = setDirectiveInput(tNode, tView, lView, this.target, this.publicName, value); + + if (ngDevMode) { + if (!hasSet) { + throw new RuntimeError( + RuntimeErrorCode.NO_BINDING_TARGET, + `${this.target.type.name} does not have an input with a public name of "${this.publicName}".`, + ); + } + storePropertyBindingMetadata(tView.data, tNode, this.publicName, bindingIndex); + } + } + } +} + +/** + * Creates an input binding. + * @param publicName Public name of the input to bind to. + * @param value Callback that returns the current value for the binding. Can be either a signal or + * a plain getter function. + * + * ### Usage Example + * In this example we create an instance of the `MyButton` component and bind the value of + * the `isDisabled` signal to its `disabled` input. + * + * ``` + * const isDisabled = signal(false); + * + * createComponent(MyButton, { + * bindings: [inputBinding('disabled', isDisabled)] + * }); + * ``` + */ +export function inputBinding(publicName: string, value: () => unknown): Binding { + return new InputBinding(publicName, value); +} diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index dc1c6e4fea70..736d02c77b10 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -672,7 +672,7 @@ export function setDirectiveInput( lView: LView, target: DirectiveDef, publicName: string, - value: string, + value: unknown, ): boolean { let hostIndex: number | null = null; let hostDirectivesStart: number | null = null; From 68c5ee0fe4a8949ebac15b23377e452e6ad6e977 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 26 Feb 2025 14:38:13 +0100 Subject: [PATCH 06/10] fix(core): input targeting not checking if input exists on host Fixes that the `setDirectiveInput` function wasn't checkin if an input exists before writing to it which can lead to assertion errors. --- packages/core/src/render3/instructions/shared.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index 736d02c77b10..06c830af1ca0 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -714,7 +714,7 @@ export function setDirectiveInput( } } - if (hostIndex !== null) { + if (hostIndex !== null && target.inputs.hasOwnProperty(publicName)) { ngDevMode && assertIndexInRange(lView, hostIndex); writeToDirectiveInput(target, lView[hostIndex], publicName, value); hasSet = true; From 589a4a34e8a7a391a99f6410f1b82ae031fa6a9f Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 26 Feb 2025 16:05:56 +0100 Subject: [PATCH 07/10] feat(core): add input binding support to dynamically-created components Adds the ability to bind to inputs on dynamically-created components, either by targeting the component itself or one of its directives. The new API looks as follows: ```ts const value = signal(123); createComponent(MyComp, { // Bind the value `'hello'` to `someInput` of `MyComp`. bindings: [inputBinding('someInput', () => 'hello')], directives: [{ type: MyDir, // Bind the `value` signal to the `otherInput` of `MyDir`. bindings: [inputBinding('otherInput', value)] }] }); ``` This behavior overlaps with `ComponentRef.setInput`, with a few key differences: 1. `setInput` sets the value on *all* inputs whereas `inputBinding` only targets the specified directive and its host directives. This makes it easier to know which directive you're targeting. 2. `inputBinding` is executed as if it's in a template, making it consistent with how bindings behave for selector-matched components, whereas `setInput` executes outside the lifecycle of the component. 3. It resolves a long-standing issue with `setInput` where it wasn't possible to set the initial value of an input before the first change detection run. Currently `inputBinding` is used only for `createComponent`, `ViewContainerRef.createComponent` and `ComponentFactory.create`, however it is going to be base for more APIs in the future. --- goldens/public-api/core/index.api.md | 10 +- packages/core/src/linker/component_factory.ts | 6 +- .../core/src/linker/view_container_ref.ts | 26 +- packages/core/src/render3/component.ts | 6 +- packages/core/src/render3/component_ref.ts | 124 ++++- .../test/acceptance/create_component_spec.ts | 482 ++++++++++++++++++ .../acceptance/view_container_ref_spec.ts | 64 +++ .../bundle.golden_symbols.json | 1 + .../animations/bundle.golden_symbols.json | 1 + .../cyclic_import/bundle.golden_symbols.json | 1 + .../bundling/defer/bundle.golden_symbols.json | 3 + .../forms_reactive/bundle.golden_symbols.json | 1 + .../bundle.golden_symbols.json | 1 + .../hello_world/bundle.golden_symbols.json | 1 + .../hydration/bundle.golden_symbols.json | 4 +- .../router/bundle.golden_symbols.json | 1 + .../bundle.golden_symbols.json | 4 +- .../bundling/todo/bundle.golden_symbols.json | 1 + 18 files changed, 698 insertions(+), 39 deletions(-) diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index 847e8f2529fd..1774e4042761 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -295,7 +295,7 @@ export interface ComponentDecorator { // @public @deprecated export abstract class ComponentFactory { abstract get componentType(): Type; - abstract create(injector: Injector, projectableNodes?: any[][], rootSelectorOrNode?: string | any, environmentInjector?: EnvironmentInjector | NgModuleRef, directives?: Type[]): ComponentRef; + abstract create(injector: Injector, projectableNodes?: any[][], rootSelectorOrNode?: string | any, environmentInjector?: EnvironmentInjector | NgModuleRef, directives?: (Type | DirectiveWithBindings)[], bindings?: Binding[]): ComponentRef; abstract get inputs(): { propName: string; templateName: string; @@ -454,7 +454,8 @@ export function createComponent(component: Type, options: { hostElement?: Element; elementInjector?: Injector; projectableNodes?: Node[][]; - directives?: Type[]; + directives?: (Type | DirectiveWithBindings)[]; + bindings?: Binding[]; }): ComponentRef; // @public @@ -1984,10 +1985,11 @@ export abstract class ViewContainerRef { ngModuleRef?: NgModuleRef; environmentInjector?: EnvironmentInjector | NgModuleRef; projectableNodes?: Node[][]; - directives?: Type[]; + directives?: (Type | DirectiveWithBindings)[]; + bindings?: Binding[]; }): ComponentRef; // @deprecated - abstract createComponent(componentFactory: ComponentFactory, index?: number, injector?: Injector, projectableNodes?: any[][], environmentInjector?: EnvironmentInjector | NgModuleRef, directives?: Type[]): ComponentRef; + abstract createComponent(componentFactory: ComponentFactory, index?: number, injector?: Injector, projectableNodes?: any[][], environmentInjector?: EnvironmentInjector | NgModuleRef, directives?: (Type | DirectiveWithBindings)[], bindings?: Binding[]): ComponentRef; abstract createEmbeddedView(templateRef: TemplateRef, context?: C, options?: { index?: number; injector?: Injector; diff --git a/packages/core/src/linker/component_factory.ts b/packages/core/src/linker/component_factory.ts index 80c2769a9fc8..9b4acb02971d 100644 --- a/packages/core/src/linker/component_factory.ts +++ b/packages/core/src/linker/component_factory.ts @@ -9,7 +9,8 @@ import type {ChangeDetectorRef} from '../change_detection/change_detection'; import type {Injector} from '../di/injector'; import type {EnvironmentInjector} from '../di/r3_injector'; -import {Type} from '../interface/type'; +import type {Type} from '../interface/type'; +import type {Binding, DirectiveWithBindings} from '../render3/dynamic_bindings'; import type {ElementRef} from './element_ref'; import type {NgModuleRef} from './ng_module_factory'; @@ -122,6 +123,7 @@ export abstract class ComponentFactory { projectableNodes?: any[][], rootSelectorOrNode?: string | any, environmentInjector?: EnvironmentInjector | NgModuleRef, - directives?: Type[], + directives?: (Type | DirectiveWithBindings)[], + bindings?: Binding[], ): ComponentRef; } diff --git a/packages/core/src/linker/view_container_ref.ts b/packages/core/src/linker/view_container_ref.ts index 53c0fef4e63b..bec830a97231 100644 --- a/packages/core/src/linker/view_container_ref.ts +++ b/packages/core/src/linker/view_container_ref.ts @@ -77,6 +77,7 @@ import {TemplateRef} from './template_ref'; import {EmbeddedViewRef, ViewRef} from './view_ref'; import {addLViewToLContainer, createLContainer, detachView} from '../render3/view/container'; import {addToEndOfViewTree} from '../render3/view/construction'; +import {Binding, DirectiveWithBindings} from '../render3/dynamic_bindings'; /** * Represents a container where one or more views can be attached to a component. @@ -226,6 +227,7 @@ export abstract class ViewContainerRef { * * projectableNodes: list of DOM nodes that should be projected through * [``](api/core/ng-content) of the new component instance. * * directives: Directives that should be applied to the component. + * * bindings: Bindings that should be applied to the component. * * @returns The new `ComponentRef` which contains the component instance and the host view. */ @@ -237,7 +239,8 @@ export abstract class ViewContainerRef { ngModuleRef?: NgModuleRef; environmentInjector?: EnvironmentInjector | NgModuleRef; projectableNodes?: Node[][]; - directives?: Type[]; + directives?: (Type | DirectiveWithBindings)[]; + bindings?: Binding[]; }, ): ComponentRef; @@ -253,6 +256,7 @@ export abstract class ViewContainerRef { * @param ngModuleRef An instance of the NgModuleRef that represent an NgModule. * This information is used to retrieve corresponding NgModule injector. * @param directives Directives that should be applied to the component. + * @param bindings Bindings that should be applied to the component. * * @returns The new `ComponentRef` which contains the component instance and the host view. * @@ -266,7 +270,8 @@ export abstract class ViewContainerRef { injector?: Injector, projectableNodes?: any[][], environmentInjector?: EnvironmentInjector | NgModuleRef, - directives?: Type[], + directives?: (Type | DirectiveWithBindings)[], + bindings?: Binding[], ): ComponentRef; /** @@ -430,7 +435,8 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { injector?: Injector; projectableNodes?: Node[][]; ngModuleRef?: NgModuleRef; - directives?: Type[]; + directives?: (Type | DirectiveWithBindings)[]; + bindings?: Binding[]; }, ): ComponentRef; /** @@ -444,7 +450,8 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { injector?: Injector | undefined, projectableNodes?: any[][] | undefined, environmentInjector?: EnvironmentInjector | NgModuleRef | undefined, - directives?: Type[], + directives?: (Type | DirectiveWithBindings)[], + bindings?: Binding[], ): ComponentRef; override createComponent( componentFactoryOrType: ComponentFactory | Type, @@ -457,12 +464,14 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { ngModuleRef?: NgModuleRef; environmentInjector?: EnvironmentInjector | NgModuleRef; projectableNodes?: Node[][]; - directives?: Type[]; + directives?: (Type | DirectiveWithBindings)[]; + bindings?: Binding[]; }, injector?: Injector | undefined, projectableNodes?: any[][] | undefined, environmentInjector?: EnvironmentInjector | NgModuleRef | undefined, - directives?: Type[], + directives?: (Type | DirectiveWithBindings)[], + bindings?: Binding[], ): ComponentRef { const isComponentFactory = componentFactoryOrType && !isType(componentFactoryOrType); let index: number | undefined; @@ -507,7 +516,8 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { ngModuleRef?: NgModuleRef; environmentInjector?: EnvironmentInjector | NgModuleRef; projectableNodes?: Node[][]; - directives?: Type[]; + directives?: (Type | DirectiveWithBindings)[]; + bindings?: Binding[]; }; if (ngDevMode && options.environmentInjector && options.ngModuleRef) { throwError( @@ -519,6 +529,7 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { projectableNodes = options.projectableNodes; environmentInjector = options.environmentInjector || options.ngModuleRef; directives = options.directives; + bindings = options.bindings; } const componentFactory: ComponentFactory = isComponentFactory @@ -564,6 +575,7 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { rNode, environmentInjector, directives, + bindings, ); this.insertImpl( componentRef.hostView, diff --git a/packages/core/src/render3/component.ts b/packages/core/src/render3/component.ts index a8d50fc3ed6d..7d884988fc5b 100644 --- a/packages/core/src/render3/component.ts +++ b/packages/core/src/render3/component.ts @@ -13,6 +13,7 @@ import {ComponentRef} from '../linker/component_factory'; import {ComponentFactory} from './component_ref'; import {getComponentDef} from './def_getters'; +import {Binding, DirectiveWithBindings} from './dynamic_bindings'; import {assertComponentDef} from './errors'; /** @@ -74,6 +75,7 @@ import {assertComponentDef} from './errors'; * `[[element1, element2], [element3]]`: projects `element1` and `element2` into one ``, * and `element3` into a separate ``. * * `directives` (optional): Directives that should be applied to the component. + * * `binding` (optional): Bindings to apply to the root component. * @returns ComponentRef instance that represents a given Component. * * @publicApi @@ -85,7 +87,8 @@ export function createComponent( hostElement?: Element; elementInjector?: Injector; projectableNodes?: Node[][]; - directives?: Type[]; + directives?: (Type | DirectiveWithBindings)[]; + bindings?: Binding[]; }, ): ComponentRef { ngDevMode && assertComponentDef(component); @@ -98,6 +101,7 @@ export function createComponent( options.hostElement, options.environmentInjector, options.directives, + options.bindings, ); } diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index 859185b4b668..a01d501b9e32 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -16,7 +16,7 @@ import { import {Injector} from '../di/injector'; import {EnvironmentInjector} from '../di/r3_injector'; import {RuntimeError, RuntimeErrorCode} from '../errors'; -import {Type} from '../interface/type'; +import {Type, Writable} from '../interface/type'; import { ComponentFactory as AbstractComponentFactory, ComponentRef as AbstractComponentRef, @@ -40,7 +40,7 @@ import { locateHostElement, setAllInputsForProperty, } from './instructions/shared'; -import {ComponentDef, DirectiveDef} from './interfaces/definition'; +import {ComponentDef, ComponentTemplate, DirectiveDef, RenderFlags} from './interfaces/definition'; import {InputFlags} from './interfaces/input_flags'; import {TContainerNode, TElementContainerNode, TElementNode, TNode} from './interfaces/node'; import {RElement, RNode} from './interfaces/renderer_dom'; @@ -50,6 +50,7 @@ import { LView, LViewEnvironment, LViewFlags, + TView, TVIEW, TViewType, } from './interfaces/view'; @@ -73,6 +74,7 @@ import {getComponentLViewByIndex, getTNode} from './util/view_utils'; import {elementEndFirstCreatePass, elementStartFirstCreatePass} from './view/elements'; import {ViewRef} from './view_ref'; import {createLView, createTView, getInitialLViewFlagsFromDef} from './view/construction'; +import {BINDING, Binding, DirectiveWithBindings} from './dynamic_bindings'; export class ComponentFactoryResolver extends AbstractComponentFactoryResolver { /** @@ -231,7 +233,8 @@ export class ComponentFactory extends AbstractComponentFactory { projectableNodes?: any[][] | undefined, rootSelectorOrNode?: any, environmentInjector?: NgModuleRef | EnvironmentInjector | undefined, - directives?: Type[], + directives?: (Type | DirectiveWithBindings)[], + componentBindings?: Binding[], ): AbstractComponentRef { profiler(ProfilerEvent.DynamicComponentStart); @@ -240,25 +243,7 @@ export class ComponentFactory extends AbstractComponentFactory { const cmpDef = this.componentDef; ngDevMode && verifyNotAnOrphanComponent(cmpDef); - const tAttributes = rootSelectorOrNode - ? ['ng-version', '0.0.0-PLACEHOLDER'] - : // Extract attributes and classes from the first selector only to match VE behavior. - extractAttrsAndClassesFromSelector(this.componentDef.selectors[0]); - // Create the root view. Uses empty TView and ContentTemplate. - const rootTView = createTView( - TViewType.Root, - null, - null, - 1, - 0, - null, - null, - null, - null, - [tAttributes], - null, - ); - + const rootTView = createRootTView(rootSelectorOrNode, cmpDef, componentBindings, directives); const rootViewInjector = createRootViewInjector( cmpDef, environmentInjector || this.ngModule, @@ -293,7 +278,8 @@ export class ComponentFactory extends AbstractComponentFactory { const directivesToApply: DirectiveDef[] = [this.componentDef]; if (directives) { - for (const directiveType of directives) { + for (const directive of directives) { + const directiveType = typeof directive === 'function' ? directive : directive.type; const directiveDef = getDirectiveDef(directiveType, true); if (ngDevMode && !directiveDef.standalone) { @@ -376,6 +362,98 @@ export class ComponentFactory extends AbstractComponentFactory { } } +function createRootTView( + rootSelectorOrNode: any, + componentDef: ComponentDef, + componentBindings: Binding[] | undefined, + directives: (Type | DirectiveWithBindings)[] | undefined, +): TView { + const tAttributes = rootSelectorOrNode + ? ['ng-version', '0.0.0-PLACEHOLDER'] + : // Extract attributes and classes from the first selector only to match VE behavior. + extractAttrsAndClassesFromSelector(componentDef.selectors[0]); + let creationBindings: Binding[] | null = null; + let updateBindings: Binding[] | null = null; + let varsToAllocate = 0; + + if (componentBindings) { + for (const binding of componentBindings) { + varsToAllocate += binding[BINDING].requiredVars; + + if (binding.create) { + (binding as Writable).target = componentDef; + (creationBindings ??= []).push(binding); + } + + if (binding.update) { + (binding as Writable).target = componentDef; + (updateBindings ??= []).push(binding); + } + } + } + + if (directives) { + for (const directive of directives) { + if (typeof directive !== 'function') { + const def: DirectiveDef = getDirectiveDef(directive.type, true); + + for (const binding of directive.bindings) { + varsToAllocate += binding[BINDING].requiredVars; + + if (binding.create) { + (binding as Writable).target = def; + (creationBindings ??= []).push(binding); + } + + if (binding.update) { + (binding as Writable).target = def; + (updateBindings ??= []).push(binding); + } + } + } + } + } + + const rootTView = createTView( + TViewType.Root, + null, + getRootTViewTemplate(creationBindings, updateBindings), + 1, + varsToAllocate, + null, + null, + null, + null, + [tAttributes], + null, + ); + + return rootTView; +} + +function getRootTViewTemplate( + creationBindings: Binding[] | null, + updateBindings: Binding[] | null, +): ComponentTemplate | null { + if (!creationBindings && !updateBindings) { + return null; + } + + return (flags) => { + if (flags & RenderFlags.Create && creationBindings) { + for (const binding of creationBindings) { + binding.create!(); + } + } + + if (flags & RenderFlags.Update && updateBindings) { + for (const binding of updateBindings) { + binding.update!(); + } + } + }; +} + /** * Represents an instance of a Component created via a {@link ComponentFactory}. * diff --git a/packages/core/test/acceptance/create_component_spec.ts b/packages/core/test/acceptance/create_component_spec.ts index 3002c25cfef5..14d05ca10996 100644 --- a/packages/core/test/acceptance/create_component_spec.ts +++ b/packages/core/test/acceptance/create_component_spec.ts @@ -19,8 +19,13 @@ import { InjectionToken, Injector, Input, + inputBinding, NgModule, + OnChanges, OnDestroy, + signal, + SimpleChange, + SimpleChanges, Type, ViewChild, } from '@angular/core'; @@ -563,6 +568,29 @@ describe('createComponent', () => { }).toThrowError(/Type NotADir does not have 'ɵdir' property/); }); + it('should throw if a non-directive class is attached using the DirectiveWithBinding syntax', () => { + class NotADir {} + + @Component({template: ''}) + class HostComponent {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => { + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [ + { + type: NotADir, + bindings: [], + }, + ], + }); + }).toThrowError(/Type NotADir does not have 'ɵdir' property/); + }); + it('should throw if a component class is attached', () => { @Component({template: '', standalone: true}) class NotADir {} @@ -604,6 +632,460 @@ describe('createComponent', () => { }); }); + describe('root component inputs', () => { + it('should be able to bind to inputs of the root component', () => { + @Component({template: '{{one}} - {{two}} - {{other}}'}) + class RootComp { + @Input() one = ''; + @Input({alias: 'twoAlias'}) two = ''; + other = 'other'; + } + + const oneValue = signal('initial'); + let twoValue = 'initial'; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [inputBinding('one', oneValue), inputBinding('twoAlias', () => twoValue)], + }); + ref.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('initial - initial - other'); + + oneValue.set('1'); + ref.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('1 - initial - other'); + + twoValue = '1'; + ref.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('1 - 1 - other'); + + oneValue.set('2'); + twoValue = '2'; + ref.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('2 - 2 - other'); + }); + + it('should not bind root component inputs to directives', () => { + let dirInstance!: RootDir; + + @Directive() + class RootDir { + @Input() someInput = ''; + + constructor() { + dirInstance = this; + } + } + + @Component({template: ''}) + class RootComp { + @Input() someInput = ''; + } + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + directives: [RootDir], + bindings: [inputBinding('someInput', value)], + }); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe('initial'); + expect(dirInstance.someInput).toBe(''); + + value.set('changed'); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe('changed'); + expect(dirInstance.someInput).toBe(''); + }); + + it('should bind root component inputs to host directives of the root component, in addition to the component itself', () => { + let hostDirInstance!: RootHostDir; + let dirInstance!: RootDir; + + @Directive() + class RootDir { + @Input() someInput = ''; + + constructor() { + dirInstance = this; + } + } + + @Directive() + class RootHostDir { + @Input() someInput = ''; + + constructor() { + hostDirInstance = this; + } + } + + @Component({ + template: '', + hostDirectives: [{directive: RootHostDir, inputs: ['someInput']}], + }) + class RootComp { + @Input() someInput = ''; + } + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + directives: [RootDir], + bindings: [inputBinding('someInput', value)], + }); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe('initial'); + expect(hostDirInstance.someInput).toBe('initial'); + expect(dirInstance.someInput).toBe(''); + + value.set('changed'); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe('changed'); + expect(hostDirInstance.someInput).toBe('changed'); + expect(dirInstance.someInput).toBe(''); + }); + + it('should bind to inputs of host directives of directives applied to the root component', () => { + let hostDirInstance!: RootHostDir; + + @Directive() + class RootHostDir { + @Input() someInput = ''; + + constructor() { + hostDirInstance = this; + } + } + + @Directive({ + hostDirectives: [ + { + directive: RootHostDir, + inputs: ['someInput: alias'], + }, + ], + }) + class RootDir {} + + @Component({template: ''}) + class RootComp {} + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + directives: [ + { + type: RootDir, + bindings: [inputBinding('alias', value)], + }, + ], + }); + ref.changeDetectorRef.detectChanges(); + expect(hostDirInstance.someInput).toBe('initial'); + + value.set('changed'); + ref.changeDetectorRef.detectChanges(); + expect(hostDirInstance.someInput).toBe('changed'); + }); + + it('should bind to aliased inputs of host directives of the root component', () => { + let dirInstance!: RootHostDir; + + @Directive() + class RootHostDir { + @Input({alias: 'someAlias'}) someInput = ''; + + constructor() { + dirInstance = this; + } + } + + @Component({ + template: '', + hostDirectives: [{directive: RootHostDir, inputs: ['someAlias: alias']}], + }) + class RootComp {} + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [inputBinding('alias', value)], + }); + ref.changeDetectorRef.detectChanges(); + expect(dirInstance.someInput).toBe('initial'); + + value.set('changed'); + ref.changeDetectorRef.detectChanges(); + expect(dirInstance.someInput).toBe('changed'); + }); + + it('should bind input to directives, but not the root component', () => { + let dir1Instance!: RootDir1; + let dir2Instance!: RootDir2; + + @Directive() + class RootDir1 { + @Input() someInput = ''; + + constructor() { + dir1Instance = this; + } + } + + @Directive() + class RootDir2 { + @Input() someOtherInput = ''; + + constructor() { + dir2Instance = this; + } + } + + @Component({template: ''}) + class RootComp { + @Input() someInput = ''; + @Input() someOtherInput = ''; + } + + const oneValue = signal('initial'); + const twoValue = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + directives: [ + { + type: RootDir1, + bindings: [inputBinding('someInput', oneValue)], + }, + { + type: RootDir2, + bindings: [inputBinding('someOtherInput', twoValue)], + }, + ], + }); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe(''); + expect(ref.instance.someOtherInput).toBe(''); + expect(dir1Instance.someInput).toBe('initial'); + expect(dir2Instance.someOtherInput).toBe('initial'); + + oneValue.set('one changed'); + twoValue.set('two changed'); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe(''); + expect(ref.instance.someOtherInput).toBe(''); + expect(dir1Instance.someInput).toBe('one changed'); + expect(dir2Instance.someOtherInput).toBe('two changed'); + }); + + it('should invoke ngOnChanges when binding to a root component input', () => { + const changes: SimpleChange[] = []; + + @Component({template: ''}) + class RootComp implements OnChanges { + @Input() someInput = ''; + + ngOnChanges(c: SimpleChanges) { + changes.push(c['someInput']); + } + } + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [inputBinding('someInput', value)], + }); + ref.changeDetectorRef.detectChanges(); + expect(changes).toEqual([ + jasmine.objectContaining({ + firstChange: true, + previousValue: undefined, + currentValue: 'initial', + }), + ]); + + value.set('1'); + ref.changeDetectorRef.detectChanges(); + expect(changes).toEqual([ + jasmine.objectContaining({ + firstChange: true, + previousValue: undefined, + currentValue: 'initial', + }), + jasmine.objectContaining({ + firstChange: false, + previousValue: 'initial', + currentValue: '1', + }), + ]); + }); + + it('should transform input bound to the root component', () => { + @Component({template: ''}) + class RootComp { + @Input({transform: (value: string) => parseInt(value)}) someInput = -1; + } + + const value = signal(0); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [inputBinding('someInput', value)], + }); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe(0); + + value.set(1); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe(1); + }); + + it('should bind different values to inputs that all have the same name', () => { + let dir1Instance!: RootDir1; + let dir2Instance!: RootDir2; + + @Directive() + class RootDir1 { + @Input() someInput = ''; + + constructor() { + dir1Instance = this; + } + } + + @Directive() + class RootDir2 { + @Input() someInput = ''; + + constructor() { + dir2Instance = this; + } + } + + @Component({template: ''}) + class RootComp { + @Input() someInput = ''; + } + + const rootValue = signal('initial'); + const oneValue = signal('initial'); + const twoValue = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [inputBinding('someInput', rootValue)], + directives: [ + { + type: RootDir1, + bindings: [inputBinding('someInput', oneValue)], + }, + { + type: RootDir2, + bindings: [inputBinding('someInput', twoValue)], + }, + ], + }); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe('initial'); + expect(dir1Instance.someInput).toBe('initial'); + expect(dir2Instance.someInput).toBe('initial'); + + rootValue.set('root changed'); + oneValue.set('one changed'); + twoValue.set('two changed'); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe('root changed'); + expect(dir1Instance.someInput).toBe('one changed'); + expect(dir2Instance.someInput).toBe('two changed'); + }); + + it('should only invoke setters if the value has changed', () => { + let setterCount = 0; + + @Component({template: ''}) + class RootComp { + @Input() + set someInput(_: string) { + setterCount++; + } + } + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [inputBinding('someInput', value)], + }); + expect(setterCount).toBe(0); + + ref.changeDetectorRef.detectChanges(); + expect(setterCount).toBe(1); + ref.changeDetectorRef.detectChanges(); + expect(setterCount).toBe(1); + + value.set('changed'); + ref.changeDetectorRef.detectChanges(); + expect(setterCount).toBe(2); + ref.changeDetectorRef.detectChanges(); + expect(setterCount).toBe(2); + }); + + it('should throw if target does not have an input with a specific name', () => { + @Component({template: ''}) + class RootComp { + @Input() someInput = ''; + } + + @Directive() + class RootDir {} + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + directives: [ + { + type: RootDir, + // `someInput` exists on `RootComp`, but not `RootDir`. + bindings: [inputBinding('someInput', value)], + }, + ], + }); + + expect(() => { + ref.changeDetectorRef.detectChanges(); + }).toThrowError(/RootDir does not have an input with a public name of "someInput"/); + }); + }); + describe('error checking', () => { it('should throw when provided class is not a component', () => { class NotAComponent {} diff --git a/packages/core/test/acceptance/view_container_ref_spec.ts b/packages/core/test/acceptance/view_container_ref_spec.ts index 9693db95e7c7..00e0bcf1c5cb 100644 --- a/packages/core/test/acceptance/view_container_ref_spec.ts +++ b/packages/core/test/acceptance/view_container_ref_spec.ts @@ -23,6 +23,7 @@ import { InjectionToken, Injector, Input, + inputBinding, NgModule, NgModuleRef, NO_ERRORS_SCHEMA, @@ -35,6 +36,7 @@ import { RendererFactory2, RendererType2, Sanitizer, + signal, TemplateRef, ViewChild, ViewChildren, @@ -1859,6 +1861,68 @@ describe('ViewContainerRef', () => { ); }); + it('should support binding to inputs of a component', () => { + let dirInstance!: Dir; + + @Directive({selector: '[dir]'}) + class Dir { + @Input() dirInput = ''; + + constructor() { + dirInstance = this; + } + } + + @Component({ + template: 'Value: {{hostInput}}', + standalone: false, + }) + class HostComponent { + @Input() hostInput = ''; + } + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + declarations: [EmbeddedViewInsertionComp, VCRefDirective, HostComponent], + }); + const hostValue = signal('initial'); + let dirValue = 'initial'; + const fixture = TestBed.createComponent(EmbeddedViewInsertionComp); + const vcRefDir = fixture.debugElement + .query(By.directive(VCRefDirective)) + .injector.get(VCRefDirective); + fixture.detectChanges(); + + const ref = vcRefDir.vcref.createComponent(HostComponent, { + index: 0, + bindings: [inputBinding('hostInput', hostValue)], + directives: [ + { + type: Dir, + bindings: [inputBinding('dirInput', () => dirValue)], + }, + ], + }); + fixture.detectChanges(); + + expect(ref.instance.hostInput).toBe('initial'); + expect(dirInstance.dirInput).toBe('initial'); + expect(fixture.nativeElement.textContent).toContain('Value: initial'); + + hostValue.set('host changed'); + fixture.detectChanges(); + + expect(ref.instance.hostInput).toBe('host changed'); + expect(dirInstance.dirInput).toBe('initial'); + expect(fixture.nativeElement.textContent).toContain('Value: host changed'); + + dirValue = 'dir changed'; + fixture.detectChanges(); + expect(ref.instance.hostInput).toBe('host changed'); + expect(dirInstance.dirInput).toBe('dir changed'); + expect(fixture.nativeElement.textContent).toContain('Value: host changed'); + }); + describe('`options` argument handling', () => { it('should work correctly when an empty object is provided', () => { fixture.componentInstance.viewContainerRef.createComponent(ChildA, {}); diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index 61b4ad5af265..96610adcc6bf 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -23,6 +23,7 @@ "AnonymousSubject", "ApplicationInitStatus", "ApplicationRef", + "BINDING", "BROWSER_ANIMATIONS_PROVIDERS", "BROWSER_MODULE_PROVIDERS", "BaseAnimationRenderer", diff --git a/packages/core/test/bundling/animations/bundle.golden_symbols.json b/packages/core/test/bundling/animations/bundle.golden_symbols.json index dbbf9076feaa..1814a52b4ce3 100644 --- a/packages/core/test/bundling/animations/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations/bundle.golden_symbols.json @@ -26,6 +26,7 @@ "ApplicationInitStatus", "ApplicationModule", "ApplicationRef", + "BINDING", "BROWSER_ANIMATIONS_PROVIDERS", "BROWSER_MODULE_PROVIDERS", "BROWSER_NOOP_ANIMATIONS_PROVIDERS", diff --git a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json index 3f230a261965..385aec05b3c1 100644 --- a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json +++ b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json @@ -8,6 +8,7 @@ "ApplicationInitStatus", "ApplicationModule", "ApplicationRef", + "BINDING", "BROWSER_MODULE_PROVIDERS", "BehaviorSubject", "BrowserDomAdapter", diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index f6c662956588..dcc121b9980f 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -11,6 +11,7 @@ "AppComponent_Defer_6_DepsFn", "ApplicationInitStatus", "ApplicationRef", + "BINDING", "BLOOM_BUCKET_BITS", "BLOOM_MASK", "BROWSER_MODULE_PROVIDERS", @@ -474,6 +475,7 @@ "init_dom", "init_dom_node_manipulation", "init_dom_triggers", + "init_dynamic_bindings", "init_earlyeventcontract", "init_effect", "init_element", @@ -893,6 +895,7 @@ "wasLastNodeCreated", "writeToDirectiveInput", "ɵɵdefer", + "ɵɵdeferWhen", "ɵɵdefineComponent", "ɵɵdefineInjectable", "ɵɵdirectiveInject", diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index 06b62bc51537..133c821988f3 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -12,6 +12,7 @@ "ApplicationInitStatus", "ApplicationModule", "ApplicationRef", + "BINDING", "BROWSER_MODULE_PROVIDERS", "BaseControlValueAccessor", "BehaviorSubject", diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 103f0136b777..9407583c3655 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -13,6 +13,7 @@ "ApplicationInitStatus", "ApplicationModule", "ApplicationRef", + "BINDING", "BROWSER_MODULE_PROVIDERS", "BaseControlValueAccessor", "BehaviorSubject", diff --git a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json index fc688c9ba2c6..0b4c00d45b1e 100644 --- a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json @@ -6,6 +6,7 @@ "AnonymousSubject", "ApplicationInitStatus", "ApplicationRef", + "BINDING", "BehaviorSubject", "BrowserDomAdapter", "CIRCULAR", diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 53c4d483dcbd..2f7ac1395f39 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -7,6 +7,7 @@ "AnonymousSubject", "ApplicationInitStatus", "ApplicationRef", + "BINDING", "BODY", "BROWSER_MODULE_PROVIDERS", "BehaviorSubject", @@ -456,5 +457,6 @@ "ɵɵdefineComponent", "ɵɵdefineInjectable", "ɵɵdirectiveInject", - "ɵɵinject" + "ɵɵinject", + "ɵɵtext" ] \ No newline at end of file diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index d853c0aad8a7..594ae24390a9 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -14,6 +14,7 @@ "ApplicationInitStatus", "ApplicationRef", "ApplyRedirects", + "BINDING", "BOOTSTRAP_DONE", "BROWSER_MODULE_PROVIDERS", "BaseRouteReuseStrategy", diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index a4e8500cd3c9..9106e643e8f0 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -6,6 +6,7 @@ "AnonymousSubject", "ApplicationInitStatus", "ApplicationRef", + "BINDING", "BROWSER_MODULE_PROVIDERS", "BehaviorSubject", "BrowserDomAdapter", @@ -379,5 +380,6 @@ "ɵɵdefineComponent", "ɵɵdefineInjectable", "ɵɵdirectiveInject", - "ɵɵinject" + "ɵɵinject", + "ɵɵtext" ] \ No newline at end of file diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index 5f5559875de1..89b63db7c181 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -8,6 +8,7 @@ "ApplicationInitStatus", "ApplicationModule", "ApplicationRef", + "BINDING", "BROWSER_MODULE_PROVIDERS", "BehaviorSubject", "BrowserDomAdapter", From 7f326a72f3fbfa6bd8ad0e24bc7b19a112d814b6 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 27 Feb 2025 08:17:29 +0100 Subject: [PATCH 08/10] fix(core): do not allow setInput to be used with inputBinding Calling `setInput` while the component already has an `inputBinding` active can lead to inconsistent state. These changes add an error that will be thrown if that's the case. --- goldens/public-api/core/errors.api.md | 2 ++ packages/core/src/errors.ts | 1 + packages/core/src/render3/component_ref.ts | 19 ++++++++++++++-- .../test/acceptance/create_component_spec.ts | 22 +++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/goldens/public-api/core/errors.api.md b/goldens/public-api/core/errors.api.md index 4f575ac92e86..9832a0741152 100644 --- a/goldens/public-api/core/errors.api.md +++ b/goldens/public-api/core/errors.api.md @@ -79,6 +79,8 @@ export const enum RuntimeErrorCode { // (undocumented) INVALID_MULTI_PROVIDER = -209, // (undocumented) + INVALID_SET_INPUT_CALL = 317, + // (undocumented) INVALID_SKIP_HYDRATION_HOST = -504, // (undocumented) LOOP_TRACK_DUPLICATE_KEYS = -955, diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index ff71b1d956a4..afeda548b34b 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -62,6 +62,7 @@ export const enum RuntimeErrorCode { UNINITIALIZED_LET_ACCESS = 314, NO_BINDING_TARGET = 315, INVALID_BINDING_TARGET = 316, + INVALID_SET_INPUT_CALL = 317, // Bootstrap Errors MULTIPLE_PLATFORMS = 400, diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index a01d501b9e32..aab486edd64a 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -260,6 +260,9 @@ export class ComponentFactory extends AbstractComponentFactory { rootViewInjector, ) : createHostElement(cmpDef, hostRenderer); + const hasInputBindings = + componentBindings?.some(isInputBinding) || + directives?.some((d) => typeof d !== 'function' && d.bindings.some(isInputBinding)); const rootLView = createLView( null, @@ -355,7 +358,7 @@ export class ComponentFactory extends AbstractComponentFactory { leaveView(); } - return new ComponentRef(this.componentType, rootLView); + return new ComponentRef(this.componentType, rootLView, !!hasInputBindings); } finally { setActiveConsumer(prevConsumer); } @@ -454,6 +457,10 @@ function getRootTViewTemplate( }; } +function isInputBinding(binding: Binding): boolean { + return binding[BINDING].kind === 'input'; +} + /** * Represents an instance of a Component created via a {@link ComponentFactory}. * @@ -473,7 +480,8 @@ export class ComponentRef extends AbstractComponentRef { constructor( componentType: Type, - private _rootLView: LView, + private readonly _rootLView: LView, + private readonly _hasInputBindings: boolean, ) { super(); this._tNode = getTNode(_rootLView[TVIEW], HEADER_OFFSET) as TElementNode; @@ -487,6 +495,13 @@ export class ComponentRef extends AbstractComponentRef { } override setInput(name: string, value: unknown): void { + if (this._hasInputBindings && ngDevMode) { + throw new RuntimeError( + RuntimeErrorCode.INVALID_SET_INPUT_CALL, + 'Cannot call `setInput` on a component that is using the `inputBinding` function.', + ); + } + const tNode = this._tNode; this.previousInputValues ??= new Map(); // Do not set the input if it is the same as the last value diff --git a/packages/core/test/acceptance/create_component_spec.ts b/packages/core/test/acceptance/create_component_spec.ts index 14d05ca10996..66d4c48d4dab 100644 --- a/packages/core/test/acceptance/create_component_spec.ts +++ b/packages/core/test/acceptance/create_component_spec.ts @@ -1084,6 +1084,28 @@ describe('createComponent', () => { ref.changeDetectorRef.detectChanges(); }).toThrowError(/RootDir does not have an input with a public name of "someInput"/); }); + + it('should throw when using setInput on a component already using inputBindings', () => { + @Component({template: ''}) + class RootComp { + @Input() someInput = ''; + } + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [inputBinding('someInput', () => 'hello')], + }); + ref.changeDetectorRef.detectChanges(); + + expect(() => { + ref.setInput('someInput', 'changed'); + }).toThrowError( + /Cannot call `setInput` on a component that is using the `inputBinding` function/, + ); + }); }); describe('error checking', () => { From 79530b16c551fc31dc4c33172774340bfa706da1 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 27 Feb 2025 11:06:15 +0100 Subject: [PATCH 09/10] feat(core): support listening to outputs on dynamically-created components Adds the new `outputBinding` function that allows users to listen to outputs on dynamically-created components in a similar way to templates. For example, here we create an instance of `MyCheckbox` and listen to its `onChange` event: ```ts interface CheckboxChange { value: string; } createComponent(MyCheckbox, { bindings: [ outputBinding('onChange', event => console.log(event.value)) ], }); ``` Note that while it has always been possible to listen to events like this by getting a hold of of the instance and subscribing to it, there are a few key differences: 1. `outputBinding` behaves in the same way as if the event was bound in a template which comes with some behaviors like forwarding errors to the `ErrorHandler` and marking the view as dirty. 2. With `outputBinding` the listeners will be cleaned up automatically when the component is destroyed. 3. `outputBinding` accounts for host directive outputs by binding to them through the host. E.g. if the `onChange` event above was coming from a host directive, `outputBinding` would bind to it automatically. Currently `outputBinding` is available only in `createComponent`, `ViewContainerRef.createComponent` and `ComponentFactory.create`, but it will serve as a base for APIs in the future. --- goldens/public-api/core/index.api.md | 3 + .../size-tracking/integration-payloads.json | 2 +- packages/core/src/core.ts | 2 +- packages/core/src/render3/dynamic_bindings.ts | 72 ++++- .../core/src/render3/instructions/listener.ts | 79 +++++- .../test/acceptance/create_component_spec.ts | 261 ++++++++++++++++++ .../bundle.golden_symbols.json | 1 + .../animations/bundle.golden_symbols.json | 1 + .../cyclic_import/bundle.golden_symbols.json | 1 + .../bundling/defer/bundle.golden_symbols.json | 1 + .../forms_reactive/bundle.golden_symbols.json | 1 + .../bundle.golden_symbols.json | 1 + .../hello_world/bundle.golden_symbols.json | 1 + .../hydration/bundle.golden_symbols.json | 1 + .../router/bundle.golden_symbols.json | 1 + .../bundle.golden_symbols.json | 1 + .../bundling/todo/bundle.golden_symbols.json | 1 + 17 files changed, 425 insertions(+), 5 deletions(-) diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index 1774e4042761..d9d980d6f84d 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -1363,6 +1363,9 @@ export const Output: OutputDecorator; // @public export function output(opts?: OutputOptions): OutputEmitterRef; +// @public +export function outputBinding(eventName: string, listener: (event: T) => unknown): Binding; + // @public export interface OutputDecorator { (alias?: string): any; diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index a1885374cef1..4a1625e16536 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -1,7 +1,7 @@ { "cli-hello-world": { "uncompressed": { - "main": 132425, + "main": 137893, "polyfills": 33792 } }, diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 684268c85c6e..2bab7ce4e521 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -112,7 +112,7 @@ export { afterNextRender, ɵFirstAvailable, } from './render3/after_render/hooks'; -export {inputBinding} from './render3/dynamic_bindings'; +export {inputBinding, outputBinding} from './render3/dynamic_bindings'; export {ApplicationConfig, mergeApplicationConfig} from './application/application_config'; export {makeStateKey, StateKey, TransferState} from './transfer_state'; export {booleanAttribute, numberAttribute} from './util/coercion'; diff --git a/packages/core/src/render3/dynamic_bindings.ts b/packages/core/src/render3/dynamic_bindings.ts index 75513e6c904a..a35423660bb4 100644 --- a/packages/core/src/render3/dynamic_bindings.ts +++ b/packages/core/src/render3/dynamic_bindings.ts @@ -9,9 +9,11 @@ import {RuntimeError, RuntimeErrorCode} from '../errors'; import {Type} from '../interface/type'; import {bindingUpdated} from './bindings'; +import {listenToDirectiveOutput, wrapListener} from './instructions/listener'; import {setDirectiveInput, storePropertyBindingMetadata} from './instructions/shared'; import {DirectiveDef} from './interfaces/definition'; -import {getLView, getSelectedTNode, getTView, nextBindingIndex} from './state'; +import {CONTEXT} from './interfaces/view'; +import {getCurrentTNode, getLView, getSelectedTNode, getTView, nextBindingIndex} from './state'; /** Symbol used to store and retrieve metadata about a binding. */ export const BINDING = /* @__PURE__ */ Symbol('BINDING'); @@ -47,8 +49,9 @@ export interface DirectiveWithBindings { bindings: Binding[]; } -// This is constant between all the bindings so we can reuse the object. +// These are constant between all the bindings so we can reuse the objects. const INPUT_BINDING_METADATA: Binding[typeof BINDING] = {kind: 'input', requiredVars: 1}; +const OUTPUT_BINDING_METADATA: Binding[typeof BINDING] = {kind: 'output', requiredVars: 0}; class InputBinding implements Binding { readonly target!: DirectiveDef; @@ -89,6 +92,46 @@ class InputBinding implements Binding { } } +class OutputBinding implements Binding { + readonly target!: DirectiveDef; + readonly [BINDING] = OUTPUT_BINDING_METADATA; + + constructor( + private readonly eventName: string, + private readonly listener: (event: T) => unknown, + ) {} + + create(): void { + if (!this.target && ngDevMode) { + throw new RuntimeError( + RuntimeErrorCode.NO_BINDING_TARGET, + `Output binding to "${this.eventName}" does not have a target.`, + ); + } + + const lView = getLView<{} | null>(); + const tView = getTView(); + const tNode = getCurrentTNode()!; + const context = lView[CONTEXT]; + const wrappedListener = wrapListener(tNode, lView, context, this.listener); + const hasBound = listenToDirectiveOutput( + tNode, + tView, + lView, + this.target, + this.eventName, + wrappedListener, + ); + + if (!hasBound && ngDevMode) { + throw new RuntimeError( + RuntimeErrorCode.INVALID_BINDING_TARGET, + `${this.target.type.name} does not have an output with a public name of "${this.eventName}".`, + ); + } + } +} + /** * Creates an input binding. * @param publicName Public name of the input to bind to. @@ -110,3 +153,28 @@ class InputBinding implements Binding { export function inputBinding(publicName: string, value: () => unknown): Binding { return new InputBinding(publicName, value); } + +/** + * Creates an output binding. + * @param eventName Public name of the output to listen to. + * @param listener Function to be called when the output emits. + * + * ### Usage example + * In this example we create an instance of the `MyCheckbox` component and listen + * to its `onChange` event. + * + * ``` + * interface CheckboxChange { + * value: string; + * } + * + * createComponent(MyCheckbox, { + * bindings: [ + * outputBinding('onChange', event => console.log(event.value)) + * ], + * }); + * ``` + */ +export function outputBinding(eventName: string, listener: (event: T) => unknown): Binding { + return new OutputBinding(eventName, listener); +} diff --git a/packages/core/src/render3/instructions/listener.ts b/packages/core/src/render3/instructions/listener.ts index e057dbc1639a..93dfeacf68fb 100644 --- a/packages/core/src/render3/instructions/listener.ts +++ b/packages/core/src/render3/instructions/listener.ts @@ -328,7 +328,7 @@ function executeListenerWithErrorHandling( * @param wrapWithPreventDefault Whether or not to prevent default behavior * (the procedural renderer does this already, so in those cases, we should skip) */ -function wrapListener( +export function wrapListener( tNode: TNode, lView: LView<{} | null>, context: {} | null, @@ -378,3 +378,80 @@ function isOutputSubscribable(value: unknown): value is SubscribableOutput>).subscribe === 'function' ); } + +/** Listens to an output on a specific directive. */ +export function listenToDirectiveOutput( + tNode: TNode, + tView: TView, + lView: LView, + target: DirectiveDef, + eventName: string, + listenerFn: (e?: any) => any, +): boolean { + const tCleanup = tView.firstCreatePass ? getOrCreateTViewCleanup(tView) : null; + const lCleanup = getOrCreateLViewCleanup(lView); + let hostIndex: number | null = null; + let hostDirectivesStart: number | null = null; + let hostDirectivesEnd: number | null = null; + let hasOutput = false; + + if (ngDevMode && !tNode.directiveToIndex?.has(target.type)) { + throw new Error(`Node does not have a directive with type ${target.type.name}`); + } + + const data = tNode.directiveToIndex!.get(target.type)!; + + if (typeof data === 'number') { + hostIndex = data; + } else { + [hostIndex, hostDirectivesStart, hostDirectivesEnd] = data; + } + + if ( + hostDirectivesStart !== null && + hostDirectivesEnd !== null && + tNode.hostDirectiveOutputs?.hasOwnProperty(eventName) + ) { + const hostDirectiveOutputs = tNode.hostDirectiveOutputs[eventName]; + + for (let i = 0; i < hostDirectiveOutputs.length; i += 2) { + const index = hostDirectiveOutputs[i] as number; + + if (index >= hostDirectivesStart && index <= hostDirectivesEnd) { + ngDevMode && assertIndexInRange(lView, index); + hasOutput = true; + listenToOutput( + tNode, + tView, + lView, + index, + hostDirectiveOutputs[i + 1] as string, + eventName, + listenerFn, + lCleanup, + tCleanup, + ); + } else if (index > hostDirectivesEnd) { + break; + } + } + } + + if (hostIndex !== null && target.outputs.hasOwnProperty(eventName)) { + ngDevMode && assertIndexInRange(lView, hostIndex); + hasOutput = true; + listenToOutput( + tNode, + tView, + lView, + hostIndex, + eventName, + eventName, + listenerFn, + lCleanup, + tCleanup, + ); + } + + return hasOutput; +} diff --git a/packages/core/test/acceptance/create_component_spec.ts b/packages/core/test/acceptance/create_component_spec.ts index 66d4c48d4dab..eaa226c1d95d 100644 --- a/packages/core/test/acceptance/create_component_spec.ts +++ b/packages/core/test/acceptance/create_component_spec.ts @@ -14,6 +14,8 @@ import { ElementRef, EmbeddedViewRef, EnvironmentInjector, + ErrorHandler, + EventEmitter, inject, Injectable, InjectionToken, @@ -23,6 +25,8 @@ import { NgModule, OnChanges, OnDestroy, + Output, + outputBinding, signal, SimpleChange, SimpleChanges, @@ -1108,6 +1112,263 @@ describe('createComponent', () => { }); }); + describe('root component outputs', () => { + it('should be able to bind to outputs of the root component', () => { + @Component({template: ''}) + class RootComp { + @Output() event = new EventEmitter<{value: number}>(); + } + + const events: {value: number}[] = []; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [outputBinding<{value: number}>('event', (event) => events.push(event))], + }); + ref.changeDetectorRef.detectChanges(); + expect(events).toEqual([]); + + ref.instance.event.emit({value: 0}); + expect(events).toEqual([jasmine.objectContaining({value: 0})]); + + ref.instance.event.emit({value: 1}); + expect(events).toEqual([ + jasmine.objectContaining({value: 0}), + jasmine.objectContaining({value: 1}), + ]); + }); + + it('should clean up root component output listeners', () => { + @Component({template: ''}) + class RootComp { + @Output() event = new EventEmitter(); + } + + let count = 0; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [outputBinding('event', () => count++)], + }); + ref.changeDetectorRef.detectChanges(); + expect(count).toBe(0); + + ref.instance.event.emit(); + expect(count).toBe(1); + + ref.destroy(); + ref.instance.event.emit(); + expect(count).toBe(1); + }); + + it('should handle errors in root component listeners through the ErrorHandler', () => { + @Component({template: ''}) + class RootComp { + @Output() event = new EventEmitter(); + } + + TestBed.configureTestingModule({ + providers: [ + { + provide: ErrorHandler, + useValue: { + handleError: (error: Error) => errors.push(error.message), + }, + }, + ], + }); + const errors: string[] = []; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [ + outputBinding('event', () => { + throw new Error('oh no'); + }), + ], + }); + ref.changeDetectorRef.detectChanges(); + expect(errors).toEqual([]); + + ref.instance.event.emit(); + expect(errors).toEqual(['oh no']); + }); + + it('should listen to host directive outputs on the root component', () => { + let hostDirInstance!: RootHostDir; + let dirInstance!: RootDir; + + @Directive() + class RootHostDir { + @Output() myEvent = new EventEmitter(); + + constructor() { + hostDirInstance = this; + } + } + + @Component({ + template: '', + hostDirectives: [ + { + directive: RootHostDir, + outputs: ['myEvent: event'], + }, + ], + }) + class RootComp { + @Output() event = new EventEmitter(); + } + + @Directive() + class RootDir { + @Output() event = new EventEmitter(); + + constructor() { + dirInstance = this; + } + } + + const logs: string[] = []; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [outputBinding('event', (value) => logs.push(value))], + directives: [RootDir], + }); + ref.changeDetectorRef.detectChanges(); + expect(logs).toEqual([]); + + ref.instance.event.emit('component'); + expect(logs).toEqual(['component']); + + hostDirInstance.myEvent.emit('host directive'); + expect(logs).toEqual(['component', 'host directive']); + + dirInstance.event.emit('directive'); + expect(logs).toEqual(['component', 'host directive']); + }); + + it('should not listen to directive outputs with the same name as outputs on the root component', () => { + let dirInstance!: RootDir; + + @Component({template: ''}) + class RootComp { + @Output() event = new EventEmitter(); + } + + @Directive() + class RootDir { + @Output() event = new EventEmitter(); + + constructor() { + dirInstance = this; + } + } + + const logs: string[] = []; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [outputBinding('event', (value) => logs.push(value))], + directives: [RootDir], + }); + ref.changeDetectorRef.detectChanges(); + expect(logs).toEqual([]); + + ref.instance.event.emit('component'); + expect(logs).toEqual(['component']); + + dirInstance.event.emit('directive'); + expect(logs).toEqual(['component']); + }); + + it('should not listen to root component outputs with the same name as outputs on one of the directives', () => { + let dirInstance!: RootDir; + + @Component({template: ''}) + class RootComp { + @Output() event = new EventEmitter(); + } + + @Directive() + class RootDir { + @Output() event = new EventEmitter(); + + constructor() { + dirInstance = this; + } + } + + const logs: string[] = []; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + directives: [ + { + type: RootDir, + bindings: [outputBinding('event', (value) => logs.push(value))], + }, + ], + }); + ref.changeDetectorRef.detectChanges(); + expect(logs).toEqual([]); + + dirInstance.event.emit('directive'); + expect(logs).toEqual(['directive']); + + ref.instance.event.emit('component'); + expect(logs).toEqual(['directive']); + }); + + it('should throw if root component does not have an output with the specified name', () => { + @Component({template: ''}) + class RootComp {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => { + createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [outputBinding('click', () => {})], + }); + }).toThrowError(/RootComp does not have an output with a public name of "click"/); + }); + + it('should not listen to native event when creating an output binding', () => { + @Component({template: ''}) + class RootComp { + @Output() click = new EventEmitter(); + } + + const hostElement = document.createElement('button'); + const spy = spyOn(hostElement, 'addEventListener'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [outputBinding('click', () => {})], + }); + ref.changeDetectorRef.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + describe('error checking', () => { it('should throw when provided class is not a component', () => { class NotAComponent {} diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index 96610adcc6bf..07bb86c40151 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -371,6 +371,7 @@ "isEnvironmentProviders", "isFunction", "isInlineTemplate", + "isInputBinding", "isLContainer", "isLView", "isNodeMatchingSelector", diff --git a/packages/core/test/bundling/animations/bundle.golden_symbols.json b/packages/core/test/bundling/animations/bundle.golden_symbols.json index 1814a52b4ce3..bbfce89db393 100644 --- a/packages/core/test/bundling/animations/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations/bundle.golden_symbols.json @@ -394,6 +394,7 @@ "isEnvironmentProviders", "isFunction", "isInlineTemplate", + "isInputBinding", "isLContainer", "isLView", "isNodeMatchingSelector", diff --git a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json index 385aec05b3c1..31904f30c409 100644 --- a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json +++ b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json @@ -316,6 +316,7 @@ "isEnvironmentProviders", "isFunction", "isInlineTemplate", + "isInputBinding", "isLContainer", "isLView", "isNodeMatchingSelector", diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index dcc121b9980f..393d598ebca4 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -778,6 +778,7 @@ "isEnvironmentProviders", "isFunction", "isInlineTemplate", + "isInputBinding", "isLContainer", "isLView", "isNodeMatchingSelector", diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index 133c821988f3..0e2179c677a0 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -462,6 +462,7 @@ "isForwardRef", "isFunction", "isInlineTemplate", + "isInputBinding", "isInteropObservable", "isIterable", "isLContainer", diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 9407583c3655..7a91ed45e15c 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -448,6 +448,7 @@ "isForwardRef", "isFunction", "isInlineTemplate", + "isInputBinding", "isInteropObservable", "isIterable", "isLContainer", diff --git a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json index 0b4c00d45b1e..be8e3353c4e3 100644 --- a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json @@ -256,6 +256,7 @@ "isDetachedByI18n", "isEnvironmentProviders", "isFunction", + "isInputBinding", "isLContainer", "isLView", "isPositive", diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 2f7ac1395f39..7fa717a3c6e4 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -333,6 +333,7 @@ "isFunction", "isHydrationSupportEnabled", "isInSkipHydrationBlock", + "isInputBinding", "isInteropObservable", "isIterable", "isLContainer", diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 594ae24390a9..2be8fa88239a 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -539,6 +539,7 @@ "isFunction", "isFunction2", "isInlineTemplate", + "isInputBinding", "isInteropObservable", "isIterable", "isLContainer", diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 9106e643e8f0..3230ce79269d 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -283,6 +283,7 @@ "isDetachedByI18n", "isEnvironmentProviders", "isFunction", + "isInputBinding", "isLContainer", "isLView", "isPlatformServer", diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index 89b63db7c181..b5147e9f816b 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -377,6 +377,7 @@ "isEnvironmentProviders", "isFunction", "isInlineTemplate", + "isInputBinding", "isLContainer", "isLView", "isListLikeIterable", From 3311f2e152b67d11690ccc671cbaa58fe56ffa9e Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 3 Mar 2025 11:20:47 +0100 Subject: [PATCH 10/10] refactor(core): allow InputBinding and OutputBinding to be tree shaken Reworks the `InputBinding` and `OutputBinding` functionality to be in object literals constructed in functions, rather than classes, because it seems like Terser was having a hard time tree shaking the classes when the functions weren't used. --- goldens/public-api/core/index.api.md | 2 +- .../size-tracking/integration-payloads.json | 2 +- packages/core/src/render3/dynamic_bindings.ts | 160 +++++++++--------- .../bundling/defer/bundle.golden_symbols.json | 1 - .../hydration/bundle.golden_symbols.json | 3 +- .../bundle.golden_symbols.json | 3 +- 6 files changed, 82 insertions(+), 89 deletions(-) diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index d9d980d6f84d..aeb361446ace 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -997,7 +997,7 @@ export const Input: InputDecorator; export const input: InputFunction; // @public -export function inputBinding(publicName: string, value: () => unknown): Binding; +export function inputBinding(publicName: string, value: () => unknown): Binding; // @public (undocumented) export interface InputDecorator { diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index 4a1625e16536..a1885374cef1 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -1,7 +1,7 @@ { "cli-hello-world": { "uncompressed": { - "main": 137893, + "main": 132425, "polyfills": 33792 } }, diff --git a/packages/core/src/render3/dynamic_bindings.ts b/packages/core/src/render3/dynamic_bindings.ts index a35423660bb4..2db4c5374c8b 100644 --- a/packages/core/src/render3/dynamic_bindings.ts +++ b/packages/core/src/render3/dynamic_bindings.ts @@ -14,6 +14,7 @@ import {setDirectiveInput, storePropertyBindingMetadata} from './instructions/sh import {DirectiveDef} from './interfaces/definition'; import {CONTEXT} from './interfaces/view'; import {getCurrentTNode, getLView, getSelectedTNode, getTView, nextBindingIndex} from './state'; +import {stringifyForError} from './util/stringify_utils'; /** Symbol used to store and retrieve metadata about a binding. */ export const BINDING = /* @__PURE__ */ Symbol('BINDING'); @@ -53,85 +54,6 @@ export interface DirectiveWithBindings { const INPUT_BINDING_METADATA: Binding[typeof BINDING] = {kind: 'input', requiredVars: 1}; const OUTPUT_BINDING_METADATA: Binding[typeof BINDING] = {kind: 'output', requiredVars: 0}; -class InputBinding implements Binding { - readonly target!: DirectiveDef; - readonly [BINDING] = INPUT_BINDING_METADATA; - - constructor( - private readonly publicName: string, - private readonly value: () => unknown, - ) {} - - update(): void { - const lView = getLView(); - const bindingIndex = nextBindingIndex(); - const value = this.value(); - if (bindingUpdated(lView, bindingIndex, value)) { - const tView = getTView(); - const tNode = getSelectedTNode(); - - if (!this.target && ngDevMode) { - throw new RuntimeError( - RuntimeErrorCode.NO_BINDING_TARGET, - `Input binding to property "${this.publicName}" does not have a target.`, - ); - } - - const hasSet = setDirectiveInput(tNode, tView, lView, this.target, this.publicName, value); - - if (ngDevMode) { - if (!hasSet) { - throw new RuntimeError( - RuntimeErrorCode.NO_BINDING_TARGET, - `${this.target.type.name} does not have an input with a public name of "${this.publicName}".`, - ); - } - storePropertyBindingMetadata(tView.data, tNode, this.publicName, bindingIndex); - } - } - } -} - -class OutputBinding implements Binding { - readonly target!: DirectiveDef; - readonly [BINDING] = OUTPUT_BINDING_METADATA; - - constructor( - private readonly eventName: string, - private readonly listener: (event: T) => unknown, - ) {} - - create(): void { - if (!this.target && ngDevMode) { - throw new RuntimeError( - RuntimeErrorCode.NO_BINDING_TARGET, - `Output binding to "${this.eventName}" does not have a target.`, - ); - } - - const lView = getLView<{} | null>(); - const tView = getTView(); - const tNode = getCurrentTNode()!; - const context = lView[CONTEXT]; - const wrappedListener = wrapListener(tNode, lView, context, this.listener); - const hasBound = listenToDirectiveOutput( - tNode, - tView, - lView, - this.target, - this.eventName, - wrappedListener, - ); - - if (!hasBound && ngDevMode) { - throw new RuntimeError( - RuntimeErrorCode.INVALID_BINDING_TARGET, - `${this.target.type.name} does not have an output with a public name of "${this.eventName}".`, - ); - } - } -} - /** * Creates an input binding. * @param publicName Public name of the input to bind to. @@ -150,8 +72,44 @@ class OutputBinding implements Binding { * }); * ``` */ -export function inputBinding(publicName: string, value: () => unknown): Binding { - return new InputBinding(publicName, value); +export function inputBinding(publicName: string, value: () => unknown): Binding { + // Note: ideally we would use a class here, but it seems like they + // don't get tree shaken when constructed by a function like this. + const binding: Binding = { + [BINDING]: INPUT_BINDING_METADATA, + target: null, + update: () => { + const target = binding.target as DirectiveDef; + const lView = getLView(); + const bindingIndex = nextBindingIndex(); + const resolvedValue = value(); + if (bindingUpdated(lView, bindingIndex, resolvedValue)) { + const tView = getTView(); + const tNode = getSelectedTNode(); + + if (!target && ngDevMode) { + throw new RuntimeError( + RuntimeErrorCode.NO_BINDING_TARGET, + `Input binding to property "${publicName}" does not have a target.`, + ); + } + + const hasSet = setDirectiveInput(tNode, tView, lView, target, publicName, resolvedValue); + + if (ngDevMode) { + if (!hasSet) { + throw new RuntimeError( + RuntimeErrorCode.NO_BINDING_TARGET, + `${stringifyForError(target.type)} does not have an input with a public name of "${publicName}".`, + ); + } + storePropertyBindingMetadata(tView.data, tNode, publicName, bindingIndex); + } + } + }, + }; + + return binding; } /** @@ -176,5 +134,43 @@ export function inputBinding(publicName: string, value: () => unknown): Bindi * ``` */ export function outputBinding(eventName: string, listener: (event: T) => unknown): Binding { - return new OutputBinding(eventName, listener); + // Note: ideally we would use a class here, but it seems like they + // don't get tree shaken when constructed by a function like this. + const binding: Binding = { + [BINDING]: OUTPUT_BINDING_METADATA, + target: null, + create: () => { + const target = binding.target as DirectiveDef; + + if (!target && ngDevMode) { + throw new RuntimeError( + RuntimeErrorCode.NO_BINDING_TARGET, + `Output binding to "${eventName}" does not have a target.`, + ); + } + + const lView = getLView<{} | null>(); + const tView = getTView(); + const tNode = getCurrentTNode()!; + const context = lView[CONTEXT]; + const wrappedListener = wrapListener(tNode, lView, context, listener); + const hasBound = listenToDirectiveOutput( + tNode, + tView, + lView, + target, + eventName, + wrappedListener, + ); + + if (!hasBound && ngDevMode) { + throw new RuntimeError( + RuntimeErrorCode.INVALID_BINDING_TARGET, + `${stringifyForError(target.type)} does not have an output with a public name of "${eventName}".`, + ); + } + }, + }; + + return binding; } diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 393d598ebca4..c72b0e500788 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -896,7 +896,6 @@ "wasLastNodeCreated", "writeToDirectiveInput", "ɵɵdefer", - "ɵɵdeferWhen", "ɵɵdefineComponent", "ɵɵdefineInjectable", "ɵɵdirectiveInject", diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 7fa717a3c6e4..193dd8e49449 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -458,6 +458,5 @@ "ɵɵdefineComponent", "ɵɵdefineInjectable", "ɵɵdirectiveInject", - "ɵɵinject", - "ɵɵtext" + "ɵɵinject" ] \ No newline at end of file diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 3230ce79269d..9b25bf1d176d 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -381,6 +381,5 @@ "ɵɵdefineComponent", "ɵɵdefineInjectable", "ɵɵdirectiveInject", - "ɵɵinject", - "ɵɵtext" + "ɵɵinject" ] \ No newline at end of file