Skip to content

Commit

Permalink
fix(attributes): properly resolve constructor types from directives
Browse files Browse the repository at this point in the history
Since Angular v10 directives no longer contain their constructor arguments in Reflect API and
instead NGC stores it in private API. Now library tries to access that private API first to resolve
arguments and if it fails - falls back to Reflect API.
  • Loading branch information
gund committed Jun 28, 2020
1 parent ceda54b commit 16efb28
Show file tree
Hide file tree
Showing 11 changed files with 132 additions and 58 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
export class ComponentOutletInjectorDirective
implements DynamicComponentInjector {
get componentRef(): ComponentRef<any> {
// NOTE: Accessing private APIs of Angular
return (this.componentOutlet as any)._componentRef;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
Directive,
DoCheck,
Host,
Inject,
Injector,
Input,
Expand All @@ -13,7 +12,6 @@ import {
} from '@angular/core';

import {
ComponentOutletInjectorDirective,
DynamicComponentInjector,
DynamicComponentInjectorToken,
} from '../component-injector';
Expand Down Expand Up @@ -47,22 +45,12 @@ export class DynamicAttributesDirective implements DoCheck {
);
}

private get _compInjector() {
return this.componentOutletInjector || this.componentInjector;
}

private get _nativeElement() {
return (
this._compInjector.componentRef &&
this._compInjector.componentRef.location.nativeElement
);
return this.componentInjector.componentRef?.location.nativeElement;
}

private get _compType() {
return (
this._compInjector.componentRef &&
this._compInjector.componentRef.componentType
);
return this.componentInjector.componentRef?.componentType;
}

private get _isCompChanged() {
Expand All @@ -80,9 +68,6 @@ export class DynamicAttributesDirective implements DoCheck {
@Inject(DynamicComponentInjectorToken)
@Optional()
private componentInjector?: DynamicComponentInjector,
@Host()
@Optional()
private componentOutletInjector?: ComponentOutletInjectorDirective,
) {}

ngDoCheck(): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';

import { ComponentOutletInjectorModule } from '../component-injector';
import { DynamicAttributesDirective } from './dynamic-attributes.directive';

@NgModule({
imports: [CommonModule],
exports: [DynamicAttributesDirective],
exports: [DynamicAttributesDirective, ComponentOutletInjectorModule],
declarations: [DynamicAttributesDirective],
})
export class DynamicAttributesModule {}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// tslint:disable: no-string-literal
// tslint:disable: directive-selector
import { CommonModule, NgClass } from '@angular/common';
import {
AfterContentChecked,
AfterContentInit,
Expand Down Expand Up @@ -32,7 +33,7 @@ import {
DynamicComponentInjectorToken,
} from '../component-injector';
import { IoFactoryService } from '../io';
import { WindowRefToken, WindowRefService } from '../window-ref';
import { WindowRefService, WindowRefToken } from '../window-ref';
import {
DirectiveRef,
DynamicDirectiveDef,
Expand Down Expand Up @@ -420,10 +421,20 @@ describe('Directive: DynamicDirectives', () => {

beforeEach(() => {
created.mockReset();
const template = `<ng-container [ngComponentOutlet]="comp"
const template = `<ng-container
[ngComponentOutlet]="comp"
[ndcDynamicDirectives]="dirs"
(ndcDynamicDirectivesCreated)="created($event)"></ng-container>`;
TestBed.overrideComponent(TestComponent, { set: { template } });
TestBed.resetTestingModule()
.configureTestingModule({
imports: [CommonModule, TestModule],
declarations: [
DynamicDirectivesDirective,
TestComponent,
ComponentOutletInjectorDirective,
],
})
.overrideComponent(TestComponent, { set: { template } });
fixture = TestBed.createComponent(TestComponent);
fixture.componentInstance['comp'] = InjectedComponent;
fixture.componentInstance['created'] = created;
Expand All @@ -441,7 +452,7 @@ describe('Directive: DynamicDirectives', () => {
const [{ instance }] = getCreateDirs<TestDirective>(created);

expect(instance).toBeTruthy();
expect(instance.elem).toEqual(expect.any(ElementRef));
expect(instance.elem).toBeInstanceOf(ElementRef);
});

it('should be able to inject `ChangeDetectorRef`', () => {
Expand Down Expand Up @@ -686,6 +697,47 @@ describe('Directive: DynamicDirectives', () => {
expect(callback).not.toHaveBeenCalled();
});
});

describe('example: NgClass', () => {
type HostComponent = { dirs: DynamicDirectiveDef<any>[]; comp: any };
let fixture: ComponentFixture<HostComponent>;
let hostComp: HostComponent;

beforeEach(() => {
TestBed.resetTestingModule()
.configureTestingModule({
imports: [CommonModule, TestModule],
declarations: [
DynamicDirectivesDirective,
TestComponent,
ComponentOutletInjectorDirective,
],
})
.overrideTemplate(
TestComponent,
`<ng-container *ngComponentOutlet="comp; ndcDynamicDirectives: dirs"></ng-container>`,
);

fixture = TestBed.createComponent(TestComponent as any);
hostComp = fixture.componentInstance;

hostComp.comp = InjectedComponent;
});

it('should apply classes', () => {
hostComp.dirs = [dynamicDirectiveDef(NgClass, { ngClass: 'cls1 cls2' })];

fixture.detectChanges();

const injectedComp = fixture.debugElement.query(
By.directive(InjectedComponent),
);

expect(injectedComp).toBeTruthy();
expect(injectedComp.classes.cls1).toBeTruthy();
expect(injectedComp.classes.cls2).toBeTruthy();
});
});
});

function getFirstDir() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
DoCheck,
ElementRef,
EventEmitter,
Host,
Inject,
Injector,
Input,
Expand All @@ -20,12 +19,11 @@ import {
} from '@angular/core';

import {
ComponentOutletInjectorDirective,
DynamicComponentInjector,
DynamicComponentInjectorToken,
} from '../component-injector';
import { InputsType, IoFactoryService, IoService, OutputsType } from '../io';
import { getCtorType } from '../util';
import { extractNgParamTypes, getCtorParamTypes } from '../util';
import { WindowRefService } from '../window-ref';

export interface DynamicDirectiveDef<T> {
Expand Down Expand Up @@ -74,12 +72,8 @@ export class DynamicDirectivesDirective implements OnDestroy, DoCheck {
);
}

private get compInjector() {
return this.componentOutletInjector || this.componentInjector;
}

private get componentRef() {
return this.compInjector.componentRef;
return this.componentInjector.componentRef;
}

private get compInstance() {
Expand Down Expand Up @@ -121,9 +115,6 @@ export class DynamicDirectivesDirective implements OnDestroy, DoCheck {
@Inject(DynamicComponentInjectorToken)
@Optional()
private componentInjector?: DynamicComponentInjector,
@Host()
@Optional()
private componentOutletInjector?: ComponentOutletInjectorDirective,
) {}

ngDoCheck(): void {
Expand Down Expand Up @@ -250,20 +241,31 @@ export class DynamicDirectivesDirective implements OnDestroy, DoCheck {
}

private createDirective<T>(dirType: Type<T>): T {
const ctorParams: any[] = getCtorType(dirType, this.reflect);
const resolvedParams = ctorParams.map(p => this.resolveDep(p));
return new dirType(...resolvedParams);
}

private resolveDep(dep: any): any {
// tslint:disable-next-line: deprecation
return this.maybeResolveVCR(dep) || this.hostInjector.get(dep);
const directiveInjector = Injector.create({
providers: [
{
provide: dirType,
useClass: dirType,
deps: this.resolveDirParamTypes(dirType),
},
{ provide: ElementRef, useValue: this.componentRef.location },
],
parent: this.hostInjector,
name: `DynamicDirectiveInjector:${dirType.name}@${this.componentRef.componentType.name}`,
});

return directiveInjector.get(dirType);
}

private maybeResolveVCR(dep: any): ViewContainerRef | undefined {
if (dep === ViewContainerRef) {
return this.hostVcr;
}
private resolveDirParamTypes(dirType: Type<any>): any[] {
return (
// First try Angular Compiler's metadata
extractNgParamTypes(dirType) ??
// Then fallback to Typescript Reflect API
getCtorParamTypes(dirType, this.reflect) ??
// Bailout
[]
);
}

private callInitHooks(obj: any) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';

import { ComponentOutletInjectorModule } from '../component-injector';
import { DynamicDirectivesDirective } from './dynamic-directives.directive';

@NgModule({
imports: [CommonModule],
exports: [DynamicDirectivesDirective],
exports: [DynamicDirectivesDirective, ComponentOutletInjectorModule],
declarations: [DynamicDirectivesDirective],
})
export class DynamicDirectivesModule {}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
Directive,
DoCheck,
Host,
Inject,
Input,
OnChanges,
Expand All @@ -10,7 +9,6 @@ import {
} from '@angular/core';

import {
ComponentOutletInjectorDirective,
DynamicComponentInjector,
DynamicComponentInjectorToken,
} from '../component-injector';
Expand Down Expand Up @@ -40,20 +38,13 @@ export class DynamicIoDirective implements OnChanges, DoCheck {
return this.ndcDynamicOutputs || this.ngComponentOutletNdcDynamicOutputs;
}

private get compInjector() {
return this.componentOutletInjector || this.componentInjector;
}

constructor(
private ioService: IoService,
@Inject(DynamicComponentInjectorToken)
@Optional()
private componentInjector?: DynamicComponentInjector,
@Host()
@Optional()
private componentOutletInjector?: ComponentOutletInjectorDirective,
) {
this.ioService.init(this.compInjector);
this.ioService.init(this.componentInjector);
}

ngOnChanges(changes: SimpleChanges) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ComponentOutletInjectorModule } from '../component-injector';
import { DynamicIoDirective } from './dynamic-io.directive';

@NgModule({
imports: [CommonModule, ComponentOutletInjectorModule],
imports: [CommonModule],
exports: [DynamicIoDirective, ComponentOutletInjectorModule],
declarations: [DynamicIoDirective],
})
Expand Down
31 changes: 31 additions & 0 deletions projects/ng-dynamic-component/src/lib/util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NgClass } from '@angular/common';

import { extractNgParamTypes } from './util';
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';

describe('Util', () => {
describe('function extractNgParamTypes()', () => {
it('should return constructor argument types collected by NGC', () => {
const args = extractNgParamTypes(NgClass);

// To be more flexible just check that there are some arguments
// and do not check for specific types as they are irrelevant and may change
expect(args.length).toBeGreaterThan(0);
});

it('should return `undefined` for constructor not processed by NGC', () => {
// tslint:disable-next-line: component-selector
@Component({ selector: 'test', template: '' })
class TestComponent {}

TestBed.configureTestingModule({
declarations: [TestComponent],
}).compileComponents();

const args = extractNgParamTypes(TestComponent);

expect(args).toBe(undefined);
});
});
});
11 changes: 10 additions & 1 deletion projects/ng-dynamic-component/src/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
KeyValueChangeRecord,
SimpleChange,
SimpleChanges,
Type,
} from '@angular/core';

export type KeyValueChangeRecordAny = KeyValueChangeRecord<any, any>;
Expand Down Expand Up @@ -59,9 +60,17 @@ export function changesFromRecord(opts: DefaultOpts = defaultOpts) {

export function noop(): void {}

export function getCtorType(
export function getCtorParamTypes(
ctor: any,
reflect: { getMetadata: (type: string, obj: object) => any[] },
): any[] {
return reflect.getMetadata('design:paramtypes', ctor);
}

/**
* Extract type arguments from Angular Directive/Component
*/
export function extractNgParamTypes(type: Type<any>): any[] | undefined {
// NOTE: Accessing private APIs of Angular
return (type as any)?.ctorParameters?.()?.map(param => param.type);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class ComponentInjectorComponent implements DynamicComponentInjector {
get componentRef(): ComponentRef<ComponentInjectorComponent> {
return this.component
? ({
componentType: MockedInjectedComponent,
instance: this.component,
injector: { get: this.injectorGet },
} as any)
Expand Down

0 comments on commit 16efb28

Please sign in to comment.