Skip to content

Calling ChangeDetectorRef.detectChanges in child view causes error in parent NgForOf directive #45577

Open

Description

@anthonyrgreen

Which @angular/* package(s) are the source of the bug?

core, forms

Is this a regression?

No

Description

TL;DR: When a child view calls ChangeDetectorRef.detectChanges at an appropriate moment in its creation lifecycle, it can cause ngForOf to throw an error when deleting that child in response to input binding changes. The nature of the connection between these two events is mysterious to me.

Longer explanation:
I have an ngForOf directive that operates over a list of FormControl elements, and produces a <list-item> view for every one. ListItem implements the ControlValueAccessor interface, and every ListItem is hooked up to its associated FormControl element. I have button that, when clicked, deletes one of the FormControl elements, and thus causes ngForOf to update, removing that control's associated view. It looks like this:

@Component({
  selector: 'my-app',
  template: `
<ul>
  <list-item 
    *ngFor="let control of controls"
    [formControl]="control"
    (registerOnChangeCalled)="changeDetectorRef.detectChanges()"
    ></list-item>
</ul>
<button (click)="controls.splice(0, 1)">remove first list item</button>`,
})
export class AppComponent {
  controls = [new FormControl(), new FormControl()];

  constructor(private readonly changeDetectorRef: ChangeDetectorRef) {}
}

Note that a registerOnChangeCalled event fired from a <list-Item> causes changeDetectorRef.detectChanges() to be called.

My ListItem component is very simple:

@Component({
  selector: 'list-item',
  template: `<li>I am list item</li>`,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => ListItem),
      multi: true,
    },
  ],
})
export class ListItem implements ControlValueAccessor {
  constructor(private readonly changeDetectorRef: ChangeDetectorRef) {}
  @Output() registerOnChangeCalled = new EventEmitter();

  registerOnChange(updateModelValue) {
    this.registerOnChangeCalled.emit(null);
  }

  writeValue(modelValue) { /* unimplemented */ }
  registerOnTouched() { /* unimplemented */ }
}

In this minimal repro example, registerOnChange only emits an event to registerOnChangeCalled, although in a real app it would do more. registerOnChange is a ControlValueAccessor method which is called once during the FormControl directive initialization, after ListItem's ngOnInit and before its ngAfterContentInit.

With this setup, clicking the AppComponent button causes the following error to be thrown:

ERROR TypeError: Cannot read properties of null (reading 'destroyed')
    at ViewContainerRef_.move (refs.ts:218:17)
    at eval (ng_for_of.ts:244:33)
    at DefaultIterableDiffer.forEachOperation (default_iterable_differ.ts:112:9)
    at NgForOf._applyChanges (ng_for_of.ts:227:13)
    at NgForOf.ngDoCheck (ng_for_of.ts:221:25)
    at checkAndUpdateDirectiveInline (provider.ts:220:15)
    at checkAndUpdateNodeInline (view.ts:442:14)
    at checkAndUpdateNode (view.ts:402:12)
    at debugCheckAndUpdateNode (services.ts:435:44)
    at debugCheckDirectivesFn (services.ts:396:7)

If I do not call changeDetectorRef.detectChanges(), the error will not be thrown. If I call changeDetectorRef.detectChanges() in ListItem.ngOnInit or ngAfterContentInit, the error will not be thrown.

This is effectively a repro of #45417, which was closed 12 days ago due to a failure to repro.

Please provide a link to a minimal reproduction of the bug

https://stackblitz.com/edit/angular-yjdjfe?file=src/app/app.component.ts

Please provide the exception or error you saw

ERROR TypeError: Cannot read properties of null (reading 'destroyed')
    at ViewContainerRef_.move (refs.ts:218:17)
    at eval (ng_for_of.ts:244:33)
    at DefaultIterableDiffer.forEachOperation (default_iterable_differ.ts:112:9)
    at NgForOf._applyChanges (ng_for_of.ts:227:13)
    at NgForOf.ngDoCheck (ng_for_of.ts:221:25)
    at checkAndUpdateDirectiveInline (provider.ts:220:15)
    at checkAndUpdateNodeInline (view.ts:442:14)
    at checkAndUpdateNode (view.ts:402:12)
    at debugCheckAndUpdateNode (services.ts:435:44)
    at debugCheckDirectivesFn (services.ts:396:7)

Please provide the environment you discovered this bug in (run ng version)

