attaform 0.21.1 → 0.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/chunks/dev-key-collision-warnings.cjs +1 -1
  2. package/dist/chunks/dev-key-collision-warnings.mjs +1 -1
  3. package/dist/chunks/devtools.cjs +1 -1
  4. package/dist/chunks/devtools.mjs +1 -1
  5. package/dist/chunks/fingerprint2.cjs +1 -1
  6. package/dist/chunks/fingerprint2.mjs +1 -1
  7. package/dist/chunks/indexeddb.cjs +1 -1
  8. package/dist/chunks/indexeddb.mjs +1 -1
  9. package/dist/chunks/local-storage.cjs +1 -1
  10. package/dist/chunks/local-storage.mjs +1 -1
  11. package/dist/chunks/multi-tab-sync.cjs +2 -2
  12. package/dist/chunks/multi-tab-sync.mjs +2 -2
  13. package/dist/chunks/session-storage.cjs +1 -1
  14. package/dist/chunks/session-storage.mjs +1 -1
  15. package/dist/chunks/wire-persistence.cjs +2 -2
  16. package/dist/chunks/wire-persistence.mjs +2 -2
  17. package/dist/index.cjs +37 -24
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +20 -18
  20. package/dist/index.d.mts +20 -18
  21. package/dist/index.d.ts +20 -18
  22. package/dist/index.mjs +38 -25
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/nuxt.d.cts +1 -1
  25. package/dist/nuxt.d.mts +1 -1
  26. package/dist/nuxt.d.ts +1 -1
  27. package/dist/runtime/components/AttaformDevtoolsPanel.vue +396 -216
  28. package/dist/runtime/components/DevtoolsValueTree.vue +176 -114
  29. package/dist/runtime/plugins/attaform.cjs +2 -2
  30. package/dist/runtime/plugins/attaform.mjs +2 -2
  31. package/dist/shared/{attaform.D32WwKk6.cjs → attaform.B5LNzqQh.cjs} +243 -352
  32. package/dist/shared/attaform.B5LNzqQh.cjs.map +1 -0
  33. package/dist/shared/{attaform.S-pYLSo4.cjs → attaform.BBDIKtKY.cjs} +13 -16
  34. package/dist/shared/attaform.BBDIKtKY.cjs.map +1 -0
  35. package/dist/shared/{attaform.Bv7dRDWK.d.ts → attaform.BCcrLApm.d.mts} +54 -63
  36. package/dist/shared/{attaform.BupwXkj_.mjs → attaform.BFWb6hDk.mjs} +29 -23
  37. package/dist/shared/attaform.BFWb6hDk.mjs.map +1 -0
  38. package/dist/shared/{attaform.BWfliRIK.d.cts → attaform.BGf_J22U.d.ts} +54 -63
  39. package/dist/shared/{attaform.DMEP_ENr.mjs → attaform.BVeLgfEh.mjs} +14 -17
  40. package/dist/shared/attaform.BVeLgfEh.mjs.map +1 -0
  41. package/dist/shared/{attaform.MtrpT6Ki.d.ts → attaform.BYgioWLF.d.ts} +1 -1
  42. package/dist/shared/{attaform.NQ8mybyW.d.mts → attaform.BkjJfMvJ.d.cts} +54 -63
  43. package/dist/shared/{attaform.Duecg2NO.d.mts → attaform.BoY6RZUl.d.cts} +1 -1
  44. package/dist/shared/{attaform.Bq5sX7TF.cjs → attaform.BwLp9KM7.cjs} +2 -2
  45. package/dist/shared/{attaform.Bq5sX7TF.cjs.map → attaform.BwLp9KM7.cjs.map} +1 -1
  46. package/dist/shared/{attaform.CICFZ1iS.cjs → attaform.BwrowMp2.cjs} +19 -45
  47. package/dist/shared/attaform.BwrowMp2.cjs.map +1 -0
  48. package/dist/shared/{attaform.Y1ZGhM4k.mjs → attaform.C41gjp-a.mjs} +2 -2
  49. package/dist/shared/{attaform.Y1ZGhM4k.mjs.map → attaform.C41gjp-a.mjs.map} +1 -1
  50. package/dist/shared/{attaform.BM6YD9kZ.cjs → attaform.CR6wGvNu.cjs} +29 -23
  51. package/dist/shared/attaform.CR6wGvNu.cjs.map +1 -0
  52. package/dist/shared/{attaform.DR6RmxWZ.mjs → attaform.CTheKoTc.mjs} +341 -221
  53. package/dist/shared/attaform.CTheKoTc.mjs.map +1 -0
  54. package/dist/shared/{attaform.BSkvn43g.cjs → attaform.CcnF1AKJ.cjs} +4 -4
  55. package/dist/shared/attaform.CcnF1AKJ.cjs.map +1 -0
  56. package/dist/shared/{attaform.FudOcHaa.d.cts → attaform.CnEl--PF.d.mts} +1 -1
  57. package/dist/shared/{attaform.DozgVlCE.mjs → attaform.CrD73S4m.mjs} +4 -4
  58. package/dist/shared/attaform.CrD73S4m.mjs.map +1 -0
  59. package/dist/shared/{attaform.ClXwitZj.cjs → attaform.D2ZuIOCf.cjs} +347 -226
  60. package/dist/shared/attaform.D2ZuIOCf.cjs.map +1 -0
  61. package/dist/shared/{attaform.Be8NZG9M.mjs → attaform.D6GYGshL.mjs} +19 -45
  62. package/dist/shared/attaform.D6GYGshL.mjs.map +1 -0
  63. package/dist/shared/{attaform.pmtahXKy.mjs → attaform.DP-u7_tk.mjs} +243 -352
  64. package/dist/shared/attaform.DP-u7_tk.mjs.map +1 -0
  65. package/dist/shared/{attaform.D0dWZsJt.d.mts → attaform.ory-3WhV.d.cts} +128 -129
  66. package/dist/shared/{attaform.D0dWZsJt.d.cts → attaform.ory-3WhV.d.mts} +128 -129
  67. package/dist/shared/{attaform.D0dWZsJt.d.ts → attaform.ory-3WhV.d.ts} +128 -129
  68. package/dist/transforms.cjs +1 -1
  69. package/dist/transforms.mjs +1 -1
  70. package/dist/vite.cjs +1 -1
  71. package/dist/vite.mjs +1 -1
  72. package/dist/zod-v3.cjs +3 -4
  73. package/dist/zod-v3.cjs.map +1 -1
  74. package/dist/zod-v3.d.cts +4 -4
  75. package/dist/zod-v3.d.mts +4 -4
  76. package/dist/zod-v3.d.ts +4 -4
  77. package/dist/zod-v3.mjs +2 -3
  78. package/dist/zod-v3.mjs.map +1 -1
  79. package/dist/zod-v4.cjs +3 -4
  80. package/dist/zod-v4.cjs.map +1 -1
  81. package/dist/zod-v4.d.cts +4 -4
  82. package/dist/zod-v4.d.mts +4 -4
  83. package/dist/zod-v4.d.ts +4 -4
  84. package/dist/zod-v4.mjs +2 -3
  85. package/dist/zod-v4.mjs.map +1 -1
  86. package/dist/zod.cjs +6 -6
  87. package/dist/zod.cjs.map +1 -1
  88. package/dist/zod.d.cts +31 -22
  89. package/dist/zod.d.mts +31 -22
  90. package/dist/zod.d.ts +31 -22
  91. package/dist/zod.mjs +5 -6
  92. package/dist/zod.mjs.map +1 -1
  93. package/package.json +3 -11
  94. package/dist/shared/attaform.BM6YD9kZ.cjs.map +0 -1
  95. package/dist/shared/attaform.BSkvn43g.cjs.map +0 -1
  96. package/dist/shared/attaform.Be8NZG9M.mjs.map +0 -1
  97. package/dist/shared/attaform.BupwXkj_.mjs.map +0 -1
  98. package/dist/shared/attaform.CICFZ1iS.cjs.map +0 -1
  99. package/dist/shared/attaform.ClXwitZj.cjs.map +0 -1
  100. package/dist/shared/attaform.D32WwKk6.cjs.map +0 -1
  101. package/dist/shared/attaform.DMEP_ENr.mjs.map +0 -1
  102. package/dist/shared/attaform.DR6RmxWZ.mjs.map +0 -1
  103. package/dist/shared/attaform.DozgVlCE.mjs.map +0 -1
  104. package/dist/shared/attaform.S-pYLSo4.cjs.map +0 -1
  105. package/dist/shared/attaform.pmtahXKy.mjs.map +0 -1
  106. package/dist/shared/{attaform.DSD85fHb.d.cts → attaform.nf83TIR5.d.cts} +10 -10
  107. package/dist/shared/{attaform.DSD85fHb.d.mts → attaform.nf83TIR5.d.mts} +10 -10
  108. package/dist/shared/{attaform.DSD85fHb.d.ts → attaform.nf83TIR5.d.ts} +10 -10
