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[] = []; + for (let i = 0; i < 10; i++) { + model.update((m: any) => ({ ...m, items: [...m.items, { n: i }] })); + const node = tree.items[model().items.length - 1](); + refs.push(new WeakRef(node)); + h.collector.push(); + await tick(0); + model.update((m: any) => ({ ...m, items: m.items.slice(0, -1) })); + tree.items().structure.materializedChildren(); + h.collector.push(); + await tick(0); + } + await tick(0); + gc(); + gc(); + expect(refs.slice(0, -1).filter((ref) => ref.deref())).toHaveLength(0); + + const formEl = fixture.nativeElement.querySelector('form') as HTMLFormElement; + formEl.requestSubmit(); + await tick(); + expect(h.lastEvents().filter((e) => e.type === 'submit')).toHaveLength(1); + }); + it('wraps a Signal Form again when a new one appears after the old one left', async () => { const first = await mount(Profile); const h = harness(); diff --git a/packages/devtools/src/forms-collector.ts b/packages/devtools/src/forms-collector.ts index 00c366c5..af5faef9 100644 --- a/packages/devtools/src/forms-collector.ts +++ b/packages/devtools/src/forms-collector.ts @@ -142,7 +142,7 @@ export function attachForms( const lastValue = new Map(); const pendingSince = new Map(); const wrappedSubmits = new WeakSet(); - const signalRoots = new Map void)[] }>(); + const signalRoots = new Map void> }>(); const setupErrors: string[] = []; let foundById = new Map(); let fieldElements = new WeakMap(); @@ -231,7 +231,8 @@ export function attachForms( const entry = signalRoots.get(root); if (!entry) return; signalRoots.delete(root); - for (const unwrap of entry.unwrap.splice(0)) unwrap(); + for (const unwrap of entry.unwrap.values()) unwrap(); + entry.unwrap.clear(); instrumentation?.forgetSignalRoot(root); } @@ -284,25 +285,37 @@ export function attachForms( function wrapSignalSubmit(form: FoundForm) { let entry = signalRoots.get(form.root); - if (!entry) signalRoots.set(form.root, (entry = { formId: '', unwrap: [] })); + if (!entry) signalRoots.set(form.root, (entry = { formId: '', unwrap: new Map() })); entry.formId = idOf(form.root); const unwrap = entry.unwrap; + const reached = new Set(); const visit = (node: AnyRecord, depth: number) => { - wrapSubmitFlag(form.root, node, unwrap); + wrapSubmitFlag(form.root, node, unwrap, reached); if (depth >= 8) return; const children = read(() => node['structure'].materializedChildren() as AnyRecord[], []); for (const child of children.slice(0, 200)) visit(child, depth + 1); }; visit(form.root, 0); + for (const [flag, restore] of unwrap) { + if (reached.has(flag)) continue; + restore(); + unwrap.delete(flag); + } } - function wrapSubmitFlag(root: AnyRecord, node: AnyRecord, unwrap: (() => void)[]) { + function wrapSubmitFlag( + root: AnyRecord, + node: AnyRecord, + unwrap: Map void>, + reached: Set, + ) { const flag = read(() => node['submitState']['selfSubmitting'] as AnyRecord, null); + if (flag) reached.add(flag); if (!flag || wrappedSubmits.has(flag) || typeof flag['set'] !== 'function') return; wrappedSubmits.add(flag); const path = node === root ? '' : read(() => fieldPath(node), ''); const original = flag['set']; - unwrap.push(() => { + unwrap.set(flag, () => { flag['set'] = original; wrappedSubmits.delete(flag); });