@pilotiq/pilotiq 0.10.0 → 0.12.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.
Files changed (54) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +128 -0
  3. package/dist/react/AppShell.d.ts +1 -1
  4. package/dist/react/AppShell.d.ts.map +1 -1
  5. package/dist/react/AppShell.js +7 -1
  6. package/dist/react/AppShell.js.map +1 -1
  7. package/dist/react/CollabTextRendererRegistry.d.ts +75 -0
  8. package/dist/react/CollabTextRendererRegistry.d.ts.map +1 -0
  9. package/dist/react/CollabTextRendererRegistry.js +18 -0
  10. package/dist/react/CollabTextRendererRegistry.js.map +1 -0
  11. package/dist/react/CurrentUserContext.d.ts +39 -0
  12. package/dist/react/CurrentUserContext.d.ts.map +1 -0
  13. package/dist/react/CurrentUserContext.js +27 -0
  14. package/dist/react/CurrentUserContext.js.map +1 -0
  15. package/dist/react/FormCollabBindingRegistry.d.ts +161 -17
  16. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
  17. package/dist/react/FormCollabBindingRegistry.js.map +1 -1
  18. package/dist/react/FormStateContext.d.ts +39 -1
  19. package/dist/react/FormStateContext.d.ts.map +1 -1
  20. package/dist/react/FormStateContext.js +126 -33
  21. package/dist/react/FormStateContext.js.map +1 -1
  22. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  23. package/dist/react/fields/BuilderInput.js +112 -10
  24. package/dist/react/fields/BuilderInput.js.map +1 -1
  25. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  26. package/dist/react/fields/MarkdownInput.js +60 -1
  27. package/dist/react/fields/MarkdownInput.js.map +1 -1
  28. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  29. package/dist/react/fields/RepeaterInput.js +113 -10
  30. package/dist/react/fields/RepeaterInput.js.map +1 -1
  31. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  32. package/dist/react/fields/TextLikeInput.js +83 -6
  33. package/dist/react/fields/TextLikeInput.js.map +1 -1
  34. package/dist/react/formStateHelpers.d.ts +102 -0
  35. package/dist/react/formStateHelpers.d.ts.map +1 -1
  36. package/dist/react/formStateHelpers.js +234 -0
  37. package/dist/react/formStateHelpers.js.map +1 -1
  38. package/dist/react/index.d.ts +4 -2
  39. package/dist/react/index.d.ts.map +1 -1
  40. package/dist/react/index.js +3 -1
  41. package/dist/react/index.js.map +1 -1
  42. package/package.json +5 -5
  43. package/src/react/AppShell.tsx +11 -1
  44. package/src/react/CollabTextRendererRegistry.ts +84 -0
  45. package/src/react/CurrentUserContext.tsx +50 -0
  46. package/src/react/FormCollabBindingRegistry.ts +160 -17
  47. package/src/react/FormStateContext.tsx +157 -34
  48. package/src/react/fields/BuilderInput.tsx +97 -8
  49. package/src/react/fields/MarkdownInput.tsx +118 -1
  50. package/src/react/fields/RepeaterInput.tsx +97 -8
  51. package/src/react/fields/TextLikeInput.tsx +129 -5
  52. package/src/react/formStateHelpers.test.ts +312 -0
  53. package/src/react/formStateHelpers.ts +246 -0
  54. package/src/react/index.ts +15 -0
