From f3e2252f4bb0fa1fd98c86029705ceeda958997b Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Wed, 29 Oct 2025 21:53:52 -0700 Subject: [PATCH] refactor(forms): add better support for Object.keys and Symbol.iterator Improves support for using `Object.keys` (and related methods) and `Symbol.iterator` to work with a `FieldTree` of unknown shape. (cherry picked from commit e87f423d9077abf70d2792d1fc6c6b2fd3e47f91) --- goldens/public-api/forms/signals/index.api.md | 2 + packages/forms/signals/src/api/types.ts | 2 + packages/forms/signals/src/field/proxy.ts | 32 +++++++++- .../signals/test/node/field_proxy.spec.ts | 60 +++++++++++++++++++ 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index b0c0c8f002c..ddcc5253877 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -487,6 +487,8 @@ export class StandardSchemaValidationError extends _NgValidationError { // @public export type Subfields = { readonly [K in keyof TValue as TValue[K] extends Function ? never : K]: MaybeFieldTree; +} & { + [Symbol.iterator](): Iterator<[string, MaybeFieldTree]>; }; // @public diff --git a/packages/forms/signals/src/api/types.ts b/packages/forms/signals/src/api/types.ts index f4683c3e747..875d32b0317 100644 --- a/packages/forms/signals/src/api/types.ts +++ b/packages/forms/signals/src/api/types.ts @@ -173,6 +173,8 @@ export type Subfields = { TValue[K], string >; +} & { + [Symbol.iterator](): Iterator<[string, MaybeFieldTree]>; }; /** diff --git a/packages/forms/signals/src/field/proxy.ts b/packages/forms/signals/src/field/proxy.ts index e17a2dc0313..531c7524813 100644 --- a/packages/forms/signals/src/field/proxy.ts +++ b/packages/forms/signals/src/field/proxy.ts @@ -7,14 +7,14 @@ */ import {untracked} from '@angular/core'; -import {isArray} from '../util/type_guards'; +import {isArray, isObject} from '../util/type_guards'; import type {FieldNode} from './node'; /** * Proxy handler which implements `FieldTree` on top of `FieldNode`. */ export const FIELD_PROXY_HANDLER: ProxyHandler<() => FieldNode> = { - get(getTgt: () => FieldNode, p: string | symbol) { + get(getTgt: () => FieldNode, p: string | symbol, receiver: {[key: string]: unknown}) { const tgt = getTgt(); // First, check whether the requested property is a defined child node of this node. @@ -41,14 +41,40 @@ export const FIELD_PROXY_HANDLER: ProxyHandler<() => FieldNode> = { // Allow access to the iterator. This allows the user to spread the field array into a // standard array in order to call methods like `filter`, `map`, etc. if (p === Symbol.iterator) { - return (Array.prototype as any)[p]; + return Array.prototype[p as any]; } // Note: We can consider supporting additional array methods if we want in the future, // but they should be thoroughly tested. Just forwarding the method directly from the // `Array` prototype results in broken behavior for some methods like `map`. } + if (isObject(value)) { + // For object fields, allow iteration over their entries for convenience of use with `@for`. + if (p === Symbol.iterator) { + return function* () { + for (const key in receiver) { + yield [key, receiver[key]]; + } + }; + } + } + // Otherwise, this property doesn't exist. return undefined; }, + + getOwnPropertyDescriptor(getTgt, prop) { + const value = untracked(getTgt().value) as Object; + const desc = Reflect.getOwnPropertyDescriptor(value, prop); + // In order for `Object.keys` to function properly, keys must be reported as configurable. + if (desc && !desc.configurable) { + desc.configurable = true; + } + return desc; + }, + + ownKeys(getTgt: () => FieldNode) { + const value = untracked(getTgt().value); + return typeof value === 'object' && value !== null ? Reflect.ownKeys(value) : []; + }, }; diff --git a/packages/forms/signals/test/node/field_proxy.spec.ts b/packages/forms/signals/test/node/field_proxy.spec.ts index dd5a0cda6e3..be7d058e75d 100644 --- a/packages/forms/signals/test/node/field_proxy.spec.ts +++ b/packages/forms/signals/test/node/field_proxy.spec.ts @@ -31,4 +31,64 @@ describe('FieldTree proxy', () => { // @ts-expect-error f.arr[0] = f.arr[0]; }); + + it('should get keys and values for object field', () => { + const f = form(signal({x: 1, y: 2}), {injector: TestBed.inject(Injector)}); + expect(Object.keys(f)).toEqual(['x', 'y']); + expect(Object.getOwnPropertyNames(f)).toEqual(['x', 'y']); + expect(Object.entries(f).map(([key, child]) => [key, child().value()])).toEqual([ + ['x', 1], + ['y', 2], + ]); + expect(Object.values(f).map((child) => child().value())).toEqual([1, 2]); + }); + + it('should get keys and values for array field', () => { + const f = form(signal([1, 2]), {injector: TestBed.inject(Injector)}); + expect(Object.keys(f)).toEqual(['0', '1']); + expect(Object.getOwnPropertyNames(f)).toEqual(['0', '1', 'length']); + expect(Object.entries(f).map(([key, child]) => [key, child().value()])).toEqual([ + ['0', 1], + ['1', 2], + ]); + expect(Object.values(f).map((child) => child().value())).toEqual([1, 2]); + }); + + it('should get keys and values for primitive field', () => { + const f = form(signal(1), {injector: TestBed.inject(Injector)}); + expect(Object.keys(f)).toEqual([]); + expect(Object.getOwnPropertyNames(f)).toEqual([]); + expect(Object.entries(f)).toEqual([]); + expect(Object.values(f)).toEqual([]); + }); + + it('should iterate over object field', () => { + const f = form(signal({x: 1, y: 2}), {injector: TestBed.inject(Injector)}); + const result: [string, number][] = []; + for (const [key, child] of f) { + result.push([key, child().value()]); + } + expect(result).toEqual([ + ['x', 1], + ['y', 2], + ]); + }); + + it('should iterate over array field', () => { + const f = form(signal([1, 2]), {injector: TestBed.inject(Injector)}); + const result: number[] = []; + for (const child of f) { + result.push(child().value()); + } + expect(result).toEqual([1, 2]); + }); + + it('should not iterate over primitive field', () => { + const f = form(signal(1), {injector: TestBed.inject(Injector)}); + expect(() => { + // @ts-expect-error - not iterable + for (const child of f) { + } + }).toThrow(); + }); });