Skip to content

Commit

Permalink
fix: unable to bind ngIf in angular 17+ #8884
Browse files Browse the repository at this point in the history
  • Loading branch information
satanTime committed Aug 25, 2024
1 parent 777940f commit 0ba55d0
Show file tree
Hide file tree
Showing 8 changed files with 176 additions and 34 deletions.
1 change: 0 additions & 1 deletion e2e/a12/jest.es2015ivy.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
module.exports = {
preset: 'jest-preset-angular',
workerIdleMemoryLimit: '1024MB',
maxWorkers: 1,
setupFilesAfterEnv: ['<rootDir>/src/setup-jest.ts'],
testURL: 'http://localhost',
Expand Down
1 change: 0 additions & 1 deletion e2e/a12/jest.es5ivy.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
module.exports = {
preset: 'jest-preset-angular',
workerIdleMemoryLimit: '1024MB',
maxWorkers: 1,
setupFilesAfterEnv: ['<rootDir>/src/setup-jest.ts'],
testURL: 'http://localhost',
Expand Down
2 changes: 1 addition & 1 deletion e2e/a12/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"test:jest": "npm run test:jest:es5:ivy && npm run test:jest:es2015:ivy",
"test:jest:es5:ivy": "jest --config jest.es5ivy.js",
"test:jest:es2015:ivy": "jest --config jest.es2015ivy.js",
"test:jest:debug": "jest -i --watch"
"test:jest:debug": "npm run test:jest:es2015:ivy -- -i --watch"
},
"dependencies": {
"@angular/animations": "12.2.17",
Expand Down
2 changes: 1 addition & 1 deletion e2e/a12/src/setup-jest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'jest-preset-angular';
import 'jest-preset-angular/setup-jest';
import { ngMocks } from 'ng-mocks';

ngMocks.autoSpy('jest');
76 changes: 76 additions & 0 deletions libs/ng-mocks/src/lib/mock-component/mock-component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
],
Expand All @@ -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',
}),
],
Expand All @@ -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',
}),
],
Expand Down
16 changes: 10 additions & 6 deletions libs/ng-mocks/src/lib/mock-component/mock-component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
AfterContentInit,
AfterViewInit,
ChangeDetectorRef,
Component,
EmbeddedViewRef,
Expand Down Expand Up @@ -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<never> = instance[`__trIf_${type}_${selector}`];

if (vcrNgIf && trNgIf && !instance[`ngMocksRender_${type}_${selector}`]) {
instance[`ngMocksRender_${type}_${selector}`] = vcrNgIf.createEmbeddedView(trNgIf, {});
cdr.detectChanges();
}

Expand Down Expand Up @@ -152,13 +155,14 @@ const mixHide = (instance: MockConfig & Record<keyof any, any>, changeDetector:
mixHideHandler(instance, type, selector, indices);

if (!indices) {
instance[`ngMocksRender_${type}_${selector}`] = false;
(instance[`ngMocksRender_${type}_${selector}`] as EmbeddedViewRef<never>).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,
Expand All @@ -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)) {
Expand Down
23 changes: 14 additions & 9 deletions libs/ng-mocks/src/lib/mock-component/render/generate-template.ts
Original file line number Diff line number Diff line change
@@ -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 = `<div data-${key}="${selector}"><ng-template #${key}_${selector}></ng-template></div>`;
const condition = `ngMocksRender_${key}_${selector}`;
return hasControlFlow
? `@if (${condition}) { ${content} }`
: /* istanbul ignore next */ `<ng-template [ngIf]="${condition}">${content}</ng-template>`;

return `<ng-template #ngIf_${key}_${selector}>${content}</ng-template>`;
};

const isTemplateRefQuery = (query: Query): boolean => {
Expand All @@ -34,16 +32,23 @@ export default (queries?: Record<keyof any, any>): string => {

for (const key of Object.keys(queries)) {
const query: Query = queries[key];
if (key.indexOf('__mock') === 0) {
continue;
}
if (!isTemplateRefQuery(query)) {
continue;
}
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'));
}

Expand Down
89 changes: 74 additions & 15 deletions tests/issue-8884/test.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { NgTemplateOutlet } from '@angular/common';
import {
Component,
ContentChild,
Expand All @@ -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();

Expand All @@ -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: `<ng-template [ngTemplateOutlet]="content" />`,
})
class StandaloneComponent {
class Standalone8884Component {
@ContentChild('content', {} as never)
content?: TemplateRef<any>;
}

beforeEach(() => MockBuilder(null, StandaloneComponent));
describe('real', () => {
beforeEach(() => MockBuilder(Standalone8884Component));

it('should create', () => {
MockRender(`<app-standalone>Test content</app-standalone>`);
it('renders content', () => {
const fixture = MockRender(`
<standalone-8884>
<ng-template #content>content</ng-template>
</standalone-8884>
`);

expect(ngMocks.findInstance(StandaloneComponent)).toBeTruthy();
expect(ngMocks.formatText(fixture)).toEqual('content');
});
});

describe('mock', () => {
beforeEach(() => MockBuilder(null, Standalone8884Component));

it('renders content', () => {
const fixture = MockRender(`
<standalone-8884>
<ng-template #content>content</ng-template>
</standalone-8884>
`);
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: `<ng-template [ngTemplateOutlet]="content" />`,
})
class TargetComponent {
class Target8884Component {
@ContentChild('content', {} as never)
content?: TemplateRef<any>;
}

@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(`
<target-8884>
<ng-template #content>content</ng-template>
</target-8884>
`);

expect(ngMocks.formatText(fixture)).toEqual('content');
});
});

describe('mock', () => {
beforeEach(() => MockBuilder(null, TargetModule));

it('render contents', () => {
const fixture = MockRender(`
<target-8884>
<ng-template #content>content</ng-template>
</target-8884>
`);

it('should create', () => {
MockRender(`<app-target>Test content</app-target>`);
expect(ngMocks.formatText(fixture)).toEqual('');

expect(ngMocks.findInstance(TargetComponent)).toBeTruthy();
ngMocks.render(
ngMocks.findInstance(Target8884Component),
ngMocks.findTemplateRef('content'),
);
expect(ngMocks.formatText(fixture)).toEqual('content');
});
});
});
});

0 comments on commit 0ba55d0

Please sign in to comment.