@pilotiq/pilotiq 0.10.0 → 0.11.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.
@@ -82,8 +82,137 @@ export interface FormCollabBinding {
82
82
  getTextBinding?(name: string): TextBinding | null
83
83
  /** Cleanup hook called when the form unmounts. */
84
84
  destroy(): void
85
+
86
+ // ── Phase F.5 — Repeater / Builder row-identity surface (all optional) ──
87
+
88
+ /**
89
+ * Add a row to a Repeater or Builder field. `rowId` is the stable
90
+ * `__id` the renderer already mints (UUID for new rows / DB PK for
91
+ * relationship-backed rows) — the binding indexes the row's CRDT
92
+ * surface by this id so concurrent inserts from peers both survive.
93
+ *
94
+ * Idempotent — calling with a `rowId` that already exists in the
95
+ * Repeater's array is a no-op. `initial` may carry pre-filled values
96
+ * for the new row (e.g. the inner fields' `default()` from schema
97
+ * resolution); the binding seeds them onto the row's storage under
98
+ * the same idempotent posture as top-level `set` (first writer wins
99
+ * across concurrent first-mounters).
100
+ *
101
+ * Renderer call sites: `RepeaterInput.addRow()` + `BuilderInput.addBlock()`.
102
+ * When absent, F.5 row-level CRDT degrades to v1 behaviour (whole-array
103
+ * LWW under the top-level Y.Map).
104
+ */
105
+ addRow?(arrayName: string, rowId: string, initial: Record<string, unknown>): void
106
+
107
+ /**
108
+ * Remove a row from a Repeater/Builder field by stable id. Idempotent
109
+ * — a missing `rowId` is a no-op. Triggered by the renderer's row
110
+ * delete button.
111
+ */
112
+ removeRow?(arrayName: string, rowId: string): void
113
+
114
+ /**
115
+ * Apply a new row order. `newOrder` is the full list of row ids in
116
+ * their final positions; the binding computes the minimal CRDT move
117
+ * sequence and applies it inside a single transaction (peers see one
118
+ * coalesced update, not N intermediate states).
119
+ *
120
+ * Called by drag-and-drop / up-down move handlers in
121
+ * `RepeaterInput` + `BuilderInput`.
122
+ */
123
+ reorderRows?(arrayName: string, newOrder: string[]): void
124
+
125
+ /**
126
+ * Write a single field on a row. Replaces the dotted-path `set` for
127
+ * row leaves (`tags.0.label` → `setRow('tags', rowId, 'label', value)`).
128
+ *
129
+ * Binding routes by allowlist same as top-level `set`: text-shaped
130
+ * fields go through the row's `TextBinding` when a `Y.Text` exists
131
+ * (see `getRowTextBinding`); non-text fields land on the row's
132
+ * scalar field-map under LWW.
133
+ *
134
+ * `FormStateProvider` calls this on every local edit when both a
135
+ * dotted-path name is being written AND `setRow` is implemented;
136
+ * otherwise the v1 skip-on-dot path runs.
137
+ */
138
+ setRow?(arrayName: string, rowId: string, fieldName: string, value: unknown): void
139
+
140
+ /**
141
+ * Per-row text-CRDT handle. Composes with F.6's `BoundTextInput`:
142
+ * the renderer asks for one of these when a row leaf is text-shaped
143
+ * AND not opted out via `.collab(false)`, then threads it through
144
+ * the existing `TextBinding` plumbing inside the row.
145
+ *
146
+ * Returns `null` for non-text fields, fields opted out via collab,
147
+ * rows not yet present in the binding's index (e.g. before
148
+ * `addRow` has propagated), or when the binding doesn't yet
149
+ * implement per-row text CRDT (deferred to F.5c).
150
+ */
151
+ getRowTextBinding?(arrayName: string, rowId: string, fieldName: string): TextBinding | null
152
+
153
+ /**
154
+ * Subscribe to row-lifecycle events for a Repeater/Builder array.
155
+ * Fires on remote add / remove / move; the renderer reconciles its
156
+ * `rows` state by `__id`. Local mutations fire too — the renderer
157
+ * deduplicates by checking whether the event's `rowId` matches one
158
+ * it just dispatched itself (the binding's roundtrip + the
159
+ * renderer's optimistic update converge).
160
+ */
161
+ subscribeRows?(arrayName: string, fn: (event: RowsEvent) => void): () => void
162
+ }
163
+
164
+ /**
165
+ * Phase F.5 — array-scoped imperative API exposed to Repeater / Builder
166
+ * renderers through `useRowBinding(arrayName)`. Each method's first arg
167
+ * (`arrayName`) is pre-bound by the hook so call sites read naturally.
168
+ *
169
+ * Returned by the hook only when the active binding implements all four
170
+ * F.5 row methods (`addRow + removeRow + reorderRows`); partial impls
171
+ * read as null so renderers can do a single existence check before
172
+ * proceeding. `setRow` is intentionally NOT exposed here — it's invoked
173
+ * implicitly via `FormStateProvider.setValue`'s dotted-path routing,
174
+ * so renderers never need to touch it directly.
175
+ */
176
+ export interface RowBindingApi {
177
+ /** Register a new row in the array's CRDT surface. `initial` may
178
+ * carry pre-seeded values (e.g. clone of a sibling); pass `{}` (or
179
+ * omit) for empty rows. Idempotent under existing rowId. */
180
+ add(rowId: string, initial?: Record<string, unknown>): void
181
+ /** Remove the row with the given id. Idempotent for unknown ids. */
182
+ remove(rowId: string): void
183
+ /** Replace the array's row order. `newOrder` is the full list of row
184
+ * ids in their post-reorder positions; the binding emits the minimal
185
+ * CRDT move sequence in one transaction so peers observe a single
186
+ * coalesced update. */
187
+ reorder(newOrder: string[]): void
188
+ /** Subscribe to remote row-lifecycle events on this array. Returns
189
+ * an unsubscribe fn the renderer's `useEffect` cleanup hangs on.
190
+ * Local mutations may also surface here (Yjs observers fire on
191
+ * local transactions); the renderer is expected to dedupe by
192
+ * rowId presence in its current state. Optional on the underlying
193
+ * contract — `null` when the binding lacks `subscribeRows`. */
194
+ subscribe(fn: (event: RowsEvent) => void): () => void
85
195
  }
86
196
 
197
+ /**
198
+ * Phase F.5 — row-lifecycle event surfaced through `subscribeRows`.
199
+ *
200
+ * - `add` — a new row was inserted at `index`. `values` carries
201
+ * the row's seeded field values (mirrors `addRow.initial`).
202
+ * - `remove` — a row was removed; `index` is its position at the
203
+ * moment of removal.
204
+ * - `move` — a row shifted positions. `from` and `to` are 0-based
205
+ * indices in the post-reorder layout (i.e. the row at
206
+ * position `from` ended up at `to`).
207
+ *
208
+ * Pilotiq core stays Yjs-free — the binding impl in `@pilotiq-pro/collab`
209
+ * translates `Y.Array<Y.Map>` `observe` deltas into these events.
210
+ */
211
+ export type RowsEvent =
212
+ | { kind: 'add'; rowId: string; index: number; values: Record<string, unknown> }
213
+ | { kind: 'remove'; rowId: string; index: number }
214
+ | { kind: 'move'; rowId: string; from: number; to: number }
215
+
87
216
  export interface FormCollabBindingFactoryArgs {
88
217
  /** Active collab room — provides `ydoc`, `provider`, `user`. Opaque to pilotiq core. */
89
218
  room: CollabRoom
@@ -10,9 +10,14 @@ import React, {
10
10
  import type { ElementMeta } from '../schema/Element.js'
11
11
  import {
12
12
  collectFieldDefaults,
13
+ collectRowArrayFieldNames,
14
+ collectRowTextLeavesByArray,
13
15
  findFieldMeta,
14
16
  parseFormDataToNested,
17
+ parseRowFieldPath,
15
18
  readNestedValue,
19
+ routeBindingWrite,
20
+ rowIdAtIndex,
16
21
  writeNestedValue,
17
22
  } from './formStateHelpers.js'
18
23
  import { runJsHandler } from './fieldJsHandler.js'
@@ -21,6 +26,7 @@ import { useCollabRoom } from './CollabRoomContext.js'
21
26
  import {
22
27
  getFormCollabBinding,
23
28
  type FormCollabBinding,
29
+ type RowBindingApi,
24
30
  type TextBinding,
25
31
  } from './FormCollabBindingRegistry.js'
26
32
 
@@ -51,6 +57,29 @@ export interface FormStateApi {
51
57
  * non-null answers, so a `Map.get()` hit means the binding has opted
52
58
  * this field into the character-level path. */
53
59
  textBindings: ReadonlyMap<string, TextBinding> | null
60
+ /** Phase F.5 — per-Repeater/Builder row-array bindings. `null` outside a
61
+ * collab room or when the binding doesn't implement F.5 row methods.
62
+ * Each entry's API methods are pre-bound to the array name so renderers
63
+ * call `.add(rowId, initial)` rather than `binding.addRow(name, …)`. */
64
+ rowBindings: ReadonlyMap<string, RowBindingApi> | null
65
+ /**
66
+ * Phase F.5c — per-array set of inner-field names that should route
67
+ * through `Y.Text` (character-level CRDT) instead of row-level Y.Map
68
+ * LWW. Built from a single meta walk at binding mount. Read by
69
+ * `useFieldState(dottedName).textBinding` to decide whether to call
70
+ * `getRowTextBinding`. Sparse — only arrays with at least one
71
+ * text-shaped row leaf appear; absence on a key means "no text leaves
72
+ * in this Repeater/Builder".
73
+ */
74
+ rowTextLeaves: ReadonlyMap<string, ReadonlySet<string>> | null
75
+ /**
76
+ * Phase F.5c — resolve a per-row `TextBinding`. Pre-bound to the
77
+ * active F.5 binding so consumers don't reach for `bindingRef`
78
+ * directly. Returns `null` when no binding implements F.5c OR the
79
+ * row+field doesn't qualify (renderer caller should fall back to
80
+ * `defaultValue` like a non-collab form).
81
+ */
82
+ getRowTextBinding: ((arrayName: string, rowId: string, fieldName: string) => TextBinding | null) | null
54
83
  }
55
84
 
56
85
  const FormStateContext = createContext<FormStateApi | null>(null)
@@ -71,19 +100,6 @@ export function useFormState(): FormStateApi | null {
71
100
  */
72
101
  export const FormIdContext = createContext<string>('')
73
102
 
74
- /**
75
- * Phase F2 — returns `true` iff the named field has explicitly opted out
76
- * of realtime collab via `Field.collab(false)`. Sparse meta — absent =
77
- * inherit the panel default (collab on). Walks the form meta tree the
78
- * same way `findFieldMeta` does; cheap because it only runs on the
79
- * per-write path (already a hot path, but every check is one map
80
- * lookup + one boolean compare).
81
- */
82
- function fieldOptsOutOfCollab(formMeta: ElementMeta, name: string): boolean {
83
- const meta = findFieldMeta(formMeta, name) as { collab?: boolean } | undefined
84
- return meta?.collab === false
85
- }
86
-
87
103
  export interface UseFieldStateResult {
88
104
  /** True when the field is inside a controlled form (live fields enabled).
89
105
  * Renderers should fall back to their `defaultValue` path when false.
@@ -115,6 +131,26 @@ export interface UseFieldStateResult {
115
131
  textBinding: TextBinding | null
116
132
  }
117
133
 
134
+ /**
135
+ * Phase F.5 — return the row-array CRDT API for a Repeater/Builder field.
136
+ * Returns `null` when:
137
+ *
138
+ * - No `FormStateProvider` is mounted (e.g. uncontrolled form path).
139
+ * - No `<RecordCollabRoom>` is up-tree (no binding registered).
140
+ * - The active binding doesn't implement F.5's row methods.
141
+ * - The named field opted out via `.collab(false)` (skipped at meta walk).
142
+ * - The named field isn't a Repeater/Builder.
143
+ *
144
+ * RepeaterInput + BuilderInput call this once per render and proceed
145
+ * with the v1 local-only behaviour when null. The returned API methods
146
+ * are pre-bound to the array name so consumers don't repeat it.
147
+ */
148
+ export function useRowBinding(arrayName: string): RowBindingApi | null {
149
+ const ctx = useContext(FormStateContext)
150
+ if (!ctx?.rowBindings) return null
151
+ return ctx.rowBindings.get(arrayName) ?? null
152
+ }
153
+
118
154
  /** Per-field accessor. Inside a `FormStateProvider` it returns the controlled
119
155
  * value + setter + live trigger; outside, it returns sentinels and callers
120
156
  * should fall back to `defaultValue` (uncontrolled inputs). */
@@ -141,14 +177,35 @@ export function useFieldState(name: string): UseFieldStateResult {
141
177
  triggerLive: (valueOverride?: unknown) => ctx.triggerLive(name, valueOverride),
142
178
  pending: ctx.fieldStatus(name) === 'pending',
143
179
  errors: ctx.errors[name] ?? [],
144
- // Phase F.6 — dotted-path inner-Repeater rows skipped in v1 (deferred
145
- // to F.5 alongside Y.Array row identity). Outside a collab room or
146
- // for non-text fields, the stash returns null and the renderer falls
147
- // back to today's whole-string LWW path.
148
- textBinding: dotted ? null : (ctx.textBindings?.get(name) ?? null),
180
+ // Phase F.6 — top-level text fields resolve from the binding-mount
181
+ // text stash. Phase F.5c dotted-path row leaves resolve through
182
+ // `getRowTextBinding` when the field is text-shaped AND the row's
183
+ // `__id` is already stamped in the values map. Outside a collab
184
+ // room, for non-text fields, or before `addRow` has settled the
185
+ // row's id, the lookup returns null and `BoundTextInput` falls
186
+ // back to today's uncontrolled-input path.
187
+ textBinding: dotted
188
+ ? resolveRowTextBinding(ctx, name)
189
+ : (ctx.textBindings?.get(name) ?? null),
149
190
  }
150
191
  }
151
192
 
193
+ /**
194
+ * Phase F.5c — dotted-name `TextBinding` resolver. Returns `null`
195
+ * whenever any precondition fails so the caller's renderer can take a
196
+ * single branch on null vs non-null.
197
+ */
198
+ function resolveRowTextBinding(ctx: FormStateApi, dottedName: string): TextBinding | null {
199
+ if (!ctx.rowTextLeaves || !ctx.getRowTextBinding) return null
200
+ const parsed = parseRowFieldPath(dottedName)
201
+ if (!parsed) return null
202
+ const set = ctx.rowTextLeaves.get(parsed.arrayName)
203
+ if (!set?.has(parsed.fieldName)) return null
204
+ const rowId = rowIdAtIndex(ctx.values, parsed.arrayName, parsed.index)
205
+ if (!rowId) return null
206
+ return ctx.getRowTextBinding(parsed.arrayName, rowId, parsed.fieldName)
207
+ }
208
+
152
209
  /** Response shape from `POST {base}/.../_form/:formId/state`. */
153
210
  interface FormStateResponse {
154
211
  ok: boolean
@@ -203,6 +260,15 @@ export function FormStateProvider({
203
260
  // existing `setValuesState` overlay below already triggers one when the
204
261
  // room has pre-existing state.
205
262
  const [textBindings, setTextBindings] = useState<ReadonlyMap<string, TextBinding> | null>(null)
263
+ // Phase F.5 — per-Repeater/Builder row-array stash. Same lifecycle as
264
+ // `textBindings`: populated on collab mount when the binding implements
265
+ // F.5 row methods, cleared on unmount.
266
+ const [rowBindings, setRowBindings] = useState<ReadonlyMap<string, RowBindingApi> | null>(null)
267
+ // Phase F.5c — per-array set of text-shaped row leaves + a pre-bound
268
+ // resolver. Both populated at binding mount when the active binding
269
+ // implements `getRowTextBinding`; cleared on unmount.
270
+ const [rowTextLeaves, setRowTextLeaves] = useState<ReadonlyMap<string, ReadonlySet<string>> | null>(null)
271
+ const [getRowTextBinding, setGetRowTextBinding] = useState<((arrayName: string, rowId: string, fieldName: string) => TextBinding | null) | null>(null)
206
272
 
207
273
  const { notify } = useToast()
208
274
 
@@ -285,6 +351,55 @@ export function FormStateProvider({
285
351
  if (textStash.size > 0) setTextBindings(textStash)
286
352
  }
287
353
 
354
+ // Phase F.5 — build a `RowBindingApi` per top-level Repeater/Builder
355
+ // field when the binding implements all three lifecycle methods. The
356
+ // walk reads from formMeta (structural — fields exist regardless of
357
+ // whether the form has any rows yet); the API is then pre-bound to
358
+ // the array name so `RepeaterInput` calls `rb.add(rowId, …)` rather
359
+ // than `binding.addRow(name, rowId, …)`. Partial F.5 impls (e.g. a
360
+ // binding that has addRow but not reorderRows) skip the stash — the
361
+ // contract says all three or nothing. F.5c's per-row text path is
362
+ // exposed separately via `getRowTextBinding` and stays optional.
363
+ if (binding.addRow && binding.removeRow && binding.reorderRows) {
364
+ const { addRow, removeRow, reorderRows, subscribeRows } = binding
365
+ const arrayNames = collectRowArrayFieldNames(formMetaRef.current)
366
+ if (arrayNames.length > 0) {
367
+ const rowStash = new Map<string, RowBindingApi>()
368
+ for (const arrayName of arrayNames) {
369
+ rowStash.set(arrayName, {
370
+ add: (rowId, initial = {}) => addRow.call(binding, arrayName, rowId, initial),
371
+ remove: (rowId) => removeRow.call(binding, arrayName, rowId),
372
+ reorder: (newOrder) => reorderRows.call(binding, arrayName, newOrder),
373
+ // Partial F.5 impl: a binding may ship add/remove/reorder
374
+ // without `subscribeRows` (e.g. tests). Substitute a no-op
375
+ // subscription so renderer code stays uniform — the cleanup
376
+ // fn is still called on unmount but no events ever arrive.
377
+ subscribe: subscribeRows
378
+ ? (fn) => subscribeRows.call(binding, arrayName, fn)
379
+ : () => () => {},
380
+ })
381
+ }
382
+ setRowBindings(rowStash)
383
+ }
384
+ }
385
+
386
+ // Phase F.5c — capture the per-array text-leaf allowlist + bind the
387
+ // row-text resolver to the active binding. `getRowTextBinding` may
388
+ // be absent on partial F.5 impls; we expose the resolver as null in
389
+ // that case so `useFieldState` short-circuits cleanly.
390
+ if (binding.getRowTextBinding) {
391
+ const leaves = collectRowTextLeavesByArray(formMetaRef.current)
392
+ if (leaves.size > 0) {
393
+ setRowTextLeaves(leaves)
394
+ const bound = binding.getRowTextBinding
395
+ // useState's functional-updater overload would invoke the
396
+ // stored function during set; wrapping in a fresh closure keeps
397
+ // React's setState path from confusing it for an updater fn.
398
+ setGetRowTextBinding(() => (arrayName: string, rowId: string, fieldName: string) =>
399
+ bound.call(binding, arrayName, rowId, fieldName))
400
+ }
401
+ }
402
+
288
403
  // Subscribe to remote changes. Local writes ALSO trigger this
289
404
  // (Yjs observers fire on local transactions too) — the per-key
290
405
  // Object.is short-circuit below collapses them into no-op renders.
@@ -307,6 +422,9 @@ export function FormStateProvider({
307
422
  binding.destroy()
308
423
  bindingRef.current = null
309
424
  setTextBindings(null)
425
+ setRowBindings(null)
426
+ setRowTextLeaves(null)
427
+ setGetRowTextBinding(null)
310
428
  }
311
429
  // `valuesRef.current` is intentionally read once at mount — initial
312
430
  // values seed the binding; subsequent edits flow through `setValue`
@@ -367,14 +485,13 @@ export function FormStateProvider({
367
485
  if (Object.is(prev[name], value)) return prev
368
486
  return { ...prev, [name]: value }
369
487
  })
370
- // Phase F2 — proxy the write through the collab binding when active
371
- // AND the field hasn't opted out via `.collab(false)`. Dotted-path
372
- // names (Repeater / Builder row leaves) stay local-only in v1; their
373
- // syncing belongs to Phase F.5 (`Y.Array<Y.Map>` row identity).
374
- const binding = bindingRef.current
375
- if (binding && !name.includes('.') && !fieldOptsOutOfCollab(formMetaRef.current, name)) {
376
- binding.set(name, value)
377
- }
488
+ // Phase F2 / F.5 — proxy the write through the collab binding when
489
+ // active AND the field hasn't opted out via `.collab(false)`. Top-level
490
+ // fields ride `binding.set`. Row leaves (dotted paths matching
491
+ // `parseRowFieldPath`) route through `binding.setRow` when the
492
+ // binding implements F.5 — otherwise stay local-only (same posture
493
+ // as pre-F.5).
494
+ routeBindingWrite(bindingRef.current, formMetaRef.current, valuesRef.current, name, value)
378
495
  // Fire the client-side JS hook synchronously after the state write.
379
496
  // Dotted-name fields don't go through here (their setter is a no-op
380
497
  // in `useFieldState`); they fire JS via `triggerLive` instead so we
@@ -448,16 +565,19 @@ export function FormStateProvider({
448
565
  const serverValues = (data.form as { values?: Record<string, unknown> }).values
449
566
  if (serverValues) {
450
567
  setValuesState((prev) => ({ ...prev, ...serverValues }))
451
- // Phase F2 (Q2) — derived fields propagate to peers via the
452
- // collab binding so every client sees the auto-`slug` / etc.
453
- // without each peer roundtripping the server. Skip dotted-path
454
- // names + fields that opted out.
568
+ // Phase F2 (Q2) / F.5 — derived fields propagate to peers via the
569
+ // collab binding so every client sees the auto-`slug` / etc. without
570
+ // each peer roundtripping the server. Row leaves route through
571
+ // `setRow` when the binding implements F.5; top-level fields ride
572
+ // `set`. The rowId lookup needs the freshest values — merge
573
+ // `valuesRef.current` with the server overlay so a row-id stamped
574
+ // by this very server-resolve response is visible to `rowIdAtIndex`.
455
575
  const binding = bindingRef.current
456
576
  if (binding) {
577
+ const lookupValues = { ...valuesRef.current, ...serverValues }
457
578
  for (const [k, v] of Object.entries(serverValues)) {
458
- if (k.includes('.')) continue
459
- if (fieldOptsOutOfCollab(data.form, k)) continue
460
- binding.set(k, v)
579
+ // routeBindingWrite handles `.collab(false)` opt-out internally.
580
+ routeBindingWrite(binding, data.form, lookupValues, k, v)
461
581
  }
462
582
  }
463
583
  }
@@ -538,7 +658,10 @@ export function FormStateProvider({
538
658
  inFlight,
539
659
  fieldStatus,
540
660
  textBindings,
541
- }), [values, setValue, triggerLive, errors, applyErrors, formMeta, inFlight, fieldStatus, textBindings])
661
+ rowBindings,
662
+ rowTextLeaves,
663
+ getRowTextBinding,
664
+ }), [values, setValue, triggerLive, errors, applyErrors, formMeta, inFlight, fieldStatus, textBindings, rowBindings, rowTextLeaves, getRowTextBinding])
542
665
 
543
666
  return (
544
667
  <FormStateContext.Provider value={api}>
@@ -3,7 +3,7 @@ import { ChevronDownIcon, PlusIcon } from 'lucide-react'
3
3
  import type { ElementMeta } from '../../schema/Element.js'
4
4
  import { Button } from '../ui/button.js'
5
5
  import { SchemaRenderer } from '../SchemaRenderer.js'
6
- import { FormIdContext, useFormState } from '../FormStateContext.js'
6
+ import { FormIdContext, useFormState, useRowBinding } from '../FormStateContext.js'
7
7
  import { findFieldMeta } from '../formStateHelpers.js'
8
8
  import { useIconFor } from '../icon-context.js'
9
9
  import { reorderRows, ExtraActionStrip, buildGridContainer } from './RepeaterInput.js'
@@ -185,6 +185,74 @@ export function BuilderInput({
185
185
  if (!metaRows) return
186
186
  setRows(prev => syncRowGates(prev, metaRows))
187
187
  }, [metaRows])
188
+ // Phase F.5 — row-array CRDT binding (mirrors `RepeaterInput`). The
189
+ // initial-row payload that lands on `add()` carries the block's `type`
190
+ // alongside the empty field map so peers see the discriminator from
191
+ // the first event — without it, the picker dropdown choice doesn't
192
+ // propagate until the user makes their first inner-field edit.
193
+ const rowBinding = useRowBinding(name)
194
+ // Phase F.5c — mirror row identities into the form's values map so dotted
195
+ // row-leaf consumers (`useFieldState('${name}.${i}.data.text').textBinding`)
196
+ // can resolve the row's `__id` via `rowIdAtIndex(ctx.values, name, i)`.
197
+ // Without this stamp the F.5c per-row Y.Text path stays null on Builder
198
+ // and inner text fields never sync. Mirrors the same fix in RepeaterInput.
199
+ const formStateForIds = useFormState()
200
+ const ctxSetValue = formStateForIds?.setValue
201
+ useEffect(() => {
202
+ if (!ctxSetValue) return
203
+ for (let i = 0; i < rows.length; i++) {
204
+ const row = rows[i]
205
+ if (!row) continue
206
+ ctxSetValue(`${name}.${i}.__id`, row.id)
207
+ }
208
+ }, [rows, name, ctxSetValue])
209
+ // Phase F.5 — reconcile remote row events. Builder mirrors
210
+ // RepeaterInput but reads `event.values.type` to pick the block whose
211
+ // template seeds the new row's children. Falls back to the first
212
+ // registered block when the remote `type` doesn't match a known one;
213
+ // the row still mounts so the user sees the change rather than a
214
+ // silent drop (matches the server-side `unknownType` fallback).
215
+ useEffect(() => {
216
+ if (!rowBinding) return
217
+ return rowBinding.subscribe((event) => {
218
+ if (event.kind === 'add') {
219
+ setRows((prev) => {
220
+ if (prev.some(r => r.id === event.rowId)) return prev
221
+ const blockType = typeof event.values['type'] === 'string'
222
+ ? event.values['type'] as string
223
+ : (meta.blocks?.[0]?.name ?? '')
224
+ const block = blocksByName.get(blockType)
225
+ const incoming: RowState = {
226
+ id: event.rowId,
227
+ type: blockType,
228
+ children: block?.template ?? [],
229
+ }
230
+ const next = prev.slice()
231
+ const at = Math.max(0, Math.min(event.index, next.length))
232
+ next.splice(at, 0, incoming)
233
+ return next
234
+ })
235
+ return
236
+ }
237
+ if (event.kind === 'remove') {
238
+ setRows((prev) => {
239
+ if (!prev.some(r => r.id === event.rowId)) return prev
240
+ return prev.filter(r => r.id !== event.rowId)
241
+ })
242
+ return
243
+ }
244
+ setRows((prev) => {
245
+ const fromIdx = prev.findIndex(r => r.id === event.rowId)
246
+ if (fromIdx < 0) return prev
247
+ if (fromIdx === event.to) return prev
248
+ const next = prev.slice()
249
+ const [moved] = next.splice(fromIdx, 1)
250
+ if (!moved) return prev
251
+ next.splice(event.to, 0, moved)
252
+ return next
253
+ })
254
+ })
255
+ }, [rowBinding, blocksByName, meta.blocks])
188
256
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() =>
189
257
  accordion ? {} : initSeedCollapsed(initialRows, formId, name, defaultCollapsed, collapsible),
190
258
  )
@@ -232,6 +300,9 @@ export function BuilderInput({
232
300
  const i = Math.max(0, Math.min(atIndex, prev.length))
233
301
  return [...prev.slice(0, i), newRow, ...prev.slice(i)]
234
302
  })
303
+ // F.5 — seed the new block's discriminator on the CRDT side so peers
304
+ // pick the right inner schema without waiting for the user's first edit.
305
+ rowBinding?.add(newRow.id, { type: block.name })
235
306
  if (accordion) {
236
307
  setAccordionOpenId(newRow.id)
237
308
  writeAccordionToStorage(formId, name, newRow.id)
@@ -246,6 +317,7 @@ export function BuilderInput({
246
317
  const removeRow = (id: string): void => {
247
318
  if (atMin) return
248
319
  setRows(prev => prev.filter(r => r.id !== id))
320
+ rowBinding?.remove(id)
249
321
  if (accordion) {
250
322
  if (accordionOpenId === id) {
251
323
  setAccordionOpenId(null)
@@ -262,6 +334,8 @@ export function BuilderInput({
262
334
 
263
335
  const cloneRow = (id: string): void => {
264
336
  if (atMax) return
337
+ let cloneId: string | null = null
338
+ let cloneType: string | null = null
265
339
  setRows(prev => {
266
340
  const idx = prev.findIndex(r => r.id === id)
267
341
  if (idx < 0) return prev
@@ -270,8 +344,10 @@ export function BuilderInput({
270
344
  const block = blocksByName.get(source.type)
271
345
  const cap = block?.maxItems
272
346
  if (cap !== undefined && (typeCounts.get(source.type) ?? 0) >= cap) return prev
347
+ cloneId = generateRowId()
348
+ cloneType = source.type
273
349
  const clone: RowState = {
274
- id: generateRowId(),
350
+ id: cloneId,
275
351
  type: source.type,
276
352
  children: source.children,
277
353
  ...(source.itemLabel !== undefined ? { itemLabel: source.itemLabel } : {}),
@@ -280,23 +356,32 @@ export function BuilderInput({
280
356
  next.splice(idx + 1, 0, clone)
281
357
  return next
282
358
  })
359
+ if (cloneId !== null && cloneType !== null) {
360
+ rowBinding?.add(cloneId, { type: cloneType })
361
+ }
283
362
  }
284
363
 
285
364
  const moveRow = (id: string, dir: -1 | 1): void => {
365
+ let newOrder: string[] | null = null
286
366
  setRows(prev => {
287
367
  const idx = prev.findIndex(r => r.id === id)
288
368
  if (idx < 0) return prev
369
+ let next: RowState[]
289
370
  if (dir === -1) {
290
371
  let target = idx - 1
291
372
  while (target >= 0 && prev[target]?.hidden) target--
292
373
  if (target < 0) return prev
293
- return reorderRows(prev, idx, target)
374
+ next = reorderRows(prev, idx, target)
375
+ } else {
376
+ let target = idx + 1
377
+ while (target < prev.length && prev[target]?.hidden) target++
378
+ if (target >= prev.length) return prev
379
+ next = reorderRows(prev, idx, target + 1)
294
380
  }
295
- let target = idx + 1
296
- while (target < prev.length && prev[target]?.hidden) target++
297
- if (target >= prev.length) return prev
298
- return reorderRows(prev, idx, target + 1)
381
+ if (next !== prev) newOrder = next.map(r => r.id)
382
+ return next
299
383
  })
384
+ if (newOrder !== null) rowBinding?.reorder(newOrder)
300
385
  }
301
386
 
302
387
  // ── DnD state (skipped when buttonsOnly) ────────────────
@@ -310,11 +395,15 @@ export function BuilderInput({
310
395
  } = useRowReorderDnd({
311
396
  enabled: dndEnabled,
312
397
  onDrop: (fromId, at) => {
398
+ let newOrder: string[] | null = null
313
399
  setRows(prev => {
314
400
  const fromIdx = prev.findIndex(r => r.id === fromId)
315
401
  if (fromIdx < 0) return prev
316
- return reorderRows(prev, fromIdx, at)
402
+ const next = reorderRows(prev, fromIdx, at)
403
+ if (next !== prev) newOrder = next.map(r => r.id)
404
+ return next
317
405
  })
406
+ if (newOrder !== null) rowBinding?.reorder(newOrder)
318
407
  },
319
408
  })
320
409