Template with signal not updating when the value of the signal changes
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:
- Not as part of the initial change detection cycle
- Inside of a component (later referenced as "child component") that uses OnPush change detection
- ...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:
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
markAncestorsForTraversalon the corresponding lView, and triggering change detection (see the picture of the stacktrace to follow along):- Runs
detectChangesInViewwith root, entersrefreshViewand sets root as active consumer - Runs
detectChangesInViewwith parent component, entersrefreshViewand sets parent component as active consumer - Runs
detectChangesInViewwith child component, does not enterrefreshViewand because of this does not set it as the active consumer. However, It has been marked withHasChildViewsToRefresh(because of the creation of the embedded view) and triggers change detection in its children. - Runs detectChangesInView on the embedded view, entering
refreshView(first time it is created). Because it is not a component it does not rungetOrBorrowReactiveLViewConsumer. There is already an active consumer (the parent component) so it does not hit thegetOrCreateTemporaryConsumerfunction either. No consumer is set, so the root component is still the active consumer - 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
- Runs
-
The signal changes value:
- This causes the signal to run
markAncestorsForTraversalon the parent component - Runs
detectChangesInViewwith root, entersrefreshView - Runs
detectChangesInViewwith parent component, entersrefreshView - Runs
detectChangesInViewwith child component, but the component is neither refreshed or has the flagHasChildViewsToRefresh, thus skips change detection of this component and the embedded view - The parent component is then removed as a live consumer of the signal because the signal was not called during this change detection cycle
- This causes the signal to run
-
The signal changes value again:
- 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
This is indeed a bug. The reproduction boils down to:
- Default parent, OnPush child
- Create an embedded view in the child without any other dirty-marking. This marks the child for traversal, and the new embedded view is dirty from creation.
- CD runs & refreshes the parent (Default CD mode)
- The child is not refreshed (OnPush), but we descend to embedded views b/c it's marked for traversal
- The EV is refreshed, but in the reactive context of the parent (wrong!)
- The signal change marks the parent component dirty, but not the child.
- The parent refreshes, and doesn't read the signal of course so it gets dropped as a dependency
- CD never reaches the child again, as nothing will ever mark it dirty.
Theory of a fix:
When we don't refresh the child (it's only marked for traversal), switch the reactive consumer to null. That ensures that when we do descend into the EV, we create a temporary consumer for its first CD. Then on a signal update, we'll properly run the child and then the EV in the child's consumer, picking up the signal dep there.
That is great to get confirmed from the man himself! Caused quite an elusive bug in one of our prod environments.
The fix you suggest was my gut reaction also. I'd love to provide a pull request that implements it, I just didn't want to get ahead of my self before a) getting the bug confirmed and b) a discusson on how the bug should be resolved. Would that be okay?
This issue has been automatically locked due to inactivity. Please file a new issue if you are encountering a similar or related problem.
Read more about our automatic conversation locking policy.
This action has been performed automatically by a bot.