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); });