@@ -55,11 +55,18 @@ export interface TextBinding {
55
55
  * - `subscribe(fn)` registers a listener that fires when REMOTE
56
56
  * changes land; `fn(snapshot)` receives the full updated map.
57
57
  * The provider re-applies this snapshot onto its React state.
58
- * - `getTextBinding(name)` (Phase F.6) returns a `Y.Text`-backed
59
- * handle for text-shaped fields, or `null` for non-text fields and
60
- * text fields opted out via `.collab(false)`. The text/non-text
61
- * allowlist lives in the binding impl `FormStateProvider` asks
62
- * for every field and routes per-field on the answer.
58
+ * - `getTextBinding(name)` (Phase F.6) was the per-field `Y.Text`
59
+ * handle for character-level CRDT on top-level text inputs. After
60
+ * the Tiptap-backed text-collab swap (Phase D, 2026-05), bindings
61
+ * are expected to return `null` for every top-level field —
62
+ * character-level CRDT now lives in the Tiptap renderer's
63
+ * `Collaboration` extension (`Y.XmlFragment` per field). Returning
64
+ * a non-null `TextBinding` here is still supported for legacy
65
+ * bindings, but the active `@pilotiq-pro/collab` impl no longer
66
+ * allocates `Y.Text` for top-level fields. Row-text leaves under
67
+ * Repeater / Builder continue to route through Y.Text via
68
+ * `getRowTextBinding` — the Tiptap-backed renderer only handles
69
+ * top-level (non-dotted-path) field names today.
63
70
  * - `destroy()` is called on unmount — gives the plugin a chance to
64
71
  * remove its CRDT observer. Implementations are expected to cascade
65
72
  * into every `TextBinding` they issued.
@@ -74,16 +81,150 @@ export interface FormCollabBinding {
74
81
  set(name: string, value: unknown): void
75
82
  /** Subscribe to remote changes. Returns an unsubscribe function. */
76
83
  subscribe(fn: (snapshot: Record<string, unknown>) => void): () => void
77
- /** Phase F.6 — per-field text-CRDT handle. Returns `null` for non-text
78
- * fields or text fields opted out via `.collab(false)`. Optional so
79
- * existing F1-era plugins keep type-checking without a no-op stub;
80
- * when absent, every text field stays on today's whole-string LWW
81
- * path (i.e. F.6 character-level CRDT is opt-in by impl). */
84
+ /** Phase F.6 / Phase D — per-field text-CRDT handle. Post Phase D,
85
+ * `@pilotiq-pro/collab` returns `null` for every top-level text
86
+ * field (character-level CRDT moved into `@pilotiq/tiptap`'s
87
+ * `CollabTextRenderer`, which mounts its own `Y.XmlFragment` per
88
+ * field via the `Collaboration` extension). The method is preserved
89
+ * on the contract for legacy bindings and for the row-text leaves
90
+ * routed through `getRowTextBinding` (under Repeater / Builder).
91
+ * Optional so existing F1-era plugins keep type-checking without a
92
+ * no-op stub; absent reads as "binding has no top-level text CRDT
93
+ * surface," same as returning `null` for every field. */
82
94
  getTextBinding?(name: string): TextBinding | null
83
95
  /** Cleanup hook called when the form unmounts. */
84
96
  destroy(): void
97
+
98
+ // ── Phase F.5 — Repeater / Builder row-identity surface (all optional) ──
99
+
100
+ /**
101
+ * Add a row to a Repeater or Builder field. `rowId` is the stable
102
+ * `__id` the renderer already mints (UUID for new rows / DB PK for
103
+ * relationship-backed rows) — the binding indexes the row's CRDT
104
+ * surface by this id so concurrent inserts from peers both survive.
105
+ *
106
+ * Idempotent — calling with a `rowId` that already exists in the
107
+ * Repeater's array is a no-op. `initial` may carry pre-filled values
108
+ * for the new row (e.g. the inner fields' `default()` from schema
109
+ * resolution); the binding seeds them onto the row's storage under
110
+ * the same idempotent posture as top-level `set` (first writer wins
111
+ * across concurrent first-mounters).
112
+ *
113
+ * Renderer call sites: `RepeaterInput.addRow()` + `BuilderInput.addBlock()`.
114
+ * When absent, F.5 row-level CRDT degrades to v1 behaviour (whole-array
115
+ * LWW under the top-level Y.Map).
116
+ */
117
+ addRow?(arrayName: string, rowId: string, initial: Record<string, unknown>): void
118
+
119
+ /**
120
+ * Remove a row from a Repeater/Builder field by stable id. Idempotent
121
+ * — a missing `rowId` is a no-op. Triggered by the renderer's row
122
+ * delete button.
123
+ */
124
+ removeRow?(arrayName: string, rowId: string): void
125
+
126
+ /**
127
+ * Apply a new row order. `newOrder` is the full list of row ids in
128
+ * their final positions; the binding computes the minimal CRDT move
129
+ * sequence and applies it inside a single transaction (peers see one
130
+ * coalesced update, not N intermediate states).
131
+ *
132
+ * Called by drag-and-drop / up-down move handlers in
133
+ * `RepeaterInput` + `BuilderInput`.
134
+ */
135
+ reorderRows?(arrayName: string, newOrder: string[]): void
136
+
137
+ /**
138
+ * Write a single field on a row. Replaces the dotted-path `set` for
139
+ * row leaves (`tags.0.label` → `setRow('tags', rowId, 'label', value)`).
140
+ *
141
+ * Binding routes by allowlist same as top-level `set`: text-shaped
142
+ * fields go through the row's `TextBinding` when a `Y.Text` exists
143
+ * (see `getRowTextBinding`); non-text fields land on the row's
144
+ * scalar field-map under LWW.
145
+ *
146
+ * `FormStateProvider` calls this on every local edit when both a
147
+ * dotted-path name is being written AND `setRow` is implemented;
148
+ * otherwise the v1 skip-on-dot path runs.
149
+ */
150
+ setRow?(arrayName: string, rowId: string, fieldName: string, value: unknown): void
151
+
152
+ /**
153
+ * Per-row text-CRDT handle. Composes with F.6's `BoundTextInput`:
154
+ * the renderer asks for one of these when a row leaf is text-shaped
155
+ * AND not opted out via `.collab(false)`, then threads it through
156
+ * the existing `TextBinding` plumbing inside the row.
157
+ *
158
+ * Returns `null` for non-text fields, fields opted out via collab,
159
+ * rows not yet present in the binding's index (e.g. before
160
+ * `addRow` has propagated), or when the binding doesn't yet
161
+ * implement per-row text CRDT (deferred to F.5c).
162
+ */
163
+ getRowTextBinding?(arrayName: string, rowId: string, fieldName: string): TextBinding | null
164
+
165
+ /**
166
+ * Subscribe to row-lifecycle events for a Repeater/Builder array.
167
+ * Fires on remote add / remove / move; the renderer reconciles its
168
+ * `rows` state by `__id`. Local mutations fire too — the renderer
169
+ * deduplicates by checking whether the event's `rowId` matches one
170
+ * it just dispatched itself (the binding's roundtrip + the
171
+ * renderer's optimistic update converge).
172
+ */
173
+ subscribeRows?(arrayName: string, fn: (event: RowsEvent) => void): () => void
174
+ }
175
+
176
+ /**
177
+ * Phase F.5 — array-scoped imperative API exposed to Repeater / Builder
178
+ * renderers through `useRowBinding(arrayName)`. Each method's first arg
179
+ * (`arrayName`) is pre-bound by the hook so call sites read naturally.
180
+ *
181
+ * Returned by the hook only when the active binding implements all four
182
+ * F.5 row methods (`addRow + removeRow + reorderRows`); partial impls
183
+ * read as null so renderers can do a single existence check before
184
+ * proceeding. `setRow` is intentionally NOT exposed here — it's invoked
185
+ * implicitly via `FormStateProvider.setValue`'s dotted-path routing,
186
+ * so renderers never need to touch it directly.
187
+ */
188
+ export interface RowBindingApi {
189
+ /** Register a new row in the array's CRDT surface. `initial` may
190
+ * carry pre-seeded values (e.g. clone of a sibling); pass `{}` (or
191
+ * omit) for empty rows. Idempotent under existing rowId. */
192
+ add(rowId: string, initial?: Record<string, unknown>): void
193
+ /** Remove the row with the given id. Idempotent for unknown ids. */
194
+ remove(rowId: string): void
195
+ /** Replace the array's row order. `newOrder` is the full list of row
196
+ * ids in their post-reorder positions; the binding emits the minimal
197
+ * CRDT move sequence in one transaction so peers observe a single
198
+ * coalesced update. */
199
+ reorder(newOrder: string[]): void
200
+ /** Subscribe to remote row-lifecycle events on this array. Returns
201
+ * an unsubscribe fn the renderer's `useEffect` cleanup hangs on.
202
+ * Local mutations may also surface here (Yjs observers fire on
203
+ * local transactions); the renderer is expected to dedupe by
204
+ * rowId presence in its current state. Optional on the underlying
205
+ * contract — `null` when the binding lacks `subscribeRows`. */
206
+ subscribe(fn: (event: RowsEvent) => void): () => void
85
207
  }
86
208
 
209
+ /**
210
+ * Phase F.5 — row-lifecycle event surfaced through `subscribeRows`.
211
+ *
212
+ * - `add` — a new row was inserted at `index`. `values` carries
213
+ * the row's seeded field values (mirrors `addRow.initial`).
214
+ * - `remove` — a row was removed; `index` is its position at the
215
+ * moment of removal.
216
+ * - `move` — a row shifted positions. `from` and `to` are 0-based
217
+ * indices in the post-reorder layout (i.e. the row at
218
+ * position `from` ended up at `to`).
219
+ *
220
+ * Pilotiq core stays Yjs-free — the binding impl in `@pilotiq-pro/collab`
221
+ * translates `Y.Array<Y.Map>` `observe` deltas into these events.
222
+ */
223
+ export type RowsEvent =
224
+ | { kind: 'add'; rowId: string; index: number; values: Record<string, unknown> }
225
+ | { kind: 'remove'; rowId: string; index: number }
226
+ | { kind: 'move'; rowId: string; from: number; to: number }
227
+
87
228
  export interface FormCollabBindingFactoryArgs {
88
229
  /** Active collab room — provides `ydoc`, `provider`, `user`. Opaque to pilotiq core. */
89
230
  room: CollabRoom
@@ -104,13 +245,15 @@ export interface FormCollabBindingFactoryArgs {
104
245
  initial: Record<string, unknown>
105
246
  /**
106
247
  * Phase F.6 — initial form meta from the server. The binding walks
107
- * this once at construction to decide which fields are text-shaped
108
- * (`fieldType { text, textarea, email, slug, markdown }`) and
109
- * which have opted out via `.collab(false)`. Text fields get a
110
- * dedicated `Y.Text` and route through `getTextBinding`; non-text
111
- * fields stay on the `Y.Map`. The meta is captured at mount; later
112
- * structural changes from `live()` re-resolves aren't re-walked
113
- * (rare in practice dynamic field add/remove is an F-followup).
248
+ * this once at construction to index Repeater / Builder array names
249
+ * for row-level CRDT and to identify which row leaves are text-shaped
250
+ * (`fieldType { text, textarea, email, slug, markdown }` and not
251
+ * `.collab(false)`). Post Phase D, top-level text fields no longer
252
+ * need to be partitioned here their character-level CRDT lives in
253
+ * the Tiptap renderer's `Y.XmlFragment`, not in a binding-allocated
254
+ * `Y.Text`. The meta is captured at mount; later structural changes
255
+ * from `live()` re-resolves aren't re-walked (rare in practice —
256
+ * dynamic field add/remove is an F-followup).
114
257
  */
115
258
  formMeta: ElementMeta
116
259
  }
@@ -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