Skip to content

fix(forms): keep field cleanup from materializing unused children - #71659

Open
splincode wants to merge 1 commit into
angular:mainfrom
splincode:fix/signal-form-cleanup-laziness
Open

splincode wants to merge 1 commit into
angular:mainfrom
splincode:fix/signal-form-cleanup-laziness

Conversation

@splincode

Copy link
Copy Markdown
Contributor

What is the current behavior?

Fixes #70627.

The field cleanup effect calls children(), which forces unused descendants to materialize. A large model with no child rules therefore creates a complete field tree on the first effect flush and blocks the page.

What is the new behavior?

Walk materializedChildren() during cleanup. Explicit field access and schema logic continue to create fields as before.

The regression tests cover laziness after effects flush and model updates, unread descendants of an accessed field, cleanup of fields first accessed after the initial sweep, identity across array reordering, and validation of fields with rules. Two tests fail with the original traversal.

Production-page measurements

Used the reporter's application with Angular 22.2.3, CLI/build 22.2.4, Node 24.22.1, and Chrome Headless 158.0.8117.0. Both optimized builds use identical instrumentation; the patched build changes only this traversal in the installed forms bundle.

For 10,000 rows, five alternating baseline/patched pairs in fresh browser contexts:

Metric (median) Before After
Form constructor to next timer, including the initial effect flush 561.8 ms 3.8 ms
Materialized child fields 50,000 0
Additional live JS heap after forced GC 372,986,828 B 679,972 B

The baseline produced a 536–627 ms long task in every run; the patched run produced no long task over 50 ms. Model allocation happens before the measured action. These are local page measurements, not INP or device-independent guarantees.

Control: 50 rendered inputs with required(), seven alternating pairs. Medians were 29.6 ms before / 28.7 ms after; both created 250 child fields, and editing updated the model in every run. The patched app also completed the reporter's original 100,000-row case in three runs without materializing children. The baseline was measured at 10,000 rows rather than run to exhaustion at 100,000.

Validation

  • Signal forms Node suite: 546 specs, 0 failures.
  • Signal forms Chromium suite: 362 specs, 0 failures. The local runner needed a loopback-interface shim because the container rejects os.networkInterfaces(); that shim is outside this change.
  • Regression check with the original traversal: 2 failures among the 5 lazy-materialization specs.
  • ng-dev format, Prettier, and git diff --check pass.

Related prior work: #70633 and #71635.

PR Checklist

  • The commit message follows the guidelines.
  • Tests for the changes have been added.
  • Docs have been added / updated: no public API or documented behavior changes.

PR Type

  • Bugfix

Does this PR introduce a breaking change?

  • Yes
  • No

@pullapprove
pullapprove Bot requested a review from crisbeto October 27, 2026 11:34
@ngbot ngbot Bot added this to the Backlog milestone Oct 27, 2026

@JeanMeche JeanMeche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Thanks for this fix! The change in markStructuresLive correctly resolves the eager traversal during the GC effect flush that was defeating the lazy instantiation from 98c5afdb02.

A few observations and questions to challenge the broader scope and tests:

  1. Broader Scope of Laziness (markAsTouched, reloadValidation, submit):
    markStructuresLive is only one of the internal tree walkers. Other methods like markAsTouched() (node.ts:293) and reloadValidation() (node.ts:372) still call structure.children().
    Since submit() marks the root as touched, calling submit() on a form containing a 10,000-row model will still recursively call children() and trigger the exact same eager materialization / OOM freeze described in #70627. Should tree traversals have a uniform contract regarding materialized vs unmaterialized nodes?

  2. JSDoc on materializedChildren():
    In FieldNodeStructure (structure.ts:126), the JSDoc explicitly notes:

    Note: This method assumes it is called within an untracked context (or in a non-reactive context) as it reads signals without wrapping them in untracked().
    createFieldManagementEffect now calls materializedChildren() inside an effect() without untracked(), which intentionally subscribes the effect to childrenMap. We should update this JSDoc note so it doesn't contradict the new usage.

  3. Test Assertions in structure.spec.ts:

    • In the first test, replacing _areChildrenMaterialized() with structure.materializedChildren().toEqual([]) weakens the check. _areChildrenMaterialized() directly verifies that the childrenMap linkedSignal was not computed (undefined), whereas materializedChildren().toEqual([]) does not distinguish between uncomputed and an empty map.
    • In should not materialize unread descendants during cleanup, accessing f.a() eagerly computes root's childrenMap, which materializes all direct siblings at the same depth (unused). Only unused.nested remained unmaterialized. It would be helpful to clarify the test description and verify whether unused itself was materialized.
    • In the array cleanup test, both elements are materialized before array modification (which passes even on main). Adding a case where only a subset of array items are materialized before an array splice/reorder would give more robust coverage.

@splincode

Copy link
Copy Markdown
Contributor Author

Thanks for the review, @JeanMeche! I updated the JSDoc to explain that reading the children map in the cleanup effect tracks structural changes.

The tests now check the map directly with _areChildrenMaterialized(). They verify that accessing a materializes its direct sibling while both child maps stay lazy. In the array case, only one row’s descendants are materialized before reordering. The test checks that moved fields keep their identity, the removed row and its materialized child are destroyed exactly once, and the other row’s child map stays lazy.

I think the traversal contract depends on the operation. Cleanup only needs to visit existing fields. markAsTouched(), which submit() calls, also needs to cover eligible descendants that are first accessed later. A lazy implementation would need to preserve their touched state. For reloadValidation(), the equivalent check is that all descendant async validators still reload. I kept this patch scoped to the cleanup effect.

The review updates are in f0aa9c1.

Walk only materialized fields during cleanup so large model values do not create an unused field tree. Preserve cleanup of removed fields and identity of reordered array items.

Cover lazy child maps and verify cleanup of removed array fields and
their materialized descendants after reordering.

Fixes angular#70627
@splincode
splincode force-pushed the fix/signal-form-cleanup-laziness branch from f0aa9c1 to ef53278 Compare November 12, 2026 06:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Signal form children initialization is not really lazy

2 participants