From 73d72005bc2a45eb067f3215987ca73cbd15e9cf Mon Sep 17 00:00:00 2001
From: erKam <88717125+erkamyaman@users.noreply.github.com>
Date: Sun, 11 Oct 2026 06:13:06 +0300
Subject: [PATCH] fix(forms): release removed Signal Forms array fields
The Signal Forms collector wraps each field's selfSubmitting flag and
keeps an undo closure, which holds the field, until the whole form root
leaves the page. Removing an array item drops its field from
materializedChildren and adding one creates a new field, so a form whose
array grows and shrinks kept every field it had ever seen. Each push now
unwraps and forgets the flags it no longer reaches.
---
.../src/__tests__/forms-collector.test.ts | 43 +++++++++++++++++++
packages/devtools/src/forms-collector.ts | 25 ++++++++---
2 files changed, 62 insertions(+), 6 deletions(-)
diff --git a/packages/devtools/src/__tests__/forms-collector.test.ts b/packages/devtools/src/__tests__/forms-collector.test.ts
index 5f4e32d8..ac6f1931 100644
--- a/packages/devtools/src/__tests__/forms-collector.test.ts
+++ b/packages/devtools/src/__tests__/forms-collector.test.ts
@@ -11,6 +11,8 @@ import {
required,
} from '@angular/forms/signals';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
+import { setFlagsFromString } from 'node:v8';
+import { runInNewContext } from 'node:vm';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { attachForms, setupErrorOf } from '../forms-collector.ts';
import type { FormEvent } from '../forms.ts';
@@ -62,6 +64,18 @@ Component({
template: `
`,
})(SignUp);
+class Tasks {
+ model = signal({ name: '', items: [{ n: -1 }] });
+ form = form(this.model, (p) => required(p.name), {
+ submission: { action: async () => undefined },
+ });
+}
+Component({
+ selector: 'tasks-form',
+ imports: [FormField, FormRoot],
+ template: ``,
+})(Tasks);
+
function harness(maxEvents?: number) {
const calls: { name: string; args: any[] }[] = [];
const handlers = new Map unknown>();
@@ -182,6 +196,35 @@ describe('forms collector', () => {
expect(model.update).toBe(original.update);
});
+ it('lets array fields be collected once they are removed, while the form stays', async () => {
+ setFlagsFromString('--expose-gc');
+ const gc = runInNewContext('gc') as () => void;
+ const fixture = await mount(Tasks);
+ const { model, form: tree } = fixture.componentInstance as any;
+ const h = harness();
+ const refs: WeakRef