Skip to content

Signal Forms: NG01904 from provideSignalFormsConfig({ classes }) when an array is replaced while the form's view is detached (RouteReuseStrategy) #70934

Description

@andreialecu

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)

  1. The app opens on "Form page": a signal form over an array, rendered with @for (item of itemsForm.items; track item).
  2. Click "Other page". The RouteReuseStrategy detaches and stores the form page.
  3. 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.
  • Tracking by field identity (track item, as the docs recommend) and tracking by $index both reproduce it. The NG0956 warning in the console comes from track item and 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.
  • 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. */
const fetchItems = () => [{ name: "a" }, { name: "b" }];

@Injectable({ providedIn: "root" })
class ItemsStore {
  readonly items = 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" />
    }
  `,
})
class ItemsFormPage {
  private readonly store = inject(ItemsStore);
  private readonly model = linkedSignal(() => ({ items: this.store.items() }));
  readonly itemsForm = form(this.model);
}

@Component({
  selector: "app-other",
  template: `<h2>Other page</h2>`,
})
class OtherPage {}

/** Keeps every page alive when you leave it, as a tabbed UI does. */
class KeepAliveStrategy implements RouteReuseStrategy {
  private readonly handles = new Map<Route, DetachedRouteHandle>();

  shouldDetach(): boolean {
    return true;
  }

  store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null) {
    if (!route.routeConfig) return;
    if (handle) this.handles.set(route.routeConfig, handle);
    else this.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 {
    return future.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 />
  `,
})
class App {
  readonly store = 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

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

RuntimeError: NG01904: Orphan field, can't find element in array <root>.items

Production stack (22.1.7, zoneless), innermost frame last:
  zoneless_scheduling_impl.ts:291   ChangeDetectionSchedulerImpl.tick
  application_ref.ts:683            ApplicationRef.synchronizeOnce
  render3/after_render/manager.ts:86  AfterRenderImpl.execute
  after_render_effect.ts:117        AFTER_RENDER_PHASE_EFFECT_NODE.phaseFn
  signals/src/graph.ts:486          consumerPollProducersForChange   (x4, nested computeds)
  forms/signals/src/util/deep_signal.ts:26   read = computed(() => source()[prop()])
  forms/signals/src/field/structure.ts:279   keyInParent -> throw ORPHAN_FIELD_NOT_FOUND

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

Angular CLI       : 22.2.0
Angular           : 22.2.0
Node.js           : 24.16.0
Package Manager   : yarn 1.22.22
Operating System  : darwin arm64

@angular/build            22.2.0
@angular/cli              22.2.0
@angular/common           22.2.0
@angular/compiler         22.2.0
@angular/compiler-cli     22.2.0
@angular/core             22.2.0
@angular/forms            22.2.0
@angular/platform-browser 22.2.0
@angular/router           22.2.0
rxjs                      7.8.2
typescript                6.0.3

Anything else?

Our workaround: while the page is detached, we skip the model refill and do it on reattach.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: formsgemini-triagedLabel noting that an issue has been triaged by gemini

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions