@reformer/core 10.2.0 → 11.0.0-beta.2

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 (63) hide show
  1. package/dist/behaviors.js +134 -134
  2. package/dist/form/form-submitter.d.ts +0 -4
  3. package/dist/form/hooks/types.d.ts +3 -3
  4. package/dist/form/index.d.ts +0 -3
  5. package/dist/form/nodes/array-node.d.ts +0 -84
  6. package/dist/form/nodes/form-node.d.ts +0 -14
  7. package/dist/form/nodes/group-node.d.ts +0 -4
  8. package/dist/form/status-machine.d.ts +0 -38
  9. package/dist/form/types/contracts.d.ts +0 -6
  10. package/dist/form/types/deep-schema.d.ts +0 -9
  11. package/dist/form/types/index.d.ts +4 -38
  12. package/dist/form/types/schema-node.d.ts +13 -16
  13. package/dist/form/types/validation-schema.d.ts +0 -25
  14. package/dist/form/unique-id.d.ts +3 -11
  15. package/dist/form/validators.d.ts +8 -3
  16. package/dist/form-model-DhW2HZOB.js +323 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +448 -636
  19. package/dist/runtime-token.d.ts +23 -0
  20. package/dist/state/form-model.d.ts +14 -0
  21. package/dist/state/index.d.ts +2 -1
  22. package/dist/state/types.d.ts +39 -11
  23. package/dist/state.js +18 -18
  24. package/dist/validation.js +1 -1
  25. package/dist/validators.d.ts +0 -4
  26. package/dist/validators.js +88 -122
  27. package/docs/llms/01-api-reference.md +63 -0
  28. package/docs/llms/02-quick-start.md +143 -0
  29. package/docs/llms/03-api-signatures.md +273 -0
  30. package/docs/llms/04-common-patterns.md +202 -0
  31. package/docs/llms/05-common-mistakes.md +167 -0
  32. package/docs/llms/06-troubleshooting.md +11 -0
  33. package/docs/llms/07-complete-import.md +99 -0
  34. package/docs/llms/08-form-types.md +47 -0
  35. package/docs/llms/09-formschema.md +143 -0
  36. package/docs/llms/10-arrays.md +161 -0
  37. package/docs/llms/11-async-watchfield.md +53 -0
  38. package/docs/llms/12-array-cleanup.md +31 -0
  39. package/docs/llms/13-multi-step.md +91 -0
  40. package/docs/llms/14-extended-mistakes.md +98 -0
  41. package/docs/llms/15-project-structure.md +125 -0
  42. package/docs/llms/16-ui-components.md +189 -0
  43. package/docs/llms/17-nonexistent-api.md +93 -0
  44. package/docs/llms/18-conditional-fields.md +183 -0
  45. package/docs/llms/19-reading-values.md +88 -0
  46. package/docs/llms/20-compute-vs-watch.md +147 -0
  47. package/docs/llms/21-array-operations.md +113 -0
  48. package/docs/llms/22-cycle-detection.md +86 -0
  49. package/docs/llms/23-copy-from.md +141 -0
  50. package/docs/llms/24-sync-fields.md +114 -0
  51. package/docs/llms/25-reset-when.md +115 -0
  52. package/docs/llms/26-transform-value.md +136 -0
  53. package/docs/llms/27-revalidate-when.md +132 -0
  54. package/docs/llms/28-submit-and-reset.md +259 -0
  55. package/docs/llms/29-async-preload.md +193 -0
  56. package/docs/llms/30-type-safety-recipes.md +159 -0
  57. package/docs/llms/31-async-validator-debounce.md +135 -0
  58. package/docs/llms/32-async-options-loading.md +108 -0
  59. package/docs/llms/33-validation-strategy.md +193 -0
  60. package/llms.txt +219 -1533
  61. package/package.json +33 -10
  62. package/dist/derived-registry-CluZCQ7U.js +0 -16
  63. package/dist/form-model-DYCzGGKF.js +0 -286
@@ -0,0 +1,93 @@
1
+ ## 15. NON-EXISTENT API (DO NOT USE)
2
+
3
+ **Следующего API НЕТ в @reformer/core** (наследие старой path-based архитектуры,
4
+ удалено при переходе на M1 и при разделении валидации/поведения):
5
+
6
+ > ⚠️ Операторы валидации `validate`/`validateAsync`/`validateWhen`/`cross`/`each`/`apply`
7
+ > **существуют** — но живут в сабпути `@reformer/core/validation` (ambient-схема
8
+ > `defineValidationSchema(({ model }) => …)`, раннер `validateModel`), а НЕ в корне и НЕ
9
+ > в `@reformer/core/validators`. Ниже перечислено то, чего действительно нет.
10
+
11
+ | Wrong | Correct | Notes |
12
+ | -------------------------------- | ------------------------------------------------ | ---------------------------------------------- |
13
+ | `useForm` | `createModel` + `createForm` | Хука useForm нет |
14
+ | `validateForm`, `validateFormModel` | `validateModel(model, schema)` из `@reformer/core/validation` | Legacy-движок дерева `{ value, validators }` удалён; внешний раннер — `validateModel` |
15
+ | `validateModelSync` | `await validateModel(model, schema)` | Синхронного раннера нет — прогон асинхронный (`Promise<boolean>`) |
16
+ | `applyWhen` | `validateWhen(() => cond, () => { … })` | Условная валидация — оператором `validateWhen`, не узлом `{ when, children }` |
17
+ | `validateItems`, `validateGroup`, `validateTree` | `each(model.arr, (im) => { … })` | Per-item — оператором `each`; под-схема — прямой вызов `sub({ model: model.child })` |
18
+ | `ValidationSchemaFn`, `BehaviorSchemaFn` | `ValidationSchema<T>` (`defineValidationSchema`) / `defineFormBehavior` | Типы path-схем удалены |
19
+ | `equalTo`, `custom`, `notEmpty` (validators) | inline `Rule<T>` `(value) => err \| null` в `validate(sig, [...])`; сравнение полей — `cross(sig, fn)` | Таких фабрик нет; кастомное правило — обычная функция значения, cross-field — `cross` |
20
+ | `form.submit()` / `form.validate()` прогоняют схему | `await validateModel(model, schema)` перед submit/шагом | submit/validate НЕ запускают ambient-схему валидации |
21
+ | `FieldPath`, `FieldPathNode` | `model.$.field` (`PathAwareSignal`) | Пути заменены сигналами |
22
+ | `ctx.form.x.value.value` | `model.x` / `model.$.x.value` | В behaviors читаем модель напрямую |
23
+ | `ctx.setFieldValue(name, value)` | `model.x = value` / `compute(...)` | Не существует |
24
+ | `transformers`, `createTransformer` | `transformValue(signal, fn)` | Готового набора трансформеров нет |
25
+ | `useHiddenCondition` | `useFormControlValue` + условный рендер в JSX | Хука нет |
26
+ | `FormProvider`, `control` prop, `register()` | `<Component form={form} />`, `useFormControl(form.field)` | Форма передаётся через props |
27
+ | `getFieldValue()` | `model.field` / `useFormControlValue(form.field)`| Не существует |
28
+
29
+ ### Common Import Errors
30
+
31
+ ```typescript
32
+ // WRONG - этих символов / путей НЕ существует
33
+ import { useForm, validateForm, validateFormModel } from '@reformer/core'; // NO!
34
+ import { validate, applyWhen, equalTo } from '@reformer/core/validators'; // NO! операторов тут нет
35
+ import { transformers } from '@reformer/core/behaviors'; // NO!
36
+ import type { FieldPath, ValidationSchemaFn } from '@reformer/core'; // NO!
37
+
38
+ // CORRECT
39
+ import { createModel, createForm, useFormControl } from '@reformer/core';
40
+ import type { FormModel, ValidationError, FieldConfig } from '@reformer/core';
41
+ // Операторы валидации + раннер + defineValidationSchema — отдельный сабпуть:
42
+ import {
43
+ validate, validateAsync, validateWhen, cross, each, apply,
44
+ defineValidationSchema, validateModel,
45
+ type Rule, type AsyncRule, type ValidationSchema,
46
+ } from '@reformer/core/validation';
47
+ // Фабрики-валидаторы (value-only, принимают nullable):
48
+ import { required, email, min, minLength, pattern } from '@reformer/core/validators';
49
+ // Поведение (отдельный слой, контракт не менялся):
50
+ import { defineFormBehavior, compute, onChange, revalidateWhen } from '@reformer/core/behaviors';
51
+ ```
52
+
53
+ ### Schema Common Mistakes
54
+
55
+ Валидация и layout — **разные** контракты. Layout-узел привязывает поле к сигналу и
56
+ НЕ несёт `validators`; правила живут в отдельной `defineValidationSchema`.
57
+
58
+ ```typescript
59
+ // WRONG - примитив/литерал вместо сигнала, и validators прямо в layout-узле
60
+ const schema = {
61
+ name: '', // нет привязки к сигналу
62
+ email: { value: '', validators: [required()] }, // value — литерал, а validators в layout больше нет
63
+ };
64
+
65
+ // CORRECT - layout только связывает поле с сигналом модели...
66
+ const layout = {
67
+ name: { value: model.$.name, component: Input, componentProps: { label: 'Name' } },
68
+ email: { value: model.$.email, component: Input, componentProps: { label: 'Email' } },
69
+ };
70
+
71
+ // ...а правила — отдельная ambient-схема (прогоняется раннером validateModel):
72
+ const validation = defineValidationSchema<Form>(({ model }) => {
73
+ validate(model.$.email, [required({ message: 'Email обязателен' }), email()]);
74
+ });
75
+ ```
76
+
77
+ ### Behaviors Common Mistakes
78
+
79
+ ```typescript
80
+ // WRONG - строковые пути и (form) => ... — старый API
81
+ enableWhen(path.city, (form) => Boolean(form.country));
82
+
83
+ // CORRECT - сигналы модели, условие читает model
84
+ enableWhen(model.$.city, () => Boolean(model.country), { resetOnDisable: true });
85
+ ```
86
+
87
+ Поведение **не владеет** валидацией. Чтобы поведение инициировало прогон схемы — мост
88
+ `revalidateWhen` (validate/submit сами схему не запускают):
89
+
90
+ ```typescript
91
+ // CORRECT - поведение дёргает внешний раннер валидации при изменении зависимости
92
+ revalidateWhen([model.$.password], () => void validateModel(model, validation));
93
+ ```
@@ -0,0 +1,183 @@
1
+ # Условные поля — видимость, доступность и валидация
2
+
3
+ ## Purpose
4
+
5
+ «Показать поле X только если Y» под M1 решается тремя независимыми механизмами — выбирай по
6
+ тому, что именно должно быть условным:
7
+
8
+ 1. **Видимость / доступность** (поле остаётся в модели, но выключается/включается и не валидируется)
9
+ — behaviors: `enableWhen`, а для условной записи значения — `compute` / `copyFrom` с опцией `{ when }`.
10
+ 2. **Условная валидация** (правила применяются только в истинной ветке) — оператор
11
+ `validateWhen(() => cond, () => {…})` внутри схемы `defineValidationSchema`, прогоняется раннером `validateModel`.
12
+ 3. **Скрытие из разметки** (узел вообще не рендерится) — `useFormControlValue` + условный рендер в JSX.
13
+
14
+ > Условная **валидация** — оператор `validateWhen` из `@reformer/core/validation`; старого branch-узла
15
+ > `{ when, children }` и хелпера `applyWhen` больше нет — см.
16
+ > [Anti-patterns удалённого контракта валидации](#anti-patterns-удалённого-контракта-валидации) и `17-nonexistent-api.md`.
17
+
18
+ ## Видимость и доступность
19
+
20
+ `enableWhen(target, () => condition, { resetOnDisable })` из `@reformer/core/behaviors`
21
+ включает/выключает поле реактивно. При `disabled` поле не участвует в валидации; с
22
+ `resetOnDisable: true` его значение сбрасывается при выключении (иначе — сохраняется).
23
+
24
+ ```typescript
25
+ import { defineFormBehavior, enableWhen, compute, copyFrom } from '@reformer/core/behaviors';
26
+
27
+ type CreditForm = {
28
+ loanType: 'mortgage' | 'car' | 'consumer';
29
+ propertyValue: number | null;
30
+ initialPayment: number | null;
31
+ sameEmail: boolean;
32
+ email: string;
33
+ emailAdditional: string;
34
+ };
35
+
36
+ export const creditBehavior = defineFormBehavior<CreditForm>(({ model }) => {
37
+ // один или несколько таргетов; условие читает model.* реактивно
38
+ enableWhen(
39
+ [model.$.propertyValue, model.$.initialPayment],
40
+ () => model.loanType === 'mortgage',
41
+ { resetOnDisable: true },
42
+ );
43
+
44
+ // условная ЗАПИСЬ производного значения — compute с { when }
45
+ compute(model.$.initialPayment, () => Math.round((model.propertyValue ?? 0) * 0.2), {
46
+ when: () => model.loanType === 'mortgage',
47
+ });
48
+
49
+ // условное КОПИРОВАНИЕ из другого поля — copyFrom с { when }
50
+ copyFrom(model.$.email, model.$.emailAdditional, { when: () => model.sameEmail === true });
51
+ });
52
+ ```
53
+
54
+ Требования:
55
+
56
+ - **Все поля должны быть материализованы в модели** (`createModel`), даже те, что показываются
57
+ только под условием. `enableWhen`/`compute`/`copyFrom` принимают сигналы `model.$.field`, а не
58
+ строковые пути; сигнала для несуществующего поля нет.
59
+ - Поведение подключается к форме через `createForm({ model, schema, behavior })` — форма владеет
60
+ жизненным циклом, DSL-операторы регистрируют свой cleanup сами (ручной массив cleanup'ов не нужен).
61
+ - Таргетом может быть и **группа**: `enableWhen(model.$.residenceAddress, () => model.sameAsRegistration === false)`
62
+ проходит по поддереву; без `resetOnDisable` значение группы сохраняется (удобно, когда оно копируется
63
+ из другого источника).
64
+
65
+ > **Групповой таргет работает только у DSL-`enableWhen` из `@reformer/core/behaviors`**, не у
66
+ > одноимённого корневого `enableWhen` из `@reformer/core` (тот резолвит только leaf-сигналы через
67
+ > реестр → на group-сигнале это тихий no-op). `get_symbol_docs("enableWhen")` возвращает именно
68
+ > корневой, не group-capable вариант.
69
+
70
+ ## Условная валидация
71
+
72
+ Правила, действующие только при выполнении условия, оборачиваются оператором
73
+ `validateWhen(() => cond, () => {…})` внутри схемы `defineValidationSchema<T>(({ model }) => …)`.
74
+ Условие `cond` читает модель напрямую (`model.field` — снимок на момент прогона): при истинном условии
75
+ раннер применяет вложенные `validate`/`cross`/…, при ложном — правила ветки **гасятся** (поля,
76
+ затронутые ранее, получают `setErrors([])`). Прогон — внешним раннером
77
+ `validateModel(model, schema): Promise<boolean>` (ошибки сами роутятся в ноды формы).
78
+
79
+ ```typescript
80
+ import {
81
+ validate,
82
+ validateWhen,
83
+ defineValidationSchema,
84
+ validateModel,
85
+ } from '@reformer/core/validation';
86
+ import { required, min } from '@reformer/core/validators';
87
+
88
+ const schema = defineValidationSchema<CreditForm>(({ model }) => {
89
+ validate(model.$.loanType, [required()]);
90
+
91
+ // ветка применяется только для ипотеки
92
+ validateWhen(
93
+ () => model.loanType === 'mortgage',
94
+ () => {
95
+ validate(model.$.propertyValue, [required(), min(1_000_000)]);
96
+ validate(model.$.initialPayment, [required()]);
97
+ },
98
+ );
99
+ });
100
+
101
+ const ok: boolean = await validateModel(model, schema);
102
+ ```
103
+
104
+ Переключение `loanType` с `mortgage` на другой тип автоматически снимает ошибки `required` с
105
+ `propertyValue`/`initialPayment` — при ложном условии ветка `validateWhen` гасит их (`setErrors([])`).
106
+ `validateWhen` управляет только **валидацией**; чтобы поля ещё и выпадали из состояния (сброс
107
+ значения), держи их под `enableWhen` из раздела выше — слои независимы.
108
+
109
+ ## Скрытие в JSX
110
+
111
+ Чтобы условный блок вообще не рендерился, читай значение-триггер хуком `useFormControlValue(form.field)`
112
+ и делай ранний `return null`. Хук возвращает **только значение** (без деструктуризации `{ value }`).
113
+
114
+ ```tsx
115
+ import { useFormControlValue } from '@reformer/core';
116
+
117
+ function MortgageFields({ form }: { form: CreditForm }) {
118
+ const loanType = useFormControlValue(form.loanType);
119
+ if (loanType !== 'mortgage') return null;
120
+
121
+ return (
122
+ <>
123
+ <PropertyValueField form={form} />
124
+ <InitialPaymentField form={form} />
125
+ </>
126
+ );
127
+ }
128
+ ```
129
+
130
+ Скрытие в JSX — чисто визуальное: если поле должно ещё и **выпадать из валидации/состояния**,
131
+ сочетай его с `enableWhen({ resetOnDisable: true })` (доступность) или `validateWhen` (валидация).
132
+
133
+ ## Anti-patterns удалённого контракта валидации
134
+
135
+ Условную валидацию раньше собирали иначе; эти формы удалены при переходе на `@reformer/core/validation`.
136
+ Ниже — **как НЕ надо** (чтобы не регрессировать); рабочий вариант — `validateWhen` из раздела выше.
137
+
138
+ - **Нет branch-узла `{ when, children }` в дереве схемы.** Старая схема была деревом
139
+ `{ value, validators: [...] }` с ветками `{ when: (scope, root) => boolean, children: [...] }`,
140
+ которое обходил `validateFormModel`. Теперь схема — функция `({ model }) => void`, а условие —
141
+ оператор `validateWhen(() => cond, () => {…})`; отдельного объекта-узла нет.
142
+
143
+ ```typescript
144
+ // УДАЛЕНО — так больше нельзя
145
+ const schema = {
146
+ children: [
147
+ { value: model.$.loanType, validators: [required()] },
148
+ { when: (_s, root) => root.loanType === 'mortgage', children: [ /* … */ ] },
149
+ ],
150
+ };
151
+ await validateFormModel(model, schema); // символа нет
152
+ ```
153
+
154
+ - **`applyWhen` не существует** — ни как экспорт, ни как локальный сахар: он эмитил branch-узел,
155
+ которого больше нет. Оборачивай условные правила оператором `validateWhen`.
156
+
157
+ - **`ValidationSchemaFn` удалён** (тип старой path-схемы). Тип схемы — `ValidationSchema<T>`
158
+ (`(ctx: { model: FormModel<T> }) => void`), фабрика-обёртка — `defineValidationSchema<T>(fn)`.
159
+
160
+ - **Cross-field — обычная функция над снапшотом, а не `ModelValidator (value, scope, root)`.**
161
+ Соседние поля читаются из снимка модели (`f: Root`, эквивалент `model.get()`), а правило навешивается
162
+ оператором `cross(sig, fn)`. Каноничное использование — `complex-multy-step-form/schemas/validation.ts`.
163
+
164
+ ```typescript
165
+ import { cross } from '@reformer/core/validation';
166
+ import type { ValidationError } from '@reformer/core';
167
+
168
+ // (f: Root) => error — сравнение с соседним полем, без scope/root-параметров
169
+ const passwordsMatch = (f: { password: string; confirmPassword: string }): ValidationError | null =>
170
+ f.confirmPassword && f.password && f.confirmPassword !== f.password
171
+ ? { code: 'mismatch', message: 'Пароли не совпадают' }
172
+ : null;
173
+
174
+ // внутри defineValidationSchema — fn получает снапшот текущего scope (model.get()):
175
+ cross(model.$.confirmPassword, passwordsMatch);
176
+ ```
177
+
178
+ ## See also
179
+
180
+ - [03-api-signatures.md](./03-api-signatures.md) — сигнатуры `enableWhen`/`compute`/`copyFrom` (поведение) и `validate`/`validateWhen`/`cross`/`validateModel` (валидация)
181
+ - [17-nonexistent-api.md](./17-nonexistent-api.md) — полный список удалённого API (`applyWhen`, `ValidationSchemaFn`, `validateFormModel`, …)
182
+ - [25-reset-when.md](./25-reset-when.md) — `resetWhen` как альтернатива, когда поле остаётся enabled
183
+ - [19-reading-values.md](./19-reading-values.md) — `useFormControlValue` и чтение значений в React
@@ -0,0 +1,88 @@
1
+ ## 16. READING FIELD VALUES (CRITICALLY IMPORTANT)
2
+
3
+ Под M1 значения живут в модели. Есть три контекста чтения: value-доступ модели, сигналы, и React-хуки.
4
+
5
+ ### В React-компоненте — хуки
6
+
7
+ ```typescript
8
+ // Полное состояние поля (объект)
9
+ const { value, errors, disabled, touched, shouldShowError } = useFormControl(control.email);
10
+
11
+ // Только значение (напрямую, БЕЗ деструктуризации!)
12
+ const email = useFormControlValue(control.email);
13
+
14
+ // Реактивная длина массива
15
+ const count = useArrayLength(control.items);
16
+ ```
17
+
18
+ ### Вне React — модель
19
+
20
+ ```typescript
21
+ // value-доступ (реактивно внутри effect/computed, запись присваиванием)
22
+ model.email; // читать
23
+ model.email = 'a@b.c'; // писать
24
+ model.address.city; // вложенное поле (model.address — под-модель FormModel<Address>)
25
+
26
+ // через сигнал (escape-hatch)
27
+ model.$.email.value; // реактивное чтение/запись
28
+ model.$.email.peek(); // нереактивный снимок
29
+ model.$.address.city.value; // сигнал вложенного поля (≡ model.address.$.city у под-модели)
30
+
31
+ // весь объект
32
+ model.get(); // НЕреактивный снимок { email, address: { city }, ... } — для submit
33
+ ```
34
+
35
+ > ⚠️ `model.get()` и `model.isDirty()` читают через `peek()` — внутри `effect`/`computed` они НЕ
36
+ > создают зависимостей. `computed(() => model.get())` никогда не пересчитается. Для реактивного
37
+ > чтения всего объекта — узел дерева `$` (ниже).
38
+
39
+ ### Подписка на группу и на модель целиком
40
+
41
+ Каждый узел дерева `$` — сигнал: лист отдаёт `PathAwareSignal`, а корень, вложенные объекты-группы
42
+ и массивы — `ReadonlySignal` агрегированного значения поддерева. Поэтому подписаться можно на любом
43
+ уровне, а не только на конкретном поле.
44
+
45
+ ```typescript
46
+ model.$.subscribe((all) => autosave(all)); // любое изменение модели; all: T целиком
47
+ model.$.address.subscribe((addr) => ...); // только поддерево address
48
+ model.$.address.value; // реактивный снимок группы
49
+ model.$.address.peek(); // нереактивный снимок группы
50
+ model.$.items.subscribe((rows) => ...); // массив: и правка элемента, и push/removeAt/move
51
+
52
+ // узел — обычный ReadonlySignal, поэтому принимается операциями слоя данных
53
+ watchField(model.$.address, (addr) => geocode(addr));
54
+ ```
55
+
56
+ Дети узла доступны как раньше — `model.$.address.city` по-прежнему сигнал поля. Подписчик вызывается
57
+ сразу с текущим значением (семантика `Signal.subscribe`), а `model.set(...)`/`model.reset()`
58
+ уведомляют один раз, а не по разу на поле.
59
+
60
+ > ⚠️ Доступ к полю выигрывает у свойства сигнала: если в форме есть поле с именем `value`, `peek`,
61
+ > `subscribe`, `valueOf`, `toString`, `toJSON` или `brand`, то `model.$.<группа>.<это имя>` вернёт
62
+ > сигнал поля. `subscribe` при этом продолжает работать.
63
+
64
+ ### В behaviors — читаем model напрямую
65
+
66
+ `compute`/`onChange`/условия `when` читают значения из value-модели (`model.field`) —
67
+ подписка на сигналы происходит автоматически внутри реактивного эффекта.
68
+
69
+ ```typescript
70
+ import { defineFormBehavior, compute, onChange } from '@reformer/core/behaviors';
71
+
72
+ const behavior = defineFormBehavior<MyForm>(({ model, form }) => {
73
+ // читаем несколько полей — compute сам подпишется на прочитанные сигналы
74
+ compute(model.$.fullName, () => `${model.firstName} ${model.lastName}`);
75
+
76
+ // onChange: 1-й аргумент — новое значение; остальные поля берём из model
77
+ onChange(model.$.loanAmount, (amount) => {
78
+ const term = model.loanTerm;
79
+ if (amount && term) {
80
+ form.monthlyPayment.updateComponentProps({ hint: `≈ ${amount / term}` });
81
+ }
82
+ });
83
+ });
84
+ ```
85
+
86
+ > Cross-field ЗАПИСЬ производного значения делай через `compute` (цель не входит в источники →
87
+ > цикла нет). Для side-эффектов (загрузка опций, обновление componentProps) — `onChange`.
88
+ > `computeFrom`/`copyFrom`/`enableWhen` принимают сигналы (`model.$.x`), НЕ строковые пути.
@@ -0,0 +1,147 @@
1
+ ## 17. COMPUTE vs ONCHANGE
2
+
3
+ Под M1 behaviors работают на сигналах модели. Есть два способа их писать:
4
+
5
+ - **примитивы из `@reformer/core`** — принимают сигналы, возвращают cleanup, вызываются
6
+ императивно (например, в `useEffect`), cleanup складывается в массив;
7
+ - **декларативный DSL из `@reformer/core/behaviors`** — `defineFormBehavior(...)` + операторы,
8
+ cleanup управляется формой, передаётся в `createForm({ behavior })`.
9
+
10
+ Для производных значений — `compute`/`computeFrom`. Для side-эффектов на изменение (async,
11
+ обновление componentProps) — `onChange` (DSL) или примитив `watchField`.
12
+
13
+ ### compute — auto-tracking (DSL)
14
+
15
+ `compute(target, read)` подписывается на сигналы, прочитанные внутри `read()`, и пишет
16
+ результат в `target`. Цель не входит в источники → цикла нет; запись идемпотентна (peek-guard).
17
+ Кросс-уровневые вычисления работают так же — читай любые поля модели.
18
+
19
+ ```typescript
20
+ import { defineFormBehavior, compute } from '@reformer/core/behaviors';
21
+
22
+ const behavior = defineFormBehavior<MyForm>(({ model }) => {
23
+ // same-level
24
+ compute(model.$.total, () => (model.price ?? 0) * (model.quantity ?? 0));
25
+
26
+ // nested-to-nested / cross-level — просто читаем нужные поля
27
+ compute(model.$.fullName, () =>
28
+ [model.personalData.firstName, model.personalData.lastName].filter(Boolean).join(' ')
29
+ );
30
+
31
+ // условный пересчёт
32
+ compute(model.$.initialPayment, () => model.propertyValue * 0.2, {
33
+ when: () => model.loanType === 'mortgage',
34
+ });
35
+ });
36
+ ```
37
+
38
+ > **Не читай `model.get()` внутри `compute`/`when`** — это нереактивный снимок, зависимость не
39
+ > отследится и пересчёта не будет. Читай поля по отдельности (`model.field` или `model.$.field.value`).
40
+ > `model.get()` — только вне реактивного контекста (в `onChange`, обработчиках событий).
41
+
42
+ ### Computed sum over a FormArray
43
+
44
+ Агрегат по массиву (сумма доходов созаёмщиков и т.п.) считается **реактивно** через value-proxy
45
+ массива — `model.<array>.map(...)`:
46
+
47
+ ```typescript
48
+ compute(model.$.coBorrowersIncome, () =>
49
+ model.coBorrowers.map((cb) => cb.monthlyIncome ?? 0).reduce((s, v) => s + v, 0)
50
+ );
51
+ ```
52
+
53
+ Почему реактивно: `.map` читает сигнал самого массива (трекает `push`/`removeAt`/reorder) **и**
54
+ внутри колбэка читает каждый `cb.<field>` (трекает правки элементов) → пересчёт срабатывает на оба
55
+ вида изменений.
56
+
57
+ Ключевой нюанс — **на каком proxy** живёт `.map`/`.reduce`(-via-`.map`):
58
+
59
+ - **value-proxy `model.coBorrowers`** — есть `.map`/`.forEach`/`.at`/индексы (обходит элементы как
60
+ значения). Именно его читай в `compute`.
61
+ - **signals-proxy `model.$.coBorrowers`** — только индексный доступ и `.length`, без `.map`. Для
62
+ агрегата не подходит.
63
+
64
+ Если нужен только **счётчик** (пересчёт лишь на изменение длины, без чтения полей элементов) —
65
+ `model.<array>.map(() => null)` (длина трекается, значения — нет):
66
+
67
+ ```typescript
68
+ // зависит только от КОЛИЧЕСТВА элементов, не от их полей
69
+ compute(model.$.interestRate, () => computeRate(model.properties.map(() => null).length));
70
+ ```
71
+
72
+ > **Не агрегируй через `model.get()` / `model.<array>.peek()`** в `compute` — это нереактивный
73
+ > снапшот массива, зависимость не отследится и сумма не пересчитается. Читай массив только через
74
+ > value-proxy `model.<array>.map(...)`.
75
+
76
+ ### computeFrom — явный список источников
77
+
78
+ Когда нужен явный контроль зависимостей — `computeFrom(sources, target, fn)`. Значения
79
+ источников приходят в `fn` позиционно.
80
+
81
+ ```typescript
82
+ import { computeFrom } from '@reformer/core/behaviors'; // или из '@reformer/core' как примитив
83
+
84
+ computeFrom(
85
+ [model.$.loanAmount, model.$.loanTerm, model.$.interestRate],
86
+ model.$.monthlyPayment,
87
+ (amount, term, rate) => annuityMonthly(amount ?? 0, term ?? 0, rate ?? 0)
88
+ );
89
+ ```
90
+
91
+ > Примитив `computeFrom` из `@reformer/core` имеет ту же сигнатуру и возвращает cleanup-функцию.
92
+
93
+ ### onChange — реакция на изменение (async, side-effects)
94
+
95
+ `onChange(source, cb, { debounce, immediate })` вызывает `cb(value, { signal })` при изменении.
96
+ Колбэк выполняется ВНЕ effect-контекста — можно писать сигналы/ноды. `signal` (AbortSignal)
97
+ аннулируется при следующей смене значения.
98
+
99
+ ```typescript
100
+ import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
101
+
102
+ const behavior = defineFormBehavior<MyForm>(({ model, form }) => {
103
+ onChange(
104
+ model.$.country,
105
+ async (country, { signal }) => {
106
+ const cities = await fetchCities(country, { signal });
107
+ form.city.updateComponentProps({ options: cities });
108
+ },
109
+ { debounce: 300 }
110
+ );
111
+ });
112
+ ```
113
+
114
+ ### Примитив watchField
115
+
116
+ Низкоуровневая подписка из `@reformer/core` (без debounce/AbortSignal). `onChange` построен
117
+ поверх неё. Для простых синхронных реакций:
118
+
119
+ ```typescript
120
+ import { watchField } from '@reformer/core';
121
+ const stop = watchField(model.$.country, () => { model.city = ''; });
122
+ ```
123
+
124
+ Источником может быть не только поле: узлы-контейнеры дерева `$` (корень, группы, массивы) — тоже
125
+ `ReadonlySignal`, поэтому за поддеревом целиком следят без перечисления полей.
126
+
127
+ ```typescript
128
+ watchField(model.$.address, (addr) => geocode(addr)); // любое поле address
129
+ watchField(model.$, (all) => autosave(all)); // любое поле формы
130
+ ```
131
+
132
+ ### Rule of Thumb
133
+
134
+ | Scenario | Use |
135
+ |----------|-----|
136
+ | Производное значение (любой уровень) | `compute` (auto-tracking) |
137
+ | Производное с явными зависимостями | `computeFrom` |
138
+ | Async-реакция, обновление componentProps | `onChange` (debounce + AbortSignal) |
139
+ | Простая синхронная реакция (примитив) | `watchField` |
140
+ | Реакция на любое изменение группы/формы | `watchField(model.$.<группа>)` или `.subscribe` |
141
+
142
+ ### Chained computeds
143
+
144
+ Если несколько вычислений зависят друг от друга (`interestRate` → `monthlyPayment` →
145
+ `paymentToIncomeRatio`), объяви каждое отдельным `compute` — они выстроятся в правильном
146
+ порядке через реактивный граф (цель одного = источник другого). Расходящиеся взаимные
147
+ `compute` без стабилизации бросят понятную ошибку (см. `22-cycle-detection.md`).
@@ -0,0 +1,113 @@
1
+ ## 18. ARRAY OPERATIONS
2
+
3
+ Массивы объектов — model-owned. Мутации делаются через `ModelArray` (`model.arrayField`);
4
+ рендер — через ноду формы (`form.items.map` / `.at`).
5
+
6
+ ### Array Access
7
+
8
+ ```typescript
9
+ // Через ModelArray (данные)
10
+ model.items.at(0); // FormModel<Item> | undefined (под-модель элемента)
11
+ model.items.map((item, i) => item.name); // item — FormModel<Item>
12
+ model.items.length; // реактивная длина
13
+ model.items.toArray(); // снимок значений
14
+
15
+ // Через ноду формы (рендер / доступ к нодам полей)
16
+ form.items.at(0); // FormProxy<Item> | undefined
17
+ form.items.map((item, i) => …); // item — FormProxy<Item>
18
+ ```
19
+
20
+ ### Array Methods (на модели)
21
+
22
+ ```typescript
23
+ model.items.push({ name: '', price: 0 }); // добавить в конец (ПЛОСКИЕ значения!)
24
+ model.items.insertAt(0, { name: '', price: 0 }); // вставить по индексу
25
+ model.items.removeAt(index); // удалить по индексу
26
+ model.items.move(fromIndex, toIndex); // переместить
27
+ model.items.swap(a, b); // поменять местами
28
+ model.items.clear(); // очистить
29
+ ```
30
+
31
+ > **push принимает ПЛОСКИЕ значения** (`{ name, price }`), а НЕ FieldConfig-шаблон
32
+ > (`{ value, component }`). Component/componentProps берутся из `item`-фабрики схемы.
33
+ > Передача FieldConfig-объектов сломает рендер (`[object Object]` в инпутах).
34
+
35
+ ### Rendering Arrays
36
+
37
+ ```tsx
38
+ import { useArrayLength } from '@reformer/core';
39
+
40
+ function ItemsList({ form }: { form: FormProxy<MyForm> }) {
41
+ const length = useArrayLength(form.items);
42
+
43
+ return (
44
+ <div>
45
+ {form.items.map((item, index) => (
46
+ <div key={index}>
47
+ <FormField control={item.name} />
48
+ <FormField control={item.price} />
49
+ <button onClick={() => model.items.removeAt(index)}>Remove</button>
50
+ </div>
51
+ ))}
52
+ {length === 0 && <p>No items yet</p>}
53
+ <button onClick={() => model.items.push({ name: '', price: 0 })}>Add Item</button>
54
+ </div>
55
+ );
56
+ }
57
+ ```
58
+
59
+ > В монорепо используется `FormArraySection` из `@reformer/ui-kit`: `control={form.items}`,
60
+ > `itemComponent`, `initialValue`, add/remove/reorder из коробки. См. `find_recipe(topic="form-array")`.
61
+
62
+ ### Per-item behavior — applyEach
63
+
64
+ Чтобы применить поведение к КАЖДОМУ элементу (реагируя на add/remove), используй `applyEach`:
65
+
66
+ ```typescript
67
+ import { defineFormBehavior, applyEach, compute } from '@reformer/core/behaviors';
68
+
69
+ const behavior = defineFormBehavior<MyForm>(({ model }) => {
70
+ applyEach(
71
+ model.$.items,
72
+ defineFormBehavior<Item>(({ model: row }) => {
73
+ compute(row.$.lineTotal, () => row.qty * row.price); // per-row value-op
74
+ })
75
+ );
76
+ });
77
+ ```
78
+
79
+ ### Aggregate write — aggregateInto
80
+
81
+ Агрегатная запись в строки (например, «последняя строка = 100 − Σ остальных»):
82
+
83
+ ```typescript
84
+ import { aggregateInto } from '@reformer/core/behaviors';
85
+
86
+ aggregateInto(model.$.rows, (rows) => {
87
+ const n = rows.length;
88
+ if (n === 0) return [];
89
+ const others = rows.slice(0, n - 1).reduce((s, r) => s + r.percent, 0);
90
+ return [{ index: n - 1, patch: { percent: 100 - others } }]; // derive должна сходиться
91
+ });
92
+ ```
93
+
94
+ ### Array Cross-Validation
95
+
96
+ Whole-array правило — оператор `cross(sig, (f) => ...)` из `@reformer/core/validation`:
97
+ `f` — снапшот `model.get()`, ошибка вешается на скалярное поле-носитель `sig`. Per-item
98
+ правила — `each(model.<array>, (im) => ...)`.
99
+
100
+ ```typescript
101
+ import { cross } from '@reformer/core/validation';
102
+ import type { ValidationError } from '@reformer/core';
103
+
104
+ const percentagesSumTo100 = (f: MyForm): ValidationError | null => {
105
+ const total = f.items.reduce((sum, i) => sum + (i.percentage || 0), 0);
106
+ return Math.abs(total - 100) > 0.01
107
+ ? { code: 'invalid_total', message: 'Percentages must sum to 100%' }
108
+ : null;
109
+ };
110
+
111
+ // внутри defineValidationSchema<MyForm>(({ model }) => { ... })
112
+ cross(model.$.totalPercent, percentagesSumTo100); // носитель — скалярное поле формы
113
+ ```