fix(core): ensure init lifecycle events are called (#20258)
Throwing an exception in a lifecycle event will delay but not prevent an Init method, such as `ngOnInit`, `ngAfterContentInit`, or `ngAfterViewInit`, from being called. Also, calling `detectChanges()` in a way that causes duplicate change detection (such as a child component causing a parent to call `detectChanges()` on its own `ChangeDetectorRef`, will no longer prevent change `ngOnInit`, `ngAfterContentInit` and `ngAfterViewInit` from being called. With this change lifecycle methods are still not guarenteed to be called but the Init methods will be called if at least one change detection pass on its view is completed. Fixes: #17035 PR Close #20258
This commit is contained in:

committed by
Jason Aden

parent
743651f5e8
commit
24cf8b3269
@ -1152,7 +1152,6 @@ export function main() {
|
||||
]);
|
||||
}));
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('enforce no new changes', () => {
|
||||
@ -1463,6 +1462,151 @@ export function main() {
|
||||
expect(divEl.nativeElement).toHaveCssClass('foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle asserts', () => {
|
||||
let logged: string[];
|
||||
|
||||
function log(value: string) { logged.push(value); }
|
||||
function clearLog() { logged = []; }
|
||||
|
||||
function expectOnceAndOnlyOnce(log: string) {
|
||||
expect(logged.indexOf(log) >= 0)
|
||||
.toBeTruthy(`'${log}' not logged. Log was ${JSON.stringify(logged)}`);
|
||||
expect(logged.lastIndexOf(log) === logged.indexOf(log))
|
||||
.toBeTruthy(`'${log}' logged more than once. Log was ${JSON.stringify(logged)}`);
|
||||
}
|
||||
|
||||
beforeEach(() => { clearLog(); });
|
||||
|
||||
enum LifetimeMethods {
|
||||
None = 0,
|
||||
ngOnInit = 1 << 0,
|
||||
ngOnChanges = 1 << 1,
|
||||
ngAfterViewInit = 1 << 2,
|
||||
ngAfterContentInit = 1 << 3,
|
||||
ngDoCheck = 1 << 4,
|
||||
InitMethods = ngOnInit | ngAfterViewInit | ngAfterContentInit,
|
||||
InitMethodsAndChanges = InitMethods | ngOnChanges,
|
||||
All = InitMethodsAndChanges | ngDoCheck,
|
||||
}
|
||||
|
||||
function forEachMethod(methods: LifetimeMethods, cb: (method: LifetimeMethods) => void) {
|
||||
if (methods & LifetimeMethods.ngOnInit) cb(LifetimeMethods.ngOnInit);
|
||||
if (methods & LifetimeMethods.ngOnChanges) cb(LifetimeMethods.ngOnChanges);
|
||||
if (methods & LifetimeMethods.ngAfterContentInit) cb(LifetimeMethods.ngAfterContentInit);
|
||||
if (methods & LifetimeMethods.ngAfterViewInit) cb(LifetimeMethods.ngAfterViewInit);
|
||||
if (methods & LifetimeMethods.ngDoCheck) cb(LifetimeMethods.ngDoCheck);
|
||||
}
|
||||
|
||||
interface Options {
|
||||
childRecursion: LifetimeMethods;
|
||||
childThrows: LifetimeMethods;
|
||||
}
|
||||
|
||||
describe('calling init', () => {
|
||||
function initialize(options: Options) {
|
||||
@Component({selector: 'my-child', template: ''})
|
||||
class MyChild {
|
||||
private thrown = LifetimeMethods.None;
|
||||
|
||||
@Input() inp: boolean;
|
||||
@Output() outp = new EventEmitter<any>();
|
||||
|
||||
constructor() {}
|
||||
|
||||
ngDoCheck() { this.check(LifetimeMethods.ngDoCheck); }
|
||||
ngOnInit() { this.check(LifetimeMethods.ngOnInit); }
|
||||
ngOnChanges() { this.check(LifetimeMethods.ngOnChanges); }
|
||||
ngAfterViewInit() { this.check(LifetimeMethods.ngAfterViewInit); }
|
||||
ngAfterContentInit() { this.check(LifetimeMethods.ngAfterContentInit); }
|
||||
|
||||
private check(method: LifetimeMethods) {
|
||||
log(`MyChild::${LifetimeMethods[method]}()`);
|
||||
|
||||
if ((options.childRecursion & method) !== 0) {
|
||||
if (logged.length < 20) {
|
||||
this.outp.emit(null);
|
||||
} else {
|
||||
fail(`Unexpected MyChild::${LifetimeMethods[method]} recursion`);
|
||||
}
|
||||
}
|
||||
if ((options.childThrows & method) !== 0) {
|
||||
if ((this.thrown & method) === 0) {
|
||||
this.thrown |= method;
|
||||
log(`<THROW from MyChild::${LifetimeMethods[method]}>()`);
|
||||
throw new Error(`Throw from MyChild::${LifetimeMethods[method]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'my-component',
|
||||
template: `<my-child [inp]='true' (outp)='onOutp()'></my-child>`
|
||||
})
|
||||
class MyComponent {
|
||||
constructor(private changeDetectionRef: ChangeDetectorRef) {}
|
||||
ngDoCheck() { this.check(LifetimeMethods.ngDoCheck); }
|
||||
ngOnInit() { this.check(LifetimeMethods.ngOnInit); }
|
||||
ngAfterViewInit() { this.check(LifetimeMethods.ngAfterViewInit); }
|
||||
ngAfterContentInit() { this.check(LifetimeMethods.ngAfterContentInit); }
|
||||
onOutp() {
|
||||
log('<RECURSION START>');
|
||||
this.changeDetectionRef.detectChanges();
|
||||
log('<RECURSION DONE>');
|
||||
}
|
||||
|
||||
private check(method: LifetimeMethods) {
|
||||
log(`MyComponent::${LifetimeMethods[method]}()`);
|
||||
}
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({declarations: [MyChild, MyComponent]});
|
||||
|
||||
return createCompFixture(`<my-component></my-component>`);
|
||||
}
|
||||
|
||||
function ensureOneInit(options: Options) {
|
||||
const ctx = initialize(options);
|
||||
|
||||
|
||||
const throws = options.childThrows != LifetimeMethods.None;
|
||||
if (throws) {
|
||||
log(`<CYCLE 0 START>`);
|
||||
expect(() => {
|
||||
// Expect child to throw.
|
||||
ctx.detectChanges();
|
||||
}).toThrow();
|
||||
log(`<CYCLE 0 END>`);
|
||||
log(`<CYCLE 1 START>`);
|
||||
}
|
||||
ctx.detectChanges();
|
||||
if (throws) log(`<CYCLE 1 DONE>`);
|
||||
expectOnceAndOnlyOnce('MyComponent::ngOnInit()');
|
||||
expectOnceAndOnlyOnce('MyChild::ngOnInit()');
|
||||
expectOnceAndOnlyOnce('MyComponent::ngAfterViewInit()');
|
||||
expectOnceAndOnlyOnce('MyComponent::ngAfterContentInit()');
|
||||
expectOnceAndOnlyOnce('MyChild::ngAfterViewInit()');
|
||||
expectOnceAndOnlyOnce('MyChild::ngAfterContentInit()');
|
||||
}
|
||||
|
||||
forEachMethod(LifetimeMethods.InitMethodsAndChanges, method => {
|
||||
it(`should ensure that init hooks are called once an only once with recursion in ${LifetimeMethods[method]} `,
|
||||
() => {
|
||||
// Ensure all the init methods are called once.
|
||||
ensureOneInit({childRecursion: method, childThrows: LifetimeMethods.None});
|
||||
});
|
||||
});
|
||||
forEachMethod(LifetimeMethods.All, method => {
|
||||
it(`should ensure that init hooks are called once an only once with a throw in ${LifetimeMethods[method]} `,
|
||||
() => {
|
||||
// Ensure all the init methods are called once.
|
||||
// the first cycle throws but the next cycle should complete the inits.
|
||||
ensureOneInit({childRecursion: LifetimeMethods.None, childThrows: method});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user