fix(core): error if detectChanges is called at the wrong time under specific circumstances (#40206)
Internally we store lifecycle hooks in the format `[index, hook, index, hook]` and when iterating over them, we check one place ahead to figure out whether we've hit found a hook or an index. The problem is that the loop is set up to iterate up to `hooks.length` which means that we may go out of bounds on the last iteration, depending on where we started. This appears to happen under a specific set of circumstances where a directive calls `detectChanges` from an input setter while it has `ngOnChanges` and `ngAfterViewInit` hooks. These changes resolve the issue by only iterating up to `length - 1` which guarantees that we can always look one place ahead. This appears to have regressed some time in version 10. Fixes #38611. PR Close #40206
This commit is contained in:
parent
34f083c8ed
commit
e4fbab9ec8
|
@ -212,9 +212,10 @@ function callHooks(
|
||||||
(currentView[PREORDER_HOOK_FLAGS] & PreOrderHookFlags.IndexOfTheNextPreOrderHookMaskMask) :
|
(currentView[PREORDER_HOOK_FLAGS] & PreOrderHookFlags.IndexOfTheNextPreOrderHookMaskMask) :
|
||||||
0;
|
0;
|
||||||
const nodeIndexLimit = currentNodeIndex != null ? currentNodeIndex : -1;
|
const nodeIndexLimit = currentNodeIndex != null ? currentNodeIndex : -1;
|
||||||
|
const max = arr.length - 1; // Stop the loop at length - 1, because we look for the hook at i + 1
|
||||||
let lastNodeIndexFound = 0;
|
let lastNodeIndexFound = 0;
|
||||||
for (let i = startIndex; i < arr.length; i++) {
|
for (let i = startIndex; i < max; i++) {
|
||||||
const hook = arr[i + 1] as () => void;
|
const hook = arr[i + 1] as number | (() => void);
|
||||||
if (typeof hook === 'number') {
|
if (typeof hook === 'number') {
|
||||||
lastNodeIndexFound = arr[i] as number;
|
lastNodeIndexFound = arr[i] as number;
|
||||||
if (currentNodeIndex != null && lastNodeIndexFound >= currentNodeIndex) {
|
if (currentNodeIndex != null && lastNodeIndexFound >= currentNodeIndex) {
|
||||||
|
@ -250,8 +251,7 @@ function callHook(currentView: LView, initPhase: InitPhaseState, arr: HookData,
|
||||||
const directive = currentView[directiveIndex];
|
const directive = currentView[directiveIndex];
|
||||||
if (isInitHook) {
|
if (isInitHook) {
|
||||||
const indexWithintInitPhase = currentView[FLAGS] >> LViewFlags.IndexWithinInitPhaseShift;
|
const indexWithintInitPhase = currentView[FLAGS] >> LViewFlags.IndexWithinInitPhaseShift;
|
||||||
// The init phase state must be always checked here as it may have been recursively
|
// The init phase state must be always checked here as it may have been recursively updated.
|
||||||
// updated
|
|
||||||
if (indexWithintInitPhase <
|
if (indexWithintInitPhase <
|
||||||
(currentView[PREORDER_HOOK_FLAGS] >> PreOrderHookFlags.NumberOfInitHooksCalledShift) &&
|
(currentView[PREORDER_HOOK_FLAGS] >> PreOrderHookFlags.NumberOfInitHooksCalledShift) &&
|
||||||
(currentView[FLAGS] & LViewFlags.InitPhaseStateMask) === initPhase) {
|
(currentView[FLAGS] & LViewFlags.InitPhaseStateMask) === initPhase) {
|
||||||
|
|
|
@ -7,8 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {CommonModule} from '@angular/common';
|
import {CommonModule} from '@angular/common';
|
||||||
import {ChangeDetectorRef, Component, ComponentFactoryResolver, ContentChildren, Directive, Input, NgModule, OnChanges, QueryList, SimpleChanges, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core';
|
import {AfterViewInit, ChangeDetectorRef, Component, ComponentFactoryResolver, ContentChildren, Directive, DoCheck, Input, NgModule, OnChanges, QueryList, SimpleChange, SimpleChanges, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core';
|
||||||
import {SimpleChange} from '@angular/core/src/core';
|
|
||||||
import {TestBed} from '@angular/core/testing';
|
import {TestBed} from '@angular/core/testing';
|
||||||
import {By} from '@angular/platform-browser';
|
import {By} from '@angular/platform-browser';
|
||||||
import {onlyInIvy} from '@angular/private/testing';
|
import {onlyInIvy} from '@angular/private/testing';
|
||||||
|
@ -4378,4 +4377,72 @@ describe('non-regression', () => {
|
||||||
|
|
||||||
expect(destroyed).toBeTruthy();
|
expect(destroyed).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onlyInIvy('Use case is not supported in ViewEngine')
|
||||||
|
.it('should not throw when calling detectChanges from a setter in the presence of a data binding, ngOnChanges and ngAfterViewInit',
|
||||||
|
() => {
|
||||||
|
const hooks: string[] = [];
|
||||||
|
|
||||||
|
@Directive({selector: '[testDir]'})
|
||||||
|
class TestDirective implements OnChanges, AfterViewInit {
|
||||||
|
constructor(private _changeDetectorRef: ChangeDetectorRef) {}
|
||||||
|
|
||||||
|
@Input('testDir')
|
||||||
|
set value(_value: any) {
|
||||||
|
this._changeDetectorRef.detectChanges();
|
||||||
|
}
|
||||||
|
ngOnChanges() {
|
||||||
|
hooks.push('ngOnChanges');
|
||||||
|
}
|
||||||
|
ngAfterViewInit() {
|
||||||
|
hooks.push('ngAfterViewInit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({template: `<div [testDir]="value">{{value}}</div>`})
|
||||||
|
class App {
|
||||||
|
value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({declarations: [App, TestDirective]});
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
expect(() => fixture.detectChanges()).not.toThrow();
|
||||||
|
expect(hooks).toEqual(['ngOnChanges', 'ngAfterViewInit']);
|
||||||
|
expect(fixture.nativeElement.textContent.trim()).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
onlyInIvy('Use case is not supported in ViewEngine')
|
||||||
|
.it('should call hooks in the correct order when calling detectChanges in a setter', () => {
|
||||||
|
const hooks: string[] = [];
|
||||||
|
|
||||||
|
@Directive({selector: '[testDir]'})
|
||||||
|
class TestDirective implements OnChanges, DoCheck, AfterViewInit {
|
||||||
|
constructor(private _changeDetectorRef: ChangeDetectorRef) {}
|
||||||
|
|
||||||
|
@Input('testDir')
|
||||||
|
set value(_value: any) {
|
||||||
|
this._changeDetectorRef.detectChanges();
|
||||||
|
}
|
||||||
|
ngOnChanges() {
|
||||||
|
hooks.push('ngOnChanges');
|
||||||
|
}
|
||||||
|
ngDoCheck() {
|
||||||
|
hooks.push('ngDoCheck');
|
||||||
|
}
|
||||||
|
ngAfterViewInit() {
|
||||||
|
hooks.push('ngAfterViewInit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({template: `<div [testDir]="value">{{value}}</div>`})
|
||||||
|
class App {
|
||||||
|
value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({declarations: [App, TestDirective]});
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
expect(() => fixture.detectChanges()).not.toThrow();
|
||||||
|
expect(hooks).toEqual(['ngOnChanges', 'ngDoCheck', 'ngAfterViewInit']);
|
||||||
|
expect(fixture.nativeElement.textContent.trim()).toBe('1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue