angular/packages/forms/signals/src/util/parser.ts
Alex Rickabaugh 849dba6c65 fix(forms): implement custom control reset propagation
Introduce a highly decoupled FVC and CVA custom control reset mechanism, and implement the framework-wide automatic `transformedValue` and native controls clearing bridge for both new Signal Forms and legacy forms (Template-driven and Reactive).

1. Custom Control Reset Propagation (Bug #2):
- Establish agnostic custom control resetting via `FormFieldBindingOptions.reset` in `FormField`.
- Ensure that `FieldNode.reset()` unconditionally triggers `writeValue` updates on CVA custom controls.
- Protect against duplicate writes during subsequent change detection updates in `control_cva.ts` by verifying and tracking previous written values in the local bindings cache.

2. Unified Framework-wide FormControl Integration:
- Introduce a monorepo-wide private InjectionToken `ɵFORM_CONTROL_INTEGRATION` and `ɵFormControlIntegration` interface to act as the single, decoupled bridge for hooking up FVC parse errors and receiving control resets across both Signal and legacy forms architectures.
- Simplify Signal Forms: make `FormField` implements `ɵFormControlIntegration` directly, removing the intermediate context object and reducing DI boilerplate down to a clean `useExisting: FormField` provider. Triggers the `onReset` callback directly inside `FormField.reset()`.
- Upgrade Legacy Forms: `NG_CONTROL_INTEGRATION_PROVIDER` provides the renamed token. `NgControl` handles the event subscription internally (`set onReset(callback)`) to recursively listen to `control.events` (`FormResetEvent`) lazily only when assigned, resolving all `FormControl` swapping timing and lifecycle cleanup races automatically.

3. Automatic `transformedValue` and Native Controls Utility Clearing:
- Make `Parser.reset()` method required in the interface for a cleaner and non-defensive execution.
- Wire `transformedValue` into the new integration token `ɵFORM_CONTROL_INTEGRATION` to clear validation parsing states on resets.
- Lazily resets the UI-facing `rawValue` linked signal utilizing the original native `linkedSignal.set` callback (`originalSet`), correctly bypassing the UI-to-model parser loopback and preventing redundant model writes during `reset()`.
- Wire up Native Controls (`control_native.ts\Device`): Hook `parent.onReset` inside native element creation to automatically trigger the native `parser.reset()` and force DOM writes (`setNativeControlValue`) back down to the DOM input value during resets, ensuring native elements with pending parsing validation errors are successfully cleared and synced on form resets.

TAG=agy
CONV=8b4cee1e-2117-42a4-b242-c8ec7bf01752
2026-05-06 10:45:40 -07:00

66 lines
2 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 {type Signal, linkedSignal} from '@angular/core';
import type {ValidationError} from '../api/rules';
import {normalizeErrors} from '../api/rules/validation/util';
import type {ParseResult} from '../api/transformed_value';
/**
* An object that handles parsing raw UI values into model values.
*/
export interface Parser<TRaw> {
/**
* Errors encountered during the last parse attempt.
*/
errors: Signal<readonly ValidationError.WithoutFieldTree[]>;
/**
* Parses the given raw value and updates the underlying model value if successful.
*/
setRawValue: (rawValue: TRaw) => void;
/**
* Resets the parser errors.
*/
readonly reset: () => void;
}
/**
* Creates a {@link Parser} that synchronizes a raw value with an underlying model value.
*
* @param getValue Function to get the current model value.
* @param setValue Function to update the model value.
* @param parse Function to parse the raw value into a {@link ParseResult}.
* @returns A {@link Parser} instance.
*/
export function createParser<TValue, TRaw>(
getValue: () => TValue,
setValue: (value: TValue) => void,
parse: (raw: TRaw) => ParseResult<TValue>,
): Parser<TRaw> {
const errors = linkedSignal({
source: getValue,
computation: () => [] as readonly ValidationError.WithoutFieldTree[],
});
const setRawValue = (rawValue: TRaw) => {
const result = parse(rawValue);
errors.set(normalizeErrors(result.error));
if (result.value !== undefined) {
setValue(result.value);
}
// `errors` is a linked signal sourced from the model value; write parse errors after
// model updates so `{value, errors}` results do not get reset by the recomputation.
errors.set(normalizeErrors(result.error));
};
const reset = () => {
errors.set([]);
};
return {errors: errors.asReadonly(), setRawValue, reset};
}