From 1e35de536d8332dfcbb3d89d01ca84b50aa56bd2 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Thu, 20 Aug 2026 15:00:19 -0700 Subject: [PATCH] fix(forms): use dot-access for readonly rule configuration The readonly rule previously used 'when' in configOrLogic to determine if a configuration object was provided. Under property renaming/minification, the string literal property lookup fails and causes the rule to fall back to being permanently readonly. This change switches to the dot-access form (configOrLogic?.when), matching hidden() and ensuring property renaming works correctly. --- .../forms/signals/src/api/rules/readonly.ts | 12 +- .../signals/test/node/api/readonly.spec.ts | 172 ++++++++++++++++++ 2 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 packages/forms/signals/test/node/api/readonly.spec.ts diff --git a/packages/forms/signals/src/api/rules/readonly.ts b/packages/forms/signals/src/api/rules/readonly.ts index b390f13b38d..953c7f4f176 100644 --- a/packages/forms/signals/src/api/rules/readonly.ts +++ b/packages/forms/signals/src/api/rules/readonly.ts @@ -46,19 +46,13 @@ export function readonly( configOrLogic?: | {when?: NoInfer>} | NoInfer>, -) { +): void { assertPathIsCurrent(path); const pathNode = FieldPathNode.unwrapFieldPath(path); - let logic: LogicFn; - if (typeof configOrLogic === 'object' && configOrLogic !== null && 'when' in configOrLogic) { - logic = configOrLogic.when ?? (() => true); - } else if (typeof configOrLogic === 'function') { - logic = configOrLogic; - } else { - logic = () => true; - } + const logic = + typeof configOrLogic === 'function' ? configOrLogic : (configOrLogic?.when ?? (() => true)); pathNode.builder.addReadonlyRule(logic); } diff --git a/packages/forms/signals/test/node/api/readonly.spec.ts b/packages/forms/signals/test/node/api/readonly.spec.ts new file mode 100644 index 00000000000..9e79837393c --- /dev/null +++ b/packages/forms/signals/test/node/api/readonly.spec.ts @@ -0,0 +1,172 @@ +/** + * @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 {form, readonly, required, validate} from '@angular/forms/signals'; + +describe('readonly', () => { + it('should initially be false', () => { + const cat = signal({name: 'Pirojok-the-cat', age: 5}); + const f = form( + cat, + (p) => { + readonly(p, { + when: ({value}) => { + return value().name === 'readonly-cat'; + }, + }); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f().readonly()).toBe(false); + expect(f.name().readonly()).toBe(false); + }); + + it('returns true when condition is met', () => { + const cat = signal({name: 'Pirojok-the-cat', age: 5}); + const f = form( + cat, + (p) => { + readonly(p.name, { + when: ({value}) => { + return value() === 'readonly-cat'; + }, + }); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f.name().readonly()).toBe(false); + f.name().value.set('readonly-cat'); + expect(f.name().readonly()).toBe(true); + }); + + it('returns true when config is empty object', () => { + const cat = signal({name: 'Pirojok-the-cat', age: 5}); + const f = form( + cat, + (p) => { + readonly(p.name, {}); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f.name().readonly()).toBe(true); + }); + + it('returns true when configOrLogic is omitted', () => { + const cat = signal({name: 'Pirojok-the-cat', age: 5}); + const f = form( + cat, + (p) => { + readonly(p.name); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f.name().readonly()).withContext('Name is permanently readonly').toBeTrue(); + + f.name().value.set('some-other-cat'); + expect(f.name().readonly()).toBeTrue(); + }); + + it('propagates the value down', () => { + const cat = signal({name: 'Pirojok-the-cat', age: 5}); + const f = form( + cat, + (p) => { + readonly(p, { + when: ({value}) => { + return value().name === 'readonly-cat'; + }, + }); + }, + {injector: TestBed.inject(Injector)}, + ); + + f.name().value.set('readonly-cat'); + expect(f.name().readonly()).toBe(true); + expect(f().readonly()).toBe(true); + }); + + it('disables validation for the field', () => { + const cat = signal({name: 'Pirojok-the-cat', age: 5}); + const f = form( + cat, + (p) => { + readonly(p.name, { + when: ({value}) => { + return value() === 'readonly-cat'; + }, + }); + + validate(p.name, () => { + return {kind: 'dog'}; + }); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f.name().valid()).withContext('Name is initially invalid').toBeFalse(); + expect(f().valid()).withContext('Form is initially invalid').toBeFalse(); + + f.name().value.set('readonly-cat'); + expect(f.name().readonly()).toBeTrue(); + expect(f.name().valid()).toBeTrue(); + expect(f().valid()).toBeTrue(); + + f.name().value.set('interactive-cat'); + expect(f.name().valid()).toBeFalse(); + expect(f().valid()).toBeFalse(); + }); + + it('disables touch state propagation', () => { + const cat = signal({name: 'Pirojok-the-cat', age: 5}); + const f = form( + cat, + (p) => { + readonly(p.name, { + when: ({value}) => { + return value() === 'readonly-cat'; + }, + }); + }, + {injector: TestBed.inject(Injector)}, + ); + + expect(f.name().touched()).withContext('Name is initially untouched').toBeFalse(); + expect(f().touched()).withContext('Form is initially untouched').toBeFalse(); + + f.name().markAsTouched(); + expect(f.name().touched()).toBeTrue(); + expect(f().touched()).toBeTrue(); + + f.name().value.set('readonly-cat'); + + expect(f.name().touched()).withContext('readonly name is not touched').toBeFalse(); + expect(f().touched()) + .withContext('form with a readonly touched field is not touched') + .toBeFalse(); + }); + + it('supports deprecated function syntax', () => { + const cat = signal({name: 'Pirojok-the-cat', age: 5}); + const f = form( + cat, + (p) => { + readonly(p.name, ((ctx: any) => ctx.value() === 'readonly-cat') as any); + }, + {injector: TestBed.inject(Injector)}, + ); + + f.name().value.set('readonly-cat'); + expect(f.name().readonly()).toBe(true); + }); +});