@reformer/core 10.0.0-beta.3 → 10.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/form/form-submitter.d.ts +0 -4
  2. package/dist/form/hooks/types.d.ts +3 -3
  3. package/dist/form/index.d.ts +0 -3
  4. package/dist/form/nodes/array-node.d.ts +0 -84
  5. package/dist/form/nodes/form-node.d.ts +0 -14
  6. package/dist/form/nodes/group-node.d.ts +0 -4
  7. package/dist/form/status-machine.d.ts +0 -38
  8. package/dist/form/types/contracts.d.ts +0 -6
  9. package/dist/form/types/deep-schema.d.ts +0 -9
  10. package/dist/form/types/index.d.ts +4 -38
  11. package/dist/form/types/schema-node.d.ts +13 -16
  12. package/dist/form/types/validation-schema.d.ts +0 -25
  13. package/dist/form/unique-id.d.ts +3 -11
  14. package/dist/form/validators.d.ts +8 -3
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +429 -617
  17. package/dist/runtime-token.d.ts +23 -0
  18. package/dist/validators.d.ts +0 -4
  19. package/dist/validators.js +88 -122
  20. package/docs/llms/01-api-reference.md +63 -0
  21. package/docs/llms/02-quick-start.md +143 -0
  22. package/docs/llms/03-api-signatures.md +273 -0
  23. package/docs/llms/04-common-patterns.md +202 -0
  24. package/docs/llms/05-common-mistakes.md +167 -0
  25. package/docs/llms/06-troubleshooting.md +11 -0
  26. package/docs/llms/07-complete-import.md +99 -0
  27. package/docs/llms/08-form-types.md +47 -0
  28. package/docs/llms/09-formschema.md +143 -0
  29. package/docs/llms/10-arrays.md +161 -0
  30. package/docs/llms/11-async-watchfield.md +53 -0
  31. package/docs/llms/12-array-cleanup.md +31 -0
  32. package/docs/llms/13-multi-step.md +91 -0
  33. package/docs/llms/14-extended-mistakes.md +98 -0
  34. package/docs/llms/15-project-structure.md +125 -0
  35. package/docs/llms/16-ui-components.md +189 -0
  36. package/docs/llms/17-nonexistent-api.md +93 -0
  37. package/docs/llms/18-conditional-fields.md +183 -0
  38. package/docs/llms/19-reading-values.md +59 -0
  39. package/docs/llms/20-compute-vs-watch.md +138 -0
  40. package/docs/llms/21-array-operations.md +113 -0
  41. package/docs/llms/22-cycle-detection.md +86 -0
  42. package/docs/llms/23-copy-from.md +141 -0
  43. package/docs/llms/24-sync-fields.md +114 -0
  44. package/docs/llms/25-reset-when.md +115 -0
  45. package/docs/llms/26-transform-value.md +136 -0
  46. package/docs/llms/27-revalidate-when.md +132 -0
  47. package/docs/llms/28-submit-and-reset.md +259 -0
  48. package/docs/llms/29-async-preload.md +193 -0
  49. package/docs/llms/30-type-safety-recipes.md +159 -0
  50. package/docs/llms/31-async-validator-debounce.md +135 -0
  51. package/docs/llms/32-async-options-loading.md +108 -0
  52. package/docs/llms/33-validation-strategy.md +193 -0
  53. package/llms.txt +144 -1557
  54. package/package.json +33 -10
@@ -0,0 +1,141 @@
1
+ # copyFrom — Копирование значений между полями
2
+
3
+ ## Purpose
4
+
5
+ `copyFrom` декларативно копирует значение одного поля (или группы) в другое при выполнении
6
+ условия `when`. Используется для UX-сценариев «совпадает с …»: «адрес проживания = адрес
7
+ регистрации», «email для уведомлений = основной email», «billing = shipping». Оператор сам
8
+ выходит из reactive-контекста (`runOutsideEffect`), поэтому не порождает «Cycle detected».
9
+
10
+ ## API
11
+
12
+ Есть две формы. **Примитив из `@reformer/core`** (сигнал → сигнал):
13
+
14
+ ```typescript
15
+ function copyFrom<T>(
16
+ source: ReadonlySignal<T>,
17
+ target: Signal<T>,
18
+ options?: { when?: () => boolean; transform?: (value: T) => T }
19
+ ): () => void; // возвращает cleanup
20
+ ```
21
+
22
+ **DSL-оператор из `@reformer/core/behaviors`** (сигнал ИЛИ группа):
23
+
24
+ ```typescript
25
+ function copyFrom<T>(
26
+ source: ReadonlySignal<T> | object, // model.$.field или model.$.group
27
+ target: Signal<T> | object,
28
+ options?: { when?: () => boolean; transform?: (value: T) => T }
29
+ ): void; // cleanup управляется формой
30
+ ```
31
+
32
+ `when` — реактивное условие (читает сигналы модели через `model.*`). `transform` применяется
33
+ к значению перед записью в target. Отдельной опции `fields`/`debounce` нет.
34
+
35
+ ## Examples
36
+
37
+ ### Базовый сценарий — синхронизация двух адресов
38
+
39
+ ```typescript
40
+ import { defineFormBehavior, copyFrom } from '@reformer/core/behaviors';
41
+
42
+ type OrderForm = {
43
+ useShippingAsBilling: boolean;
44
+ shippingAddress: string;
45
+ billingAddress: string;
46
+ };
47
+
48
+ export const orderBehavior = defineFormBehavior<OrderForm>(({ model }) => {
49
+ copyFrom(model.$.shippingAddress, model.$.billingAddress, {
50
+ when: () => model.useShippingAsBilling === true,
51
+ });
52
+ });
53
+ ```
54
+
55
+ Пример: `copyFrom(model.$.shippingAddress, model.$.billingAddress, { when: () => model.useShippingAsBilling })`.
56
+
57
+ ### Копирование группы целиком
58
+
59
+ Когда source/target — группы (`model.$.registrationAddress`), копируется всё значение группы:
60
+
61
+ ```typescript
62
+ import { defineFormBehavior, copyFrom } from '@reformer/core/behaviors';
63
+
64
+ type ProfileForm = {
65
+ sameAsRegistration: boolean;
66
+ registrationAddress: Address;
67
+ residenceAddress: Address;
68
+ };
69
+
70
+ export const profileBehavior = defineFormBehavior<ProfileForm>(({ model }) => {
71
+ copyFrom(model.$.registrationAddress, model.$.residenceAddress, {
72
+ when: () => model.sameAsRegistration === true,
73
+ });
74
+ });
75
+ ```
76
+
77
+ ### Copy + transform — нормализация при копировании
78
+
79
+ ```typescript
80
+ import { defineFormBehavior, copyFrom } from '@reformer/core/behaviors';
81
+
82
+ type ContactForm = { sameEmail: boolean; email: string; emailAdditional: string };
83
+
84
+ export const contactBehavior = defineFormBehavior<ContactForm>(({ model }) => {
85
+ copyFrom(model.$.email, model.$.emailAdditional, {
86
+ when: () => model.sameEmail === true,
87
+ transform: (value) => (typeof value === 'string' ? value.trim().toLowerCase() : value),
88
+ });
89
+ });
90
+ ```
91
+
92
+ ### Как примитив (вне defineFormBehavior)
93
+
94
+ ```typescript
95
+ import { copyFrom } from '@reformer/core';
96
+
97
+ const cleanups = [
98
+ copyFrom(model.$.email, model.$.emailAdditional, { when: () => model.sameEmail === true }),
99
+ ];
100
+ // teardown: cleanups.forEach((c) => c());
101
+ ```
102
+
103
+ ## Anti-patterns
104
+
105
+ ```typescript
106
+ // ❌ Двусторонняя связь через два copyFrom — конфликт направлений
107
+ copyFrom(model.$.a, model.$.b);
108
+ copyFrom(model.$.b, model.$.a);
109
+
110
+ // ✅ Двусторонняя синхронизация — это работа syncFields
111
+ syncFields(model.$.a, model.$.b);
112
+ ```
113
+
114
+ ```typescript
115
+ // ❌ when, читающий саму target — лишний триггер при перезаписи target
116
+ copyFrom(model.$.source, model.$.target, { when: () => model.target === '' });
117
+
118
+ // ✅ when опирается на независимый флаг
119
+ copyFrom(model.$.source, model.$.target, { when: () => model.copyEnabled === true });
120
+ ```
121
+
122
+ ## Troubleshooting
123
+
124
+ **Q: Скопированное значение не появляется в target.**
125
+ A: Проверьте, что (1) behavior передан в `createForm({ behavior })` (или примитив вызван и не
126
+ отписан); (2) `when` возвращает `true`; (3) типы source/target совместимы (`copyFrom` пишет как есть).
127
+
128
+ **Q: «Cycle detected» при копировании.**
129
+ A: Обычно `when` читает значение target (см. anti-pattern). Условие должно зависеть только от
130
+ source и независимых флагов.
131
+
132
+ **Q: Как откатить копию при снятии флага `when`?**
133
+ A: `copyFrom` при `when === false` просто не пишет (не сбрасывает). Для сброса используй параллельно
134
+ `resetWhen(model.$.target, () => !model.copyEnabled)` (см. `25-reset-when.md`).
135
+
136
+ ## See also
137
+
138
+ - [24-sync-fields.md](./24-sync-fields.md) — двусторонняя синхронизация
139
+ - [25-reset-when.md](./25-reset-when.md) — сброс target при выключенном условии
140
+ - [20-compute-vs-watch.md](./20-compute-vs-watch.md) — `compute` для производных значений
141
+ - [22-cycle-detection.md](./22-cycle-detection.md) — почему `runOutsideEffect` защищает от циклов
@@ -0,0 +1,114 @@
1
+ # syncFields — Двусторонняя синхронизация полей
2
+
3
+ ## Purpose
4
+
5
+ `syncFields` создаёт двунаправленную связь между двумя полями: изменение любого из них
6
+ переписывает второе. Применяется для дублей одного значения в разных частях формы
7
+ (тех. поле + видимое представление, mirror-поля). Внутренний флаг + `runOutsideEffect`
8
+ исключают петли. Для одностороннего копирования — [`copyFrom`](./23-copy-from.md); для
9
+ расчётов — [`compute`](./20-compute-vs-watch.md).
10
+
11
+ ## API
12
+
13
+ Одинаково в примитиве (`@reformer/core`) и DSL (`@reformer/core/behaviors`):
14
+
15
+ ```typescript
16
+ // примитив: возвращает cleanup
17
+ function syncFields<T>(a: Signal<T>, b: Signal<T>, options?: { transform?: (value: T) => T }): () => void;
18
+
19
+ // DSL: cleanup управляется формой
20
+ function syncFields<T>(a: Signal<T>, b: Signal<T>, options?: { transform?: (value: T) => T }): void;
21
+ ```
22
+
23
+ `a`/`b` — сигналы (`model.$.field`) совместимого типа `T`. `transform` **асимметричен**:
24
+ применяется только при движении значения `a → b`. Опции `debounce`/`when` нет.
25
+
26
+ ## Examples
27
+
28
+ ### Базовый сценарий — отзеркаливание текста
29
+
30
+ ```typescript
31
+ import { defineFormBehavior, syncFields } from '@reformer/core/behaviors';
32
+
33
+ type MirrorForm = { syncField1: string; syncField2: string };
34
+
35
+ export const mirrorBehavior = defineFormBehavior<MirrorForm>(({ model }) => {
36
+ syncFields(model.$.syncField1, model.$.syncField2);
37
+ });
38
+ ```
39
+
40
+ Пример: `syncFields(model.$.syncField1, model.$.syncField2)`.
41
+
42
+ ### С трансформацией — нормализация при прямой записи
43
+
44
+ ```typescript
45
+ import { defineFormBehavior, syncFields } from '@reformer/core/behaviors';
46
+
47
+ type DisplayForm = { internalCode: string; displayCode: string };
48
+
49
+ export const codeBehavior = defineFormBehavior<DisplayForm>(({ model }) => {
50
+ // internalCode → displayCode: uppercase; обратно значение пишется как есть
51
+ syncFields(model.$.internalCode, model.$.displayCode, {
52
+ transform: (value) => (typeof value === 'string' ? value.toUpperCase() : value),
53
+ });
54
+ });
55
+ ```
56
+
57
+ ### Как примитив (вне defineFormBehavior)
58
+
59
+ ```typescript
60
+ import { syncFields } from '@reformer/core';
61
+ const stop = syncFields(model.$.syncField1, model.$.syncField2);
62
+ // stop() — отписаться
63
+ ```
64
+
65
+ ## Anti-patterns
66
+
67
+ ```typescript
68
+ // ❌ Симметрично через два copyFrom — конфликт направлений
69
+ copyFrom(model.$.a, model.$.b);
70
+ copyFrom(model.$.b, model.$.a);
71
+
72
+ // ✅ syncFields умеет двустороннюю связь без петель
73
+ syncFields(model.$.a, model.$.b);
74
+ ```
75
+
76
+ ```typescript
77
+ // ❌ Ожидание, что transform применится в обе стороны
78
+ syncFields(model.$.a, model.$.b, { transform: (v) => v.trim() });
79
+ // при записи в b значение НЕ trim-ается
80
+
81
+ // ✅ Симметричные трансформы — syncFields + transformValue на обоих полях
82
+ syncFields(model.$.a, model.$.b);
83
+ transformValue(model.$.a, (v) => (typeof v === 'string' ? v.trim() : v));
84
+ transformValue(model.$.b, (v) => (typeof v === 'string' ? v.trim() : v));
85
+ ```
86
+
87
+ ```typescript
88
+ // ❌ Поля разного типа — рантайм-приведение и баги
89
+ syncFields(model.$.amountString, model.$.amountNumber); // string ↔ number
90
+
91
+ // ✅ Для конвертации — compute в обе стороны или один канонический формат + computed отображение
92
+ compute(model.$.amountNumber, () => Number(model.amountString));
93
+ ```
94
+
95
+ ## Troubleshooting
96
+
97
+ **Q: Поля «дёргаются», несколько перезаписей.**
98
+ A: Чаще всего на одном из полей висит `transformValue`/`compute`. Убедитесь, что transform
99
+ идемпотентен (`f(f(x)) === f(x)`).
100
+
101
+ **Q: «Cycle detected» при `syncFields`.**
102
+ A: Не вешайте дополнительно `onChange`/`watchField`, которые сами пишут в эти же поля.
103
+ `syncFields` уже занимает оба направления.
104
+
105
+ **Q: Как ограничить sync условием (как `when` у copyFrom)?**
106
+ A: У `syncFields` нет `when`. Эмулируй через два `copyFrom(a→b, { when })` / `copyFrom(b→a, { when })`
107
+ с флагами, разрешающими только одну активную сторону, либо через `apply` под условием.
108
+
109
+ ## See also
110
+
111
+ - [23-copy-from.md](./23-copy-from.md) — однонаправленное копирование с `when`
112
+ - [26-transform-value.md](./26-transform-value.md) — нормализация значений на месте
113
+ - [22-cycle-detection.md](./22-cycle-detection.md) — почему симметричный copy ломается
114
+ - [20-compute-vs-watch.md](./20-compute-vs-watch.md) — `compute` для производных значений
@@ -0,0 +1,115 @@
1
+ # resetWhen — Условный сброс полей
2
+
3
+ ## Purpose
4
+
5
+ `resetWhen` сбрасывает значение поля к `resetValue`, когда `condition` истинно. Это
6
+ альтернатива `enableWhen({ resetOnDisable: true })`, когда поле остаётся **enabled**, но
7
+ содержимое нужно очистить (например, переключение способа оплаты обнуляет «номер карты», но
8
+ поле по-прежнему доступно). По умолчанию пишет `null`; `resetValue` задаёт произвольное значение.
9
+
10
+ ## API
11
+
12
+ Одинаково в примитиве (`@reformer/core`) и DSL (`@reformer/core/behaviors`):
13
+
14
+ ```typescript
15
+ // примитив: возвращает cleanup
16
+ function resetWhen<T>(target: Signal<T>, condition: () => boolean, options?: { resetValue?: T }): () => void;
17
+
18
+ // DSL: cleanup управляется формой
19
+ function resetWhen<T>(target: Signal<T>, condition: () => boolean, options?: { resetValue?: T }): void;
20
+ ```
21
+
22
+ `target` — сигнал (`model.$.field`). `condition` — реактивное условие (читает `model.*`).
23
+ `resetValue` по умолчанию `null`. Опций `onlyIfDirty`/`debounce` нет; флаги dirty/touched
24
+ поля не трогаются оператором (значения принадлежат модели).
25
+
26
+ ## Examples
27
+
28
+ ### Базовый сценарий — сброс номера карты при смене способа оплаты
29
+
30
+ ```typescript
31
+ import { defineFormBehavior, resetWhen } from '@reformer/core/behaviors';
32
+
33
+ type CheckoutForm = { paymentType: 'card' | 'cash'; cardNumber: string };
34
+
35
+ export const checkoutBehavior = defineFormBehavior<CheckoutForm>(({ model }) => {
36
+ resetWhen(model.$.cardNumber, () => model.paymentType !== 'card', { resetValue: '' });
37
+ });
38
+ ```
39
+
40
+ Пример: `resetWhen(model.$.cardNumber, () => model.paymentType !== 'card', { resetValue: '' })`.
41
+
42
+ ### resetValue для числовых полей
43
+
44
+ ```typescript
45
+ import { defineFormBehavior, resetWhen } from '@reformer/core/behaviors';
46
+
47
+ type MortgageForm = { propertyValue: number | null; initialPayment: number };
48
+
49
+ export const mortgageBehavior = defineFormBehavior<MortgageForm>(({ model }) => {
50
+ // initialPayment теряет смысл без propertyValue — сбрасываем в 0
51
+ resetWhen(model.$.initialPayment, () => !model.propertyValue, { resetValue: 0 });
52
+ });
53
+ ```
54
+
55
+ ### Как примитив (вне defineFormBehavior)
56
+
57
+ ```typescript
58
+ import { resetWhen } from '@reformer/core';
59
+ const stop = resetWhen(model.$.cardNumber, () => model.paymentType !== 'card', { resetValue: '' });
60
+ ```
61
+
62
+ ## Anti-patterns
63
+
64
+ ```typescript
65
+ // ❌ resetWhen вместо enableWhen для disable-сценария
66
+ resetWhen(model.$.field, () => !model.show);
67
+ // поле останется enabled и валидируемым — ошибка required всё равно прилетит
68
+
69
+ // ✅ Если поле должно «исчезнуть» — блокируем + сбрасываем
70
+ enableWhen(model.$.field, () => model.show, { resetOnDisable: true });
71
+ ```
72
+
73
+ ```typescript
74
+ // ❌ resetValue с типом, отличным от поля
75
+ resetWhen(model.$.amount, () => model.skipPayment, { resetValue: 'none' }); // amount: number ← string
76
+
77
+ // ✅ resetValue совместим с типом поля
78
+ resetWhen(model.$.amount, () => model.skipPayment, { resetValue: 0 });
79
+ ```
80
+
81
+ ```typescript
82
+ // ❌ Для строкового поля без resetValue прилетит null, а Input ждёт string
83
+ resetWhen(model.$.cardNumber, () => model.paymentType !== 'card');
84
+
85
+ // ✅ Явный resetValue для строк
86
+ resetWhen(model.$.cardNumber, () => model.paymentType !== 'card', { resetValue: '' });
87
+ ```
88
+
89
+ ```typescript
90
+ // ❌ condition читает саму цель — самотриггер (см. 22-cycle-detection.md)
91
+ resetWhen(model.$.cardNumber, () => model.cardNumber !== '');
92
+
93
+ // ✅ condition зависит только от независимого поля
94
+ resetWhen(model.$.cardNumber, () => model.paymentType !== 'card', { resetValue: '' });
95
+ ```
96
+
97
+ ## Troubleshooting
98
+
99
+ **Q: Сброс не срабатывает, хотя `condition` возвращает true.**
100
+ A: Проверьте, что behavior зарегистрирован (`createForm({ behavior })`) или примитив не отписан,
101
+ и что `condition` читает реактивные поля (`model.*`), а не снимок `model.get()`.
102
+
103
+ **Q: Реактивная цепочка resetWhen → compute → resetWhen ломается.**
104
+ A: Убедитесь, что `condition` не зависит от значения самого поля, иначе после сброса попадёте
105
+ в новый триггер.
106
+
107
+ **Q: Сбросить вложенную группу целиком?**
108
+ A: `resetWhen` рассчитан на скалярные сигналы. Для группы используй `enableWhen({ resetOnDisable: true })`
109
+ (проходит по поддереву) или `model.reset()` для сброса всей формы к initial-снимку.
110
+
111
+ ## See also
112
+
113
+ - [04-common-patterns.md](./04-common-patterns.md) — `enableWhen({ resetOnDisable: true })` как альтернатива
114
+ - [23-copy-from.md](./23-copy-from.md) — копирование, у которого нет встроенного отката
115
+ - [22-cycle-detection.md](./22-cycle-detection.md) — почему `condition` не должен читать целевое поле
@@ -0,0 +1,136 @@
1
+ # transformValue — Автоматическая трансформация значений
2
+
3
+ ## Purpose
4
+
5
+ `transformValue` подписывается на изменение поля и переписывает его трансформированной
6
+ версией: uppercase для кодов, trim+toLowerCase для email, округление для чисел. Применяется
7
+ единообразно ко всем источникам изменения (пользователь, `model.field = …`, `model.set/patch`,
8
+ `copyFrom`). **Идемпотентность (`f(f(x)) === f(x)`) обязательна** — иначе бесконечный цикл.
9
+ Оператор откладывает запись вне effect-контекста (`runOutsideEffect`) и не пишет, если
10
+ `transformer(value) === value` — базовый guard от циклов.
11
+
12
+ ## API
13
+
14
+ Одинаково в примитиве (`@reformer/core`) и DSL (`@reformer/core/behaviors`):
15
+
16
+ ```typescript
17
+ // примитив: возвращает cleanup
18
+ function transformValue<T>(target: Signal<T>, transformer: (value: T) => T): () => void;
19
+
20
+ // DSL: cleanup управляется формой
21
+ function transformValue<T>(target: Signal<T>, transformer: (value: T) => T): void;
22
+ ```
23
+
24
+ `target` — сигнал (`model.$.field`). `transformer` — чистая идемпотентная функция значения.
25
+ Дополнительных опций (`debounce`, `onUserChangeOnly`, `emitEvent`) и готового набора
26
+ `transformers`/`createTransformer` НЕТ — трансформер пишется как обычная функция.
27
+
28
+ ## Examples
29
+
30
+ ### Базовый сценарий — uppercase для кода
31
+
32
+ ```typescript
33
+ import { defineFormBehavior, transformValue } from '@reformer/core/behaviors';
34
+
35
+ type PromoForm = { uppercaseField: string };
36
+
37
+ export const promoBehavior = defineFormBehavior<PromoForm>(({ model }) => {
38
+ transformValue(model.$.uppercaseField, (value) => (value ?? '').toUpperCase());
39
+ });
40
+ ```
41
+
42
+ Пример: `transformValue(model.$.uppercaseField, (v) => (v ?? '').toUpperCase())`.
43
+
44
+ ### Несколько трансформаций
45
+
46
+ ```typescript
47
+ import { defineFormBehavior, transformValue } from '@reformer/core/behaviors';
48
+
49
+ type ContactForm = { email: string; phone: string; amount: number };
50
+
51
+ export const contactBehavior = defineFormBehavior<ContactForm>(({ model }) => {
52
+ // email: trim + lowercase
53
+ transformValue(model.$.email, (value) => (value ?? '').trim().toLowerCase());
54
+
55
+ // телефон: только цифры → формат
56
+ transformValue(model.$.phone, (value) => {
57
+ if (!value) return value;
58
+ const digits = value.replace(/\D/g, '');
59
+ if (digits.length === 11) {
60
+ return `+7 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7, 9)}-${digits.slice(9)}`;
61
+ }
62
+ return value;
63
+ });
64
+
65
+ // округление до целого
66
+ transformValue(model.$.amount, (value) => (typeof value === 'number' ? Math.round(value) : value));
67
+ });
68
+ ```
69
+
70
+ ### Как примитив (вне defineFormBehavior)
71
+
72
+ ```typescript
73
+ import { transformValue } from '@reformer/core';
74
+ const stop = transformValue(model.$.promoCode, (v) => (v ?? '').toUpperCase());
75
+ ```
76
+
77
+ ### Переиспользуемые трансформеры — обычные функции
78
+
79
+ Готового набора нет, но легко собрать свои и применять их к любому сигналу:
80
+
81
+ ```typescript
82
+ const toUpper = (target: Signal<string>) => transformValue(target, (v) => (v ?? '').toUpperCase());
83
+ const trim = (target: Signal<string>) => transformValue(target, (v) => (v ?? '').trim());
84
+
85
+ // в схеме поведения:
86
+ toUpper(model.$.promoCode);
87
+ trim(model.$.username);
88
+ ```
89
+
90
+ ## Anti-patterns
91
+
92
+ ```typescript
93
+ // ❌ Неидемпотентный transformer — бесконечный цикл
94
+ transformValue(model.$.field, (v) => `prefix-${v}`); // f(f(x)) ≠ f(x)
95
+
96
+ // ✅ Guard внутри transformer
97
+ transformValue(model.$.field, (v) => (v?.startsWith('prefix-') ? v : `prefix-${v}`));
98
+ ```
99
+
100
+ ```typescript
101
+ // ❌ transformValue ОДНОВРЕМЕННО с syncFields на том же поле
102
+ syncFields(model.$.a, model.$.b);
103
+ transformValue(model.$.b, (v) => v.toUpperCase()); // взаимные перезаписи
104
+
105
+ // ✅ Трансформируй источник ДО синхронизации
106
+ transformValue(model.$.a, (v) => v.toUpperCase());
107
+ syncFields(model.$.a, model.$.b);
108
+ ```
109
+
110
+ ```typescript
111
+ // ❌ transformValue для производных полей (нет доступа к другим полям)
112
+ transformValue(model.$.fullName, () => `${model.firstName} ${model.lastName}`);
113
+
114
+ // ✅ Для зависимостей от других полей — compute
115
+ compute(model.$.fullName, () => `${model.firstName} ${model.lastName}`);
116
+ ```
117
+
118
+ ## Troubleshooting
119
+
120
+ **Q: «Cycle detected» при transformValue.**
121
+ A: 99% — неидемпотентный transformer. Проверь `transformer(transformer(x)) === transformer(x)`.
122
+
123
+ **Q: Трансформация не применяется.**
124
+ A: Проверь, что behavior зарегистрирован (`createForm({ behavior })`) / примитив не отписан,
125
+ и что форма не пересоздаётся на каждый рендер (используй `useMemo`).
126
+
127
+ **Q: Каретка input прыгает при наборе.**
128
+ A: Симптом частых записей. Форматирование с динамическими разделителями (телефон, кредитка)
129
+ лучше делать через `<InputMask>` из `@reformer/ui-kit`, а не `transformValue`.
130
+
131
+ ## See also
132
+
133
+ - [24-sync-fields.md](./24-sync-fields.md) — порядок применения с syncFields
134
+ - [23-copy-from.md](./23-copy-from.md) — `transform`-опция при копировании
135
+ - [22-cycle-detection.md](./22-cycle-detection.md) — про идемпотентность
136
+ - [16-ui-components.md](./16-ui-components.md) — `<InputMask>` для тяжёлого форматирования
@@ -0,0 +1,132 @@
1
+ # revalidateWhen — Перевалидация по триггерам
2
+
3
+ ## Purpose
4
+
5
+ `revalidateWhen` вызывает переданный колбэк ревалидации, когда изменяется любая из
6
+ зависимостей. Применяется, когда правило зависит от значений других полей
7
+ (`amount <= maxAmount`, `confirmPassword === password`, `initialPayment >= propertyValue * 0.2`).
8
+ Валидация — отдельная функция-схема (`@reformer/core/validation`), прогоняемая по требованию через
9
+ `validateModel(model, schema)`. Поэтому ревалидация выражается явным колбэком, а не автоматической
10
+ привязкой к полю: поведение (`revalidateWhen`) лишь _инициирует_ прогон схемы валидации при изменении зависимости.
11
+
12
+ ## API
13
+
14
+ `revalidateWhen` — оператор поведения (`@reformer/core/behaviors`, либо примитив `@reformer/core`).
15
+ Его сигнатура НЕ зависит от контракта валидации:
16
+
17
+ ```typescript
18
+ // примитив: возвращает cleanup
19
+ function revalidateWhen(deps: ReadonlySignal<unknown>[], revalidate: () => void): () => void;
20
+
21
+ // DSL: cleanup управляется формой
22
+ function revalidateWhen(deps: ReadonlySignal<unknown>[], revalidate: () => void): void;
23
+ ```
24
+
25
+ `deps` — массив сигналов-триггеров (`model.$.field`). `revalidate` — колбэк (обычно
26
+ `() => void validateModel(model, schema)`, где `schema` — `defineValidationSchema<T>(({ model }) => …)`).
27
+ Вызывается при изменении любой зависимости (НЕ на инициализации), вне effect-контекста.
28
+
29
+ ## Examples
30
+
31
+ ### Базовый сценарий — перевалидация amount при смене maxAmount
32
+
33
+ ```typescript
34
+ import { defineFormBehavior, revalidateWhen } from '@reformer/core/behaviors';
35
+ import { validateModel } from '@reformer/core/validation';
36
+
37
+ export const paymentBehavior = defineFormBehavior<PaymentForm>(({ model }) => {
38
+ revalidateWhen([model.$.maxAmount], () => {
39
+ void validateModel(model, paymentSchema);
40
+ });
41
+ });
42
+ ```
43
+
44
+ Пример: `revalidateWhen([model.$.maxAmount], () => void validateModel(model, paymentSchema))`.
45
+ Схема `paymentSchema` — стабильный module-level `const` (важно для отмены устаревших прогонов
46
+ в `validateModel`), где правило amount навешано через `cross(model.$.amount, amountVsMax)`.
47
+
48
+ ### Несколько триггеров
49
+
50
+ ```typescript
51
+ import { defineFormBehavior, revalidateWhen } from '@reformer/core/behaviors';
52
+ import { validateModel } from '@reformer/core/validation';
53
+
54
+ export const mortgageBehavior = defineFormBehavior<MortgageForm>(({ model }) => {
55
+ // initialPayment зависит и от propertyValue, и от loanAmount
56
+ revalidateWhen([model.$.propertyValue, model.$.loanAmount], () => {
57
+ void validateModel(model, mortgageSchema);
58
+ });
59
+ });
60
+ ```
61
+
62
+ ### Парная перевалидация — confirmPassword
63
+
64
+ ```typescript
65
+ import { defineFormBehavior, revalidateWhen } from '@reformer/core/behaviors';
66
+ import { validateModel } from '@reformer/core/validation';
67
+
68
+ // правило совпадения — cross-field в схеме: cross(model.$.confirmPassword, passwordsMatch),
69
+ // где passwordsMatch читает снапшот формы (см. 03-api-signatures.md)
70
+ export const registrationBehavior = defineFormBehavior<RegistrationForm>(({ model }) => {
71
+ // при смене password перевалидируем схему (confirm перепроверится)
72
+ revalidateWhen([model.$.password], () => {
73
+ void validateModel(model, registrationSchema);
74
+ });
75
+ });
76
+ ```
77
+
78
+ ### Как примитив (вне defineFormBehavior)
79
+
80
+ ```typescript
81
+ import { revalidateWhen } from '@reformer/core';
82
+ import { validateModel } from '@reformer/core/validation';
83
+
84
+ const stop = revalidateWhen([model.$.maxAmount], () => void validateModel(model, schema));
85
+ ```
86
+
87
+ ## Anti-patterns
88
+
89
+ ```typescript
90
+ // ❌ Триггер == поле, которое и так меняется само
91
+ revalidateWhen([model.$.amount], () => validateModel(model, schema));
92
+ // amount и так валидируется при собственном изменении в общем прогоне
93
+
94
+ // ✅ Триггеры — ДРУГИЕ поля, от которых зависит правило amount
95
+ revalidateWhen([model.$.maxAmount, model.$.discount], () => validateModel(model, schema));
96
+ ```
97
+
98
+ ```typescript
99
+ // ❌ Правило только через revalidateWhen, но в схеме нет зависимости от триггера
100
+ revalidateWhen([model.$.maxAmount], () => validateModel(model, schema));
101
+ // а валидатор amount — статичный max(1000), не читает maxAmount
102
+
103
+ // ✅ Сначала cross-field правило (читает снапшот формы) в схеме валидации, потом revalidateWhen
104
+ const amountVsMax = (f: Form): ValidationError | null =>
105
+ f.amount != null && f.maxAmount != null && f.amount > f.maxAmount
106
+ ? { code: 'tooBig', message: '...' }
107
+ : null;
108
+
109
+ const schema = defineValidationSchema<Form>(({ model }) => {
110
+ cross(model.$.amount, amountVsMax); // навешиваем правило на поле amount
111
+ });
112
+ revalidateWhen([model.$.maxAmount], () => void validateModel(model, schema));
113
+ ```
114
+
115
+ ## Troubleshooting
116
+
117
+ **Q: Ошибка target не пропадает после изменения триггера.**
118
+ A: Проверьте, что (1) правило реально читает значение триггера — cross-field `cross(sig, fn)`,
119
+ где `fn` берёт снапшот формы (`model.get()` / `f`); (2) в `deps` передан именно сигнал (`model.$.trigger`);
120
+ (3) колбэк вызывает `validateModel` (роутит ошибки в ноды через `getNodeForSignal(sig).setErrors`).
121
+
122
+ **Q: Как перевалидировать только одно поле, а не всю схему?**
123
+ A: Схема — обычная функция, поэтому опиши стабильную под-схему только для этого поля и прогони её:
124
+ `validateModel(model, oneFieldSchema)`, где
125
+ `const oneFieldSchema = defineValidationSchema<Form>(({ model }) => cross(model.$.amount, amountVsMax))`.
126
+ Раннер выставит/погасит ошибки только затронутых полей (гашение накапливается на пару `(model, schema)`).
127
+
128
+ ## See also
129
+
130
+ - [03-api-signatures.md](./03-api-signatures.md) — cross-field `cross(sig, fn)` и раннер `validateModel`
131
+ - [28-submit-and-reset.md](./28-submit-and-reset.md) — полная валидация формы
132
+ - [20-compute-vs-watch.md](./20-compute-vs-watch.md) — реактивные производные значения