@reformer/core 10.2.0 → 11.0.0-beta.10

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 (285) hide show
  1. package/README.md +90 -68
  2. package/dist/behaviors-value-D05z3my6.js +109 -0
  3. package/dist/behaviors.d.ts +1 -1
  4. package/dist/behaviors.js +376 -211
  5. package/dist/create-model-DPSG3I0O.js +30 -0
  6. package/dist/date-utils-90-qZbse.js +43 -0
  7. package/dist/en-HP8XEych.js +44 -0
  8. package/dist/form/aggregate-signals.d.ts +21 -63
  9. package/dist/form/behaviors/collections.d.ts +84 -0
  10. package/dist/form/behaviors/context.d.ts +56 -0
  11. package/dist/form/behaviors/index.d.ts +30 -0
  12. package/dist/form/behaviors/internals.d.ts +41 -0
  13. package/dist/form/{behaviors-node.d.ts → behaviors/node.d.ts} +28 -5
  14. package/dist/form/behaviors/operators.d.ts +59 -0
  15. package/dist/form/behaviors/schema-operators.d.ts +83 -0
  16. package/dist/form/behaviors/types.d.ts +37 -0
  17. package/dist/form/create-form.d.ts +59 -70
  18. package/dist/form/flow/flow.d.ts +87 -0
  19. package/dist/form/form-bundle.d.ts +153 -0
  20. package/dist/form/form-level.d.ts +121 -0
  21. package/dist/form/form-proxy-builder.d.ts +6 -22
  22. package/dist/form/index.d.ts +23 -27
  23. package/dist/form/nodes/form-array-node.d.ts +100 -0
  24. package/dist/form/nodes/form-control-node.d.ts +73 -0
  25. package/dist/form/nodes/form-group-node.d.ts +66 -0
  26. package/dist/form/nodes/node-kind.d.ts +29 -0
  27. package/dist/form/nodes/state/availability.d.ts +6 -0
  28. package/dist/form/nodes/state/component.d.ts +6 -0
  29. package/dist/form/nodes/state/index.d.ts +12 -0
  30. package/dist/form/nodes/state/interaction.d.ts +13 -0
  31. package/dist/form/nodes/state/issues.d.ts +9 -0
  32. package/dist/form/nodes/state/pending.d.ts +6 -0
  33. package/dist/form/schema/compile.d.ts +59 -0
  34. package/dist/form/schema/compiled.d.ts +143 -0
  35. package/dist/form/schema/row-key.d.ts +11 -0
  36. package/dist/form/schema-controller.d.ts +159 -0
  37. package/dist/form/schema-subtree.d.ts +28 -0
  38. package/dist/form/signal-node-registry.d.ts +27 -22
  39. package/dist/form/submission.d.ts +93 -0
  40. package/dist/form/type-guards.d.ts +14 -73
  41. package/dist/form/types/contracts.d.ts +21 -91
  42. package/dist/form/types/field-config.d.ts +27 -0
  43. package/dist/form/types/index.d.ts +5 -91
  44. package/dist/form/types/nodes.d.ts +159 -0
  45. package/dist/form/types/schema-node.d.ts +148 -50
  46. package/dist/form/types/validation-schema.d.ts +24 -41
  47. package/dist/form/validation/context.d.ts +67 -0
  48. package/dist/form/validation/index.d.ts +24 -28
  49. package/dist/form/validation/operators.d.ts +101 -0
  50. package/dist/form/validation/run.d.ts +120 -0
  51. package/dist/form/validation/strategy.d.ts +89 -0
  52. package/dist/form/validation/types.d.ts +83 -0
  53. package/dist/form/validators/date-utils.d.ts +42 -0
  54. package/dist/form/validators/email.d.ts +23 -0
  55. package/dist/form/{validation/validators → validators}/file-type.d.ts +10 -10
  56. package/dist/form/{validation/validators → validators}/file-utils.d.ts +3 -2
  57. package/dist/form/validators/future-date.d.ts +23 -0
  58. package/dist/form/{validation/validators → validators}/index.d.ts +7 -4
  59. package/dist/form/validators/integer.d.ts +22 -0
  60. package/dist/form/validators/is-date.d.ts +24 -0
  61. package/dist/form/validators/is-number.d.ts +25 -0
  62. package/dist/form/validators/max-age.d.ts +28 -0
  63. package/dist/form/validators/max-date.d.ts +25 -0
  64. package/dist/form/{validation/validators → validators}/max-file-size.d.ts +10 -10
  65. package/dist/form/validators/max-files.d.ts +26 -0
  66. package/dist/form/validators/max-length.d.ts +33 -0
  67. package/dist/form/validators/max-total-file-size.d.ts +26 -0
  68. package/dist/form/validators/max.d.ts +26 -0
  69. package/dist/form/validators/min-age.d.ts +27 -0
  70. package/dist/form/validators/min-date.d.ts +25 -0
  71. package/dist/form/{validation/validators → validators}/min-file-size.d.ts +10 -10
  72. package/dist/form/validators/min-files.d.ts +27 -0
  73. package/dist/form/validators/min-length.d.ts +33 -0
  74. package/dist/form/validators/min.d.ts +26 -0
  75. package/dist/form/validators/multiple-of.d.ts +25 -0
  76. package/dist/form/validators/non-negative.d.ts +22 -0
  77. package/dist/form/validators/non-zero.d.ts +22 -0
  78. package/dist/form/validators/past-date.d.ts +23 -0
  79. package/dist/form/validators/pattern.d.ts +26 -0
  80. package/dist/form/{validation/validators → validators}/phone.d.ts +11 -11
  81. package/dist/form/validators/required.d.ts +29 -0
  82. package/dist/form/validators/url.d.ts +45 -0
  83. package/dist/form/validators/validation-issue.d.ts +8 -0
  84. package/dist/i18n/descriptor.d.ts +54 -0
  85. package/dist/i18n/en.json.d.ts +49 -0
  86. package/dist/i18n/loader.d.ts +71 -0
  87. package/dist/i18n/locale-store.d.ts +46 -0
  88. package/dist/i18n/locale.d.ts +90 -0
  89. package/dist/i18n/message-format.d.ts +136 -0
  90. package/dist/i18n/resolve-localized.d.ts +17 -0
  91. package/dist/i18n/ru.json.d.ts +49 -0
  92. package/dist/i18n/translator.d.ts +61 -0
  93. package/dist/i18n/validation-message.d.ts +53 -0
  94. package/dist/i18n.d.ts +26 -0
  95. package/dist/i18n.js +86 -0
  96. package/dist/index.d.ts +18 -6
  97. package/dist/index.js +932 -2779
  98. package/dist/loader-BWkigpVe.js +52 -0
  99. package/dist/locale/en.d.ts +2 -0
  100. package/dist/locale/en.js +5 -0
  101. package/dist/locale/en.json +45 -0
  102. package/dist/locale/index.d.ts +18 -0
  103. package/dist/locale/ru.d.ts +2 -0
  104. package/dist/locale/ru.js +44 -0
  105. package/dist/locale/ru.json +45 -0
  106. package/dist/locale-CG54AU2Q.js +219 -0
  107. package/dist/locale.d.ts +2 -0
  108. package/dist/locale.js +9 -0
  109. package/dist/{state → model}/behaviors-value.d.ts +24 -6
  110. package/dist/{state/form-model.d.ts → model/create-model.d.ts} +18 -4
  111. package/dist/{state → model}/derived-registry.d.ts +1 -1
  112. package/dist/model/index.d.ts +42 -0
  113. package/dist/model/model-nodes.d.ts +190 -0
  114. package/dist/model/model-signals-proxy.d.ts +63 -0
  115. package/dist/model/model-value-proxy.d.ts +84 -0
  116. package/dist/{state → model}/safe-effect.d.ts +50 -2
  117. package/dist/model/types.d.ts +239 -0
  118. package/dist/model.d.ts +2 -0
  119. package/dist/model.js +27 -0
  120. package/dist/node-kind-C-r6ZMdo.js +15 -0
  121. package/dist/platforms/react/field-binding/field-adapter.d.ts +87 -0
  122. package/dist/platforms/react/field-binding/field-handle.d.ts +53 -0
  123. package/dist/platforms/react/form-bundle-context.d.ts +27 -0
  124. package/dist/{form → platforms/react}/hooks/types.d.ts +60 -38
  125. package/dist/platforms/react/hooks/use-form-bundle.d.ts +41 -0
  126. package/dist/platforms/react/hooks/use-form-flow.d.ts +40 -0
  127. package/dist/{form → platforms/react}/hooks/use-form-validation.d.ts +13 -7
  128. package/dist/{form → platforms/react}/hooks/useArrayLength.d.ts +6 -6
  129. package/dist/{form → platforms/react}/hooks/useFormControl.d.ts +7 -9
  130. package/dist/{form → platforms/react}/hooks/useFormControlValue.d.ts +18 -18
  131. package/dist/{form → platforms/react}/hooks/useSignalSubscription.d.ts +9 -1
  132. package/dist/platforms/react/i18n/context.d.ts +18 -0
  133. package/dist/platforms/react/i18n/hooks.d.ts +65 -0
  134. package/dist/platforms/react/i18n/provider.d.ts +59 -0
  135. package/dist/platforms/react/index.d.ts +30 -0
  136. package/dist/react.d.ts +22 -0
  137. package/dist/react.js +505 -0
  138. package/dist/runtime-token.d.ts +22 -0
  139. package/dist/safe-effect-BazLOv1g.js +669 -0
  140. package/dist/schema-controller-CpiO2JoK.js +143 -0
  141. package/dist/signals.d.ts +15 -9
  142. package/dist/signals.js +11 -6
  143. package/dist/strategy-DzvnsWLh.js +375 -0
  144. package/dist/type-guards-D-N_5Ddh.js +19 -0
  145. package/dist/validation-issue-B9mjI8jz.js +7 -0
  146. package/dist/validation-message-Ced9MGxs.js +194 -0
  147. package/dist/validation.d.ts +1 -1
  148. package/dist/validation.js +11 -165
  149. package/dist/validators/email.d.ts +1 -1
  150. package/dist/validators/email.js +5 -8
  151. package/dist/validators/future-date.d.ts +1 -1
  152. package/dist/validators/future-date.js +10 -13
  153. package/dist/validators/integer.d.ts +1 -1
  154. package/dist/validators/integer.js +4 -7
  155. package/dist/validators/is-date.d.ts +1 -1
  156. package/dist/validators/is-date.js +5 -8
  157. package/dist/validators/is-number.d.ts +1 -1
  158. package/dist/validators/is-number.js +4 -7
  159. package/dist/validators/max-age.d.ts +1 -1
  160. package/dist/validators/max-age.js +8 -12
  161. package/dist/validators/max-date.d.ts +1 -1
  162. package/dist/validators/max-date.js +10 -13
  163. package/dist/validators/max-length.d.ts +1 -1
  164. package/dist/validators/max-length.js +7 -10
  165. package/dist/validators/max.d.ts +1 -1
  166. package/dist/validators/max.js +4 -7
  167. package/dist/validators/min-age.d.ts +1 -1
  168. package/dist/validators/min-age.js +10 -11
  169. package/dist/validators/min-date.d.ts +1 -1
  170. package/dist/validators/min-date.js +8 -11
  171. package/dist/validators/min-length.d.ts +1 -1
  172. package/dist/validators/min-length.js +4 -7
  173. package/dist/validators/min.d.ts +1 -1
  174. package/dist/validators/min.js +4 -7
  175. package/dist/validators/multiple-of.d.ts +1 -1
  176. package/dist/validators/multiple-of.js +11 -10
  177. package/dist/validators/non-negative.d.ts +1 -1
  178. package/dist/validators/non-negative.js +2 -5
  179. package/dist/validators/non-zero.d.ts +1 -1
  180. package/dist/validators/non-zero.js +4 -7
  181. package/dist/validators/past-date.d.ts +1 -1
  182. package/dist/validators/past-date.js +10 -13
  183. package/dist/validators/pattern.d.ts +1 -1
  184. package/dist/validators/pattern.js +5 -8
  185. package/dist/validators/phone.d.ts +1 -1
  186. package/dist/validators/phone.js +10 -11
  187. package/dist/validators/required.d.ts +1 -1
  188. package/dist/validators/required.js +4 -7
  189. package/dist/validators/url.d.ts +1 -1
  190. package/dist/validators/url.js +48 -17
  191. package/dist/validators.d.ts +1 -5
  192. package/dist/validators.js +98 -154
  193. package/docs/llms/01-api-reference.md +89 -0
  194. package/docs/llms/02-quick-start.md +181 -0
  195. package/docs/llms/03-api-signatures.md +494 -0
  196. package/docs/llms/04-common-patterns.md +204 -0
  197. package/docs/llms/05-common-mistakes.md +192 -0
  198. package/docs/llms/06-troubleshooting.md +16 -0
  199. package/docs/llms/07-complete-import.md +130 -0
  200. package/docs/llms/08-form-types.md +54 -0
  201. package/docs/llms/09-formschema.md +191 -0
  202. package/docs/llms/10-arrays.md +241 -0
  203. package/docs/llms/11-async-watchfield.md +53 -0
  204. package/docs/llms/12-array-cleanup.md +31 -0
  205. package/docs/llms/13-multi-step.md +162 -0
  206. package/docs/llms/14-extended-mistakes.md +101 -0
  207. package/docs/llms/15-project-structure.md +279 -0
  208. package/docs/llms/16-ui-components.md +229 -0
  209. package/docs/llms/17-nonexistent-api.md +278 -0
  210. package/docs/llms/18-conditional-fields.md +222 -0
  211. package/docs/llms/19-reading-values.md +100 -0
  212. package/docs/llms/20-compute-vs-watch.md +153 -0
  213. package/docs/llms/21-array-operations.md +163 -0
  214. package/docs/llms/22-cycle-detection.md +86 -0
  215. package/docs/llms/23-copy-from.md +141 -0
  216. package/docs/llms/24-sync-fields.md +115 -0
  217. package/docs/llms/25-reset-when.md +122 -0
  218. package/docs/llms/26-transform-value.md +136 -0
  219. package/docs/llms/27-revalidate-when.md +133 -0
  220. package/docs/llms/28-submit-and-reset.md +409 -0
  221. package/docs/llms/29-async-preload.md +203 -0
  222. package/docs/llms/30-type-safety-recipes.md +213 -0
  223. package/docs/llms/31-async-validator-debounce.md +178 -0
  224. package/docs/llms/32-async-options-loading.md +108 -0
  225. package/docs/llms/33-validation-strategy.md +305 -0
  226. package/docs/llms/34-i18n.md +298 -0
  227. package/docs/llms/35-unified-contract.md +309 -0
  228. package/docs/llms/36-issue-messages.md +284 -0
  229. package/docs/llms/37-compiled-schema.md +332 -0
  230. package/docs/llms/38-form-flow.md +396 -0
  231. package/llms-index.json +1 -0
  232. package/llms.txt +10014 -3826
  233. package/package.json +77 -15
  234. package/dist/behaviors-node-DGEFMTQu.js +0 -18
  235. package/dist/behaviors-value-C7dSEhCc.js +0 -134
  236. package/dist/date-utils-dTT_x_Be.js +0 -31
  237. package/dist/derived-registry-CluZCQ7U.js +0 -16
  238. package/dist/form/behaviors.d.ts +0 -133
  239. package/dist/form/error-handler.d.ts +0 -166
  240. package/dist/form/factories/index.d.ts +0 -6
  241. package/dist/form/factories/node-factory.d.ts +0 -188
  242. package/dist/form/form-submitter.d.ts +0 -125
  243. package/dist/form/nodes/array-node.d.ts +0 -360
  244. package/dist/form/nodes/field-node.d.ts +0 -294
  245. package/dist/form/nodes/form-node.d.ts +0 -340
  246. package/dist/form/nodes/group-node.d.ts +0 -288
  247. package/dist/form/nodes/model-array-node.d.ts +0 -121
  248. package/dist/form/status-machine.d.ts +0 -153
  249. package/dist/form/types/deep-schema.d.ts +0 -116
  250. package/dist/form/types/form-proxy.d.ts +0 -141
  251. package/dist/form/unique-id.d.ts +0 -53
  252. package/dist/form/validation/validators/date-utils.d.ts +0 -30
  253. package/dist/form/validation/validators/email.d.ts +0 -23
  254. package/dist/form/validation/validators/future-date.d.ts +0 -23
  255. package/dist/form/validation/validators/integer.d.ts +0 -22
  256. package/dist/form/validation/validators/is-date.d.ts +0 -23
  257. package/dist/form/validation/validators/is-number.d.ts +0 -24
  258. package/dist/form/validation/validators/max-age.d.ts +0 -25
  259. package/dist/form/validation/validators/max-date.d.ts +0 -25
  260. package/dist/form/validation/validators/max-files.d.ts +0 -26
  261. package/dist/form/validation/validators/max-length.d.ts +0 -27
  262. package/dist/form/validation/validators/max-total-file-size.d.ts +0 -26
  263. package/dist/form/validation/validators/max.d.ts +0 -25
  264. package/dist/form/validation/validators/min-age.d.ts +0 -25
  265. package/dist/form/validation/validators/min-date.d.ts +0 -25
  266. package/dist/form/validation/validators/min-files.d.ts +0 -26
  267. package/dist/form/validation/validators/min-length.d.ts +0 -27
  268. package/dist/form/validation/validators/min.d.ts +0 -25
  269. package/dist/form/validation/validators/multiple-of.d.ts +0 -24
  270. package/dist/form/validation/validators/non-negative.d.ts +0 -22
  271. package/dist/form/validation/validators/non-zero.d.ts +0 -22
  272. package/dist/form/validation/validators/past-date.d.ts +0 -23
  273. package/dist/form/validation/validators/pattern.d.ts +0 -33
  274. package/dist/form/validation/validators/required.d.ts +0 -32
  275. package/dist/form/validation/validators/url.d.ts +0 -43
  276. package/dist/form/validation-schema.d.ts +0 -106
  277. package/dist/form/validation-strategy.d.ts +0 -55
  278. package/dist/form/validators.d.ts +0 -6
  279. package/dist/form-model-DYCzGGKF.js +0 -286
  280. package/dist/state/index.d.ts +0 -31
  281. package/dist/state/subscription-manager.d.ts +0 -180
  282. package/dist/state/types.d.ts +0 -144
  283. package/dist/state.d.ts +0 -2
  284. package/dist/state.js +0 -21
  285. package/dist/subscription-manager-705bls97.js +0 -171
@@ -0,0 +1,203 @@
1
+ # Async Preload — Загрузка начальных значений и справочников
2
+
3
+ ## Purpose
4
+
5
+ Загрузка формы данными с сервера: (1) **initial values** в `createModel(...)` / `createForm({ initial })`; (2)
6
+ **`model.set` / `model.patch`** для загрузки/обновления значений; (3) **external React-hook**
7
+ для full-blown async preload (параллельный fetch заявки + справочников, обработка ошибок,
8
+ race-guard через deps). Динамические `componentProps` (опции селектов) обновляются через
9
+ `form.field.updateComponentProps({ options })` в `queueMicrotask`, чтобы не пересечься с
10
+ реактивными эффектами от `set`/`patch`.
11
+
12
+ ## API
13
+
14
+ ```typescript
15
+ // Модель:
16
+ model.set(value: T): void; // полная установка значений (load полного DTO). Не меняет initial.
17
+ // DTO без части ключей: поля остаются прежними, в dev — предупреждение.
18
+ model.patch(value: DeepPartial<T>): void; // частичное слияние (только переданные ключи, на любой глубине групп; undefined — «не трогать»). Не меняет initial.
19
+ model.reset(): void; // вернуть к initial-снимку
20
+ model.captureInitial(): void; // сделать текущие значения новым initial-снимком
21
+
22
+ // Нода поля/группы:
23
+ form.field.updateComponentProps(props: Record<string, unknown>): void; // динамические опции и т.п.
24
+ ```
25
+
26
+ Initial values задаются в `createModel(initial)`; `reset()` возвращает к ним. Чтобы загруженные
27
+ данные стали новой «точкой отсчёта» для `reset()` — вызови `model.captureInitial()` после load.
28
+
29
+ ## Examples
30
+
31
+ ### Initial values в модели
32
+
33
+ ```typescript
34
+ import { createModel, createFormFromModel, type FormSchemaNode } from '@reformer/core';
35
+ import { Input, SelectAsync, CheckboxWithLabel } from '@reformer/ui-kit';
36
+
37
+ interface ProfileForm {
38
+ username: string;
39
+ language: 'ru' | 'en';
40
+ marketing: boolean;
41
+ }
42
+
43
+ const model = createModel<ProfileForm>({ username: '', language: 'ru', marketing: true });
44
+ const schema: FormSchemaNode = {
45
+ children: [
46
+ { model: model.$.username, component: Input, componentProps: { label: 'Username' } },
47
+ {
48
+ model: model.$.language,
49
+ component: SelectAsync,
50
+ componentProps: {
51
+ label: 'Язык',
52
+ options: [
53
+ { value: 'ru', label: 'Русский' },
54
+ { value: 'en', label: 'English' },
55
+ ],
56
+ },
57
+ },
58
+ {
59
+ model: model.$.marketing,
60
+ component: CheckboxWithLabel,
61
+ componentProps: { label: 'Рассылка' },
62
+ },
63
+ ],
64
+ };
65
+ const form = createFormFromModel({ model, schema });
66
+ // model.get() === { username: '', language: 'ru', marketing: true }
67
+ // после правок: model.reset() возвращает к этим значениям
68
+ ```
69
+
70
+ ### Async preload через external hook + model.set
71
+
72
+ ```tsx
73
+ import { useEffect, useState } from 'react';
74
+ import type { FormModel, FormGroup } from '@reformer/core';
75
+
76
+ interface LoadingState { isLoading: boolean; error: string | null }
77
+
78
+ export function useLoadCreditApplication(
79
+ model: FormModel<CreditApplicationForm>,
80
+ form: FormGroup<CreditApplicationForm>,
81
+ applicationId: string | null
82
+ ): LoadingState {
83
+ const [state, setState] = useState<LoadingState>({ isLoading: !!applicationId, error: null });
84
+
85
+ useEffect(() => {
86
+ if (!applicationId) { setState({ isLoading: false, error: null }); return; }
87
+ let cancelled = false;
88
+
89
+ (async () => {
90
+ setState({ isLoading: true, error: null });
91
+ try {
92
+ const [appResp, dictsResp] = await Promise.all([
93
+ fetchCreditApplication(applicationId),
94
+ fetchDictionaries(),
95
+ ]);
96
+ if (cancelled) return; // race-guard: сменили applicationId / unmount
97
+ if (appResp.status !== 200 || dictsResp.status !== 200) throw new Error('Сервер вернул ошибку');
98
+
99
+ // Загрузка значений в модель
100
+ model.set(appResp.data);
101
+
102
+ // Динамические componentProps — через queueMicrotask (после реактивных эффектов от set)
103
+ queueMicrotask(() => {
104
+ if (cancelled) return;
105
+ form.registrationAddress.city.updateComponentProps({ options: dictsResp.data.cities });
106
+ });
107
+
108
+ setState({ isLoading: false, error: null });
109
+ } catch (err) {
110
+ if (cancelled) return;
111
+ setState({ isLoading: false, error: err instanceof Error ? err.message : 'Ошибка' });
112
+ }
113
+ })();
114
+
115
+ return () => { cancelled = true; };
116
+ }, [applicationId]); // model/form стабильны (сборка через useFormBundle)
117
+
118
+ return state;
119
+ }
120
+ ```
121
+
122
+ ### Preload через behavior — onChange на поле
123
+
124
+ Загрузка справочника при выборе другого поля (после preload) — через `onChange`:
125
+
126
+ ```typescript
127
+ import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
128
+
129
+ export const addressBehavior = defineFormBehavior<AddressForm>(({ model, form }) => {
130
+ onChange(
131
+ model.$.region,
132
+ async (region, { signal }) => {
133
+ if (!region) { form.city.updateComponentProps({ options: [] }); return; }
134
+ try {
135
+ const cities = await fetchCities(region, { signal });
136
+ form.city.updateComponentProps({ options: cities });
137
+ } catch {
138
+ form.city.updateComponentProps({ options: [] });
139
+ }
140
+ },
141
+ { debounce: 300 }
142
+ );
143
+ });
144
+ ```
145
+
146
+ ## Anti-patterns
147
+
148
+ ```typescript
149
+ // ❌ model/form пересоздаются на каждый рендер → preload запускается каждый раз
150
+ function MyForm() {
151
+ const bundle = createForm<T>({ initial, schema }); // КАЖДЫЙ рендер!
152
+ }
153
+
154
+ // ✅ Стабильная сборка: useFormBundle вызывает фабрику ровно один раз (useMemo React вправе
155
+ // сбросить и пересобрать форму, потеряв введённое)
156
+ function MyForm() {
157
+ const { model, form } = useFormBundle(() => createForm<T>({ initial, schema }));
158
+ }
159
+ ```
160
+
161
+ ```typescript
162
+ // ❌ updateComponentProps синхронно после set → возможный конфликт с реактивными эффектами
163
+ model.set(data);
164
+ form.region.updateComponentProps({ options: [...] });
165
+
166
+ // ✅ queueMicrotask, чтобы реактивные эффекты от set завершились
167
+ model.set(data);
168
+ queueMicrotask(() => form.region.updateComponentProps({ options: [...] }));
169
+ ```
170
+
171
+ ```typescript
172
+ // ❌ Нет race-guard в async useEffect
173
+ useEffect(() => { fetchData(id).then((d) => model.set(d)); }, [id]);
174
+
175
+ // ✅ Cleanup-флаг
176
+ useEffect(() => {
177
+ let cancelled = false;
178
+ fetchData(id).then((d) => { if (!cancelled) model.set(d); });
179
+ return () => { cancelled = true; };
180
+ }, [id]);
181
+ ```
182
+
183
+ ## Troubleshooting
184
+
185
+ **Q: После `set`/`patch` не показываются замечания.**
186
+ A: `set`/`patch` не запускают валидацию (кроме живой стратегии `change`). После загрузки вызови
187
+ `await bundle.validate()`; без сборки — `await validateModel(model, schema)` из
188
+ `@reformer/core/validation`.
189
+
190
+ **Q: Опции в `<Select>` не появляются после `updateComponentProps`.**
191
+ A: (1) Оберни вызов в `queueMicrotask`; (2) убедись, что компонент подписан через `useFormControl`;
192
+ (3) для массивов — пройдись по элементам (`form.items.map`) и обнови каждый.
193
+
194
+ **Q: При `reset()` теряются данные с сервера.**
195
+ A: `reset()` возвращает к initial-снимку из `createModel`, не к последнему `set`. Чтобы «сбросить
196
+ к данным с сервера» — после `set` вызови `model.captureInitial()` (новая точка отсчёта).
197
+
198
+ ## See also
199
+
200
+ - [28-submit-and-reset.md](./28-submit-and-reset.md) — обратная сторона жизненного цикла
201
+ - [11-async-watchfield.md](./11-async-watchfield.md) — `onChange` для динамики после preload
202
+ - [22-cycle-detection.md](./22-cycle-detection.md) — почему `queueMicrotask` нужен
203
+ - [16-ui-components.md](./16-ui-components.md) — `updateComponentProps` для динамических опций
@@ -0,0 +1,213 @@
1
+ ## 30. TYPE-SAFETY RECIPES
2
+
3
+ Идиоматичные паттерны, которые держат сгенерированный код без `any` и `as`-кастов.
4
+
5
+ ### Recipe 1 — Imports (root cause prevention)
6
+
7
+ - Модель/форма/типы — из `@reformer/core`; хуки — из `@reformer/core/react`; примитивы над
8
+ сигналами — из `@reformer/core/model`.
9
+ - Схема валидации (операторы + раннер `validateModel`) — из `@reformer/core/validation`.
10
+ - Чистые фабрики валидаторов — из `@reformer/core/validators`.
11
+ - Словарь текстов замечаний (`defineIssueMessages`) — из `@reformer/core/i18n`.
12
+ - Декларативный DSL (`defineFormBehavior` + операторы) — из `@reformer/core/behaviors`.
13
+
14
+ ```typescript
15
+ import {
16
+ createModel,
17
+ createForm,
18
+ type FormModel,
19
+ type FormGroup,
20
+ type FormSchemaNode,
21
+ } from '@reformer/core';
22
+ import { useFormBundle, useFormControl } from '@reformer/core/react';
23
+ import {
24
+ defineValidation,
25
+ validate,
26
+ validateModel,
27
+ type Rule,
28
+ } from '@reformer/core/validation';
29
+ import { required, min, max, email } from '@reformer/core/validators';
30
+ import { defineFormBehavior, compute, enableWhen, onChange } from '@reformer/core/behaviors';
31
+ ```
32
+
33
+ > **watchField — из `@reformer/core/model`** (примитив), НЕ из `@reformer/core/behaviors` (там `onChange`)
34
+ > и не из корня `@reformer/core`.
35
+
36
+ ### Recipe 2 — Form-shape types: `interface` или `type`
37
+
38
+ Оба варианта работают одинаково. Ограничения `Record<string, …>` у `createModel<T>`,
39
+ `createForm<T>`, `FormGroup<T>` и `FormArray<T>` нет (`createModel` и `createForm` требуют только
40
+ `T extends object`). Вид ноды поля `FormFields<T>` выводит по виду значения: обычный объект —
41
+ `FormGroup`, массив объектов — `FormArray`, остальное (примитив, массив примитивов, `Date`, `File`)
42
+ — `FormControl`. Index signature здесь не участвует, поэтому `interface` распознаётся так же, как
43
+ `type`. Эталонная форма объявлена через `interface`.
44
+
45
+ ```typescript
46
+ export interface PropertyItem {
47
+ type: 'apartment' | 'house' | 'car';
48
+ description: string;
49
+ estimatedValue: number;
50
+ }
51
+
52
+ export interface CreditApplication {
53
+ loanAmount: number | null;
54
+ properties: PropertyItem[]; // form.properties — FormArray<PropertyItem>
55
+ address: Address; // form.address — FormGroup<Address>
56
+ tags: string[]; // form.tags — FormControl<string[]>
57
+ // ...
58
+ }
59
+
60
+ // вычисляемые поля — отдельным типом; форма — пересечение
61
+ export interface CreditApplicationDerived {
62
+ monthlyPayment: number;
63
+ }
64
+ export type CreditApplicationForm = CreditApplication & CreditApplicationDerived;
65
+ ```
66
+
67
+ Оговорка только для своих обобщённых хелперов: `interface` не присваивается
68
+ `Record<string, unknown>` (неявной index signature у него нет). Ограничение пиши через
69
+ `T extends object`:
70
+
71
+ ```typescript
72
+ // ❌ interface-форму этот хелпер не примет
73
+ function snapshot<T extends Record<string, unknown>>(form: FormGroup<T>): T { return form.getValue(); }
74
+
75
+ // ✅
76
+ function snapshot<T extends object>(form: FormGroup<T>): T { return form.getValue(); }
77
+ ```
78
+
79
+ ### Recipe 3 — Схема привязана к ручкам модели
80
+
81
+ Привязка узла — ручка модели (`model: model.$.field`), а не литерал. Тип узла `FormSchemaNode`
82
+ закрыт: вид узла (поле, массив под-форм, подформа) сверяется с тем, чем является привязка, а
83
+ опечатка в ключе не компилируется. Правил в схеме нет — они живут в отдельной схеме валидации.
84
+
85
+ ```typescript
86
+ const creditSchema = (model: FormModel<CreditApplicationForm>): FormSchemaNode => ({
87
+ children: [
88
+ { model: model.$.loanAmount, component: InputNumber },
89
+ // вложенная группа — подформа: билдер получает под-модель FormModel<PersonalData>
90
+ { model: model.$.personalData, part: personalData },
91
+ // массив под-форм — { model, item }: билдер получает под-модель строки
92
+ { model: model.$.properties, component: FieldArray, item: propertyRow },
93
+ ],
94
+ });
95
+
96
+ // правила — отдельно; тип правила сверяется с типом поля
97
+ const LOAN_AMOUNT_RULES: Rule<number | null>[] = [required(), min(50000)];
98
+
99
+ const validation = defineValidation<CreditApplicationForm>(({ model }) => {
100
+ validate(model.$.loanAmount, LOAN_AMOUNT_RULES);
101
+ });
102
+ ```
103
+
104
+ ### Recipe 4 — Cross-field валидаторы: `cross` над типизированным снимком
105
+
106
+ Правило — обычная функция `(snapshot: T) => ValidationIssue | null`. Оператор `cross` берётся из
107
+ аргумента схемы: тип снимка выведен из схемы, соседние поля читаются без `as`, ошибка вешается на
108
+ поле-носитель:
109
+
110
+ ```typescript
111
+ import type { ValidationIssue } from '@reformer/core';
112
+
113
+ const initialPaymentVsProperty = (application: CreditApplicationForm): ValidationIssue | null =>
114
+ application.initialPayment &&
115
+ application.propertyValue &&
116
+ application.initialPayment > application.propertyValue
117
+ ? { code: 'initialPaymentTooHigh' } // текст — в словаре формы (defineIssueMessages)
118
+ : null;
119
+
120
+ const loanRules = defineValidation<CreditApplicationForm>(({ model, cross }) => {
121
+ cross(model.$.initialPayment, initialPaymentVsProperty);
122
+ });
123
+ ```
124
+
125
+ ### Recipe 5 — `compute` читает модель напрямую (без аннотаций)
126
+
127
+ `compute(target, () => …)` читает value-модель (`model.field`) — типы полей выводятся из типа
128
+ модели, `as`-касты не нужны:
129
+
130
+ ```typescript
131
+ compute(model.$.monthlyPayment, () =>
132
+ annuityMonthly(model.loanAmount ?? 0, model.loanTerm ?? 0, model.interestRate ?? 0)
133
+ );
134
+
135
+ // nested reads — тоже напрямую
136
+ compute(model.$.fullName, () =>
137
+ [model.personalData.firstName, model.personalData.lastName].filter(Boolean).join(' ')
138
+ );
139
+ ```
140
+
141
+ ### Recipe 6 — `null` vs `undefined` для опциональных полей
142
+
143
+ Оба работают. `null` — конвенция «пользователь очистил поле». Встроенные валидаторы
144
+ (`min`, `max`, `minLength`, `maxLength`, `minDate`, `maxDate`, `minAge`, `maxAge`) пропускают
145
+ пустые значения — guard `if (value != null)` не нужен.
146
+
147
+ ```typescript
148
+ export interface CreditForm {
149
+ loanAmount: number | null; // validate(model.$.loanAmount, [min(50000)]) — ок
150
+ loanPurpose: string | null; // minLength — ок
151
+ birthDate: string | null; // minAge — ок
152
+ }
153
+ ```
154
+
155
+ ### Recipe 7 — Нода поля для кастомных компонентов
156
+
157
+ `useFormControl(control)` типизируется по `FormControl<T>`. В props компонента используй `FormControl<T>`:
158
+
159
+ ```typescript
160
+ import type { FormControl } from '@reformer/core';
161
+ import { useFormControl } from '@reformer/core/react';
162
+
163
+ type MyFieldProps<T> = { control: FormControl<T> };
164
+ function MyField<T>({ control }: MyFieldProps<T>) {
165
+ const { value, issues, disabled } = useFormControl(control);
166
+ // ...
167
+ }
168
+ ```
169
+
170
+ Для массива под-форм — `FormArray<Item>` (`useFormControl` отдаёт `ArrayControlState`), для группы —
171
+ `FormGroup<Group>`. Вид неизвестной ноды сужают гарды `isFormControl` / `isFormGroup` /
172
+ `isFormArray`.
173
+
174
+ ### Recipe 8 — Вынос правил в именованные функции
175
+
176
+ Field-правила — именованные `Rule<T>`, validation-схема остаётся плоской:
177
+
178
+ ```typescript
179
+ import type { Rule } from '@reformer/core/validation';
180
+
181
+ const validateAdultAge: Rule<string | null> = (value) => {
182
+ if (!value) return null;
183
+ const age = new Date().getFullYear() - new Date(value).getFullYear();
184
+ return age < 18 ? { code: 'birthDate.adult', params: { minAge: 18 } } : null;
185
+ };
186
+
187
+ // внутри defineValidation<MyForm>(({ model }) => { ... }):
188
+ validate(model.$.birthDate, [validateAdultAge]);
189
+ ```
190
+
191
+ ### Recipe 9 — Результаты уровня формы — сужение по `status`
192
+
193
+ `bundle.validate()`, `bundle.submission.submit(handler)` и `flow.next()` отвечают
194
+ дискриминированными объединениями: поле результата доступно после проверки `status`, без `as`.
195
+
196
+ ```typescript
197
+ const outcome = await bundle.submission.submit(api.send); // SubmitResult<Awaited<ReturnType<typeof api.send>>>
198
+ if (outcome.status === 'submitted') use(outcome.result);
199
+ else if (outcome.status === 'failed') report(outcome.error);
200
+ else showIssues(outcome.validation.issues); // 'invalid' | 'error' | 'cancelled'
201
+
202
+ const transition = await bundle.flow!.next(); // FlowTransition
203
+ if (transition.status === 'stayed') console.log(transition.reason); // 'invalid' | 'error' | …
204
+ ```
205
+
206
+ ### Anti-patterns to avoid
207
+
208
+ - `import { computeFrom } from '@reformer/core/behaviors'` для примитива, вызываемого вне
209
+ `defineFormBehavior` → примитив живёт в `@reformer/core/model` (возвращает cleanup).
210
+ - `T extends Record<string, unknown>` в своих хелперах формы → `T extends object` (Recipe 2).
211
+ - `as`-касты значений полей внутри `compute` → читай `model.field` напрямую (Recipe 5).
212
+ - строковые пути / `(form) => ...` в behaviors → это удалённый API, используй сигналы (`model.$.x`).
213
+ - `node instanceof …` → ноды — интерфейсы; гарды `isFormControl` / `isFormGroup` / `isFormArray`.
@@ -0,0 +1,178 @@
1
+ ## 31. ASYNC VALIDATOR
2
+
3
+ Для проверок типа «уникальность email», «валидация ИНН через API», «проверка адреса» —
4
+ async-правило это `AsyncRule<T>` = `(value, { signal }) => Promise<ValidationIssue | null>`.
5
+ Оно регистрируется оператором `validateAsync(sig, [asyncRules])` внутри схемы валидации
6
+ (`@reformer/core/validation`) и исполняется прогоном — уровнем формы (`bundle.validate()`,
7
+ `bundle.submission.submit(handler)`, `flow.next()`) либо раннером `validateModel`. Async-правила
8
+ прогоняются параллельно через `Promise.all`, прогон их дожидается.
9
+
10
+ Слои разделены: схема формы (билдер `createForm` / JSON) правил НЕ несёт — они живут в
11
+ отдельной функции-схеме `defineValidation<T>(({ model }) => …)`.
12
+
13
+ **Сбой async-правила блокирует.** Если правило отклонило промис (сеть, исключение), прогон получает
14
+ статус `error`, а поле — замечание `{ code: 'ruleFailed' }` («Не удалось проверить поле. Повторите
15
+ попытку»): форму, которую не удалось проверить, прогон валидной не считает.
16
+
17
+ ```ts
18
+ import { createForm, createModel } from '@reformer/core';
19
+ import {
20
+ validate,
21
+ validateAsync,
22
+ defineValidation,
23
+ validateModel,
24
+ type AsyncRule,
25
+ } from '@reformer/core/validation';
26
+ import { required, email } from '@reformer/core/validators';
27
+
28
+ // async-правило: (value, { signal }) => Promise<ValidationIssue | null>.
29
+ // `signal` — AbortSignal устаревшего прогона: прокинь его в fetch, чтобы отменить in-flight.
30
+ const checkEmailUnique: AsyncRule<string> = async (value, { signal }) => {
31
+ if (!value) return null; // пусто = валидно (sync `required` отдельно)
32
+ // try/catch не нужен: отклонённый запрос раннер запишет сбоем правила (статус `error`,
33
+ // ошибка `ruleFailed` на поле), а отмену устаревшего прогона отличит сам по `signal`.
34
+ const res = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`, { signal });
35
+ const { available } = (await res.json()) as { available: boolean };
36
+ return available ? null : { code: 'email.taken' }; // текст кода — в словаре формы
37
+ };
38
+
39
+ // Схема — обычная функция над моделью; sync и async — разными операторами на одном сигнале.
40
+ const schema = defineValidation<{ email: string }>(({ model }) => {
41
+ validate(model.$.email, [required(), email()]); // sync-фабрики
42
+ validateAsync(model.$.email, [checkEmailUnique]); // async-правило
43
+ });
44
+
45
+ // В сборке — прогон уровнем формы:
46
+ const signup = createForm<{ email: string }>({ initial: { email: '' }, validation: schema });
47
+ const outcome = await signup.submission.submit(api.signup); // обработчик — только при valid
48
+
49
+ // Без сборки — раннер:
50
+ const model = createModel<{ email: string }>({ email: '' });
51
+ const ok = await validateModel(model, schema); // Promise<boolean>
52
+ ```
53
+
54
+ ### Как это исполняется
55
+
56
+ 1. Прогон (`bundle.validate()` / `validateModel(model, schema)`) открывает ambient-окно и
57
+ **синхронно** прогоняет схему:
58
+ `validate`/`validateAsync`/`cross` регистрируют правила своих полей.
59
+ 2. Sync-правила выполняются сразу; async-правила из `validateAsync` собираются и после закрытия
60
+ ambient-окна дожидаются параллельно (`Promise.all`) с прокинутым `AbortSignal`.
61
+ 3. Пока async-правила поля не завершились, его нода в состоянии `pending`
62
+ (`form.email.pending.value`, `useFormControl(form.email).pending`).
63
+ 4. Замечания разносятся по нодам формы, UI подсвечивает поле; поля, ставшие валидными, гасятся.
64
+ Отклонённое правило даёт на своём поле замечание `ruleFailed`.
65
+ 5. Итог — `ValidationResult` со статусом (`bundle.validate()`, `runValidation`) либо `boolean`
66
+ (`validateModel`: `true` только при `valid`; `severity:'warning'` не блокирует). Замечания
67
+ (`invalid`), сбой правила (`error`) и устаревший отменённый прогон (`cancelled`) — не `valid`.
68
+
69
+ Синхронного варианта у контракта нет: прогон всегда `async`.
70
+
71
+ ### Результат прогона и сбой правила
72
+
73
+ Когда нужно отличить «в форме замечания» от «проверить не удалось», бери полный результат —
74
+ `bundle.validate()` / `bundle.validateStep(id)` сборки, а для отправки — статус `SubmitResult`:
75
+
76
+ ```ts
77
+ const outcome = await bundle.submission.submit(api.send);
78
+ if (outcome.status === 'error') {
79
+ // outcome.validation.failures: [{ handle, error }] — какое правило и почему не вернуло результат
80
+ showToast('Не удалось проверить форму. Повторите попытку');
81
+ }
82
+ ```
83
+
84
+ Без сборки — `runValidation(model, schema)` (только сбор) и `applyValidationResult`:
85
+
86
+ ```ts
87
+ import { applyValidationResult, runValidation } from '@reformer/core/validation';
88
+
89
+ const result = await runValidation(model, schema); // только сбор: ни замечаний, ни pending на нодах
90
+ if (result.status === 'cancelled') return; // вытеснен более новым прогоном — не разносится
91
+ applyValidationResult(result, { touch: true }); // замечания (и ruleFailed) — на поля
92
+ if (result.status === 'valid') await send(model.get());
93
+ ```
94
+
95
+ Если сбой сети НЕ должен блокировать отправку (проверка необязательна), перехвати его в самом
96
+ правиле и верни `null` — это явное решение автора правила, а не поведение по умолчанию:
97
+
98
+ ```ts
99
+ const softCheck: AsyncRule<string> = async (value, { signal }) => {
100
+ try {
101
+ const res = await fetch(`/api/check?value=${encodeURIComponent(value)}`, { signal });
102
+ return (await res.json()).ok ? null : { code: 'rejected' };
103
+ } catch {
104
+ return null; // осознанно: недоступность сервиса проверку не проваливает
105
+ }
106
+ };
107
+ ```
108
+
109
+ ### Sync и async — два оператора
110
+
111
+ Разделение sync/async делается не полем схемы, а разными операторами на одном сигнале:
112
+ `validate(sig, [syncRules])` и `validateAsync(sig, [asyncRules])`. Раннер прогоняет sync-правила
113
+ инлайн, а async — дожидается. Держи async-правило дешёвым: ранний `if (!value) return null`
114
+ пропускает сетевой вызов, пока `required()`/формат ещё не пройдены.
115
+
116
+ ```ts
117
+ validate(model.$.inn, [required(), pattern(/^\d{12}$/)]); // формат — sync, мгновенно
118
+ validateAsync(model.$.inn, [checkInnInRegistry]); // обращение к API — отдельным оператором
119
+ ```
120
+
121
+ ### UI integration
122
+
123
+ Пока идут async-правила поля, раннер держит его ноду в `pending` — индикатор проверки поля
124
+ берётся из состояния ноды, свой флаг не нужен:
125
+
126
+ ```tsx
127
+ const { issues, pending, shouldShowIssues } = useFormControl(form.email);
128
+ const message = useIssueMessage(formMessages); // @reformer/core/react
129
+
130
+ return pending ? <Spinner /> : shouldShowIssues ? <Error text={message(issues[0])} /> : null;
131
+ ```
132
+
133
+ Индикатор прогона всей формы — сигнал `bundle.validating` сборки (`createForm`): он истинен,
134
+ пока идёт полный прогон либо прогон шага; `bundle.submission.submitting` — пока работает обработчик
135
+ отправки.
136
+
137
+ ### Debounce и отмена
138
+
139
+ - **Отмена устаревших — бесплатно.** Быстрый повторный прогон той же пары `(model, schema)`
140
+ отменяет предыдущий in-flight прогон через `AbortController`; его `AbortSignal` прокинут в
141
+ `validateAsync`-правила, поэтому `fetch(url, { signal })` рвётся сам. Ручной debounce для
142
+ КОРРЕКТНОСТИ не нужен — держи схему стабильным `const` (`defineValidation`), иначе отмена не
143
+ сматчит прогоны по идентичности.
144
+ - Валидация запускается on-demand (submit / шаг / через `revalidateWhen`) либо живой стратегией
145
+ сборки — `validation: { rules, strategy: 'change', debounce: 300 }` (см. `33-validation-strategy.md`):
146
+ у живой фазы debounce встроен.
147
+ - Если нужно дебаунсить дорогой async-валидатор относительно частых изменений одного поля — триггерь
148
+ прогон из поведения через `onChange`, который даёт debounce из коробки:
149
+
150
+ ```ts
151
+ import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
152
+ import { validateModel } from '@reformer/core/validation';
153
+
154
+ export const behavior = defineFormBehavior<{ username: string }>(({ model }) => {
155
+ onChange(model.$.username, () => void validateModel(model, schema), { debounce: 300 });
156
+ });
157
+ ```
158
+
159
+ Либо оборачивай в свой debounce колбэк `revalidateWhen([...], () => void validateModel(...))`.
160
+ - **Async cross-field** — инлайн `validateAsync`-правило, замыкающее `model` и читающее снимок
161
+ соседей `model.get()` до первого `await` (`AsyncRule` получает только `value` и `signal`;
162
+ `cross` — синхронный):
163
+
164
+ ```ts
165
+ validateAsync(model.$.email, [
166
+ async (email, { signal }) => {
167
+ const { username } = model.get(); // снапшот соседних полей до await
168
+ const res = await fetch(`/api/check?email=${email}&user=${username}`, { signal });
169
+ return (await res.json()).ok ? null : { code: 'conflict' };
170
+ },
171
+ ]);
172
+ ```
173
+
174
+ ### See also
175
+
176
+ - [27-revalidate-when.md](27-revalidate-when.md) — перезапуск `validateModel` по триггерам
177
+ - [29-async-preload.md](29-async-preload.md) — async preload данных при init формы
178
+ - [32-async-options-loading.md](32-async-options-loading.md) — `onChange` с debounce + AbortSignal