refactor(forms): preserve logic order when apply is used on root path

In some cases the logic order was not preserved properly when using `apply`. In particular this occurs when some logic is registered on a child of the root, followed by an apply to the root, followed by further logic registered on a child. In this case the final registered logic wound up running before the applied logic.

This happened because `FieldPathNode` for a child path was caching its `LogicNodeBuilder` at creation time. This meant that if the parent's `LogicNodeBuilder` changed (e.g., due to an `apply` call), the child would still be using the old one.

This commit fixes the issue by dynamically resolving the `LogicNodeBuilder` for a child path whenever it is accessed.

(cherry picked from commit fa13a167f1)
This commit is contained in:
Miles Malerba 2025-10-27 19:04:48 -07:00 committed by Kristiyan Kostadinov
parent 6e004caa5b
commit 60c8e2779c
3 changed files with 79 additions and 10 deletions

View file

@ -8,12 +8,12 @@
import {FieldPathNode} from '../schema/path_node';
import {WritableSignal} from '@angular/core';
import {FormFieldManager} from './manager';
import {FieldNode} from './node';
import {FieldNodeState} from './state';
import {ChildFieldNodeOptions, FieldNodeOptions, FieldNodeStructure} from './structure';
import {ValidationState, FieldValidationState} from './validation';
import {WritableSignal} from '@angular/core';
import {FieldValidationState, ValidationState} from './validation';
/**
* Adapter allowing customization of the creation logic for a field and its associated

View file

@ -37,14 +37,33 @@ export class FieldPathNode {
FIELD_PATH_PROXY_HANDLER,
) as unknown as FieldPath<any>;
/**
* For a root path node this will contain the root logic builder. For non-root nodes,
* they determine their logic builder from their parent so this is undefined.
*/
private readonly logicBuilder: LogicNodeBuilder | undefined;
protected constructor(
/** The property keys used to navigate from the root path to this path. */
readonly keys: PropertyKey[],
/** The logic builder used to accumulate logic on this path node. */
readonly logic: LogicNodeBuilder,
root: FieldPathNode,
root: FieldPathNode | undefined,
/** The parent of this path node. */
private readonly parent: FieldPathNode | undefined,
/** The key of this node in its parent. */
private readonly keyInParent: PropertyKey | undefined,
) {
this.root = root ?? this;
if (!parent) {
this.logicBuilder = LogicNodeBuilder.newRoot();
}
}
/** The logic builder used to accumulate logic on this path node. */
get logic(): LogicNodeBuilder {
if (this.logicBuilder) {
return this.logicBuilder;
}
return this.parent!.logic.getChild(this.keyInParent!);
}
/**
@ -60,10 +79,7 @@ export class FieldPathNode {
*/
getChild(key: PropertyKey): FieldPathNode {
if (!this.children.has(key)) {
this.children.set(
key,
new FieldPathNode([...this.keys, key], this.logic.getChild(key), this.root),
);
this.children.set(key, new FieldPathNode([...this.keys, key], this.root, this, key));
}
return this.children.get(key)!;
}
@ -85,7 +101,7 @@ export class FieldPathNode {
/** Creates a new root path node to be passed in to a schema function. */
static newRoot() {
return new FieldPathNode([], LogicNodeBuilder.newRoot(), undefined!);
return new FieldPathNode([], undefined, undefined, undefined);
}
}

View file

@ -0,0 +1,53 @@
/**
* @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, 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'}),
]);
});
});
});