@@ -1,230 +1,410 @@
1
- <script setup>
2
- import { computed, onUnmounted, ref, watch } from "vue";
3
- import { canonicalizePath } from "attaform";
4
- import DevtoolsValueTree from "./DevtoolsValueTree.vue";
5
- const props = defineProps({
6
- bridge: { type: Object, required: true }
7
- });
8
- const updateTick = ref(0);
9
- const registry = computed(() => props.bridge.registry);
10
- const formEntries = computed(() => {
11
- void updateTick.value;
12
- return Array.from(registry.value.forms.entries());
13
- });
14
- const selectedFormKey = ref(null);
15
- const activeKey = computed(() => {
16
- if (selectedFormKey.value !== null && registry.value.forms.has(selectedFormKey.value)) {
17
- return selectedFormKey.value;
18
- }
19
- return formEntries.value[0]?.[0] ?? null;
20
- });
21
- const activeForm = computed(() => {
22
- const key = activeKey.value;
23
- return key !== null ? registry.value.forms.get(key) ?? null : null;
24
- });
25
- const formValueView = computed(() => {
26
- void updateTick.value;
27
- const form = activeForm.value;
28
- if (form === null) return null;
29
- return form.form.value;
30
- });
31
- const schemaErrorRows = computed(() => {
32
- void updateTick.value;
33
- const form = activeForm.value;
34
- if (form === null) return [];
35
- return Array.from(form.schemaErrors.entries());
36
- });
37
- const userErrorRows = computed(() => {
38
- void updateTick.value;
39
- const form = activeForm.value;
40
- if (form === null) return [];
41
- return Array.from(form.userErrors.entries());
42
- });
43
- const aggregates = computed(() => {
44
- void updateTick.value;
45
- const form = activeForm.value;
46
- if (form === null) return null;
47
- return {
48
- submitting: form.submitting.value,
49
- submissionAttempts: form.submissionAttempts.value,
50
- submitError: form.submitError.value,
51
- activeValidations: form.activeValidations.value
52
- };
53
- });
54
- function selectForm(key) {
55
- selectedFormKey.value = key;
56
- }
57
- function humanizePathKey(key) {
58
- try {
59
- const parsed = JSON.parse(key);
60
- if (Array.isArray(parsed)) {
61
- return parsed.map((seg) => String(seg)).join(".");
1
+ <script setup lang="ts">
2
+ import { computed, onUnmounted, ref, watch } from 'vue'
3
+ // Imports route through the public `attaform` entry so the published
4
+ // `.vue` file (shipped via mkdist under `dist/runtime/components/`)
5
+ // can resolve through the consumer's node_modules → `dist/index.mjs`,
6
+ // rather than through brittle relative paths into the rollup-bundled
7
+ // shared chunks (which mkdist doesn't co-locate). Type-only imports of
8
+ // internal types stay relative — they're erased at compile time and
9
+ // don't need to resolve at the consumer's runtime.
10
+ import { type AttaformDevtoolsBridge, canonicalizePath, type Segment } from 'attaform'
11
+ import type { FormStore } from '../core/create-form-store'
12
+ import type { GenericForm } from '../types/types-core'
13
+ import type { FormKey } from '../types/types-api'
14
+ import DevtoolsValueTree from './DevtoolsValueTree.vue'
15
+
16
+ const props = defineProps<{
17
+ bridge: AttaformDevtoolsBridge
18
+ }>()
19
+
20
+ // Cross-iframe reactivity bridge. Vue's reactivity system is module-
21
+ // scoped the host's `@vue/reactivity` instance and the panel's are
22
+ // different copies of the same module, each with its own targetMap.
23
+ // When the panel reads `host.form.value` in a `computed`, the proxy's
24
+ // get trap runs in the host's tracking context (the function lives
25
+ // there) but `getCurrentEffect()` returns nothing because the panel's
26
+ // active effect is in a different runtime. The dependency never
27
+ // registers, and the computed never re-evaluates on host mutations.
28
+ //
29
+ // Workaround: a tick ref that all data-fetching computeds depend on.
30
+ // We bump the tick on every host event we DO have a callback for
31
+ // (`onFormChange` / `onSubmitSuccess` / `onReset`), plus a 250ms
32
+ // polling fallback for state that changes outside those events (user
33
+ // errors via `setFieldErrors*`, submit-lifecycle flags). The panel's
34
+ // own reactivity then re-evaluates everything in one pass — cheap
35
+ // because the underlying reads are direct property accesses.
36
+ const updateTick = ref(0)
37
+
38
+ const registry = computed(() => props.bridge.registry)
39
+ const formEntries = computed(() => {
40
+ // Touch the tick so the form list refreshes when new forms register
41
+ // (the registry's reactive Map updates wouldn't notify us otherwise).
42
+ void updateTick.value
43
+ return Array.from(registry.value.forms.entries())
44
+ })
45
+
46
+ // Explicit selection. `null` means "auto-pick first available form".
47
+ const selectedFormKey = ref<FormKey | null>(null)
48
+
49
+ const activeKey = computed<FormKey | null>(() => {
50
+ if (selectedFormKey.value !== null && registry.value.forms.has(selectedFormKey.value)) {
51
+ return selectedFormKey.value
52
+ }
53
+ return formEntries.value[0]?.[0] ?? null
54
+ })
55
+
56
+ const activeForm = computed(() => {
57
+ const key = activeKey.value
58
+ return key !== null ? (registry.value.forms.get(key) ?? null) : null
59
+ })
60
+
61
+ const formValueView = computed(() => {
62
+ void updateTick.value
63
+ const form = activeForm.value
64
+ if (form === null) return null
65
+ // Devtools is dev-only; render raw values. Consumers concerned about
66
+ // screen-share leaks should close the panel before sharing, the same
67
+ // way they'd hide their browser DevTools console.
68
+ return form.form.value
69
+ })
70
+
71
+ const schemaErrorRows = computed(() => {
72
+ void updateTick.value
73
+ const form = activeForm.value
74
+ if (form === null) return []
75
+ return Array.from(form.schemaErrors.entries())
76
+ })
77
+
78
+ const userErrorRows = computed(() => {
79
+ void updateTick.value
80
+ const form = activeForm.value
81
+ if (form === null) return []
82
+ return Array.from(form.userErrors.entries())
83
+ })
84
+
85
+ const aggregates = computed(() => {
86
+ void updateTick.value
87
+ const form = activeForm.value
88
+ if (form === null) return null
89
+ return {
90
+ submitting: form.submitting.value,
91
+ submissionAttempts: form.submissionAttempts.value,
92
+ submitError: form.submitError.value,
93
+ activeValidations: form.activeValidations.value,
62
94
  }
63
- } catch {
95
+ })
96
+
97
+ function selectForm(key: FormKey): void {
98
+ selectedFormKey.value = key
64
99
  }
65
- return key;
66
- }
67
- function fmt(v) {
68
- if (v === null) return "null";
69
- if (v === void 0) return "undefined";
70
- if (typeof v === "string") return v;
71
- if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") {
72
- return String(v);
100
+
101
+ /**
102
+ * Convert a canonical PathKey (e.g. `'["users",0,"name"]'`) back to a
103
+ * readable dotted form (`users.0.name`) for the error table. Falls
104
+ * back to the raw key if it's not a JSON-array shape.
105
+ */
106
+ function humanizePathKey(key: string): string {
107
+ try {
108
+ const parsed = JSON.parse(key) as unknown[]
109
+ if (Array.isArray(parsed)) {
110
+ return parsed.map((seg) => String(seg)).join('.')
111
+ }
112
+ } catch {
113
+ // not JSON, render as-is
114
+ }
115
+ return key
73
116
  }
74
- try {
75
- return JSON.stringify(v);
76
- } catch {
77
- return String(v);
117
+
118
+ /**
119
+ * Render a JS value as a debug-friendly string with no masking —
120
+ * `null` / `undefined` show as their literal names, booleans and
121
+ * numbers as-is, strings bare (no surrounding quotes), everything
122
+ * else JSON-stringified. Devtools is for inspecting state, not for
123
+ * pretty-printing it; the user-author sees the actual runtime
124
+ * shape.
125
+ */
126
+ function fmt(v: unknown): string {
127
+ if (v === null) return 'null'
128
+ if (v === undefined) return 'undefined'
129
+ if (typeof v === 'string') return v
130
+ if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'bigint') {
131
+ return String(v)
132
+ }
133
+ try {
134
+ return JSON.stringify(v)
135
+ } catch {
136
+ return String(v)
137
+ }
78
138
  }
79
- }
80
- const selectedPath = ref(null);
81
- const selectedKey = computed(
82
- () => selectedPath.value === null ? null : JSON.stringify(selectedPath.value)
83
- );
84
- function selectPath(path) {
85
- const key = JSON.stringify(path);
86
- if (selectedKey.value === key) {
87
- selectedPath.value = null;
88
- } else {
89
- selectedPath.value = path;
139
+
140
+ /**
141
+ * Commit a leaf edit from the value tree. Mirrors the exact flow the
142
+ * Vue DevTools `editInspectorState` handler uses in
143
+ * `src/runtime/core/devtools.ts` so both surfaces have identical
144
+ * semantics:
145
+ * 1. canonicalize the structured path
146
+ * 2. refuse sensitive-name paths (the tree already gates the UI,
147
+ * but a defensive check here keeps the contract honest)
148
+ * 3. derive the persist flag from the path's element opt-ins
149
+ * 4. call setValueAtPath with the same write-meta the bound input
150
+ * would have produced
151
+ */
152
+ // Field-state inspector. Click any key in the Form value tree to
153
+ // "select" that path; the Field state section below resolves
154
+ // `form.fields(path)` for it. Click the same key again to deselect.
155
+ const selectedPath = ref<ReadonlyArray<string | number> | null>(null)
156
+ const selectedKey = computed(() =>
157
+ selectedPath.value === null ? null : JSON.stringify(selectedPath.value)
158
+ )
159
+
160
+ function selectPath(path: ReadonlyArray<string | number>): void {
161
+ const key = JSON.stringify(path)
162
+ if (selectedKey.value === key) {
163
+ selectedPath.value = null
164
+ } else {
165
+ selectedPath.value = path
166
+ }
90
167
  }
91
- }
92
- const selectedFieldState = computed(() => {
93
- void updateTick.value;
94
- const form = activeForm.value;
95
- const path = selectedPath.value;
96
- if (form === null || path === null) return null;
97
- try {
98
- const { key: canonicalKey } = canonicalizePath(path);
99
- const record = form.fields.get(canonicalKey) ?? null;
100
- let value = form.form.value;
101
- for (const seg of path) {
102
- if (value === null || typeof value !== "object") {
103
- value = void 0;
104
- break;
168
+
169
+ /**
170
+ * Raw field data at the selected path, composed from `FormStore`
171
+ * primitives. The callable `form.fields(path)` proxy lives on the
172
+ * public `useForm` return, not on `FormStore` — the bridge exposes
173
+ * the store, so we synthesise the same data from `fields.get(key)`
174
+ * + the error Maps + an inline value walk.
175
+ *
176
+ * Returns the raw `FieldRecord` (updatedAt / focused / blurred /
177
+ * touched / connected) rather than the wrapped `FieldState`
178
+ * surface. Sufficient for inspection the full aggregated
179
+ * FieldState would require either lifting the surface-proxy into
180
+ * `FormStore` (architectural change) or rebuilding the aggregation
181
+ * walker in the panel (duplication).
182
+ */
183
+ const selectedFieldState = computed<{
184
+ record: {
185
+ updatedAt: string | null
186
+ connected: boolean
187
+ focused: boolean | null
188
+ blurred: boolean | null
189
+ touched: boolean | null
190
+ } | null
191
+ value: unknown
192
+ errors: ReadonlyArray<{ message: string; code: string }>
193
+ schemaErrorCount: number
194
+ userErrorCount: number
195
+ } | null>(() => {
196
+ void updateTick.value
197
+ const form = activeForm.value
198
+ const path = selectedPath.value
199
+ if (form === null || path === null) return null
200
+ try {
201
+ const { key: canonicalKey } = canonicalizePath(path as readonly Segment[])
202
+ const record =
203
+ (form.fields.get(canonicalKey) as
204
+ | {
205
+ updatedAt: string | null
206
+ connected: boolean
207
+ focused: boolean | null
208
+ blurred: boolean | null
209
+ touched: boolean | null
210
+ }
211
+ | undefined) ?? null
212
+
213
+ // Inline path walk (avoids importing path-walker through the
214
+ // bridge — it lives in the host's shared chunk).
215
+ let value: unknown = form.form.value
216
+ for (const seg of path) {
217
+ if (value === null || typeof value !== 'object') {
218
+ value = undefined
219
+ break
220
+ }
221
+ value = (value as Record<string | number, unknown>)[seg]
222
+ }
223
+
224
+ const schemaEntries =
225
+ (form.schemaErrors.get(canonicalKey) as
226
+ | ReadonlyArray<{ message: string; code: string }>
227
+ | undefined) ?? []
228
+ const userEntries =
229
+ (form.userErrors.get(canonicalKey) as
230
+ | ReadonlyArray<{ message: string; code: string }>
231
+ | undefined) ?? []
232
+
233
+ return {
234
+ record,
235
+ value,
236
+ errors: [...schemaEntries, ...userEntries],
237
+ schemaErrorCount: schemaEntries.length,
238
+ userErrorCount: userEntries.length,
105
239
  }
106
- value = value[seg];
240
+ } catch (err) {
241
+ console.error('[attaform devtools] field-state lookup failed', { path, err })
242
+ return null
107
243
  }
108
- const schemaEntries = form.schemaErrors.get(canonicalKey) ?? [];
109
- const userEntries = form.userErrors.get(canonicalKey) ?? [];
110
- return {
111
- record,
112
- value,
113
- errors: [...schemaEntries, ...userEntries],
114
- schemaErrorCount: schemaEntries.length,
115
- userErrorCount: userEntries.length
116
- };
117
- } catch (err) {
118
- console.error("[attaform devtools] field-state lookup failed", { path, err });
119
- return null;
244
+ })
245
+
246
+ function humanizeSelectedPath(): string {
247
+ const path = selectedPath.value
248
+ if (path === null || path.length === 0) return '(root)'
249
+ return path.map((seg) => String(seg)).join('.')
120
250
  }
121
- });
122
- function humanizeSelectedPath() {
123
- const path = selectedPath.value;
124
- if (path === null || path.length === 0) return "(root)";
125
- return path.map((seg) => String(seg)).join(".");
126
- }
127
- function selectedValueView() {
128
- const fs = selectedFieldState.value;
129
- if (fs === null) return null;
130
- return fs.value;
131
- }
132
- function handleEdit(rawPath, next) {
133
- const form = activeForm.value;
134
- if (form === null) return;
135
- try {
136
- const { segments: canonicalPath, key: canonicalKey } = canonicalizePath(
137
- rawPath
138
- );
139
- form.setValueAtPath(canonicalPath, next, {
140
- persist: form.persistOptIns.hasAnyOptInForPath(canonicalKey)
141
- });
142
- } catch (err) {
143
- console.error("[attaform devtools] edit failed", { rawPath, next, err });
251
+
252
+ function selectedValueView(): unknown {
253
+ const fs = selectedFieldState.value
254
+ if (fs === null) return null
255
+ return fs.value
144
256
  }
145
- }
146
- const MAX_TIMELINE_EVENTS = 200;
147
- const events = ref([]);
148
- const expandedEventId = ref(null);
149
- let eventIdCounter = 0;
150
- function pushEvent(seed) {
151
- const entry = { id: ++eventIdCounter, ...seed };
152
- const next = [entry, ...events.value];
153
- if (next.length > MAX_TIMELINE_EVENTS) next.length = MAX_TIMELINE_EVENTS;
154
- events.value = next;
155
- }
156
- function clearTimeline() {
157
- events.value = [];
158
- expandedEventId.value = null;
159
- }
160
- function toggleEvent(id) {
161
- expandedEventId.value = expandedEventId.value === id ? null : id;
162
- }
163
- function formatTime(time) {
164
- const d = new Date(time);
165
- const hh = String(d.getHours()).padStart(2, "0");
166
- const mm = String(d.getMinutes()).padStart(2, "0");
167
- const ss = String(d.getSeconds()).padStart(2, "0");
168
- const ms = String(d.getMilliseconds()).padStart(3, "0");
169
- return `${hh}:${mm}:${ss}.${ms}`;
170
- }
171
- const subscribers = /* @__PURE__ */ new Map();
172
- function subscribeForm(key, form) {
173
- if (subscribers.has(key)) return;
174
- const captureValue = () => {
257
+
258
+ function handleEdit(rawPath: ReadonlyArray<string | number>, next: unknown): void {
259
+ const form = activeForm.value
260
+ if (form === null) return
175
261
  try {
176
- return structuredClone(form.form.value);
177
- } catch {
178
- return form.form.value;
179
- }
180
- };
181
- const unsubChange = form.onFormChange(() => {
182
- pushEvent({ type: "form.change", formKey: key, time: Date.now(), value: captureValue() });
183
- updateTick.value++;
184
- });
185
- const unsubSubmit = form.onSubmitSuccess(() => {
186
- pushEvent({ type: "submit.success", formKey: key, time: Date.now(), value: captureValue() });
187
- updateTick.value++;
188
- });
189
- const unsubReset = form.onReset(() => {
190
- pushEvent({ type: "reset", formKey: key, time: Date.now(), value: captureValue() });
191
- updateTick.value++;
192
- });
193
- subscribers.set(key, () => {
194
- unsubChange();
195
- unsubSubmit();
196
- unsubReset();
197
- });
198
- }
199
- watch(
200
- formEntries,
201
- (entries) => {
202
- const liveKeys = new Set(entries.map(([k]) => k));
203
- for (const [key, form] of entries) {
204
- if (!subscribers.has(key)) subscribeForm(key, form);
262
+ const { segments: canonicalPath, key: canonicalKey } = canonicalizePath(
263
+ rawPath as readonly Segment[]
264
+ )
265
+ form.setValueAtPath(canonicalPath, next, {
266
+ persist: form.persistOptIns.hasAnyOptInForPath(canonicalKey),
267
+ })
268
+ // The host's setValueAtPath fires `onFormChange` listeners, which
269
+ // bumps our updateTick (see subscribeForm) — that refreshes the
270
+ // panel's view of the new value on the next microtask.
271
+ } catch (err) {
272
+ // Surface cross-iframe write failures (e.g., type-instance checks
273
+ // tripping over panel-vs-host Array constructors) so we don't
274
+ // silently swallow them. Devtools-only path, so console.error is
275
+ // appropriate.
276
+
277
+ console.error('[attaform devtools] edit failed', { rawPath, next, err })
205
278
  }
206
- for (const [key, unsub] of subscribers) {
207
- if (!liveKeys.has(key)) {
208
- unsub();
209
- subscribers.delete(key);
279
+ }
280
+
281
+ // Timeline log ----------------------------------------------------------
282
+
283
+ type TimelineEventType = 'form.change' | 'submit.success' | 'reset'
284
+
285
+ interface TimelineEvent {
286
+ id: number
287
+ type: TimelineEventType
288
+ formKey: FormKey
289
+ time: number
290
+ value: unknown
291
+ }
292
+
293
+ /**
294
+ * Hard cap on the in-memory event log. Sized for a debugging session,
295
+ * not an audit log — older events fall off the back when capacity
296
+ * fills. Tunable later if real consumers ask for more.
297
+ */
298
+ const MAX_TIMELINE_EVENTS = 200
299
+ const events = ref<TimelineEvent[]>([])
300
+ const expandedEventId = ref<number | null>(null)
301
+ let eventIdCounter = 0
302
+
303
+ function pushEvent(seed: Omit<TimelineEvent, 'id'>): void {
304
+ const entry: TimelineEvent = { id: ++eventIdCounter, ...seed }
305
+ const next = [entry, ...events.value]
306
+ if (next.length > MAX_TIMELINE_EVENTS) next.length = MAX_TIMELINE_EVENTS
307
+ events.value = next
308
+ }
309
+
310
+ function clearTimeline(): void {
311
+ events.value = []
312
+ expandedEventId.value = null
313
+ }
314
+
315
+ function toggleEvent(id: number): void {
316
+ expandedEventId.value = expandedEventId.value === id ? null : id
317
+ }
318
+
319
+ function formatTime(time: number): string {
320
+ const d = new Date(time)
321
+ const hh = String(d.getHours()).padStart(2, '0')
322
+ const mm = String(d.getMinutes()).padStart(2, '0')
323
+ const ss = String(d.getSeconds()).padStart(2, '0')
324
+ const ms = String(d.getMilliseconds()).padStart(3, '0')
325
+ return `${hh}:${mm}:${ss}.${ms}`
326
+ }
327
+
328
+ // Per-form subscriber bookkeeping. Re-scoped whenever the registry's
329
+ // form set changes so newly-registered forms wire up automatically and
330
+ // evicted forms drop their subscribers (no leaks across HMR reloads).
331
+ const subscribers = new Map<FormKey, () => void>()
332
+
333
+ function subscribeForm(key: FormKey, form: FormStore<GenericForm>): void {
334
+ if (subscribers.has(key)) return
335
+ // Deep-clone the form value at the moment of fire — FormStore
336
+ // mutates form data in place, so a stored reference would update
337
+ // every existing timeline entry whenever the form changed again
338
+ // ("type a, delete a, both timeline events show the empty value"
339
+ // bug). `structuredClone` also crosses the iframe-realm boundary,
340
+ // landing a panel-native plain object instead of the host's
341
+ // reactive proxy.
342
+ const captureValue = (): unknown => {
343
+ try {
344
+ return structuredClone(form.form.value)
345
+ } catch {
346
+ // Non-cloneable values (functions, Symbols, Vue refs, etc.) —
347
+ // fall back to the live reference. Worst case: that one entry
348
+ // still shows the current state, same as before this fix.
349
+ return form.form.value
210
350
  }
211
351
  }
212
- },
213
- { immediate: true }
214
- );
215
- const POLL_INTERVAL_MS = 120;
216
- const pollHandle = window.setInterval(() => {
217
- updateTick.value++;
218
- }, POLL_INTERVAL_MS);
219
- onUnmounted(() => {
220
- window.clearInterval(pollHandle);
221
- for (const unsub of subscribers.values()) unsub();
222
- subscribers.clear();
223
- });
352
+ const unsubChange = form.onFormChange(() => {
353
+ pushEvent({ type: 'form.change', formKey: key, time: Date.now(), value: captureValue() })
354
+ updateTick.value++
355
+ })
356
+ const unsubSubmit = form.onSubmitSuccess(() => {
357
+ pushEvent({ type: 'submit.success', formKey: key, time: Date.now(), value: captureValue() })
358
+ updateTick.value++
359
+ })
360
+ const unsubReset = form.onReset(() => {
361
+ pushEvent({ type: 'reset', formKey: key, time: Date.now(), value: captureValue() })
362
+ updateTick.value++
363
+ })
364
+ subscribers.set(key, () => {
365
+ unsubChange()
366
+ unsubSubmit()
367
+ unsubReset()
368
+ })
369
+ }
370
+
371
+ watch(
372
+ formEntries,
373
+ (entries) => {
374
+ const liveKeys = new Set(entries.map(([k]) => k))
375
+ for (const [key, form] of entries) {
376
+ if (!subscribers.has(key)) subscribeForm(key, form as FormStore<GenericForm>)
377
+ }
378
+ for (const [key, unsub] of subscribers) {
379
+ if (!liveKeys.has(key)) {
380
+ unsub()
381
+ subscribers.delete(key)
382
+ }
383
+ }
384
+ },
385
+ { immediate: true }
386
+ )
387
+
388
+ // Polling fallback for state that changes outside the `onFormChange` /
389
+ // `onSubmitSuccess` / `onReset` event surface — user errors injected
390
+ // via `setFieldErrors*`, submit-lifecycle flags between events, or
391
+ // new forms registered in the host's registry. 120ms is faster than
392
+ // a human can notice between an input event and a visible panel
393
+ // refresh; gas-cost is negligible (one ref-bump every 120ms).
394
+ const POLL_INTERVAL_MS = 120
395
+ const pollHandle = window.setInterval(() => {
396
+ updateTick.value++
397
+ }, POLL_INTERVAL_MS)
398
+
399
+ onUnmounted(() => {
400
+ window.clearInterval(pollHandle)
401
+ for (const unsub of subscribers.values()) unsub()
402
+ subscribers.clear()
403
+ })
224
404
  </script>
225
405
 
226
406
  <template>
227
- <div class="atf-panel">
407
+ <div class="atf-panel">
228
408
  <header class="atf-header">
229
409
  <div class="atf-brand">
230
410
  <svg
@@ -401,7 +581,7 @@ onUnmounted(() => {
401
581
  <h2 class="atf-section-title">
402
582
  Timeline
403
583
  <span v-if="events.length" class="atf-badge atf-badge-neutral">
404
- {{ events.length }}{{ events.length === MAX_TIMELINE_EVENTS ? "+" : "" }}
584
+ {{ events.length }}{{ events.length === MAX_TIMELINE_EVENTS ? '+' : '' }}
405
585
  </span>
406
586
  <button
407
587
  v-if="events.length"
@@ -423,16 +603,16 @@ onUnmounted(() => {
423
603
  :key="event.id"
424
604
  class="atf-timeline-entry"
425
605
  :class="[
426
- `atf-timeline-${event.type.split('.')[0]}`,
427
- { expanded: expandedEventId === event.id }
428
- ]"
606
+ `atf-timeline-${event.type.split('.')[0]}`,
607
+ { expanded: expandedEventId === event.id },
608
+ ]"
429
609
  >
430
610
  <div class="atf-timeline-row" @click="toggleEvent(event.id)">
431
611
  <span class="atf-timeline-time">{{ formatTime(event.time) }}</span>
432
612
  <span class="atf-timeline-type">{{ event.type }}</span>
433
613
  <span class="atf-timeline-form">{{ event.formKey }}</span>
434
614
  <span class="atf-timeline-caret">
435
- {{ expandedEventId === event.id ? "\u2212" : "+" }}
615
+ {{ expandedEventId === event.id ? '−' : '+' }}
436
616
  </span>
437
617
  </div>
438
618
  <div v-if="expandedEventId === event.id" class="atf-timeline-detail">