"dependencies": {
    "@angular/animations": "^12.1.0",
    "@angular/common": "^12.1.0",
    "@angular/compiler": "^12.1.0",
    "@angular/core": "^12.1.0",
    "@angular/forms": "^12.1.0",
    "@angular/platform-browser": "^12.1.0",
    "@angular/platform-browser-dynamic": "^12.1.0",
    "@angular/router": "^12.1.0",
    "core-js": "^3.15.1",
    "rxjs": "^7.1.0",
    "tslib": "^2.3.0",
    "zone.js": "^0.11.4"
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "~0.1100.4",
    "@angular/cli": "~11.0.4",
    "@angular/compiler-cli": "~11.0.4",
    "@types/jasmine": "~3.6.0",
    "@types/node": "^12.11.1",
    "codelyzer": "^6.0.0",
    "ts-node": "~8.3.0",
    "tslint": "~6.1.0",
    "typescript": "~4.0.2"
  }

Anything else?

No response

Activity

  1. JoostK commented on Apr 9, 2022

    @JoostK
    Member

    The registerOnChangeCalled event is invoked during a change detection cycle, then triggering another nested change detection cycle. This results in a re-entrant invocation of NgForOf.ngDoCheck:

    image

    (the blue stack frame is the same method as the top stack frame)

    This is a problem, as IterableDiffer.diff in the re-entrant call will clobber the internal state, causing the outer invocation that is still processing changes using IterableChanges.forEachOperation—where IterableChanges is really just the IterableDiffer instance which has since been clobbered—to get into an inconsistent state.

    It is generally an anti-pattern to emit events to parents during a change detection cycle, as it goes against unidirectional dataflow. The framework should arguably not crash in the way it does today, but I'm not sure what the behaviour here should be.

    If you change registerOnChangeCalled to use an asynchronous EventEmitter (by passing true to its constructor) then the issue does not occur, as there won't be a re-entrant change detection cycle.

  2. added this to the needsTriage milestone on Apr 9, 2022
  3. anthonyrgreen commented on Apr 12, 2022

    @anthonyrgreen
    Author

    Thank you very much for the prompt response.

    The current behavior is quite surprising, no? I don't know of any documentation that could have led me to expect this. I know that emitting events during a change-detection cycle is not encouraged but the typical reasons for that -- as I understand them -- have to do with preventing infinite loops where component A modifies component B which modifies component A and so on. In this case, I think there are quite good reasons why I need non-uni-directional data flow (they have to do with particularities of CVAs and their validation functions -- can explain more if you are interested): shouldn't that be possible without crashing *ngFor?

    At the very least, I think *ngFor should be throwing a more informative error -- perhaps keeping track of when it is reentered, and throwing an error on the basis of that? The error I got is not very helpful in diagnosing the problem unless you have pretty deep framework knowledge.

  4. otodockal commented on Apr 12, 2022

    @otodockal
    Contributor

    @anthonyrgreen don't get me wrong, but if it's only about forms, I would write this way https://stackblitz.com/edit/angular-v8g6kp?file=src%2Fapp%2Fapp.component.ts

    If it's a general question, looks like this, I would stick with unidirectional flow, not a very helpful answer, I know. :-)

  5. anthonyrgreen commented on Apr 12, 2022

    @anthonyrgreen
    Author

    @otodockal :) well, yes, if you remove the change detection cycle, it fixes the issue. But I need the change detection cycle! That's how I ran into this bug! My toy reproduction doesn't make the necessity for change detection obvious, but that's because I'm trying to make the repro as simple as possible.

    The actual fix I'm using in my app is to put the change detection cycle inside ngAfterViewInit. This makes the bug go away and accomplishes my other aims!

  6. added
    P4A relatively minor issue that is not relevant to core functions
    on May 19, 2022
  7. modified the milestones: needsTriage, Backlog on May 19, 2022
  8. arobinson commented on May 23, 2022

    @arobinson

    Not sure if this is the same issue, but I am seeing the same crash. I still need to debug more to try to find the cause. But adding here in case this helps:

    [ERROR] TypeError: Cannot read properties of null (reading 'destroyed')
        at ViewContainerRef.move (view_container_ref.ts:412:1)
        at ng_for_of.ts:250:1
        at DefaultIterableDiffer.forEachOperation (default_iterable_differ.ts:112:1)
        at NgForOf._applyChanges (ng_for_of.ts:235:1)
        at NgForOf.ngDoCheck (ng_for_of.ts:229:1)
        at callHook (hooks.ts:270:1)
        at callHooks (hooks.ts:230:1)
        at executeCheckHooks (hooks.ts:151:1)
        at refreshView (shared.ts:381:1)
        at refreshEmbeddedViews (shared.ts:1674:1)
        at refreshView (shared.ts:396:1)
        at refreshEmbeddedViews (shared.ts:1674:1)
        at refreshView (shared.ts:396:1)
        at refreshComponent (shared.ts:1724:1)
        at refreshChildComponents (shared.ts:114:1)
        at refreshView (shared.ts:426:1)
        at refreshEmbeddedViews (shared.ts:1674:1)
        at refreshView (shared.ts:396:1)
        at refreshComponent (shared.ts:1724:1)
        at refreshChildComponents (shared.ts:114:1)
        at refreshView (shared.ts:426:1)
        at renderComponentOrTemplate (shared.ts:493:1)
        at tickRootContext (shared.ts:1909:1)
        at detectChangesInRootView (shared.ts:1932:1)
        at RootViewRef.detectChanges (view_ref.ts:312:1)
        at ApplicationRef.tick (application_ref.ts:863:1)
        at application_ref.ts:622:1
        at ZoneDelegate.invoke (zone.js:372:1)
        at Object.onInvoke (ng_zone.ts:405:1)
        at ZoneDelegate.invoke (zone.js:371:1)
        at Zone.run (zone.js:134:1)
        at NgZone.run (ng_zone.ts:184:1)
        at Object.next (application_ref.ts:621:1)
        at SafeSubscriber.__tryOrUnsub (Subscriber.ts:265:1)
        at SafeSubscriber.next (Subscriber.ts:207:1)
        at Subscriber._next (Subscriber.ts:139:1)
        at Subscriber.next (Subscriber.ts:99:1)
        at EventEmitter_.next (Subject.ts:70:1)
        at EventEmitter_.emit (event_emitter.ts:114:1)
        at checkStable (ng_zone.ts:323:1)
        at Object.onHasTask (ng_zone.ts:423:1)
        at ZoneDelegate.hasTask (zone.js:426:1)
        at ZoneDelegate._updateTaskCount (zone.js:447:1)
        at Zone._updateTaskCount (zone.js:274:1)
        at Zone.runTask (zone.js:195:1)
        at drainMicroTaskQueue (zone.js:582:1)
        at ZoneTask.invokeTask [as invoke] (zone.js:491:1)
        at invokeTask (zone.js:1600:1)
        at HTMLDivElement.globalZoneAwareCallback (zone.js:1626:1)
    
    @angular-devkit/architect       0.1301.4
    @angular-devkit/build-angular   13.1.2
    @angular-devkit/core            13.1.2
    @angular-devkit/schematics      13.1.2
    @angular/cli                    13.1.2
    
  9. pkozlowski-opensource commented on Jun 7, 2022

    @pkozlowski-opensource
    Member

    I know that emitting events during a change-detection cycle is not encouraged but the typical reasons for that -- as I understand them -- have to do with preventing infinite loops where component A modifies component B which modifies component A and so on. In this case, I think there are quite good reasons why I need non-uni-directional data flow (they have to do with particularities of CVAs and their validation functions -- can explain more if you are interested)

    @anthonyrgreen I would be very much interested in understanding more. Especially that you've mentioned "typical reasons" and "preventing infinite loops where component A modifies component B which modifies component A and so on.".

    Could we try to distill this use-case in a stackblitz to understand if Angular fails to cover a "typical" use-case?

  10. arturovt commented on Sep 29, 2026

    @arturovt
    Contributor

    @JeanMeche this can be closed. registerOnChange runs during the form directive's setup, so calling detectChanges() there re-enters change detection while the parent's ngForOf differ is mid-operation, which is how the differ ends up with a stale view record. Reentrant change detection from inside another view's update is not supported, which is why the same call is fine from ngOnInit.

    Guarding against it would mean detecting reentrancy in the core change detection loop, which every application runs, for a P4 with no upvotes and no duplicates since 2022.

    registerOnChange is not a place for view work: the accessor should store the callback and nothing more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P4A relatively minor issue that is not relevant to core functionsarea: coreIssues related to the framework runtimecore: change detectioncore: differs

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions