@reformer/core 11.0.0-beta.1 → 11.0.0-beta.3

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.
@@ -1,4 +1,18 @@
1
1
  import { FormModel, PathAwareSignal } from './types';
2
+ /**
3
+ * Узел дерева `model.$` — контейнер (группа/массив), а не лист?
4
+ *
5
+ * Контейнерный узел структурно совместим с `ReadonlySignal` (`peek`/`value`/`subscribe`), поэтому
6
+ * duck-typing «есть `peek` ⇒ это лист» на нём даёт ложное срабатывание. Этот guard — надёжный
7
+ * способ различить: обходчикам дерева нужно спускаться в контейнер, а не читать его целиком.
8
+ *
9
+ * @group Model
10
+ * @example
11
+ * ```typescript
12
+ * const isLeaf = (v: unknown) => isSignalLike(v) && !isModelContainerSignal(v);
13
+ * ```
14
+ */
15
+ export declare function isModelContainerSignal(value: unknown): boolean;
2
16
  /**
3
17
  * Обойти ВСЕ листовые сигналы модели (включая элементы массивов), вызвав `visit` на каждом.
4
18
  *
@@ -23,7 +23,8 @@
23
23
  * @module state
24
24
  */
25
25
  export { createModel } from './form-model';
26
- export type { FormModel, ModelArray, ModelObject, ModelValue, ModelSignals, ModelApi, PathAwareSignal, } from './types';
26
+ export type { FormModel, ModelArray, ModelArraySignals, ModelGroupSignals, ModelObject, ModelValue, ModelSignals, ModelApi, PathAwareSignal, } from './types';
27
+ export { isModelContainerSignal } from './form-model';
27
28
  export { computeFrom, copyFrom, watchField, transformValue, resetWhen, syncFields, revalidateWhen, } from './behaviors-value';
28
29
  export type { BehaviorCleanup } from './behaviors-value';
29
30
  export { markDerived, isDerived, unmarkDerived } from './derived-registry';
@@ -1,4 +1,4 @@
1
- import { Signal } from '@preact/signals-core';
1
+ import { ReadonlySignal, Signal } from '@preact/signals-core';
2
2
  /**
3
3
  * Сигнал, который знает свой путь в модели (`'personalData.lastName'`).
4
4
  * Используется как «ручка поля»: и привязка value в схеме, и идентичность для testId/devtools.
@@ -81,8 +81,8 @@ export interface ModelArray<U> {
81
81
  [index: number]: ModelValue<U>;
82
82
  }
83
83
  /**
84
- * Дерево сигналов (escape-hatch `model.$`): листья → {@link PathAwareSignal},
85
- * объекты → вложенное дерево, массивы → индексируемое дерево под-сигналов.
84
+ * Карта под-сигналов объекта в дереве `model.$` (без свойств самого узла — их добавляет
85
+ * {@link ModelGroupSignals}).
86
86
  *
87
87
  * @group Model
88
88
  */
@@ -90,16 +90,41 @@ export type ModelSignals<T> = {
90
90
  [K in keyof T]: ModelSignalNode<T[K]>;
91
91
  };
92
92
  /**
93
- * Узел дерева сигналов `model.$`: массив → индексируемый узел под-сигналов, Opaque (Date/File/Blob)
94
- * и примитив → {@link PathAwareSignal}, объект → вложенное дерево {@link ModelSignals}. Для примитивных
95
- * и Opaque массивов элемент — сам сигнал листа (а не под-дерево), поэтому `model.$.tags[0]` — это
96
- * `PathAwareSignal<string>`, а не `ModelSignals<never>`.
93
+ * Свойства контейнерного узла дерева `$`, «поверх» карты детей. Ключи детей вырезаются из
94
+ * {@link ReadonlySignal}: доступ к полю формы всегда выигрывает у одноимённого свойства сигнала
95
+ * (так же ведёт себя рантайм), поэтому `{ value: string }` не порождает конфликта типов.
97
96
  * @internal
98
97
  */
99
- type ModelSignalNode<V> = NonNullable<V> extends ReadonlyArray<infer U> ? {
98
+ type ContainerSignal<T, TKeys extends PropertyKey> = Omit<ReadonlySignal<T>, TKeys>;
99
+ /**
100
+ * Узел-группа дерева `model.$`: доступ к под-сигналам полей ({@link ModelSignals}) И одновременно
101
+ * {@link ReadonlySignal} агрегированного значения группы — `.value`/`.peek()`/`.subscribe()`.
102
+ *
103
+ * ⚠️ Поле формы с именем `value`/`peek`/`subscribe`/`valueOf`/`toString`/`toJSON`/`brand` затеняет
104
+ * одноимённое свойство сигнала (редкий краевой случай); `subscribe` при этом продолжает работать.
105
+ *
106
+ * @group Model
107
+ */
108
+ export type ModelGroupSignals<T> = ModelSignals<T> & ContainerSignal<T, keyof T>;
109
+ /**
110
+ * Узел-массив дерева `model.$`: индексируемый доступ к под-сигналам элементов, реактивная `length`
111
+ * и {@link ReadonlySignal} значения массива целиком (реагирует и на правку элемента, и на изменение
112
+ * состава — push/removeAt/move).
113
+ *
114
+ * @group Model
115
+ */
116
+ export type ModelArraySignals<U, V> = ContainerSignal<V, 'length'> & {
100
117
  readonly length: number;
101
118
  readonly [index: number]: ModelSignalNode<U>;
102
- } : NonNullable<V> extends Opaque ? PathAwareSignal<V> : NonNullable<V> extends object ? ModelSignals<NonNullable<V>> : PathAwareSignal<V>;
119
+ };
120
+ /**
121
+ * Узел дерева сигналов `model.$`: массив → {@link ModelArraySignals}, Opaque (Date/File/Blob)
122
+ * и примитив → {@link PathAwareSignal}, объект → {@link ModelGroupSignals}. Для примитивных
123
+ * и Opaque массивов элемент — сам сигнал листа (а не под-дерево), поэтому `model.$.tags[0]` — это
124
+ * `PathAwareSignal<string>`, а не `ModelGroupSignals<never>`.
125
+ * @internal
126
+ */
127
+ type ModelSignalNode<V> = NonNullable<V> extends ReadonlyArray<infer U> ? ModelArraySignals<U, NonNullable<V>> : NonNullable<V> extends Opaque ? PathAwareSignal<V> : NonNullable<V> extends object ? ModelGroupSignals<NonNullable<V>> : PathAwareSignal<V>;
103
128
  /**
104
129
  * API уровня модели (доступно на корне, под-моделях вложенных объектов-групп и элементов массива).
105
130
  *
@@ -109,8 +134,11 @@ type ModelSignalNode<V> = NonNullable<V> extends ReadonlyArray<infer U> ? {
109
134
  * @group Model
110
135
  */
111
136
  export interface ModelApi<T> {
112
- /** Escape-hatch к сигналам: `model.$.loanType` → `PathAwareSignal<LoanType>`. */
113
- readonly $: ModelSignals<T>;
137
+ /**
138
+ * Escape-hatch к сигналам: `model.$.loanType` → `PathAwareSignal<LoanType>`.
139
+ * Сам узел — {@link ReadonlySignal} модели целиком: `model.$.subscribe(v => …)`.
140
+ */
141
+ readonly $: ModelGroupSignals<T>;
114
142
  /** Снимок значений (без подписки) — для submit. */
115
143
  get(): T;
116
144
  /**
package/dist/state.js CHANGED
@@ -1,21 +1,21 @@
1
- import { c as s } from "./form-model-DYCzGGKF.js";
2
- import { c as o, a as t, r as c, b as i, d as m, e as d, f, s as l, t as n, w as u } from "./behaviors-value-C7dSEhCc.js";
3
- import { i as b, m as k, u as v } from "./derived-registry-CluZCQ7U.js";
4
- import { S as D } from "./subscription-manager-705bls97.js";
1
+ import { c as s, a as r, i as o, m as i, u as t } from "./form-model-DhW2HZOB.js";
2
+ import { c as n, a as d, r as l, b as m, d as f, e as u, f as p, s as b, t as k, w as v } from "./behaviors-value-C7dSEhCc.js";
3
+ import { S as F } from "./subscription-manager-705bls97.js";
5
4
  export {
6
- D as SubscriptionManager,
7
- o as computeFrom,
8
- t as copyFrom,
5
+ F as SubscriptionManager,
6
+ n as computeFrom,
7
+ d as copyFrom,
9
8
  s as createModel,
10
- b as isDerived,
11
- k as markDerived,
12
- c as resetWhen,
13
- i as revalidateWhen,
14
- m as runOutsideEffect,
15
- d as safeCallback,
16
- f as safeDebouncedCallback,
17
- l as syncFields,
18
- n as transformValue,
19
- v as unmarkDerived,
20
- u as watchField
9
+ r as isDerived,
10
+ o as isModelContainerSignal,
11
+ i as markDerived,
12
+ l as resetWhen,
13
+ m as revalidateWhen,
14
+ f as runOutsideEffect,
15
+ u as safeCallback,
16
+ p as safeDebouncedCallback,
17
+ b as syncFields,
18
+ k as transformValue,
19
+ t as unmarkDerived,
20
+ v as watchField
21
21
  };
@@ -1,6 +1,54 @@
1
- import { signal as M, effect as E } from "@preact/signals-core";
2
- import { e as L } from "./form-model-DYCzGGKF.js";
1
+ import { signal as A, effect as M } from "@preact/signals-core";
2
+ import { e as E } from "./form-model-DhW2HZOB.js";
3
3
  import { g as p } from "./signal-node-registry-tiwel-TV.js";
4
+ function B(e, n, t = {}) {
5
+ const { strategy: r = "submit", debounce: o = 0, liveAfterSubmit: i = "change" } = t, s = A(!1);
6
+ let a = 0, c = !1, d, l;
7
+ const w = async (u) => {
8
+ a++, s.value = !0;
9
+ try {
10
+ return await W(e, n, { touch: u });
11
+ } finally {
12
+ --a === 0 && (s.value = !1);
13
+ }
14
+ }, h = () => {
15
+ w(!1);
16
+ }, F = () => {
17
+ if (!o) {
18
+ h();
19
+ return;
20
+ }
21
+ l && clearTimeout(l), l = setTimeout(h, o);
22
+ }, k = (u, g) => {
23
+ let S = !0;
24
+ return M(() => {
25
+ if (E(e, u), S) {
26
+ S = !1;
27
+ return;
28
+ }
29
+ r === "afterFirstSubmit" && !c || g();
30
+ });
31
+ }, m = {
32
+ async validate() {
33
+ return c = !0, w(!0);
34
+ },
35
+ start() {
36
+ if (d) return () => m.dispose();
37
+ const u = r === "afterFirstSubmit" ? i : r;
38
+ return u === "change" ? d = k((g) => void g.value, F) : u === "blur" && (d = k((g) => void p(g)?.touched.value, h)), () => m.dispose();
39
+ },
40
+ dispose() {
41
+ l && (clearTimeout(l), l = void 0), d?.(), d = void 0;
42
+ },
43
+ get isValidating() {
44
+ return s.value;
45
+ },
46
+ get validating() {
47
+ return s;
48
+ }
49
+ };
50
+ return m;
51
+ }
4
52
  let v = null;
5
53
  function f(e) {
6
54
  if (!v)
@@ -9,83 +57,83 @@ function f(e) {
9
57
  );
10
58
  return v;
11
59
  }
12
- function b(e, r) {
13
- let t = e.errors.get(r);
14
- return t || e.errors.set(r, t = []), t;
60
+ function b(e, n) {
61
+ let t = e.errors.get(n);
62
+ return t || e.errors.set(n, t = []), t;
15
63
  }
16
- const y = (e) => e.whenStack.every((r) => r());
17
- function D(e, r) {
18
- const t = f("validate"), n = b(t, e);
64
+ const y = (e) => e.whenStack.every((n) => n());
65
+ function D(e, n) {
66
+ const t = f("validate"), r = b(t, e);
19
67
  if (!y(t)) return;
20
68
  const o = e.peek();
21
- for (const i of r) {
69
+ for (const i of n) {
22
70
  const s = i(o, t.model, t.model);
23
- s && n.push(s);
71
+ s && r.push(s);
24
72
  }
25
73
  }
26
- function N(e, r) {
27
- const t = f("validateAsync"), n = b(t, e);
74
+ function N(e, n) {
75
+ const t = f("validateAsync"), r = b(t, e);
28
76
  if (!y(t)) return;
29
77
  const o = e.peek(), i = t.signal;
30
- for (const s of r)
78
+ for (const s of n)
31
79
  t.pending.push(
32
80
  s(o, { signal: i }).then(
33
81
  (a) => {
34
- a && !i.aborted && n.push(a);
82
+ a && !i.aborted && r.push(a);
35
83
  },
36
84
  () => {
37
85
  }
38
86
  )
39
87
  );
40
88
  }
41
- function R(e, r) {
89
+ function R(e, n) {
42
90
  const t = f("validateWhen");
43
91
  t.whenStack.push(e);
44
92
  try {
45
- r();
93
+ n();
46
94
  } finally {
47
95
  t.whenStack.pop();
48
96
  }
49
97
  }
50
- function _(e, r) {
51
- const t = f("cross"), n = b(t, e);
98
+ function _(e, n) {
99
+ const t = f("cross"), r = b(t, e);
52
100
  if (!y(t)) return;
53
- const o = r(t.model.get());
54
- o && n.push(o);
101
+ const o = n(t.model.get());
102
+ o && r.push(o);
55
103
  }
56
- function $(e, r) {
104
+ function $(e, n) {
57
105
  f("each");
58
106
  const t = e.length;
59
- for (let n = 0; n < t; n++) r(e.at(n));
107
+ for (let r = 0; r < t; r++) n(e.at(r));
60
108
  }
61
109
  function j(...e) {
62
110
  const t = f("apply").model;
63
- for (const n of e) n({ model: t });
111
+ for (const r of e) r({ model: t });
64
112
  }
65
113
  function z(e) {
66
114
  return e;
67
115
  }
68
- const F = /* @__PURE__ */ new WeakMap();
69
- function P(e, r) {
70
- let t = F.get(e);
71
- t || F.set(e, t = /* @__PURE__ */ new WeakMap());
72
- let n = t.get(r);
73
- return n || t.set(r, n = { fields: /* @__PURE__ */ new Set(), ac: null }), n;
116
+ const x = /* @__PURE__ */ new WeakMap();
117
+ function L(e, n) {
118
+ let t = x.get(e);
119
+ t || x.set(e, t = /* @__PURE__ */ new WeakMap());
120
+ let r = t.get(n);
121
+ return r || t.set(n, r = { fields: /* @__PURE__ */ new Set(), ac: null }), r;
74
122
  }
75
- const T = (e) => {
76
- for (const r of e.values()) for (const t of r) if (t.severity !== "warning") return !0;
123
+ const P = (e) => {
124
+ for (const n of e.values()) for (const t of n) if (t.severity !== "warning") return !0;
77
125
  return !1;
78
126
  };
79
- function W(e) {
80
- return e.aborted ? Promise.resolve() : new Promise((r) => {
81
- e.addEventListener("abort", () => r(), { once: !0 });
127
+ function T(e) {
128
+ return e.aborted ? Promise.resolve() : new Promise((n) => {
129
+ e.addEventListener("abort", () => n(), { once: !0 });
82
130
  });
83
131
  }
84
- async function V(e, r, t) {
85
- const n = P(e, r);
86
- n.ac?.abort();
132
+ async function W(e, n, t) {
133
+ const r = L(e, n);
134
+ r.ac?.abort();
87
135
  const o = new AbortController();
88
- n.ac = o;
136
+ r.ac = o;
89
137
  const i = {
90
138
  model: e,
91
139
  errors: /* @__PURE__ */ new Map(),
@@ -95,73 +143,25 @@ async function V(e, r, t) {
95
143
  }, s = v;
96
144
  v = i;
97
145
  try {
98
- r({ model: e });
146
+ n({ model: e });
99
147
  } finally {
100
148
  v = s;
101
149
  }
102
- if (i.pending.length && await Promise.race([Promise.all(i.pending), W(o.signal)]), o.signal.aborted) return !1;
150
+ if (i.pending.length && await Promise.race([Promise.all(i.pending), T(o.signal)]), o.signal.aborted) return !1;
103
151
  const a = new Set(i.errors.keys());
104
- for (const c of n.fields) a.has(c) || p(c)?.setErrors([]);
152
+ for (const c of r.fields) a.has(c) || p(c)?.setErrors([]);
105
153
  for (const c of a) p(c)?.setErrors(i.errors.get(c) ?? []);
106
154
  if (t?.touch) for (const c of a) p(c)?.markAsTouched();
107
- return n.fields = a, !T(i.errors);
108
- }
109
- function G(e, r, t = {}) {
110
- const { strategy: n = "submit", debounce: o = 0, liveAfterSubmit: i = "change" } = t, s = M(!1);
111
- let a = 0, c = !1, w = !1, d, l;
112
- const k = async (u) => {
113
- a++, s.value = !0;
114
- try {
115
- return await V(e, r, { touch: u });
116
- } finally {
117
- --a === 0 && (s.value = !1);
118
- }
119
- }, h = () => {
120
- k(!1);
121
- }, A = () => {
122
- if (!o) {
123
- h();
124
- return;
125
- }
126
- l && clearTimeout(l), l = setTimeout(h, o);
127
- }, S = (u, g) => {
128
- let x = !0;
129
- return E(() => {
130
- if (L(e, u), x) {
131
- x = !1;
132
- return;
133
- }
134
- n === "afterFirstSubmit" && !c || g();
135
- });
136
- }, m = {
137
- async validate() {
138
- return c = !0, k(!0);
139
- },
140
- start() {
141
- if (w || d) return () => m.dispose();
142
- const u = n === "afterFirstSubmit" ? i : n;
143
- return u === "change" ? d = S((g) => void g.value, A) : u === "blur" && (d = S((g) => void p(g)?.touched.value, h)), () => m.dispose();
144
- },
145
- dispose() {
146
- w = !0, l && (clearTimeout(l), l = void 0), d?.(), d = void 0;
147
- },
148
- get isValidating() {
149
- return s.value;
150
- },
151
- get validating() {
152
- return s;
153
- }
154
- };
155
- return m;
155
+ return r.fields = a, !P(i.errors);
156
156
  }
157
157
  export {
158
158
  j as apply,
159
- G as createFormValidation,
159
+ B as createFormValidation,
160
160
  _ as cross,
161
161
  z as defineValidationSchema,
162
162
  $ as each,
163
163
  D as validate,
164
164
  N as validateAsync,
165
- V as validateModel,
165
+ W as validateModel,
166
166
  R as validateWhen
167
167
  };
@@ -7,14 +7,15 @@
7
7
  > `Checkbox`-обёртки с `label`-prop'ами — это anti-pattern. См.
8
8
  > `find_recipe(package="@reformer/ui-kit", topic="form-field-integration")`.
9
9
 
10
- Архитектура M1: сначала создаётся **модель данных** (`createModel`), затем **форма**
11
- (`createForm({ model, schema })`), где схема привязывает поля к сигналам модели
12
- (`model.$.field`). Layout-схема НЕ несёт валидаторов — валидация живёт в отдельной
10
+ Архитектура M1: **модель данных** — источник истины, схема привязывает поля к её сигналам
11
+ (`model.$.field`), а сборка идёт ОДНИМ вызовом `createCoreForm` (для рендера ui-kit;
12
+ в рендерерах — `createReactForm` / `createJsonForm`), который создаёт модель, строит форму,
13
+ запускает поведение и собирает валидацию. Layout-схема НЕ несёт валидаторов — валидация живёт в отдельной
13
14
  схеме `defineValidationSchema` из `@reformer/core/validation` и запускается внешним
14
15
  раннером `validateModel(model, schema)`.
15
16
 
16
17
  ```typescript
17
- import { createModel, createForm, type FormProxy } from '@reformer/core';
18
+ import { createCoreForm, createModel, useFormBundle, type FormProxy } from '@reformer/core';
18
19
  import { defineValidationSchema, validate, validateModel } from '@reformer/core/validation';
19
20
  import { required, email } from '@reformer/core/validators';
20
21
  import { FormField, Input, Button } from '@reformer/ui-kit';
@@ -49,8 +50,14 @@ const contactValidation = defineValidationSchema<ContactForm>(({ model }) => {
49
50
  validate(model.$.email, [required({ message: 'Email is required' }), email({ message: 'Invalid email' })]);
50
51
  });
51
52
 
52
- // 5. Form — ноды поверх сигналов модели
53
- const form = createForm<ContactForm>({ model, schema });
53
+ // 5. Сборка ОДНИМ вызовом: модель + ноды поверх её сигналов + валидация.
54
+ // В React оборачивают в useFormBundle — ленивый useState, фабрика зовётся один раз.
55
+ const contact = createCoreForm<ContactForm>({
56
+ model,
57
+ schema: buildSchema, // билдер (model) => tree
58
+ validation: contactValidation,
59
+ });
60
+ const form = contact.form;
54
61
 
55
62
  // 6. Use in React component — thin JSX, FormField does ALL heavy lifting
56
63
  function ContactFormComponent() {
@@ -121,7 +128,7 @@ const schema = {
121
128
  properties: { array: model.properties, item: propertyItem },
122
129
  };
123
130
 
124
- const form = createForm<MyForm>({ model, schema });
131
+ const { form } = createCoreForm<MyForm>({ model, schema: () => schema });
125
132
 
126
133
  // Операции над массивом — на модели:
127
134
  model.properties.push({ type: 'apartment', description: '', estimatedValue: 0 });
@@ -15,7 +15,7 @@ src/
15
15
  │
16
16
  ├── forms/
17
17
  │ └── [form-name]/ # Form module — flat, one file per concern
18
- │ ├── index.tsx # entry + whole form: createModel→createForm→<FormWizard> with all steps inline; arrays via FormArraySection
18
+ │ ├── index.tsx # entry + whole form: ONE createCoreForm call → <FormWizard> with all steps inline; arrays via FormArraySection
19
19
  │ ├── types.ts # form type + enums + { value, label } option type + constant dictionaries
20
20
  │ ├── model.ts # createModel + initial values + empty-array-element factories
21
21
  │ ├── form.schema.ts # FormSchema: { value: model.$.x, component, componentProps }
@@ -60,11 +60,15 @@ export const creditApplicationBehavior = defineFormBehavior<CreditApplicationFor
60
60
  });
61
61
 
62
62
  // forms/credit-application/index.tsx — entry: assembles the form, renders <FormWizard> with all steps inline
63
- import { createForm } from '@reformer/core';
64
- export const createCreditApplicationForm = () => {
65
- const model = createCreditApplicationModel();
66
- return createForm({ model, schema: creditApplicationSchema(model), behavior: creditApplicationBehavior });
67
- };
63
+ import { createCoreForm } from '@reformer/core';
64
+ // ONE call: model + form + behavior + validation. In the page: useFormBundle(createCreditApplicationForm).
65
+ export const createCreditApplicationForm = () =>
66
+ createCoreForm({
67
+ model: createCreditApplicationModel(),
68
+ schema: creditApplicationSchema, // builder (model) => tree
69
+ behavior: creditApplicationBehavior,
70
+ validation: creditApplicationValidation, // { steps, extras } → bundle.validation
71
+ });
68
72
  ```
69
73
 
70
74
  ### Scaling up: folders (large forms)
@@ -90,10 +94,10 @@ forms/
90
94
  │
91
95
  ├── schema/ # The form definition
92
96
  │ ├── model.ts # createModel factory + initial values + array-element factories
93
- │ ├── schema.ts # createForm schema tree ({ value: model.$.x, component })
97
+ │ ├── schema.ts # schema builder (model) => tree ({ value: model.$.x, component })
94
98
  │ ├── validation.ts # defineValidationSchema + validateModel config ({ validateStep, validateAll })
95
99
  │ ├── behavior.ts # defineFormBehavior(...)
96
- │ └── create-form.ts # Assembly: createForm({ model, schema, behavior })
100
+ │ └── create-form.ts # Assembly: createCoreForm({ model, schema, behavior, validation })
97
101
  │
98
102
  └── components/
99
103
  ├── steps/ # One component per wizard step
@@ -29,9 +29,38 @@ model.$.email.peek(); // нереактивный снимок
29
29
  model.$.address.city.value; // сигнал вложенного поля (≡ model.address.$.city у под-модели)
30
30
 
31
31
  // весь объект
32
- model.get(); // снимок { email, address: { city }, ... } — для submit
32
+ model.get(); // НЕреактивный снимок { email, address: { city }, ... } — для submit
33
33
  ```
34
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
+
35
64
  ### В behaviors — читаем model напрямую
36
65
 
37
66
  `compute`/`onChange`/условия `when` читают значения из value-модели (`model.field`) —
@@ -121,6 +121,14 @@ import { watchField } from '@reformer/core';
121
121
  const stop = watchField(model.$.country, () => { model.city = ''; });
122
122
  ```
123
123
 
124
+ Источником может быть не только поле: узлы-контейнеры дерева `$` (корень, группы, массивы) — тоже
125
+ `ReadonlySignal`, поэтому за поддеревом целиком следят без перечисления полей.
126
+
127
+ ```typescript
128
+ watchField(model.$.address, (addr) => geocode(addr)); // любое поле address
129
+ watchField(model.$, (all) => autosave(all)); // любое поле формы
130
+ ```
131
+
124
132
  ### Rule of Thumb
125
133
 
126
134
  | Scenario | Use |
@@ -129,6 +137,7 @@ const stop = watchField(model.$.country, () => { model.city = ''; });
129
137
  | Производное с явными зависимостями | `computeFrom` |
130
138
  | Async-реакция, обновление componentProps | `onChange` (debounce + AbortSignal) |
131
139
  | Простая синхронная реакция (примитив) | `watchField` |
140
+ | Реакция на любое изменение группы/формы | `watchField(model.$.<группа>)` или `.subscribe` |
132
141
 
133
142
  ### Chained computeds
134
143