angular/packages/forms/signals/test/node/api/structure.spec.ts
Alex Rickabaugh 98c5afdb02 perf(forms): lazily instantiate signal form fields
Currently, Signal Forms eagerly instantiates all nodes in the form tree because `childrenMap` iterates over the `value` and creates a `FieldNode` for every property. This ensures validation side-effects are run early, but creates pure overhead for fields without validation logic unless explicitly accessed.

This commit makes `childrenMap` lazy by default, skipping materialization for children without schema logic. This is achieved by introducing `hasLogicRules()` and `anyChildHasLogic()` across the `LogicNode` hierarchy. Fields are now only instantiated when a direct read occurs via `getChild()` (which calls the new `ensureChildrenMap()`) or if their subtree requires eager evaluation due to existing validation rules.

Fixes #67212
2026-03-20 15:09:26 -07:00

133 lines
4.6 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Injector, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {apply, applyEach, form, required, schema} from '@angular/forms/signals';
describe('structure APIs', () => {
describe('apply', () => {
it('should maintain order when applying to child of root path', () => {
const s = schema<{a: string}>((p) => {
required(p.a, {message: 'before'});
apply(p.a, (prop) => {
required(prop, {message: 'apply'});
});
required(p.a, {message: 'after'});
});
const data = signal({a: ''});
const f = form(data, s, {injector: TestBed.inject(Injector)});
expect(f.a().errors()).toEqual([
jasmine.objectContaining({message: 'before'}),
jasmine.objectContaining({message: 'apply'}),
jasmine.objectContaining({message: 'after'}),
]);
});
it('should maintain order when applying to root path', () => {
const s = schema<{a: {b: string}}>((p) => {
required(p.a.b, {message: 'before'});
apply(p, (prop) => {
required(prop.a.b, {message: 'apply'});
});
required(p.a.b, {message: 'after'});
});
const data = signal({a: {b: ''}});
const f = form(data, s, {injector: TestBed.inject(Injector)});
expect(f.a.b().errors()).toEqual([
jasmine.objectContaining({message: 'before'}),
jasmine.objectContaining({message: 'apply'}),
jasmine.objectContaining({message: 'after'}),
]);
});
it('should apply logic to each property', () => {
const s = schema<{a: string; b: string}>((p) => {
applyEach(p, (prop) => {
required(prop, {message: 'each'});
});
});
const data = signal({a: '', b: ''});
const f = form(data, s, {injector: TestBed.inject(Injector)});
expect(f.a().errors()).toEqual([jasmine.objectContaining({message: 'each'})]);
expect(f.b().errors()).toEqual([jasmine.objectContaining({message: 'each'})]);
});
it('should merge each property logic with specific property logic', () => {
const s = schema<{a: string; b: string}>((p) => {
required(p.a, {message: 'before'});
applyEach(p, (prop) => {
required(prop, {message: 'each'});
});
required(p.a, {message: 'after'});
});
const data = signal({a: '', b: ''});
const f = form(data, s, {injector: TestBed.inject(Injector)});
expect(f.a().errors()).toEqual([
jasmine.objectContaining({message: 'before'}),
jasmine.objectContaining({message: 'each'}),
jasmine.objectContaining({message: 'after'}),
]);
expect(f.b().errors()).toEqual([jasmine.objectContaining({message: 'each'})]);
});
});
describe('lazy materialization', () => {
it('should stay unmaterialized when children have no schema logic', () => {
const s = schema<{a: {b: string}}>((p) => {
// No logic applied
});
const data = signal({a: {b: 'test'}});
const f = form(data, s, {injector: TestBed.inject(Injector)});
// Cast to any to access the private helper method
expect((f() as any).structure._areChildrenMaterialized()).toBeFalse();
});
it('should materialize children when explicitly accessed', () => {
const s = schema<{a: {b: string}}>((p) => {
// No logic applied
});
const data = signal({a: {b: 'test'}});
const f = form(data, s, {injector: TestBed.inject(Injector)});
// Initially false
expect((f() as any).structure._areChildrenMaterialized()).toBeFalse();
// Accessing 'a' forces materialization of root's children
const childA = f.a;
expect((f() as any).structure._areChildrenMaterialized()).toBeTrue();
// 'a' itself should not have materialized its children ('b') yet
expect((childA() as any).structure._areChildrenMaterialized()).toBeFalse();
});
it('should eagerly materialize children when they have schema logic', () => {
const s = schema<{a: {b: string}}>((p) => {
required(p.a.b, {message: 'required'});
});
const data = signal({a: {b: 'test'}});
const f = form(data, s, {injector: TestBed.inject(Injector)});
// Because 'a.b' has rules, 'a' and 'b' must be materialized up the tree
expect((f() as any).structure._areChildrenMaterialized()).toBeTrue();
expect((f.a() as any).structure._areChildrenMaterialized()).toBeTrue();
});
});
});