Which @angular/* package(s) are the source of the bug?
core
Is this a regression?
No
Description
A transplanted view is an embedded view that is declared in one component's template and inserted into a ViewContainerRef of another component. Angular tracks these views in the declaration container's MOVED_VIEWS list. It uses that list to refresh them when the declaring component is checked.
detachMovedView removes a view from that list without checking that the view is in it:
export function detachMovedView(declarationContainer: LContainer, lView: LView) {
const movedViews = declarationContainer[MOVED_VIEWS]!;
const declarationViewIndex = movedViews.indexOf(lView);
movedViews.splice(declarationViewIndex, 1);
}
If the view was already removed, indexOf returns -1, and splice(-1, 1) removes the last entry instead. That entry is a different transplanted view that is still attached. From then on, markTransplantedViewsForRefresh skips that view when its declaring component is refreshed. Its bindings to the declaring component's state stop updating.
The second removal happens when a component is destroyed that clears its own ViewContainerRef in ngOnDestroy:
destroyViewTree cleans up child views first. cleanUpView of the transplanted view calls detachMovedView, which correctly removes it from MOVED_VIEWS. The destroyed view is still in the container.
- Next,
cleanUpView of the component's own view runs its ngOnDestroy. ViewContainerRef.clear() → remove() → detachView() calls detachMovedView() a second time for the same view. indexOf returns -1, and the last live view in the list is dropped.
Clearing a container on destroy is a common pattern. CdkPortalOutlet does it too: ngOnDestroy → dispose() → clear(). So every CdkPortalOutlet that renders a TemplatePortal declared in another component drops one unrelated view from MOVED_VIEWS when it is destroyed.
Minimal reproduction
Start from a new app (ng new --minimal --zoneless). Replace src/app/app.ts with the code below and keep the default app.config.ts. The code below is quite ugly, but useful to keep the reproduction example small.
OutletComponent renders a template it receives as an input, and clears its container on destroy. App declares that template and renders one outlet per item:
import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Component({
selector: 'app-outlet',
template: '<ng-container #container />',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OutletComponent implements OnInit, OnDestroy {
@Input({ required: true }) public template!: TemplateRef<unknown>;
@ViewChild('container', { read: ViewContainerRef, static: true }) public container!: ViewContainerRef;
public ngOnInit(): void {
this.container.createEmbeddedView(this.template);
}
public ngOnDestroy(): void {
this.container.clear();
}
}
@Component({
selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AsyncPipe, OutletComponent],
template: `
@let items = items$ | async;
@let counter = counter$ | async;
<ng-template #cell>{{ counter }}</ng-template>
@for (item of items; track item) {
<app-outlet [template]="cell" />
}
<button (click)="removeLastItem()">Remove last</button>
<button (click)="increment()">Increment</button>
`,
})
export class App {
public readonly items$ = new BehaviorSubject([1, 2, 3, 4, 5]);
public readonly counter$ = new BehaviorSubject(0);
public removeLastItem(): void {
this.items$.next(this.items$.value.slice(0, -1));
}
public increment(): void {
this.counter$.next(this.counter$.value + 1);
}
}
Steps:
- Click Increment. All five outlets show
1.
- Click Remove last. Four outlets remain, all showing
1.
- Click Increment twice.
Expected: all four outlets show 3.
Actual: outlets 1–3 show 3, but outlet 4 stays at 1 permanently. Removing outlet 5 detached its view twice, and the second splice(-1, 1) removed outlet 4's view from MOVED_VIEWS.
With the splice guarded by if (declarationViewIndex !== -1), all four outlets show 3.
The bug only becomes visible when both of these conditions hold:
- The view is inserted into another component. If the outlet inserts into its host
ViewContainerRef instead, the view still loses its MOVED_VIEWS entry. But it then belongs to the declaring component's own view tree, which refreshes it anyway.
- The view reads no signals. With
counter = signal(0) and {{ counter() }} in the template, the view's own reactive consumer schedules its refresh, so it keeps updating without MOVED_VIEWS. Every other binding to the declaring component's state is affected, such as component fields and @let values. This includes @let values that come from AsyncPipe, as in this reproduction.
Impact
The effect accumulates in virtualized lists and tables. Their rows are destroyed and recreated while scrolling, and each row renders cells from templates declared by the consuming component, typically through CdkPortalOutlet. Every destroyed row removes one live cell view from MOVED_VIEWS.
In our situation, in one such table with 500 rows, 36 of the 40 detachMovedView calls during a single scroll from top to bottom hit index -1. After scrolling, cells in the last rendered rows stopped reflecting changes to the declaring component's state. With the guard below, all cells update.
Proposed fix
Ignore views that are not in the list:
const declarationViewIndex = movedViews.indexOf(lView);
if (declarationViewIndex !== -1) {
movedViews.splice(declarationViewIndex, 1);
}
Alternatively, detachView could skip detachMovedView for views that are already destroyed.
Please provide a link to a minimal reproduction of the bug
See the reproduction example above
Please provide the exception or error you saw
No error, just content that became excluded from refreshing.
Please provide the environment you discovered this bug in (run ng version)
Angular CLI : 22.2.0
Angular : 22.2.0
Node.js : 26.8.1
Package Manager : npm 11.19.0
Operating System : win32 x64
┌───────────────────────────┬───────────────────┬───────────────────┐
│ Package │ Installed Version │ Requested Version │
├───────────────────────────┼───────────────────┼───────────────────┤
│ @angular/build │ 22.2.0 │ 22.2.0 │
│ @angular/cli │ 22.2.0 │ 22.2.0 │
│ @angular/common │ 22.2.0 │ 22.2.0 │
│ @angular/compiler │ 22.2.0 │ 22.2.0 │
│ @angular/compiler-cli │ 22.2.0 │ 22.2.0 │
│ @angular/core │ 22.2.0 │ 22.2.0 │
│ @angular/forms │ 22.2.0 │ 22.2.0 │
│ @angular/platform-browser │ 22.2.0 │ 22.2.0 │
│ @angular/router │ 22.2.0 │ 22.2.0 │
│ rxjs │ 7.8.2 │ ~7.8.0 │
│ typescript │ 6.0.3 │ ~6.0.2 │
└───────────────────────────┴───────────────────┴───────────────────┘
Anything else?
This issue was already present in several prior major versions, so it should not be explicitly related to Angular 22(.2).
Which @angular/* package(s) are the source of the bug?
core
Is this a regression?
No
Description
A transplanted view is an embedded view that is declared in one component's template and inserted into a
ViewContainerRefof another component. Angular tracks these views in the declaration container'sMOVED_VIEWSlist. It uses that list to refresh them when the declaring component is checked.detachMovedViewremoves a view from that list without checking that the view is in it:If the view was already removed,
indexOfreturns-1, andsplice(-1, 1)removes the last entry instead. That entry is a different transplanted view that is still attached. From then on,markTransplantedViewsForRefreshskips that view when its declaring component is refreshed. Its bindings to the declaring component's state stop updating.The second removal happens when a component is destroyed that clears its own
ViewContainerRefinngOnDestroy:destroyViewTreecleans up child views first.cleanUpViewof the transplanted view callsdetachMovedView, which correctly removes it fromMOVED_VIEWS. The destroyed view is still in the container.cleanUpViewof the component's own view runs itsngOnDestroy.ViewContainerRef.clear()→remove()→detachView()callsdetachMovedView()a second time for the same view.indexOfreturns-1, and the last live view in the list is dropped.Clearing a container on destroy is a common pattern.
CdkPortalOutletdoes it too:ngOnDestroy→dispose()→clear(). So everyCdkPortalOutletthat renders aTemplatePortaldeclared in another component drops one unrelated view fromMOVED_VIEWSwhen it is destroyed.Minimal reproduction
Start from a new app (
ng new --minimal --zoneless). Replacesrc/app/app.tswith the code below and keep the defaultapp.config.ts. The code below is quite ugly, but useful to keep the reproduction example small.OutletComponentrenders a template it receives as an input, and clears its container on destroy.Appdeclares that template and renders one outlet per item:Steps:
1.1.Expected: all four outlets show
3.Actual: outlets 1–3 show
3, but outlet 4 stays at1permanently. Removing outlet 5 detached its view twice, and the secondsplice(-1, 1)removed outlet 4's view fromMOVED_VIEWS.With the
spliceguarded byif (declarationViewIndex !== -1), all four outlets show3.The bug only becomes visible when both of these conditions hold:
ViewContainerRefinstead, the view still loses itsMOVED_VIEWSentry. But it then belongs to the declaring component's own view tree, which refreshes it anyway.counter = signal(0)and{{ counter() }}in the template, the view's own reactive consumer schedules its refresh, so it keeps updating withoutMOVED_VIEWS. Every other binding to the declaring component's state is affected, such as component fields and@letvalues. This includes@letvalues that come fromAsyncPipe, as in this reproduction.Impact
The effect accumulates in virtualized lists and tables. Their rows are destroyed and recreated while scrolling, and each row renders cells from templates declared by the consuming component, typically through
CdkPortalOutlet. Every destroyed row removes one live cell view fromMOVED_VIEWS.In our situation, in one such table with 500 rows, 36 of the 40
detachMovedViewcalls during a single scroll from top to bottom hit index-1. After scrolling, cells in the last rendered rows stopped reflecting changes to the declaring component's state. With the guard below, all cells update.Proposed fix
Ignore views that are not in the list:
Alternatively,
detachViewcould skipdetachMovedViewfor views that are already destroyed.Please provide a link to a minimal reproduction of the bug
See the reproduction example above
Please provide the exception or error you saw
Please provide the environment you discovered this bug in (run
ng version)Anything else?
This issue was already present in several prior major versions, so it should not be explicitly related to Angular 22(.2).