Skip to content

test(forms): reproduce the cleanup sweep materializing all fields - #71635

Closed
Flarette wants to merge 1 commit into
angular:mainfrom
Flarette:triage/forms-sweep-materialization
Closed

Flarette wants to merge 1 commit into
angular:mainfrom
Flarette:triage/forms-sweep-materialization

Conversation

@Flarette

Copy link
Copy Markdown
Contributor

Issue #70627 | Triage note | Failing specs | Earlier fix #70633 (closed)

Why the change

This adds failing specs showing that the field cleanup sweep creates every field of a signal form on its first run, so that we can agree on the fix before I write it.

Special things to note

Change outline

What this PR adds

packages/forms/signals/
├── test/node/field_tree_cleanup_laziness.spec.ts   # 3 failing specs, 1 passing control spec
└── triage/sweep-materialization.md                # spec output, root cause with links, fix options

How the sweep creates every field

form(signal({rows: [100 rows]}))     # 0 child fields: nothing has read them
  first effect flush
    field-management effect
      markStructuresLive(root)
        root.structure.children()
          ensureChildrenMap()
            computeChildrenMap(value, prev, forceMaterialize = true)   # creates rows
        for each child: markStructuresLive(child)                      # recurses into every row and leaf

Child fields for a form over 100 rows without rules, today:

after form()          0
after first tick    301   (rows, 100 rows, 200 leaves)

After the recommended fix:

after form()          0
after first tick      0   (a row is created when something reads it, such as f.rows[3]())

Recommended fix: the sweep walks only fields that already exist

 // packages/forms/signals/src/field/manager.ts
   private markStructuresLive(
     structure: FieldNodeStructure,
     liveStructures: Set<FieldNodeStructure>,
   ): void {
     liveStructures.add(structure);
-    for (const child of structure.children()) {
+    for (const child of structure.materializedChildren()) {
       this.markStructuresLive(child.structure, liveStructures);
     }
   }

A leaf also skips a redundant childrenMap write on every children() call:

 // packages/forms/signals/src/field/structure.ts, class FieldNodeStructure
   private ensureChildrenMap() {
     // If we're already materialized, there's nothing to do.
-    if (this._areChildrenMaterialized()) {
+    if (this._areChildrenMaterialized() || !isObject(untracked(this.value))) {
       return;
     }
  • Cleanup still works. A field that was never created has nothing to destroy. materializedChildren() still reads childrenMap(), so the sweep reruns when the model changes shape. The control spec removes a row that was read and checks that it's destroyed.
  • Only the sweep becomes lazy. markAsTouched(), reloadValidation(), focus, and WebMCP schema inference still call children().

Alternatives

Option Change Tradeoff
B: destroy children where they're removed computeChildrenMap() queues dropped fields, and the effect drains the queue Avoids walking the materialized tree on each change. But the queue needs its own reactive trigger, and tracked array items can move instead of being removed
C: make children() stop materializing Change children() itself Changes every caller. For example, a field read for the first time after submit() would start untouched. Not recommended

Decisions for maintainers

  1. Scope of laziness. Is laziness a contract only for the cleanup sweep, or for every internal walker? If it's every walker, markAsTouched() and reloadValidation() need the same treatment, and fix(forms): flush debounced descendant values when a field is touched #71150 already starts on markAsTouched().
  2. Leaf guard. Include it in the same fix, or leave it out? It only saves a write that doesn't notify anyone.
  3. Test style. The specs read internals: _areChildrenMaterialized(), fieldManager.structures, and a spy on childrenMap.update. Is that acceptable, or would you prefer another check?

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • angular.dev application / infrastructure changes
  • Other... Please describe: triage. Failing repro specs and a triage note, with no library changes.

Does this PR introduce a breaking change?

  • Yes
  • No

The signal forms field-management effect walks the field tree with
children(), which materializes every reachable field on its first run.
That undoes the lazy field creation from 98c5afd as soon as effects
flush: a form over 100 rows without rules goes from 0 to 301 child
fields.

Add specs that flush effects and assert laziness, plus a triage note
with the root cause and fix options. The specs fail until a fix lands.
The note is for review and isn't meant to be merged.

See also: angular#70627
@ngbot ngbot Bot added this to the Backlog milestone Oct 26, 2026
@JeanMeche

Copy link
Copy Markdown
Member

Going to close the PRs as they're won't me merged as is. Let's move the discussions to the issues.

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.

2 participants