From 93475bfdcd5b6239d2a21913f2a440e5e7daddd8 Mon Sep 17 00:00:00 2001 From: satanTime Date: Sun, 25 Aug 2024 13:41:19 +0200 Subject: [PATCH] fix: unable to bind ngIf in angular 17+ #8884 --- .../lib/mock-component/mock-component.spec.ts | 76 ++++++++++++++++ .../src/lib/mock-component/mock-component.ts | 16 ++-- .../render/generate-template.ts | 20 +++-- tests/issue-8884/test.spec.ts | 89 +++++++++++++++---- 4 files changed, 171 insertions(+), 30 deletions(-) diff --git a/libs/ng-mocks/src/lib/mock-component/mock-component.spec.ts b/libs/ng-mocks/src/lib/mock-component/mock-component.spec.ts index 896dcba331..aba96dc068 100644 --- a/libs/ng-mocks/src/lib/mock-component/mock-component.spec.ts +++ b/libs/ng-mocks/src/lib/mock-component/mock-component.spec.ts @@ -475,11 +475,30 @@ describe('MockComponent', () => { }), ], + __vcrIf_key_i1: [ + jasmine.objectContaining({ + selector: 'ngIf_key_i1', + isViewQuery: true, + static: false, + read: ViewContainerRef, + ngMetadataName: 'ViewChild', + }), + ], + __trIf_key_i1: [ + jasmine.objectContaining({ + selector: 'ngIf_key_i1', + isViewQuery: true, + static: false, + read: TemplateRef, + ngMetadataName: 'ViewChild', + }), + ], __mockView_key_i1: [ jasmine.objectContaining({ selector: 'key_i1', isViewQuery: true, static: false, + read: ViewContainerRef, ngMetadataName: 'ViewChild', }), ], @@ -490,20 +509,58 @@ describe('MockComponent', () => { ngMetadataName: 'ContentChild', }), ], + __vcrIf_prop_o1: [ + jasmine.objectContaining({ + selector: 'ngIf_prop_o1', + isViewQuery: true, + static: false, + read: ViewContainerRef, + ngMetadataName: 'ViewChild', + }), + ], + __trIf_prop_o1: [ + jasmine.objectContaining({ + selector: 'ngIf_prop_o1', + isViewQuery: true, + static: false, + read: TemplateRef, + ngMetadataName: 'ViewChild', + }), + ], __mockView_prop_o1: [ jasmine.objectContaining({ selector: 'prop_o1', isViewQuery: true, static: false, + read: ViewContainerRef, ngMetadataName: 'ViewChild', }), ], + __vcrIf_key_i2: [ + jasmine.objectContaining({ + selector: 'ngIf_key_i2', + isViewQuery: true, + static: false, + read: ViewContainerRef, + ngMetadataName: 'ViewChild', + }), + ], + __trIf_key_i2: [ + jasmine.objectContaining({ + selector: 'ngIf_key_i2', + isViewQuery: true, + static: false, + read: TemplateRef, + ngMetadataName: 'ViewChild', + }), + ], __mockView_key_i2: [ jasmine.objectContaining({ selector: 'key_i2', isViewQuery: true, static: false, + read: ViewContainerRef, ngMetadataName: 'ViewChild', }), ], @@ -514,11 +571,30 @@ describe('MockComponent', () => { ngMetadataName: 'ContentChildren', }), ], + __vcrIf_prop_o2: [ + jasmine.objectContaining({ + selector: 'ngIf_prop_o2', + isViewQuery: true, + static: false, + read: ViewContainerRef, + ngMetadataName: 'ViewChild', + }), + ], + __trIf_prop_o2: [ + jasmine.objectContaining({ + selector: 'ngIf_prop_o2', + isViewQuery: true, + static: false, + read: TemplateRef, + ngMetadataName: 'ViewChild', + }), + ], __mockView_prop_o2: [ jasmine.objectContaining({ selector: 'prop_o2', isViewQuery: true, static: false, + read: ViewContainerRef, ngMetadataName: 'ViewChild', }), ], diff --git a/libs/ng-mocks/src/lib/mock-component/mock-component.ts b/libs/ng-mocks/src/lib/mock-component/mock-component.ts index 0bd3b51580..4fae1e1701 100644 --- a/libs/ng-mocks/src/lib/mock-component/mock-component.ts +++ b/libs/ng-mocks/src/lib/mock-component/mock-component.ts @@ -1,5 +1,5 @@ import { - AfterContentInit, + AfterViewInit, ChangeDetectorRef, Component, EmbeddedViewRef, @@ -31,8 +31,11 @@ const mixRenderPrepareVcr = ( selector: string, cdr: ChangeDetectorRef, ): ViewContainerRef | undefined => { - if (!instance[`ngMocksRender_${type}_${selector}`]) { - instance[`ngMocksRender_${type}_${selector}`] = true; + const vcrNgIf: ViewContainerRef = instance[`__vcrIf_${type}_${selector}`]; + const trNgIf: TemplateRef = instance[`__trIf_${type}_${selector}`]; + + if (vcrNgIf && trNgIf && !instance[`ngMocksRender_${type}_${selector}`]) { + instance[`ngMocksRender_${type}_${selector}`] = vcrNgIf.createEmbeddedView(trNgIf, {}); cdr.detectChanges(); } @@ -152,13 +155,14 @@ const mixHide = (instance: MockConfig & Record, changeDetector: mixHideHandler(instance, type, selector, indices); if (!indices) { - instance[`ngMocksRender_${type}_${selector}`] = false; + (instance[`ngMocksRender_${type}_${selector}`] as EmbeddedViewRef).destroy(); + instance[`ngMocksRender_${type}_${selector}`] = undefined; } changeDetector.detectChanges(); }); }; -class ComponentMockBase extends LegacyControlValueAccessor implements AfterContentInit { +class ComponentMockBase extends LegacyControlValueAccessor implements AfterViewInit { // istanbul ignore next public constructor( injector: Injector, @@ -172,7 +176,7 @@ class ComponentMockBase extends LegacyControlValueAccessor implements AfterConte } } - public ngAfterContentInit(): void { + public ngAfterViewInit(): void { const config = (this.__ngMocksConfig as any).config; if (!(this as any).__rendered && config && config.render) { for (const block of Object.keys(config.render)) { diff --git a/libs/ng-mocks/src/lib/mock-component/render/generate-template.ts b/libs/ng-mocks/src/lib/mock-component/render/generate-template.ts index f3ae01ddf0..66234cebda 100644 --- a/libs/ng-mocks/src/lib/mock-component/render/generate-template.ts +++ b/libs/ng-mocks/src/lib/mock-component/render/generate-template.ts @@ -1,14 +1,12 @@ -import { Query, TemplateRef, ViewChild, ViewContainerRef, VERSION } from '@angular/core'; +import { Query, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core'; -const hasControlFlow = Number.parseInt(VERSION.major, 10) >= 17; -const viewChildArgs: any = { read: ViewContainerRef, static: false }; +const vcrArgs: any = { read: ViewContainerRef, static: false }; +const trArgs: any = { read: TemplateRef, static: false }; const viewChildTemplate = (selector: string, key: string): string => { const content = `
`; - const condition = `ngMocksRender_${key}_${selector}`; - return hasControlFlow - ? `@if (${condition}) { ${content} }` - : /* istanbul ignore next */ `${content}`; + + return `${content}`; }; const isTemplateRefQuery = (query: Query): boolean => { @@ -39,11 +37,15 @@ export default (queries?: Record): string => { } if (typeof query.selector === 'string') { const selector = query.selector.replace(new RegExp('\\W', 'mg'), '_'); - queries[`__mockView_key_${selector}`] = new ViewChild(`key_${selector}`, viewChildArgs); + queries[`__vcrIf_key_${selector}`] = new ViewChild(`ngIf_key_${selector}`, vcrArgs); + queries[`__trIf_key_${selector}`] = new ViewChild(`ngIf_key_${selector}`, trArgs); + queries[`__mockView_key_${selector}`] = new ViewChild(`key_${selector}`, vcrArgs); queries[`__mockTpl_key_${selector}`] = query; parts.push(viewChildTemplate(selector, 'key')); } - queries[`__mockView_prop_${key}`] = new ViewChild(`prop_${key}`, viewChildArgs); + queries[`__vcrIf_prop_${key}`] = new ViewChild(`ngIf_prop_${key}`, vcrArgs); + queries[`__trIf_prop_${key}`] = new ViewChild(`ngIf_prop_${key}`, trArgs); + queries[`__mockView_prop_${key}`] = new ViewChild(`prop_${key}`, vcrArgs); parts.push(viewChildTemplate(key, 'prop')); } diff --git a/tests/issue-8884/test.spec.ts b/tests/issue-8884/test.spec.ts index 6ff736f38f..7bb8a6192c 100644 --- a/tests/issue-8884/test.spec.ts +++ b/tests/issue-8884/test.spec.ts @@ -1,3 +1,4 @@ +import { NgTemplateOutlet } from '@angular/common'; import { Component, ContentChild, @@ -9,6 +10,10 @@ import { import { MockBuilder, MockRender, ngMocks } from 'ng-mocks'; // @see https://github.com/help-me-mom/ng-mocks/issues/8884 +// New control flow doesn't import NgIf or CommonModule by default. +// However, it still allows to use conditions, and if `ContentChild` is used, +// it causes errors such as "Can't bind to 'ngIf' since it isn't a known property of 'div'". +// The fix is to remove dependency on NgIf or CommonModule. describe('issue-8884', () => { ngMocks.throwOnConsole(); @@ -20,47 +25,101 @@ describe('issue-8884', () => { return; } - describe('when standalone component does not import NgIf', () => { + describe('standalone component without NgIf', () => { @Component({ - selector: 'app-standalone', + selector: 'standalone-8884', ['standalone' as never]: true, + ['imports' as never]: [NgTemplateOutlet], template: ``, }) - class StandaloneComponent { + class Standalone8884Component { @ContentChild('content', {} as never) content?: TemplateRef; } - beforeEach(() => MockBuilder(null, StandaloneComponent)); + describe('real', () => { + beforeEach(() => MockBuilder(Standalone8884Component)); - it('should create', () => { - MockRender(`Test content`); + it('renders content', () => { + const fixture = MockRender(` + + content + + `); - expect(ngMocks.findInstance(StandaloneComponent)).toBeTruthy(); + expect(ngMocks.formatText(fixture)).toEqual('content'); + }); + }); + + describe('mock', () => { + beforeEach(() => MockBuilder(null, Standalone8884Component)); + + it('renders content', () => { + const fixture = MockRender(` + + content + + `); + expect(ngMocks.formatText(fixture)).toEqual(''); + + ngMocks.render( + ngMocks.findInstance(Standalone8884Component), + ngMocks.findTemplateRef('content'), + ); + expect(ngMocks.formatText(fixture)).toEqual('content'); + }); }); }); - describe('when NgIf is not avaiable to a component in a module', () => { + describe('classic component without NgIf import in its module', () => { @Component({ - selector: 'app-target', + selector: 'target-8884', template: ``, }) - class TargetComponent { + class Target8884Component { @ContentChild('content', {} as never) content?: TemplateRef; } @NgModule({ - declarations: [TargetComponent], + imports: [NgTemplateOutlet], + declarations: [Target8884Component], + exports: [Target8884Component], }) class TargetModule {} - beforeEach(() => MockBuilder(null, TargetModule)); + describe('real', () => { + beforeEach(() => MockBuilder(TargetModule)); + + it('renders content', () => { + const fixture = MockRender(` + + content + + `); + + expect(ngMocks.formatText(fixture)).toEqual('content'); + }); + }); + + describe('mock', () => { + beforeEach(() => MockBuilder(null, TargetModule)); + + it('render contents', () => { + const fixture = MockRender(` + + content + + `); - it('should create', () => { - MockRender(`Test content`); + expect(ngMocks.formatText(fixture)).toEqual(''); - expect(ngMocks.findInstance(TargetComponent)).toBeTruthy(); + ngMocks.render( + ngMocks.findInstance(Target8884Component), + ngMocks.findTemplateRef('content'), + ); + expect(ngMocks.formatText(fixture)).toEqual('content'); + }); }); }); });