Skip to content

Commit 3b6497b

Browse files
JiaLiPassionjosephperrott
authored andcommitted
fix(core): markDirty() should only mark flags when really scheduling tick. (#39316)
Close #39296 Fix an issue that `markDirty()` will not trigger change detection. The case is for example we have the following component. ``` export class AppComponent implements OnInit { constructor(private router: Router) {} ngOnInit() { this.router.events .pipe(filter((e) => e instanceof NavigationEnd)) .subscribe(() => ɵmarkDirty(this)); } } export class CounterComponent implements OnInit, OnDestroy { ngOnInit() { this.countSubject.pipe(takeUntil(this.destroy)).subscribe((count) => { this.count = count; ɵmarkDirty(this); }); } ``` Then the app navigate from `AppComponent` to `CounterComponent`, so there are 2 `markDirty()` call at in a row. The `1st` call is from `AppComponent` when router changed, the `2nd` call is from `CounterComponent.ngOnInit()`. And the `markDirty()->scheduleTick()` code look like this ``` function scheduleTick(rootContext, flags) { const nothingScheduled = rootContext.flags === 0 /* Empty */; rootContext.flags |= flags; if (nothingScheduled && rootContext.clean == _CLEAN_PROMISE) { rootContext.schedule(() => { ... if (rootContext.flags & RootContextFlags.DetectChanges) rootContext.flags &= ~RootContextFlags.DetectChanges; tickContext(); rootContext.clean = _CLEAN_PROMISE; ... }); ``` So in this case, the `1st` markDirty() will 1. set rootContext.flags = 1 2. before `tickContext()`, reset rootContext.flags = 0 3. inside `tickContext()`, it will call `CounterComponent.ngOnint()`, so the `2nd` markDirty() is called. 4. and the `2nd` scheduleTick is called, `nothingScheduled` is true, but rootContext.clean is not `_CLEAN_PROMISE` yet, since the `1st` markDirty tick is still running. 5. So nowhere will reset the `rootContext.flags`. 6. then in the future, any other `markDirty()` call will not trigger the tick, since `nothingScheduled` is always false. So `nothingScheduled` means no tick is scheduled, `rootContext.clean === _CLEAN_PROMISE` means no tick is running. So we should set the flags to `rootContext` only when `no tick is scheudled or running`. PR Close #39316
1 parent 0e60dc5 commit 3b6497b

File tree

2 files changed

+104
-5
lines changed

2 files changed

+104
-5
lines changed

packages/core/src/render3/instructions/shared.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1851,9 +1851,10 @@ export function markViewDirty(lView: LView): LView|null {
18511851
*/
18521852
export function scheduleTick(rootContext: RootContext, flags: RootContextFlags) {
18531853
const nothingScheduled = rootContext.flags === RootContextFlags.Empty;
1854-
rootContext.flags |= flags;
1855-
18561854
if (nothingScheduled && rootContext.clean == _CLEAN_PROMISE) {
1855+
// https://github.com/angular/angular/issues/39296
1856+
// should only attach the flags when really scheduling a tick
1857+
rootContext.flags |= flags;
18571858
let res: null|((val: null) => void);
18581859
rootContext.clean = new Promise<null>((r) => res = r);
18591860
rootContext.scheduler(() => {

packages/core/test/render3/change_detection_spec.ts

+101-3
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,20 @@
88

99
import {withBody} from '@angular/private/testing';
1010

11-
import {ChangeDetectionStrategy, DoCheck} from '../../src/core';
11+
import {ChangeDetectionStrategy, DoCheck, OnInit} from '../../src/core';
1212
import {whenRendered} from '../../src/render3/component';
13-
import {getRenderedText, LifecycleHooksFeature, ɵɵadvance, ɵɵdefineComponent, ɵɵgetCurrentView, ɵɵproperty, ɵɵtextInterpolate1, ɵɵtextInterpolate2} from '../../src/render3/index';
14-
import {detectChanges, markDirty, tick, ɵɵelement, ɵɵelementEnd, ɵɵelementStart, ɵɵlistener, ɵɵtext, ɵɵtextInterpolate} from '../../src/render3/instructions/all';
13+
import {AttributeMarker, getRenderedText, LifecycleHooksFeature, ɵɵadvance, ɵɵdefineComponent, ɵɵgetCurrentView, ɵɵproperty, ɵɵtextInterpolate1, ɵɵtextInterpolate2} from '../../src/render3/index';
14+
import {detectChanges, markDirty, tick, ɵɵelement, ɵɵelementEnd, ɵɵelementStart, ɵɵlistener, ɵɵtemplate, ɵɵtext, ɵɵtextInterpolate} from '../../src/render3/instructions/all';
1515
import {RenderFlags} from '../../src/render3/interfaces/definition';
1616
import {Renderer3, RendererFactory3} from '../../src/render3/interfaces/renderer';
1717
import {FLAGS, LViewFlags} from '../../src/render3/interfaces/view';
1818

19+
import {NgIf} from './common_with_def';
1920
import {containerEl, createComponent, renderComponent, requestAnimationFrame} from './render_util';
2021

2122
describe('change detection', () => {
2223
describe('markDirty, detectChanges, whenRendered, getRenderedText', () => {
24+
let mycompOninit: MyComponentWithOnInit;
2325
class MyComponent implements DoCheck {
2426
value: string = 'works';
2527
doCheckCount = 0;
@@ -48,6 +50,84 @@ describe('change detection', () => {
4850
});
4951
}
5052

53+
class MyComponentWithOnInit implements OnInit, DoCheck {
54+
value: string = 'works';
55+
doCheckCount = 0;
56+
57+
ngOnInit() {
58+
markDirty(this);
59+
}
60+
61+
ngDoCheck(): void {
62+
this.doCheckCount++;
63+
}
64+
65+
click() {
66+
this.value = 'click works';
67+
markDirty(this);
68+
}
69+
70+
static ɵfac = () => mycompOninit = new MyComponentWithOnInit();
71+
static ɵcmp = ɵɵdefineComponent({
72+
type: MyComponentWithOnInit,
73+
selectors: [['my-comp-oninit']],
74+
decls: 2,
75+
vars: 1,
76+
template:
77+
(rf: RenderFlags, ctx: MyComponentWithOnInit) => {
78+
if (rf & RenderFlags.Create) {
79+
ɵɵelementStart(0, 'span');
80+
ɵɵtext(1);
81+
ɵɵelementEnd();
82+
}
83+
if (rf & RenderFlags.Update) {
84+
ɵɵadvance(1);
85+
ɵɵtextInterpolate(ctx.value);
86+
}
87+
}
88+
});
89+
}
90+
91+
class MyParentComponent implements OnInit {
92+
show = false;
93+
value = 'parent';
94+
mycomp: any = undefined;
95+
96+
ngOnInit() {}
97+
98+
click() {
99+
this.show = true;
100+
markDirty(this);
101+
}
102+
103+
static ɵfac = () => new MyParentComponent();
104+
static ɵcmp = ɵɵdefineComponent({
105+
type: MyParentComponent,
106+
selectors: [['my-parent-comp']],
107+
decls: 2,
108+
vars: 1,
109+
directives: [NgIf, MyComponentWithOnInit],
110+
consts: [[AttributeMarker.Template, 'ngIf']],
111+
template:
112+
(rf: RenderFlags, ctx: MyParentComponent) => {
113+
if (rf & RenderFlags.Create) {
114+
ɵɵtext(0, ' -->\n');
115+
ɵɵtemplate(1, (rf, ctx) => {
116+
if (rf & RenderFlags.Create) {
117+
ɵɵelementStart(0, 'div');
118+
ɵɵelement(1, 'my-comp-oninit');
119+
ɵɵelementEnd();
120+
}
121+
}, 2, 0, 'div', 0);
122+
}
123+
if (rf & RenderFlags.Update) {
124+
ɵɵadvance(1);
125+
ɵɵproperty('ngIf', ctx.show);
126+
}
127+
}
128+
});
129+
}
130+
51131
it('should mark a component dirty and schedule change detection', withBody('my-comp', () => {
52132
const myComp = renderComponent(MyComponent, {hostFeatures: [LifecycleHooksFeature]});
53133
expect(getRenderedText(myComp)).toEqual('works');
@@ -66,6 +146,24 @@ describe('change detection', () => {
66146
expect(getRenderedText(myComp)).toEqual('updated');
67147
}));
68148

149+
it('should detectChanges after markDirty is called multiple times within ngOnInit',
150+
withBody('my-comp-oninit', () => {
151+
const myParentComp =
152+
renderComponent(MyParentComponent, {hostFeatures: [LifecycleHooksFeature]});
153+
expect(myParentComp.show).toBe(false);
154+
myParentComp.click();
155+
requestAnimationFrame.flush();
156+
expect(myParentComp.show).toBe(true);
157+
const myComp = mycompOninit;
158+
expect(getRenderedText(myComp)).toEqual('works');
159+
expect(myComp.doCheckCount).toBe(1);
160+
myComp.click();
161+
expect(getRenderedText(myComp)).toEqual('works');
162+
requestAnimationFrame.flush();
163+
expect(getRenderedText(myComp)).toEqual('click works');
164+
expect(myComp.doCheckCount).toBe(2);
165+
}));
166+
69167
it('should detectChanges only once if markDirty is called multiple times',
70168
withBody('my-comp', () => {
71169
const myComp = renderComponent(MyComponent, {hostFeatures: [LifecycleHooksFeature]});

0 commit comments

Comments
 (0)