@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,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,59 @@
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
+ ### В behaviors — читаем model напрямую
36
+
37
+ `compute`/`onChange`/условия `when` читают значения из value-модели (`model.field`) —
38
+ подписка на сигналы происходит автоматически внутри реактивного эффекта.
39
+
40
+ ```typescript
41
+ import { defineFormBehavior, compute, onChange } from '@reformer/core/behaviors';
42
+
43
+ const behavior = defineFormBehavior<MyForm>(({ model, form }) => {
44
+ // читаем несколько полей — compute сам подпишется на прочитанные сигналы
45
+ compute(model.$.fullName, () => `${model.firstName} ${model.lastName}`);
46
+
47
+ // onChange: 1-й аргумент — новое значение; остальные поля берём из model
48
+ onChange(model.$.loanAmount, (amount) => {
49
+ const term = model.loanTerm;
50
+ if (amount && term) {
51
+ form.monthlyPayment.updateComponentProps({ hint: `≈ ${amount / term}` });
52
+ }
53
+ });
54
+ });
55
+ ```
56
+
57
+ > Cross-field ЗАПИСЬ производного значения делай через `compute` (цель не входит в источники →
58
+ > цикла нет). Для side-эффектов (загрузка опций, обновление componentProps) — `onChange`.
59
+ > `computeFrom`/`copyFrom`/`enableWhen` принимают сигналы (`model.$.x`), НЕ строковые пути.
@@ -0,0 +1,138 @@
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
+ ### Rule of Thumb
125
+
126
+ | Scenario | Use |
127
+ |----------|-----|
128
+ | Производное значение (любой уровень) | `compute` (auto-tracking) |
129
+ | Производное с явными зависимостями | `computeFrom` |
130
+ | Async-реакция, обновление componentProps | `onChange` (debounce + AbortSignal) |
131
+ | Простая синхронная реакция (примитив) | `watchField` |
132
+
133
+ ### Chained computeds
134
+
135
+ Если несколько вычислений зависят друг от друга (`interestRate` → `monthlyPayment` →
136
+ `paymentToIncomeRatio`), объяви каждое отдельным `compute` — они выстроятся в правильном
137
+ порядке через реактивный граф (цель одного = источник другого). Расходящиеся взаимные
138
+ `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
+ ```
@@ -0,0 +1,86 @@
1
+ # Cycle Detection — предотвращение «Cycle detected»
2
+
3
+ ## Как устроено под M1
4
+
5
+ Behaviors на сигналах уже защищены от типичных циклов:
6
+
7
+ - `compute`/`computeFrom` пишут в цель только если значение изменилось (**peek-guard**), а цель
8
+ не входит в источники → сходящийся пересчёт не зацикливается;
9
+ - `transformValue`/`resetWhen`/`syncFields`/`enableWhen` откладывают запись состояния вне
10
+ effect-контекста (`runOutsideEffect` / микротаск) — эффект «читает и пишет один сигнал» не падает;
11
+ - `onChange`-колбэк выполняется вне effect-контекста, поэтому в нём можно свободно писать сигналы/ноды.
12
+
13
+ Тебе НЕ нужно вручную ставить `{ immediate: false }`, guard'ить `disabled.value` перед
14
+ `disable()` или сравнивать значения перед записью — это делается внутри операторов.
15
+
16
+ ## Когда всё-таки возникает «Cycle detected»
17
+
18
+ ### 1. Расходящийся взаимный compute
19
+
20
+ Два вычисления, которые бесконечно гоняют значение друг у друга без стабилизации:
21
+
22
+ ```typescript
23
+ // ❌ расходится → preact бросает «Cycle detected»
24
+ compute(model.$.a, () => model.b + 1);
25
+ compute(model.$.b, () => model.a + 1);
26
+ ```
27
+
28
+ DSL перехватывает это и заменяет понятной ошибкой с именем поля и подсказкой. Решение —
29
+ однонаправленная зависимость или стабилизирующее условие `when`:
30
+
31
+ ```typescript
32
+ // ✅ одно направление
33
+ compute(model.$.total, () => model.price * model.qty);
34
+
35
+ // ✅ стабилизация условием
36
+ compute(model.$.a, () => model.b + 1, { when: () => model.a !== model.b + 1 });
37
+ ```
38
+
39
+ ### 2. Неидемпотентный transformValue
40
+
41
+ `transformValue` пишет обратно в то же поле. Если `f(f(x)) !== f(x)` — цикл:
42
+
43
+ ```typescript
44
+ // ❌ f(f(x)) = "prefix-prefix-x" ≠ f(x)
45
+ transformValue(model.$.field, (v) => `prefix-${v}`);
46
+
47
+ // ✅ guard «уже преобразовано»
48
+ transformValue(model.$.field, (v) => (v?.startsWith('prefix-') ? v : `prefix-${v}`));
49
+ ```
50
+
51
+ ### 3. Условие `resetWhen`/`copyFrom`, читающее собственную цель
52
+
53
+ Если условие зависит от значения целевого поля — сброс/копия триггерит своё же условие:
54
+
55
+ ```typescript
56
+ // ❌ самотриггер — условие читает cardNumber (цель)
57
+ resetWhen(model.$.cardNumber, () => model.cardNumber !== '');
58
+
59
+ // ✅ условие зависит только от независимого поля
60
+ resetWhen(model.$.cardNumber, () => model.paymentType !== 'card', { resetValue: '' });
61
+ ```
62
+
63
+ ## Prefer built-in operators over manual logic
64
+
65
+ Вместо ручной сборки reset-on-disable через `onChange` — используй `enableWhen({ resetOnDisable })`:
66
+
67
+ ```typescript
68
+ // ✅ SIMPLE — enableWhen с resetOnDisable (рекомендуется)
69
+ enableWhen(model.$.vehicleVin, () => model.insuranceType === 'casco', { resetOnDisable: true });
70
+ enableWhen(model.$.vehicleBrand, () => model.insuranceType === 'casco', { resetOnDisable: true });
71
+ ```
72
+
73
+ `enableWhen` принимает и **массив** целей, и группу — можно включать несколько полей одним условием:
74
+
75
+ ```typescript
76
+ enableWhen([model.$.propertyValue, model.$.initialPayment], () => model.loanType === 'mortgage', {
77
+ resetOnDisable: true,
78
+ });
79
+ ```
80
+
81
+ ## Key Rules
82
+
83
+ 1. Производные значения — через `compute`/`computeFrom` (peek-guard встроен), не ручным `onChange` + запись.
84
+ 2. `transformValue`-трансформер должен быть идемпотентным (`f(f(x)) === f(x)`).
85
+ 3. Условие `resetWhen`/`copyFrom`/`enableWhen` НЕ должно читать собственную цель.
86
+ 4. Расходящийся взаимный `compute` — разорви однонаправленной зависимостью или `when`.