Skip to content

Template of an OnPush component is no longer updated after a signal change in some cases #50320

@divdavem

Description

@divdavem

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

core

Is this a regression?

No

Description

Go to this stackblitz
Click on the Toggle state button.
item is visible appears
Click again on the Toggle state button.
item is visible disappears
Click again on the Toggle state button.
Nothing happens! I would expect the item is visible element to appear again.

Here is the code of the stackblitz:

import 'zone.js/dist/zone';
import {
  ChangeDetectionStrategy,
  Component,
  Directive,
  effect,
  signal,
  Input,
  OnChanges,
  OnDestroy,
  SimpleChanges,
  computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { bootstrapApplication } from '@angular/platform-browser';

interface State {
  isInDom: boolean;
  shouldBeInDom: boolean;
}

const createModel = () => {
  const state = signal<State>({
    isInDom: false,
    shouldBeInDom: false,
  });
  const updateState = (changes: Partial<State>) =>
    state.update((value) => ({
      ...value,
      ...changes,
    }));
  const toggle = () => updateState({ shouldBeInDom: !state().shouldBeInDom });
  const setDomState = (isInDom: boolean) => updateState({ isInDom });
  return { state, toggle, setDomState };
};

@Directive({
  standalone: true,
  selector: '[myDirective]',
})
export class MyDirective implements OnChanges, OnDestroy {
  @Input({ required: true })
  myDirective!: ReturnType<typeof createModel>;

  async ngOnChanges(changes: SimpleChanges) {
    // Comment the following line to have the issue on display:
    await 0;
    this.myDirective.setDomState(true);
  }

  async ngOnDestroy() {
    // Uncomment the following line as a work-around to fix the issue:
    // await 0;
    this.myDirective.setDomState(false);
  }
}

@Component({
  selector: 'my-component',
  standalone: true,
  // Comment the following line as a work-around to fix the issue:
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [CommonModule, MyDirective],
  template: `
    <div *ngIf="state().shouldBeInDom" [myDirective]="model">item is visible</div>
  `,
})
export class MyComponent {
  model = createModel();
  // Uncomment the following line as a work-around to fix the issue:
  // state = this.model.state;
  state = computed(() => this.model.state());

  constructor() {
    // Uncomment the following line as a work-around to fix the issue:
    // effect(() => console.log(this.state()));
  }
}

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule, MyComponent],
  template: `
    <button (click)="c.model.toggle()">Toggle state</button>
    <my-component #c></my-component>
  `,
})
export class App {}

bootstrapApplication(App);

Please provide a link to a minimal reproduction of the bug

https://stackblitz.com/edit/angular-nltu6v?file=src/main.ts

Please provide the exception or error you saw

There is no exception, no explanation.

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

Angular CLI: 16.0.1
Node: 18.16.0
Package Manager: npm 9.5.1
OS: linux x64

Angular: 16.0.1
... animations, cli, common, compiler, compiler-cli, core, forms
... platform-browser, platform-browser-dynamic, router

Package                         Version
---------------------------------------------------------
@angular-devkit/architect       0.1600.1
@angular-devkit/build-angular   16.0.1
@angular-devkit/core            16.0.1
@angular-devkit/schematics      16.0.1
@schematics/angular             16.0.1
ng-packagr                      16.0.1
rxjs                            7.8.1
typescript                      5.0.4

Anything else?

What triggers the bug seems to be the fact that the signal is synchronously changed either from ngOnChanges or from ngOnDestroy in the directive. If this is done asynchronously, the problem does not happen.

This makes me think about the famous ExpressionChangedAfterItHasBeenCheckedError issue (even if there is no exception displayed in this case), the idea is the same: there is an issue when updating data at some specific times.

As mentioned in the comments of the code, if any of the following modifications is done (alone), the bug does not happen:

  • if state is a direct reference to this.model.state in MyComponent instead of being a computed
  • if an effect that reads this.state() is added in the constructor of MyComponent
  • if MyComponent is not OnPush

Metadata

Metadata

Labels

P3An issue that is relevant to core functions, but does not impede progress. Important, but not urgentarea: coreIssues related to the framework runtimebugcore: reactivityWork related to fine-grained reactivity in the core frameworkcross-cutting: signals

Type

No type

Projects

Status

Done

Relationships

None yet

Development

No branches or pull requests

Issue actions