Skip to content

Template with signal not updating when the value of the signal changes #61662

@kristilw

Description

@kristilw

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

core

Is this a regression?

No

Description

Angular is not refreshing an embedded view even though there are signals in the template that have updated values.

More specifically the error occurs when a structural directive creates the embedded view:

  1. Not as part of the initial change detection cycle
  2. Inside of a component (later referenced as "child component") that uses OnPush change detection
  3. ...which lives inside a parent component that uses the default change detection.

Please provide a link to a minimal reproduction of the bug

https://stackblitz.com/edit/stackblitz-starters-xnch5wpq?file=src%2Fapp%2Fparent.comoponent.ts,src%2Fapp%2Fchild.component.ts,src%2Fapp%2Fdata.directive.ts

Please provide the exception or error you saw

Angular is not refreshing an embedded view even though there are signals in the template that have updated values.

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

Not sure how to get this from stackblitz, but here is the relevant package.json:

  "dependencies": {
    "@angular/animations": "19.2.12",
    "@angular/common": "19.2.12",
    "@angular/compiler": "^19.2.12",
    "@angular/core": "^19.2.12",
    "@angular/platform-browser": "^19.2.12",
    "rxjs": "^7.8.2",
    "tslib": "^2.8.1",
    "typescript": "^5.8.3",
    "zone.js": "^0.15.0"
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "^19.2.12",
    "@angular/cli": "^19.2.12",
    "@angular/compiler-cli": "^19.2.12",
    "@angular/language-service": "^19.2.12",
    "@types/node": "^22.7.2",
    "codelyzer": "^6.0.2",
    "ts-node": "^10.9.2"
  },

Anything else?

Sorry if this has already been reported, but was not able to find any similar issues.

I have tried to mimize the setup for the bug, but here are a couple of things that make the bug not appear:

  • The parent component uses OnPush change detection
    • However this is not possible on our legacy apps
  • The child component uses the default change detection
    • This would reduce performance
  • Triggering mark for check or detect changes on the embedded view after it has been created
    • Causes unnecessary change detection cycles, the template will be rendered twice

In the stackblitz I have used the ng.ɵgetSignalGraph on ngDoCheck and ngAfterViewChecked on both the parent and child component. One can then see that the signal graph does not update correctly when the bug occurs. The embedded view is initally added as a node to the parent component with the signal as a dependency, but then the signal dependency is removed on the next change detection cycle.

Furthermore, setting a breakboint during the rendering of the embedded view, one gets this result:

Image

The refreshView function is where the active consumer can be set. If its a component the viewShouldHaveReactiveConsumer returns true and sets that component as the active consumer. If not it makes a check if there is already an active consumer, and if not it creates a temporary one that gets added to the current lView later on:

  // part of refreshView before executing templates
  let context = null;
  if (viewShouldHaveReactiveConsumer(tView)) {
    currentConsumer = getOrBorrowReactiveLViewConsumer(lView);
    prevConsumer = consumerBeforeComputation(currentConsumer);
  } else if (getActiveConsumer() === null) {
    // If the current view should not have a reactive consumer but we don't have an active consumer,
    // we still need to create a temporary consumer to track any signal reads in this template.
    // This is a rare case that can happen with `viewContainerRef.createEmbeddedView(...).detectChanges()`.
    // This temporary consumer marks the first parent that _should_ have a consumer for refresh.
    // Once that refresh happens, the signals will be tracked in the parent consumer and we can destroy
    // the temporary one.
    returnConsumerToPool = false;
    currentConsumer = getOrCreateTemporaryConsumer(lView);
    prevConsumer = consumerBeforeComputation(currentConsumer);
  }
  ...
  executeTemplate(tView, lView, templateFn, RenderFlags.Update, context);

Also relvant code from detectChangesInView :

  // part of detectChangesInView
  if (shouldRefreshView) {
    refreshView(tView, lView, tView.template, lView[CONTEXT]);
  } else if (flags & LViewFlags.HasChildViewsToRefresh) {
    ...
    detectChangesInEmbeddedViews(lView, ChangeDetectionMode.Targeted);
    ...
  }

Then breaking up the timeline, it goes something like this:

  • The app is initialized, and the parent, child and the structual directive is created.

  • Some time later the embedded view is created, and on attach runs markAncestorsForTraversal on the corresponding lView, and triggering change detection (see the picture of the stacktrace to follow along):

    1. Runs detectChangesInView with root, enters refreshView and sets root as active consumer
    2. Runs detectChangesInView with parent component, enters refreshView and sets parent component as active consumer
    3. Runs detectChangesInView with child component, does not enter refreshView and because of this does not set it as the active consumer. However, It has been marked with HasChildViewsToRefresh (because of the creation of the embedded view) and triggers change detection in its children.
    4. Runs detectChangesInView on the embedded view, entering refreshView (first time it is created). Because it is not a component it does not run getOrBorrowReactiveLViewConsumer. There is already an active consumer (the parent component) so it does not hit the getOrCreateTemporaryConsumer function either. No consumer is set, so the root component is still the active consumer
    5. Because the template relies on a signal, and the active consumer is the parent component, the parent component is added as a live dependency of that signal
  • The signal changes value:

    1. This causes the signal to run markAncestorsForTraversal on the parent component
    2. Runs detectChangesInView with root, enters refreshView
    3. Runs detectChangesInView with parent component, enters refreshView
    4. Runs detectChangesInView with child component, but the component is neither refreshed or has the flag HasChildViewsToRefresh, thus skips change detection of this component and the embedded view
    5. The parent component is then removed as a live consumer of the signal because the signal was not called during this change detection cycle
  • The signal changes value again:

    1. Nothing happens because it has no live consumer

Note: If one stops det debugger at the refreshView function of the embedded view and the runs the code as if getActiveConsumer() returns null the embedded view will update when the signal changes. This is what happens when the parent component uses OnPush change detection.

I have also created a fork of Angular where I have added a unit tests which now fails: link to repo

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions