@elabs-ai/components-ui 4.1.0 → 4.2.0
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.
- package/dist/{chunk-JKPPTL63.js → chunk-FCH4ZN6G.js} +12 -3
- package/dist/chunk-FCH4ZN6G.js.map +1 -0
- package/dist/form.js +3 -3
- package/dist/form.js.map +1 -1
- package/dist/index.d.ts +126 -35
- package/dist/index.js +3346 -2079
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.js +10 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +3 -3
- package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
- package/src/__contract__/badge.contract.test.tsx +49 -0
- package/src/__contract__/bounded-number.contract.test.tsx +49 -0
- package/src/__contract__/button.contract.test.tsx +49 -0
- package/src/__contract__/color-picker.contract.test.tsx +49 -0
- package/src/__contract__/combobox.contract.test.tsx +49 -0
- package/src/__contract__/command-trigger.contract.test.tsx +49 -0
- package/src/__contract__/copyable-value.contract.test.tsx +49 -0
- package/src/__contract__/date-picker.contract.test.tsx +49 -0
- package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
- package/src/__contract__/empty-state.contract.test.tsx +49 -0
- package/src/__contract__/error-state.contract.test.tsx +49 -0
- package/src/__contract__/icon-button.contract.test.tsx +49 -0
- package/src/__contract__/input.contract.test.tsx +49 -0
- package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
- package/src/__contract__/list-editor.contract.test.tsx +49 -0
- package/src/__contract__/loading-state.contract.test.tsx +49 -0
- package/src/__contract__/metric-card.contract.test.tsx +49 -0
- package/src/__contract__/model-picker.contract.test.tsx +49 -0
- package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
- package/src/__contract__/number-input.contract.test.tsx +49 -0
- package/src/__contract__/rating.contract.test.tsx +49 -0
- package/src/__contract__/schema-form.contract.test.tsx +49 -0
- package/src/__contract__/section-header.contract.test.tsx +49 -0
- package/src/__contract__/segmented-field.contract.test.tsx +49 -0
- package/src/__contract__/slider-number.contract.test.tsx +49 -0
- package/src/__contract__/status-badge.contract.test.tsx +49 -0
- package/src/__contract__/tag-input.contract.test.tsx +49 -0
- package/src/__contract__/text.contract.test.tsx +49 -0
- package/src/__contract__/textarea.contract.test.tsx +49 -0
- package/src/__contract__/timeline.contract.test.tsx +49 -0
- package/src/__contract__/toggle.contract.test.tsx +49 -0
- package/src/__contract__/transfer.contract.test.tsx +49 -0
- package/src/__contract__/tree-select.contract.test.tsx +49 -0
- package/src/__contract__/tree.contract.test.tsx +49 -0
- package/src/__contract__/virtual-select.contract.test.tsx +49 -0
- package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
- package/src/components/accordion/accordion.tsx +13 -4
- package/src/components/alert/alert.tsx +11 -3
- package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
- package/src/components/alert-dialog/alert-dialog.tsx +28 -6
- package/src/components/app-shell/app-shell.test.tsx +99 -0
- package/src/components/app-shell/app-shell.tsx +29 -10
- package/src/components/attribution-panel/attribution-panel.tsx +2 -0
- package/src/components/attribution-panel/attributions.generated.ts +2 -2
- package/src/components/avatar/avatar.tsx +4 -1
- package/src/components/badge/badge.test.tsx +8 -0
- package/src/components/badge/badge.tsx +14 -4
- package/src/components/bento-grid/bento-grid.test.tsx +23 -2
- package/src/components/bento-grid/bento-grid.tsx +10 -0
- package/src/components/bounded-number/bounded-number.test.tsx +1 -1
- package/src/components/bounded-number/bounded-number.tsx +78 -75
- package/src/components/breadcrumb/breadcrumb.tsx +3 -2
- package/src/components/button/button.tsx +18 -12
- package/src/components/button-group/button-group.tsx +1 -1
- package/src/components/calendar/calendar.tsx +3 -2
- package/src/components/card/card.test.tsx +24 -2
- package/src/components/card/card.tsx +47 -6
- package/src/components/carousel/carousel.test.tsx +169 -0
- package/src/components/carousel/carousel.tsx +93 -32
- package/src/components/change-review/change-review.tsx +38 -29
- package/src/components/checkbox/checkbox.tsx +1 -0
- package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
- package/src/components/color-picker/color-picker.tsx +17 -11
- package/src/components/combobox/combobox.stories.tsx +10 -0
- package/src/components/combobox/combobox.test.tsx +54 -0
- package/src/components/combobox/combobox.tsx +25 -11
- package/src/components/command/command.test.tsx +15 -0
- package/src/components/command/command.tsx +8 -6
- package/src/components/context-menu/context-menu.tsx +16 -7
- package/src/components/context-rail/context-rail.tsx +4 -4
- package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
- package/src/components/copyable-value/copyable-value.test.tsx +28 -1
- package/src/components/copyable-value/copyable-value.tsx +12 -1
- package/src/components/date-picker/date-picker.stories.tsx +9 -0
- package/src/components/date-picker/date-picker.test.tsx +52 -0
- package/src/components/date-picker/date-picker.tsx +13 -15
- package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
- package/src/components/date-range-picker/date-range-picker.tsx +39 -28
- package/src/components/descriptions/descriptions.tsx +1 -1
- package/src/components/dialog/dialog.test.tsx +8 -3
- package/src/components/dialog/dialog.tsx +12 -4
- package/src/components/drawer/drawer.tsx +23 -6
- package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
- package/src/components/field/field.tsx +2 -0
- package/src/components/field-row/field-row.tsx +2 -0
- package/src/components/file-upload/file-upload.test.tsx +83 -10
- package/src/components/file-upload/file-upload.tsx +150 -28
- package/src/components/form/form.tsx +4 -2
- package/src/components/hover-card/hover-card.tsx +1 -0
- package/src/components/icon-button/icon-button.tsx +1 -1
- package/src/components/input/input.tsx +7 -3
- package/src/components/input-group/input-group.tsx +6 -6
- package/src/components/input-otp/input-otp.tsx +3 -1
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +2 -2
- package/src/components/label/label.tsx +1 -1
- package/src/components/list-editor/list-editor.test.tsx +24 -0
- package/src/components/list-editor/list-editor.tsx +126 -52
- package/src/components/locale-provider/locale-keys.test.ts +134 -0
- package/src/components/locale-provider/messages.ts +321 -0
- package/src/components/mention-input/mention-input.stories.tsx +3 -3
- package/src/components/mention-input/mention-input.test.tsx +51 -1
- package/src/components/mention-input/mention-input.tsx +58 -8
- package/src/components/menubar/menubar.tsx +16 -6
- package/src/components/nav-main/nav-main.tsx +7 -3
- package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
- package/src/components/nav-notifications/nav-notifications.tsx +11 -5
- package/src/components/nav-user/nav-user.tsx +5 -5
- package/src/components/navigation-menu/navigation-menu.tsx +16 -4
- package/src/components/number-input/number-input.stories.tsx +7 -3
- package/src/components/number-input/number-input.test.tsx +47 -0
- package/src/components/number-input/number-input.tsx +65 -24
- package/src/components/pagination/pagination.test.tsx +20 -1
- package/src/components/pagination/pagination.tsx +18 -14
- package/src/components/popover/popover.tsx +1 -0
- package/src/components/progress/progress.tsx +2 -0
- package/src/components/rating/rating.tsx +2 -0
- package/src/components/resizable/resizable.test.tsx +76 -0
- package/src/components/resizable/resizable.tsx +31 -5
- package/src/components/revision-timeline/revision-timeline.tsx +7 -2
- package/src/components/schema-form/schema-form-store.ts +506 -0
- package/src/components/schema-form/schema-form.test.tsx +122 -1
- package/src/components/schema-form/schema-form.tsx +163 -327
- package/src/components/section-header/section-header.tsx +2 -2
- package/src/components/select/select.tsx +7 -5
- package/src/components/separator/separator.tsx +1 -0
- package/src/components/sheet/sheet.test.tsx +39 -0
- package/src/components/sheet/sheet.tsx +40 -9
- package/src/components/side-dock/side-dock.test.tsx +26 -0
- package/src/components/side-dock/side-dock.tsx +15 -2
- package/src/components/sidebar/sidebar.test.tsx +35 -1
- package/src/components/sidebar/sidebar.tsx +20 -6
- package/src/components/skeleton/skeleton.tsx +1 -0
- package/src/components/slider-number/slider-number.stories.tsx +4 -1
- package/src/components/slider-number/slider-number.test.tsx +1 -1
- package/src/components/sonner/sonner.tsx +2 -0
- package/src/components/spinner/spinner.test.tsx +18 -0
- package/src/components/spinner/spinner.tsx +14 -4
- package/src/components/state-panel/state-panel.test.tsx +3 -3
- package/src/components/state-panel/state-panel.tsx +15 -8
- package/src/components/switch/switch.tsx +2 -0
- package/src/components/table/table.stories.tsx +3 -1
- package/src/components/table/table.test.tsx +9 -2
- package/src/components/table/table.tsx +18 -8
- package/src/components/tabs/index.ts +10 -1
- package/src/components/tabs/tabs.stories.tsx +48 -0
- package/src/components/tabs/tabs.test.tsx +67 -0
- package/src/components/tabs/tabs.tsx +175 -77
- package/src/components/tag-input/tag-input.tsx +4 -2
- package/src/components/team-switcher/team-switcher.tsx +3 -3
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
- package/src/components/theme-switcher/theme-switcher.tsx +134 -10
- package/src/components/timeline/timeline.tsx +0 -3
- package/src/components/toggle/toggle.tsx +7 -2
- package/src/components/toggle-group/toggle-group.tsx +2 -0
- package/src/components/tooltip/tooltip.tsx +2 -1
- package/src/components/top-nav/top-nav.tsx +1 -1
- package/src/components/transfer/transfer.tsx +35 -30
- package/src/components/tree/tree.test.tsx +41 -0
- package/src/components/tree/tree.tsx +77 -22
- package/src/components/tree/use-tree-keyboard.ts +37 -14
- package/src/components/tree-select/tree-select.stories.tsx +9 -0
- package/src/components/tree-select/tree-select.test.tsx +5 -0
- package/src/components/tree-select/tree-select.tsx +11 -14
- package/src/components/typography/prose.test.tsx +3 -2
- package/src/components/typography/prose.tsx +3 -3
- package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
- package/src/components/virtual-select/virtual-select.test.tsx +28 -2
- package/src/components/virtual-select/virtual-select.tsx +56 -18
- package/src/components/wizard/wizard.test.tsx +9 -0
- package/src/components/wizard/wizard.tsx +12 -4
- package/src/index.ts +5 -0
- package/src/lib/cn.ts +9 -0
- package/src/lib/csv.ts +48 -0
- package/src/lib/diff-rows.ts +2 -0
- package/src/lib/merge-refs.ts +2 -0
- package/src/lib/use-controllable-state.ts +47 -0
- package/src/lib/use-mobile.test.tsx +47 -0
- package/src/lib/use-mobile.ts +26 -10
- package/dist/chunk-JKPPTL63.js.map +0 -1
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* schema-form-store.ts — the per-field external store backing `SchemaForm`
|
|
5
|
+
* (perf review §3.4: "one context carries all values → every keystroke
|
|
6
|
+
* re-renders every field and re-runs isFieldVisible across the spec").
|
|
7
|
+
*
|
|
8
|
+
* One `SchemaFormStore` instance lives for a `SchemaFormProvider`'s whole
|
|
9
|
+
* lifetime (created once via a lazy `useState` initializer in
|
|
10
|
+
* `schema-form.tsx`). It owns `values`/`attempted` and derives
|
|
11
|
+
* `effectiveValues`/`errors` from them, exactly as the previous
|
|
12
|
+
* `useMemo`-based implementation did — the FORMULAS are unchanged (see
|
|
13
|
+
* `computeMergedValues`, ported byte-for-byte from the old `mergedValues`
|
|
14
|
+
* closure). What changes is how a component reads them:
|
|
15
|
+
*
|
|
16
|
+
* - `SchemaFormField` subscribes to its OWN `FieldSnapshot` via
|
|
17
|
+
* `useFieldSnapshot(name)` (a `useSyncExternalStore` selector). The
|
|
18
|
+
* snapshot is cached per field name and a fresh compute that is
|
|
19
|
+
* field-by-field equal to the cached one returns the CACHED object —
|
|
20
|
+
* `useSyncExternalStore`'s own `Object.is` bail-out then skips re-
|
|
21
|
+
* rendering that field when an unrelated field's keystroke fires the
|
|
22
|
+
* store's `emit()`. `SchemaFormField` is also wrapped in `memo()`, so a
|
|
23
|
+
* parent container re-rendering (see below) does not cascade into it
|
|
24
|
+
* either, as long as the `name`/`className`/`...props` it was given
|
|
25
|
+
* didn't change.
|
|
26
|
+
* - Containers that list CHILDREN by name (`SchemaFormFields`,
|
|
27
|
+
* `GroupTabsControl`'s per-branch content, `AdvancedGroupBranch`) read
|
|
28
|
+
* `useVisibleFieldNames(fields)` — a cached `string[]` that only changes
|
|
29
|
+
* reference when the VISIBLE SET actually changes, not on every
|
|
30
|
+
* keystroke — so typing in one field doesn't even cause its siblings'
|
|
31
|
+
* list container to reconcile new elements for them.
|
|
32
|
+
* - `useSchemaFormMeta()` covers the slow-changing, form-wide flags
|
|
33
|
+
* (`disabled`/`submitted`/`submitting`/`loading`/`error`/`attempted`/
|
|
34
|
+
* `spec`) as ONE cached object — stable across ordinary typing, so a
|
|
35
|
+
* component reading only these (e.g. `SchemaFormRoot`) doesn't re-render
|
|
36
|
+
* on every keystroke either.
|
|
37
|
+
* - `useEffectiveValues()` exposes the full merged/stripped values object
|
|
38
|
+
* with the SAME reference-stability contract the old `useMemo` gave
|
|
39
|
+
* `SchemaFormTestAction` (which compares it by `!==` to detect "the user
|
|
40
|
+
* edited while a test was in flight") — unaffected by this refactor.
|
|
41
|
+
*
|
|
42
|
+
* Every derived getter is a plain, synchronous function of the store's
|
|
43
|
+
* current `props`/`internalValues`/`attempted` — none of this maintains
|
|
44
|
+
* parallel state that could drift from `values`.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { useCallback, useSyncExternalStore, type ReactNode } from "react";
|
|
48
|
+
import {
|
|
49
|
+
collectValidatableFields,
|
|
50
|
+
isFieldVisible,
|
|
51
|
+
initialFormValues,
|
|
52
|
+
validateForm,
|
|
53
|
+
type FieldSpec,
|
|
54
|
+
type FormSubmitState,
|
|
55
|
+
type FormValue,
|
|
56
|
+
type FormValues,
|
|
57
|
+
type GroupFieldSpec,
|
|
58
|
+
type GroupItemSpec,
|
|
59
|
+
type NormalizedFormSpec,
|
|
60
|
+
} from "./schema-form-spec";
|
|
61
|
+
|
|
62
|
+
// ─── Pure helpers (spec + values → values), no React ───────────────────────
|
|
63
|
+
|
|
64
|
+
/** The effective value for a field (state value, else its default/empty). */
|
|
65
|
+
function effectiveValue(field: FieldSpec, values: FormValues): FormValue {
|
|
66
|
+
const v = values[field.name];
|
|
67
|
+
if (v !== undefined) return v;
|
|
68
|
+
if (field.type === "group") return field.default ?? field.groups[0]?.key;
|
|
69
|
+
if ("default" in field && field.default !== undefined) return field.default;
|
|
70
|
+
if (field.type === "boolean") return false;
|
|
71
|
+
if (field.type === "multi-enum" || field.type === "list") return [];
|
|
72
|
+
if (field.type === "key-value") return [];
|
|
73
|
+
if (field.type === "file") return [];
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Remove every field's key (recursively, including nested group branches) from `values`. */
|
|
78
|
+
function stripFields(fields: FieldSpec[], values: FormValues): void {
|
|
79
|
+
for (const field of fields) {
|
|
80
|
+
delete values[field.name];
|
|
81
|
+
if (field.type === "group") {
|
|
82
|
+
for (const group of field.groups) stripFields(group.fields, values);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The active branch of a `variant: "tabs"` group, given its own (possibly-just-computed) value. */
|
|
88
|
+
function activeTabBranch(field: GroupFieldSpec, activeKey: FormValue): GroupItemSpec | undefined {
|
|
89
|
+
const key = typeof activeKey === "string" ? activeKey : undefined;
|
|
90
|
+
return (
|
|
91
|
+
field.groups.find((g) => g.key === key) ??
|
|
92
|
+
field.groups.find((g) => g.key === field.default) ??
|
|
93
|
+
field.groups[0]
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `values` with every field's default/empty fallback applied and every
|
|
99
|
+
* currently-hidden field's subtree stripped. Ported byte-for-byte from the
|
|
100
|
+
* previous `SchemaFormProvider`'s `mergedValues` closure — see that
|
|
101
|
+
* implementation's history for the full reasoning on why hidden fields are
|
|
102
|
+
* stripped rather than merely skipped.
|
|
103
|
+
*/
|
|
104
|
+
function computeMergedValues(fields: FieldSpec[], base: FormValues): FormValues {
|
|
105
|
+
const merged: FormValues = { ...base };
|
|
106
|
+
const fill = (fs: FieldSpec[]) => {
|
|
107
|
+
for (const field of fs) {
|
|
108
|
+
if (!isFieldVisible(field, merged)) {
|
|
109
|
+
delete merged[field.name];
|
|
110
|
+
if (field.type === "group") {
|
|
111
|
+
for (const group of field.groups) stripFields(group.fields, merged);
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
merged[field.name] = effectiveValue(field, merged);
|
|
116
|
+
if (field.type !== "group") continue;
|
|
117
|
+
if (field.variant === "tabs") {
|
|
118
|
+
const active = activeTabBranch(field, merged[field.name]);
|
|
119
|
+
for (const group of field.groups) {
|
|
120
|
+
if (group === active) fill(group.fields);
|
|
121
|
+
else stripFields(group.fields, merged);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
for (const group of field.groups) fill(group.fields);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
fill(fields);
|
|
129
|
+
return merged;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Does any field in this branch (including nested group branches) currently have an error? */
|
|
133
|
+
function branchHasError(fields: FieldSpec[], errors: Record<string, string | null>): boolean {
|
|
134
|
+
for (const field of fields) {
|
|
135
|
+
if (errors[field.name]) return true;
|
|
136
|
+
if (field.type === "group") {
|
|
137
|
+
for (const group of field.groups) {
|
|
138
|
+
if (branchHasError(group.fields, errors)) return true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildFieldMap(fields: FieldSpec[]): Map<string, FieldSpec> {
|
|
146
|
+
const map = new Map<string, FieldSpec>();
|
|
147
|
+
const walk = (fs: FieldSpec[]) => {
|
|
148
|
+
for (const field of fs) {
|
|
149
|
+
map.set(field.name, field);
|
|
150
|
+
if (field.type === "group") {
|
|
151
|
+
for (const group of field.groups) walk(group.fields);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
walk(fields);
|
|
156
|
+
return map;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function sameNames(a: string[], b: string[]): boolean {
|
|
160
|
+
if (a.length !== b.length) return false;
|
|
161
|
+
for (let i = 0; i < a.length; i++) {
|
|
162
|
+
if (a[i] !== b[i]) return false;
|
|
163
|
+
}
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Store ──────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
export interface SchemaFormStoreProps {
|
|
170
|
+
spec: NormalizedFormSpec;
|
|
171
|
+
valuesProp: FormValues | undefined;
|
|
172
|
+
onChange?: (values: FormValues) => void;
|
|
173
|
+
onSubmit?: (state: FormSubmitState) => void;
|
|
174
|
+
disabled: boolean;
|
|
175
|
+
submitted: boolean;
|
|
176
|
+
submitting: boolean;
|
|
177
|
+
loading: boolean;
|
|
178
|
+
error: ReactNode;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface FieldSnapshot {
|
|
182
|
+
value: FormValue;
|
|
183
|
+
invalid: boolean;
|
|
184
|
+
errorText: string | null;
|
|
185
|
+
visible: boolean;
|
|
186
|
+
disabled: boolean;
|
|
187
|
+
readOnly: boolean;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface SchemaFormMeta {
|
|
191
|
+
spec: NormalizedFormSpec;
|
|
192
|
+
formId: string;
|
|
193
|
+
headingId: string;
|
|
194
|
+
disabled: boolean;
|
|
195
|
+
submitted: boolean;
|
|
196
|
+
submitting: boolean;
|
|
197
|
+
loading: boolean;
|
|
198
|
+
error: ReactNode;
|
|
199
|
+
attempted: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const EMPTY_ERRORS: Record<string, string | null> = {};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* `effectiveValue` mints a FRESH `[]` for an unset list/multi-enum/key-value/
|
|
206
|
+
* file field on every call. A controlled form whose `values` omit such a field
|
|
207
|
+
* would then never match its cached snapshot, so `getFieldSnapshot` would
|
|
208
|
+
* return a new object on every `useSyncExternalStore` read — an infinite
|
|
209
|
+
* re-render loop ("Maximum update depth exceeded"). Two empty arrays are the
|
|
210
|
+
* same value for rendering purposes.
|
|
211
|
+
*/
|
|
212
|
+
function sameFieldValue(a: FormValue, b: FormValue): boolean {
|
|
213
|
+
if (Object.is(a, b)) return true;
|
|
214
|
+
return Array.isArray(a) && Array.isArray(b) && a.length === 0 && b.length === 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function fieldSnapshotEqual(a: FieldSnapshot, b: FieldSnapshot): boolean {
|
|
218
|
+
return (
|
|
219
|
+
sameFieldValue(a.value, b.value) &&
|
|
220
|
+
a.invalid === b.invalid &&
|
|
221
|
+
a.errorText === b.errorText &&
|
|
222
|
+
a.visible === b.visible &&
|
|
223
|
+
a.disabled === b.disabled &&
|
|
224
|
+
a.readOnly === b.readOnly
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function metaEqual(a: SchemaFormMeta, b: SchemaFormMeta): boolean {
|
|
229
|
+
return (
|
|
230
|
+
a.spec === b.spec &&
|
|
231
|
+
a.formId === b.formId &&
|
|
232
|
+
a.headingId === b.headingId &&
|
|
233
|
+
a.disabled === b.disabled &&
|
|
234
|
+
a.submitted === b.submitted &&
|
|
235
|
+
a.submitting === b.submitting &&
|
|
236
|
+
a.loading === b.loading &&
|
|
237
|
+
a.error === b.error &&
|
|
238
|
+
a.attempted === b.attempted
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** See the module doc comment for the full design rationale. */
|
|
243
|
+
export class SchemaFormStore {
|
|
244
|
+
private props: SchemaFormStoreProps;
|
|
245
|
+
private internalValues: FormValues;
|
|
246
|
+
private attempted = false;
|
|
247
|
+
private readonly listeners = new Set<() => void>();
|
|
248
|
+
private readonly pendingFocusListeners = new Set<(name: string) => void>();
|
|
249
|
+
readonly formId: string;
|
|
250
|
+
readonly headingId: string;
|
|
251
|
+
|
|
252
|
+
private mergedCache: { base: FormValues; fields: FieldSpec[]; result: FormValues } | null = null;
|
|
253
|
+
private errorsCache: {
|
|
254
|
+
merged: FormValues;
|
|
255
|
+
fields: FieldSpec[];
|
|
256
|
+
attempted: boolean;
|
|
257
|
+
result: Record<string, string | null>;
|
|
258
|
+
} | null = null;
|
|
259
|
+
private metaCache: SchemaFormMeta | null = null;
|
|
260
|
+
private fieldMapCache: { fields: FieldSpec[]; map: Map<string, FieldSpec> } | null = null;
|
|
261
|
+
private readonly fieldSnapshotCache = new Map<string, FieldSnapshot>();
|
|
262
|
+
private readonly namesCache = new WeakMap<
|
|
263
|
+
FieldSpec[],
|
|
264
|
+
{ effective: FormValues; names: string[] }
|
|
265
|
+
>();
|
|
266
|
+
|
|
267
|
+
constructor(props: SchemaFormStoreProps, formId: string) {
|
|
268
|
+
this.props = props;
|
|
269
|
+
this.formId = formId;
|
|
270
|
+
this.headingId = `${formId}-title`;
|
|
271
|
+
this.internalValues = initialFormValues(props.spec.fields);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
275
|
+
this.listeners.add(listener);
|
|
276
|
+
return () => {
|
|
277
|
+
this.listeners.delete(listener);
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/** Fires once, with the field's own name, whenever an invalid submit should move focus to it. */
|
|
282
|
+
onPendingFocus(listener: (name: string) => void): () => void {
|
|
283
|
+
this.pendingFocusListeners.add(listener);
|
|
284
|
+
return () => {
|
|
285
|
+
this.pendingFocusListeners.delete(listener);
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private emit(): void {
|
|
290
|
+
for (const listener of this.listeners) listener();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Sync the latest render's props/closures. Called unconditionally on every
|
|
295
|
+
* `SchemaFormProvider` render, DURING that render (not an effect) — so a
|
|
296
|
+
* non-memoized descendant re-rendering in this SAME pass (`SchemaFormRoot`,
|
|
297
|
+
* `SchemaFormFields`, …) reads the fresh values via `getMeta`/
|
|
298
|
+
* `getFieldSnapshot`, not last render's. It deliberately does NOT call
|
|
299
|
+
* `emit()` itself — that would call an already-mounted, `memo`-wrapped
|
|
300
|
+
* `SchemaFormField`'s `useSyncExternalStore` listener synchronously WHILE
|
|
301
|
+
* `SchemaFormProvider` (a different component) is still rendering, which
|
|
302
|
+
* React explicitly disallows ("Cannot update a component while rendering a
|
|
303
|
+
* different component"). The caller instead reads the returned `changed`
|
|
304
|
+
* flag and calls `notify()` from a `useLayoutEffect` — i.e. after this
|
|
305
|
+
* whole render has committed — to wake any memoized subscriber that this
|
|
306
|
+
* render's own top-down reconciliation skipped over.
|
|
307
|
+
*/
|
|
308
|
+
syncProps(next: SchemaFormStoreProps): boolean {
|
|
309
|
+
const prev = this.props;
|
|
310
|
+
const formNameChanged = next.spec.formName !== prev.spec.formName;
|
|
311
|
+
this.props = next;
|
|
312
|
+
if (next.valuesProp === undefined && formNameChanged) {
|
|
313
|
+
this.internalValues = initialFormValues(next.spec.fields);
|
|
314
|
+
}
|
|
315
|
+
return (
|
|
316
|
+
formNameChanged ||
|
|
317
|
+
prev.spec !== next.spec ||
|
|
318
|
+
prev.valuesProp !== next.valuesProp ||
|
|
319
|
+
prev.disabled !== next.disabled ||
|
|
320
|
+
prev.submitted !== next.submitted ||
|
|
321
|
+
prev.submitting !== next.submitting ||
|
|
322
|
+
prev.loading !== next.loading ||
|
|
323
|
+
prev.error !== next.error
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Wakes every subscriber outside of any component's render phase (see `syncProps`). */
|
|
328
|
+
notify(): void {
|
|
329
|
+
this.emit();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private get isControlled(): boolean {
|
|
333
|
+
return this.props.valuesProp !== undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private getFieldMap(): Map<string, FieldSpec> {
|
|
337
|
+
const fields = this.props.spec.fields;
|
|
338
|
+
if (this.fieldMapCache && this.fieldMapCache.fields === fields) return this.fieldMapCache.map;
|
|
339
|
+
const map = buildFieldMap(fields);
|
|
340
|
+
this.fieldMapCache = { fields, map };
|
|
341
|
+
return map;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
getField(name: string): FieldSpec | undefined {
|
|
345
|
+
return this.getFieldMap().get(name);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
getValues(): FormValues {
|
|
349
|
+
return this.isControlled ? (this.props.valuesProp as FormValues) : this.internalValues;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The merged/stripped values every visibility check and submit resolves against. Reference-stable when nothing changed (SchemaFormTestAction relies on this to detect a stale in-flight test). */
|
|
353
|
+
getEffectiveValues(): FormValues {
|
|
354
|
+
const base = this.getValues();
|
|
355
|
+
const fields = this.props.spec.fields;
|
|
356
|
+
if (this.mergedCache && this.mergedCache.base === base && this.mergedCache.fields === fields) {
|
|
357
|
+
return this.mergedCache.result;
|
|
358
|
+
}
|
|
359
|
+
const result = computeMergedValues(fields, base);
|
|
360
|
+
this.mergedCache = { base, fields, result };
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Empty until the first submit attempt (`EMPTY_ERRORS`, a stable reference); live-updates after. */
|
|
365
|
+
getErrors(): Record<string, string | null> {
|
|
366
|
+
if (!this.attempted) return EMPTY_ERRORS;
|
|
367
|
+
const merged = this.getEffectiveValues();
|
|
368
|
+
const fields = this.props.spec.fields;
|
|
369
|
+
if (
|
|
370
|
+
this.errorsCache &&
|
|
371
|
+
this.errorsCache.merged === merged &&
|
|
372
|
+
this.errorsCache.fields === fields &&
|
|
373
|
+
this.errorsCache.attempted === this.attempted
|
|
374
|
+
) {
|
|
375
|
+
return this.errorsCache.result;
|
|
376
|
+
}
|
|
377
|
+
const result = validateForm(fields, merged);
|
|
378
|
+
this.errorsCache = { merged, fields, attempted: this.attempted, result };
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The slow-changing, form-wide flags — cached so ordinary typing never changes this object's reference. */
|
|
383
|
+
getMeta(): SchemaFormMeta {
|
|
384
|
+
const next: SchemaFormMeta = {
|
|
385
|
+
spec: this.props.spec,
|
|
386
|
+
formId: this.formId,
|
|
387
|
+
headingId: this.headingId,
|
|
388
|
+
disabled: this.props.disabled,
|
|
389
|
+
submitted: this.props.submitted,
|
|
390
|
+
submitting: this.props.submitting,
|
|
391
|
+
loading: this.props.loading,
|
|
392
|
+
error: this.props.error,
|
|
393
|
+
attempted: this.attempted,
|
|
394
|
+
};
|
|
395
|
+
if (this.metaCache && metaEqual(this.metaCache, next)) return this.metaCache;
|
|
396
|
+
this.metaCache = next;
|
|
397
|
+
return next;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** One field's reactive data. Cached per name — returns the SAME object when nothing about this field changed, so `useSyncExternalStore` bails out of re-rendering it. */
|
|
401
|
+
getFieldSnapshot(name: string): FieldSnapshot {
|
|
402
|
+
const field = this.getField(name);
|
|
403
|
+
const merged = this.getEffectiveValues();
|
|
404
|
+
const values = this.getValues();
|
|
405
|
+
const errors = this.getErrors();
|
|
406
|
+
const errorText = errors[name] ?? null;
|
|
407
|
+
const next: FieldSnapshot = {
|
|
408
|
+
value: field ? effectiveValue(field, values) : undefined,
|
|
409
|
+
invalid: Boolean(errorText),
|
|
410
|
+
errorText,
|
|
411
|
+
visible: field ? isFieldVisible(field, merged) : false,
|
|
412
|
+
disabled: this.props.disabled || this.props.submitted || this.props.submitting,
|
|
413
|
+
readOnly: this.props.submitted,
|
|
414
|
+
};
|
|
415
|
+
const prev = this.fieldSnapshotCache.get(name);
|
|
416
|
+
if (prev && fieldSnapshotEqual(prev, next)) return prev;
|
|
417
|
+
this.fieldSnapshotCache.set(name, next);
|
|
418
|
+
return next;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Names of `fields` that are currently visible (top-level filter of
|
|
423
|
+
* exactly that array — a `group`'s own branches call this again with
|
|
424
|
+
* their OWN `fields`, which is what lets a branch react to a nested
|
|
425
|
+
* `visibleWhen` independent of its ancestor `SchemaFormField`'s own
|
|
426
|
+
* re-render). Cached by array reference, so a caller whose visible SET
|
|
427
|
+
* didn't change gets back the SAME array across an unrelated keystroke.
|
|
428
|
+
*/
|
|
429
|
+
getVisibleNames(fields: FieldSpec[]): string[] {
|
|
430
|
+
const effective = this.getEffectiveValues();
|
|
431
|
+
const cached = this.namesCache.get(fields);
|
|
432
|
+
if (cached && cached.effective === effective) return cached.names;
|
|
433
|
+
const names = fields
|
|
434
|
+
.filter((field) => isFieldVisible(field, effective))
|
|
435
|
+
.map((field) => field.name);
|
|
436
|
+
const stable = cached && sameNames(cached.names, names) ? cached.names : names;
|
|
437
|
+
this.namesCache.set(fields, { effective, names: stable });
|
|
438
|
+
return stable;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
getBranchHasError(fields: FieldSpec[]): boolean {
|
|
442
|
+
return branchHasError(fields, this.getErrors());
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
setValue = (name: string, value: FormValue): void => {
|
|
446
|
+
const base = this.getValues();
|
|
447
|
+
const next: FormValues = { ...base, [name]: value };
|
|
448
|
+
if (!this.isControlled) this.internalValues = next;
|
|
449
|
+
this.props.onChange?.(next);
|
|
450
|
+
this.emit();
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
submit = (): void => {
|
|
454
|
+
this.attempted = true;
|
|
455
|
+
const merged = this.getEffectiveValues();
|
|
456
|
+
const errs = validateForm(this.props.spec.fields, merged);
|
|
457
|
+
const invalidNames = Object.keys(errs).filter((n) => errs[n]);
|
|
458
|
+
if (invalidNames.length > 0) {
|
|
459
|
+
const firstInvalid = collectValidatableFields(this.props.spec.fields, merged).find(
|
|
460
|
+
(f) => errs[f.name],
|
|
461
|
+
);
|
|
462
|
+
this.emit();
|
|
463
|
+
if (firstInvalid) {
|
|
464
|
+
for (const listener of this.pendingFocusListeners) listener(firstInvalid.name);
|
|
465
|
+
}
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
this.emit();
|
|
469
|
+
this.props.onSubmit?.({ formName: this.props.spec.formName, values: merged });
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
reset = (): void => {
|
|
473
|
+
this.attempted = false;
|
|
474
|
+
const seeded = initialFormValues(this.props.spec.fields);
|
|
475
|
+
if (!this.isControlled) this.internalValues = seeded;
|
|
476
|
+
this.props.onChange?.(seeded);
|
|
477
|
+
this.emit();
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ─── Hooks ──────────────────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
export function useSchemaFormMeta(store: SchemaFormStore): SchemaFormMeta {
|
|
484
|
+
const getSnapshot = useCallback(() => store.getMeta(), [store]);
|
|
485
|
+
return useSyncExternalStore(store.subscribe, getSnapshot);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function useFieldSnapshot(store: SchemaFormStore, name: string): FieldSnapshot {
|
|
489
|
+
const getSnapshot = useCallback(() => store.getFieldSnapshot(name), [store, name]);
|
|
490
|
+
return useSyncExternalStore(store.subscribe, getSnapshot);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function useEffectiveValues(store: SchemaFormStore): FormValues {
|
|
494
|
+
const getSnapshot = useCallback(() => store.getEffectiveValues(), [store]);
|
|
495
|
+
return useSyncExternalStore(store.subscribe, getSnapshot);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function useVisibleFieldNames(store: SchemaFormStore, fields: FieldSpec[]): string[] {
|
|
499
|
+
const getSnapshot = useCallback(() => store.getVisibleNames(fields), [store, fields]);
|
|
500
|
+
return useSyncExternalStore(store.subscribe, getSnapshot);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function useBranchHasError(store: SchemaFormStore, fields: FieldSpec[]): boolean {
|
|
504
|
+
const getSnapshot = useCallback(() => store.getBranchHasError(fields), [store, fields]);
|
|
505
|
+
return useSyncExternalStore(store.subscribe, getSnapshot);
|
|
506
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { Profiler, type ProfilerOnRenderCallback } from "react";
|
|
1
2
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
3
4
|
import userEvent from "@testing-library/user-event";
|
|
4
5
|
import {
|
|
5
6
|
SchemaForm,
|
|
@@ -99,6 +100,13 @@ const defaultedConditionalSpec: FormSpec = {
|
|
|
99
100
|
],
|
|
100
101
|
};
|
|
101
102
|
|
|
103
|
+
/** `SchemaFormProvider.spec` is a `NormalizedFormSpec` — normalize a plain `FormSpec` fixture once, up front, so tests can pass it directly. */
|
|
104
|
+
function normalizeSpecOrThrow(spec: FormSpec) {
|
|
105
|
+
const result = normalizeFormSpec(spec);
|
|
106
|
+
if (!result.ok) throw new Error(`expected spec to normalize: ${result.reason}`);
|
|
107
|
+
return result.spec;
|
|
108
|
+
}
|
|
109
|
+
|
|
102
110
|
describe("SchemaForm — rendering", () => {
|
|
103
111
|
it("renders the title, fields, and a submit button", () => {
|
|
104
112
|
render(<SchemaForm spec={simpleSpec} />);
|
|
@@ -390,6 +398,22 @@ describe("SchemaForm — validation (submit) + focus-first-invalid", () => {
|
|
|
390
398
|
expect(screen.getAllByText("This file type isn't accepted.").length).toBeGreaterThan(0);
|
|
391
399
|
});
|
|
392
400
|
|
|
401
|
+
it("keeps a DROPPED wrong-type file visible with its inline error instead of silently dropping it", async () => {
|
|
402
|
+
render(
|
|
403
|
+
<SchemaForm
|
|
404
|
+
spec={{
|
|
405
|
+
formName: "f",
|
|
406
|
+
fields: [{ type: "file", name: "creds", label: "Credentials", accept: ".json" }],
|
|
407
|
+
}}
|
|
408
|
+
/>,
|
|
409
|
+
);
|
|
410
|
+
const dropzone = document.querySelector("[data-slot='file-upload-dropzone']")!;
|
|
411
|
+
const badFile = new File(["x"], "creds.exe", { type: "application/x-msdownload" });
|
|
412
|
+
fireEvent.drop(dropzone, { dataTransfer: { files: [badFile] } });
|
|
413
|
+
expect(await screen.findByText("creds.exe")).toBeInTheDocument();
|
|
414
|
+
expect(screen.getAllByText("This file type isn't accepted.").length).toBeGreaterThan(0);
|
|
415
|
+
});
|
|
416
|
+
|
|
393
417
|
it("auto-reveals a collapsed advanced-group branch that holds the first invalid field, and focuses it", async () => {
|
|
394
418
|
const user = userEvent.setup();
|
|
395
419
|
const onSubmit = vi.fn();
|
|
@@ -496,6 +520,23 @@ describe("SchemaForm — file field is controlled", () => {
|
|
|
496
520
|
});
|
|
497
521
|
|
|
498
522
|
describe("SchemaForm — controlled + submitted", () => {
|
|
523
|
+
it("renders a controlled form whose `values` omit its array-valued fields without a render loop", () => {
|
|
524
|
+
// An unset file/list field resolves to a fresh `[]` on every snapshot read;
|
|
525
|
+
// unless that counts as unchanged, `useSyncExternalStore` loops forever.
|
|
526
|
+
render(
|
|
527
|
+
<SchemaForm
|
|
528
|
+
spec={{
|
|
529
|
+
formName: "f",
|
|
530
|
+
fields: [
|
|
531
|
+
{ type: "file", name: "creds", label: "Credentials" },
|
|
532
|
+
{ type: "list", name: "tags", label: "Tags" },
|
|
533
|
+
],
|
|
534
|
+
}}
|
|
535
|
+
values={{}}
|
|
536
|
+
/>,
|
|
537
|
+
);
|
|
538
|
+
expect(screen.getByText("Credentials")).toBeInTheDocument();
|
|
539
|
+
});
|
|
499
540
|
it("reflects controlled values and calls onChange", async () => {
|
|
500
541
|
const user = userEvent.setup();
|
|
501
542
|
const onChange = vi.fn();
|
|
@@ -898,3 +939,83 @@ describe("SchemaFormTestAction", () => {
|
|
|
898
939
|
expect(onTest).not.toHaveBeenCalled();
|
|
899
940
|
});
|
|
900
941
|
});
|
|
942
|
+
|
|
943
|
+
// Perf review §3.4: "one context carries all values → every keystroke
|
|
944
|
+
// re-renders every field and re-runs isFieldVisible across the spec". Each
|
|
945
|
+
// `SchemaFormField` gets its OWN `<Profiler>` (React's own render-counting
|
|
946
|
+
// primitive — no new dependency) so a "commit" of one field can be told
|
|
947
|
+
// apart from a genuine re-render of another: `Profiler`'s `onRender` fires
|
|
948
|
+
// once per commit for every Profiler whose subtree was part of that commit,
|
|
949
|
+
// but `SchemaFormField` is wrapped in `memo` and reads its own value via a
|
|
950
|
+
// per-field `useSyncExternalStore` snapshot — a keystroke in one field must
|
|
951
|
+
// not change the CACHED snapshot object identity for any other field, so its
|
|
952
|
+
// memo bails and its own nested Profiler never re-fires.
|
|
953
|
+
describe("SchemaForm — per-field subscription (#review 3.4: one context re-renders every field)", () => {
|
|
954
|
+
function renderCounts() {
|
|
955
|
+
const counts: Record<string, number> = {};
|
|
956
|
+
const onRender: ProfilerOnRenderCallback = (id) => {
|
|
957
|
+
counts[id] = (counts[id] ?? 0) + 1;
|
|
958
|
+
};
|
|
959
|
+
return { counts, onRender };
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
it("typing in one field does not re-render a sibling field", async () => {
|
|
963
|
+
const user = userEvent.setup();
|
|
964
|
+
const { counts, onRender } = renderCounts();
|
|
965
|
+
render(
|
|
966
|
+
<SchemaFormProvider spec={normalizeSpecOrThrow(simpleSpec)}>
|
|
967
|
+
<SchemaFormRoot>
|
|
968
|
+
<Profiler id="name" onRender={onRender}>
|
|
969
|
+
<SchemaFormField name="name" />
|
|
970
|
+
</Profiler>
|
|
971
|
+
<Profiler id="email" onRender={onRender}>
|
|
972
|
+
<SchemaFormField name="email" />
|
|
973
|
+
</Profiler>
|
|
974
|
+
</SchemaFormRoot>
|
|
975
|
+
</SchemaFormProvider>,
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
// Both fields commit once on mount.
|
|
979
|
+
expect(counts.name).toBe(1);
|
|
980
|
+
expect(counts.email).toBe(1);
|
|
981
|
+
|
|
982
|
+
await user.type(screen.getByLabelText(/Name/), "Ada");
|
|
983
|
+
|
|
984
|
+
// The typed-in field re-renders once per keystroke; its untouched
|
|
985
|
+
// sibling's `useFieldSnapshot` bails (same cached snapshot reference),
|
|
986
|
+
// so its `memo`-wrapped `SchemaFormField` — and the `Profiler` around
|
|
987
|
+
// it — never fires again.
|
|
988
|
+
expect(counts.name).toBe(1 + "Ada".length);
|
|
989
|
+
expect(counts.email).toBe(1);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it("typing in one group branch's field does not re-render a field from a sibling branch of the same group", async () => {
|
|
993
|
+
const user = userEvent.setup();
|
|
994
|
+
const { counts, onRender } = renderCounts();
|
|
995
|
+
render(
|
|
996
|
+
<SchemaFormProvider spec={normalizeSpecOrThrow(groupSpec)}>
|
|
997
|
+
<SchemaFormRoot>
|
|
998
|
+
<Profiler id="apiKey" onRender={onRender}>
|
|
999
|
+
<SchemaFormField name="apiKey" />
|
|
1000
|
+
</Profiler>
|
|
1001
|
+
<Profiler id="clientId" onRender={onRender}>
|
|
1002
|
+
<SchemaFormField name="clientId" />
|
|
1003
|
+
</Profiler>
|
|
1004
|
+
</SchemaFormRoot>
|
|
1005
|
+
</SchemaFormProvider>,
|
|
1006
|
+
);
|
|
1007
|
+
|
|
1008
|
+
expect(counts.apiKey).toBe(1);
|
|
1009
|
+
// `apiKey` and `clientId` live in two different branches of the SAME
|
|
1010
|
+
// `group` field (`store.getField` resolves a nested field by name via
|
|
1011
|
+
// its recursive field map) — the per-field snapshot cache is keyed by
|
|
1012
|
+
// name, not by which branch a field happens to sit in, so this still
|
|
1013
|
+
// isolates them from each other.
|
|
1014
|
+
expect(counts.clientId).toBe(1);
|
|
1015
|
+
|
|
1016
|
+
await user.type(screen.getByLabelText(/API key/), "sk-live");
|
|
1017
|
+
|
|
1018
|
+
expect(counts.apiKey).toBe(1 + "sk-live".length);
|
|
1019
|
+
expect(counts.clientId).toBe(1);
|
|
1020
|
+
});
|
|
1021
|
+
});
|