You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Signal Forms: NG01904 from provideSignalFormsConfig({ classes }) when an array is replaced while the form's view is detached (RouteReuseStrategy) #70934
Which @angular/* package(s) are the source of the bug?
forms, core
Is this a regression?
No
(Also reproduces on 22.0.7 and 22.1.7.)
Description
With provideSignalFormsConfig({ classes: ... }) (e.g. NG_STATUS_CLASSES from @angular/forms/signals/compat), every [formField] keeps its CSS classes in sync through an afterRenderEffect (FormField.installClassBindingEffect).
After-render effects keep running while their view is detached: AfterRenderImpl.execute() runs every registered sequence on every application tick. The detached view's template is not refreshed, though, so its @for never drops the rows whose array elements went away, and each FormField keeps pointing at its old field.
If the array in the model is replaced while the view is detached, the class computations read orphaned fields on the next tick and keyInParent throws NG01904, once per row. The common way to get there is a RouteReuseStrategy that keeps a page alive in the background while that page's model is re-derived from new server data (a linkedSignal over a query result, a refetch, a websocket push).
Steps (reproduction below)
The app opens on "Form page": a signal form over an array, rendered with @for (item of itemsForm.items; track item).
Click "Other page". The RouteReuseStrategy detaches and stores the form page.
Click "Reload data". The store receives new objects with the same values, so the form page's linkedSignal model changes.
Expected: no error. The detached page should pick up the new rows when it is reattached, which it does.
Actual:NG01904: Orphan field, can't find element in array <root>.items is thrown once per row. The error comes from the class-binding afterRenderEffect, whose sequence is then marked errored.
Notes
Without provideSignalFormsConfig({ classes }), the same steps log no error.
Clicking "Reload data" while the form page is attached logs no error, because the template refresh runs before the after-render hooks and the @for replaces the rows first.
Going back to the form page afterwards renders the new rows correctly. The only symptom is the thrown error, which an error tracker reports as an unhandled exception. We see it in production.
Possible fixes: skip orphaned fields in the class-binding effect (the field structure already tracks isOrphaned), or do not run after-render effects that belong to a detached view.
Minimal code (the whole app, src/main.ts):
main.ts
import{ChangeDetectionStrategy,Component,inject,Injectable,linkedSignal,signal,}from"@angular/core";import{form,FormField,provideSignalFormsConfig,}from"@angular/forms/signals";import{NG_STATUS_CLASSES}from"@angular/forms/signals/compat";import{bootstrapApplication}from"@angular/platform-browser";import{ActivatedRouteSnapshot,DetachedRouteHandle,provideRouter,Route,RouteReuseStrategy,RouterLink,RouterOutlet,}from"@angular/router";/** Stands in for a server response: every fetch returns new objects. */constfetchItems=()=>[{name: "a"},{name: "b"}];
@Injectable({providedIn: "root"})classItemsStore{readonlyitems=signal(fetchItems());reload(){this.items.set(fetchItems());}}
@Component({selector: "app-items-form",changeDetection: ChangeDetectionStrategy.OnPush,imports: [FormField],// Remove this provider and the error goes away.providers: [provideSignalFormsConfig({classes: NG_STATUS_CLASSES})],template: ` <h2>Form page</h2> @for (item of itemsForm.items; track item) { <input [formField]="item.name" /> } `,})classItemsFormPage{privatereadonlystore=inject(ItemsStore);privatereadonlymodel=linkedSignal(()=>({items: this.store.items()}));readonlyitemsForm=form(this.model);}
@Component({selector: "app-other",template: `<h2>Other page</h2>`,})classOtherPage{}/** Keeps every page alive when you leave it, as a tabbed UI does. */classKeepAliveStrategyimplementsRouteReuseStrategy{privatereadonlyhandles=newMap<Route,DetachedRouteHandle>();shouldDetach(): boolean{returntrue;}store(route: ActivatedRouteSnapshot,handle: DetachedRouteHandle|null){if(!route.routeConfig)return;if(handle)this.handles.set(route.routeConfig,handle);elsethis.handles.delete(route.routeConfig);}shouldAttach(route: ActivatedRouteSnapshot): boolean{return!!route.routeConfig&&this.handles.has(route.routeConfig);}retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle|null{return(route.routeConfig&&this.handles.get(route.routeConfig))??null;}shouldReuseRoute(future: ActivatedRouteSnapshot,current: ActivatedRouteSnapshot,): boolean{returnfuture.routeConfig===current.routeConfig;}}
@Component({selector: "app-root",imports: [RouterOutlet,RouterLink],template: ` <nav> <a routerLink="/form">Form page</a> | <a routerLink="/other">Other page</a> </nav> <button type="button" (click)="store.reload()">Reload data</button> <router-outlet /> `,})classApp{readonlystore=inject(ItemsStore);}bootstrapApplication(App,{providers: [provideRouter([{path: "",pathMatch: "full",redirectTo: "form"},{path: "form",component: ItemsFormPage},{path: "other",component: OtherPage},]),{provide: RouteReuseStrategy,useClass: KeepAliveStrategy},],}).catch((err)=>console.error(err));
Please provide a link to a minimal reproduction of the bug
Which @angular/* package(s) are the source of the bug?
forms, core
Is this a regression?
No
(Also reproduces on 22.0.7 and 22.1.7.)
Description
With
provideSignalFormsConfig({ classes: ... })(e.g.NG_STATUS_CLASSESfrom@angular/forms/signals/compat), every[formField]keeps its CSS classes in sync through anafterRenderEffect(FormField.installClassBindingEffect).After-render effects keep running while their view is detached:
AfterRenderImpl.execute()runs every registered sequence on every application tick. The detached view's template is not refreshed, though, so its@fornever drops the rows whose array elements went away, and eachFormFieldkeeps pointing at its old field.If the array in the model is replaced while the view is detached, the class computations read orphaned fields on the next tick and
keyInParentthrows NG01904, once per row. The common way to get there is aRouteReuseStrategythat keeps a page alive in the background while that page's model is re-derived from new server data (alinkedSignalover a query result, a refetch, a websocket push).Steps (reproduction below)
@for (item of itemsForm.items; track item).RouteReuseStrategydetaches and stores the form page.linkedSignalmodel changes.Expected: no error. The detached page should pick up the new rows when it is reattached, which it does.
Actual:
NG01904: Orphan field, can't find element in array <root>.itemsis thrown once per row. The error comes from the class-bindingafterRenderEffect, whose sequence is then marked errored.Notes
provideSignalFormsConfig({ classes }), the same steps log no error.@forreplaces the rows first.track item, as the docs recommend) and tracking by$indexboth reproduce it. The NG0956 warning in the console comes fromtrack itemand is the known @for(ID of IDs; track ID) and NG0956 - provide a way to prevent warning when tracking by item is legit scenario #56471.isOrphaned), or do not run after-render effects that belong to a detached view.Minimal code (the whole app,
src/main.ts):main.ts
Please provide a link to a minimal reproduction of the bug
https://github.com/andreialecu/angular-signal-forms-ng01904-detached (StackBlitz: https://stackblitz.com/github/andreialecu/angular-signal-forms-ng01904-detached)
Please provide the exception or error you saw
Please provide the environment you discovered this bug in (run
ng version)Anything else?
Our workaround: while the page is detached, we skip the model refill and do it on reattach.