@pilotiq/pilotiq 0.12.0 → 0.13.1

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 (71) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +19 -0
  3. package/dist/pageData/helpers.d.ts +16 -0
  4. package/dist/pageData/helpers.d.ts.map +1 -1
  5. package/dist/pageData/helpers.js +61 -1
  6. package/dist/pageData/helpers.js.map +1 -1
  7. package/dist/pageData.d.ts +1 -1
  8. package/dist/pageData.d.ts.map +1 -1
  9. package/dist/pageData.js +1 -1
  10. package/dist/pageData.js.map +1 -1
  11. package/dist/react/FormCollabBindingRegistry.d.ts +33 -98
  12. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
  13. package/dist/react/FormCollabBindingRegistry.js.map +1 -1
  14. package/dist/react/FormStateContext.d.ts +1 -35
  15. package/dist/react/FormStateContext.d.ts.map +1 -1
  16. package/dist/react/FormStateContext.js +15 -92
  17. package/dist/react/FormStateContext.js.map +1 -1
  18. package/dist/react/RowCoordsContext.d.ts +19 -0
  19. package/dist/react/RowCoordsContext.d.ts.map +1 -0
  20. package/dist/react/RowCoordsContext.js +6 -0
  21. package/dist/react/RowCoordsContext.js.map +1 -0
  22. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  23. package/dist/react/fields/BuilderInput.js +78 -49
  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 +35 -125
  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 +104 -60
  30. package/dist/react/fields/RepeaterInput.js.map +1 -1
  31. package/dist/react/fields/TextLikeInput.d.ts +11 -9
  32. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  33. package/dist/react/fields/TextLikeInput.js +59 -189
  34. package/dist/react/fields/TextLikeInput.js.map +1 -1
  35. package/dist/react/fields/repeaterReconcile.d.ts +66 -0
  36. package/dist/react/fields/repeaterReconcile.d.ts.map +1 -0
  37. package/dist/react/fields/repeaterReconcile.js +96 -0
  38. package/dist/react/fields/repeaterReconcile.js.map +1 -0
  39. package/dist/react/formStateHelpers.d.ts +0 -15
  40. package/dist/react/formStateHelpers.d.ts.map +1 -1
  41. package/dist/react/formStateHelpers.js +0 -91
  42. package/dist/react/formStateHelpers.js.map +1 -1
  43. package/dist/react/index.d.ts +1 -1
  44. package/dist/react/index.d.ts.map +1 -1
  45. package/dist/react/index.js.map +1 -1
  46. package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -1
  47. package/dist/react/schemaRenderer/form/FormRenderer.js +10 -0
  48. package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/pageData/helpers.ts +55 -1
  51. package/src/pageData.test.ts +67 -0
  52. package/src/pageData.ts +1 -0
  53. package/src/react/FormCollabBindingRegistry.ts +34 -91
  54. package/src/react/FormStateContext.tsx +14 -126
  55. package/src/react/RowCoordsContext.tsx +23 -0
  56. package/src/react/fields/BuilderInput.tsx +75 -39
  57. package/src/react/fields/MarkdownInput.tsx +42 -129
  58. package/src/react/fields/RepeaterInput.tsx +107 -48
  59. package/src/react/fields/TextLikeInput.tsx +67 -225
  60. package/src/react/fields/repeaterReconcile.test.ts +114 -0
  61. package/src/react/fields/repeaterReconcile.ts +104 -0
  62. package/src/react/formStateHelpers.test.ts +0 -99
  63. package/src/react/formStateHelpers.ts +0 -83
  64. package/src/react/index.ts +0 -2
  65. package/src/react/schemaRenderer/form/FormRenderer.tsx +10 -0
  66. package/dist/react/fields/textDelta.d.ts +0 -44
  67. package/dist/react/fields/textDelta.d.ts.map +0 -1
  68. package/dist/react/fields/textDelta.js +0 -80
  69. package/dist/react/fields/textDelta.js.map +0 -1
  70. package/src/react/fields/textDelta.test.ts +0 -141
  71. package/src/react/fields/textDelta.ts +0 -86
@@ -11,13 +11,10 @@ import type { ElementMeta } from '../schema/Element.js'
11
11
  import {
12
12
  collectFieldDefaults,
13
13
  collectRowArrayFieldNames,
14
- collectRowTextLeavesByArray,
15
14
  findFieldMeta,
16
15
  parseFormDataToNested,
17
- parseRowFieldPath,
18
16
  readNestedValue,
19
17
  routeBindingWrite,
20
- rowIdAtIndex,
21
18
  writeNestedValue,
22
19
  } from './formStateHelpers.js'
23
20
  import { runJsHandler } from './fieldJsHandler.js'
@@ -27,7 +24,6 @@ import {
27
24
  getFormCollabBinding,
28
25
  type FormCollabBinding,
29
26
  type RowBindingApi,
30
- type TextBinding,
31
27
  } from './FormCollabBindingRegistry.js'
32
28
 
33
29
  export type FieldStatus = 'idle' | 'pending'
@@ -50,36 +46,11 @@ export interface FormStateApi {
50
46
  formMeta: ElementMeta
51
47
  inFlight: boolean
52
48
  fieldStatus: (name: string) => FieldStatus
53
- /** Phase F.6 — per-field text-CRDT handles stashed at collab-room mount.
54
- * `null` outside a room or before the binding effect has populated the
55
- * map. The text/non-text allowlist lives in the binding impl —
56
- * `FormStateProvider` asks for every top-level field and only stashes
57
- * non-null answers, so a `Map.get()` hit means the binding has opted
58
- * this field into the character-level path. */
59
- textBindings: ReadonlyMap<string, TextBinding> | null
60
49
  /** Phase F.5 — per-Repeater/Builder row-array bindings. `null` outside a
61
50
  * collab room or when the binding doesn't implement F.5 row methods.
62
51
  * Each entry's API methods are pre-bound to the array name so renderers
63
52
  * call `.add(rowId, initial)` rather than `binding.addRow(name, …)`. */
64
53
  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
83
54
  }
84
55
 
85
56
  const FormStateContext = createContext<FormStateApi | null>(null)
@@ -120,15 +91,6 @@ export interface UseFieldStateResult {
120
91
  /** True while a live re-resolve POST is in flight for this field. */
121
92
  pending: boolean
122
93
  errors: string[]
123
- /** Phase F.6 — character-level CRDT handle for text-shaped fields when
124
- * a collab room is mounted up-tree AND the binding strategy applies
125
- * (allowlist + `.collab() !== false`). Null in every other case —
126
- * outside a `FormStateProvider`, outside a `<RecordCollabRoom>`, on
127
- * non-text fields, on dotted-path inner-Repeater rows (deferred to
128
- * F.5), and on text fields opted out via `.collab(false)`. Text input
129
- * renderers branch on this: non-null → character-level path with
130
- * `applyDelta + observe`; null → today's whole-string LWW path. */
131
- textBinding: TextBinding | null
132
94
  }
133
95
 
134
96
  /**
@@ -164,7 +126,6 @@ export function useFieldState(name: string): UseFieldStateResult {
164
126
  triggerLive: () => {},
165
127
  pending: false,
166
128
  errors: [],
167
- textBinding: null,
168
129
  }
169
130
  }
170
131
  // Dotted-path fields (inner Repeater rows) always render uncontrolled
@@ -177,35 +138,9 @@ export function useFieldState(name: string): UseFieldStateResult {
177
138
  triggerLive: (valueOverride?: unknown) => ctx.triggerLive(name, valueOverride),
178
139
  pending: ctx.fieldStatus(name) === 'pending',
179
140
  errors: ctx.errors[name] ?? [],
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),
190
141
  }
191
142
  }
192
143
 
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
-
209
144
  /** Response shape from `POST {base}/.../_form/:formId/state`. */
210
145
  interface FormStateResponse {
211
146
  ok: boolean
@@ -253,22 +188,11 @@ export function FormStateProvider({
253
188
  const [errors, setErrors] = useState<Record<string, string[]>>(initialErrors)
254
189
  const [pendingNames, setPendingNames] = useState<Set<string>>(() => new Set())
255
190
  const [inFlight, setInFlight] = useState(false)
256
- // Phase F.6 — per-field text-CRDT stash. `null` until the collab effect
257
- // populates it; stays `null` outside a collab room. Stored in state (not
258
- // a ref) so consumers of `useFieldState` re-render once the bindings
259
- // land. One extra render after collab-mount; acceptable since the
260
- // existing `setValuesState` overlay below already triggers one when the
261
- // room has pre-existing state.
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.
191
+ // Phase F.5 — per-Repeater/Builder row-array stash. Populated on
192
+ // collab mount when the binding implements F.5 row methods, cleared
193
+ // on unmount. Stored in state (not a ref) so consumers of
194
+ // `useRowBinding` re-render once the bindings land.
266
195
  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)
272
196
 
273
197
  const { notify } = useToast()
274
198
 
@@ -332,25 +256,6 @@ export function FormStateProvider({
332
256
  setValuesState((prev) => ({ ...prev, ...synced }))
333
257
  }
334
258
 
335
- // Phase F.6 — ask the binding for a `TextBinding` on every top-level
336
- // field name. The text/non-text allowlist lives in the binding impl,
337
- // not in core: the binding returns `null` for non-text fields and
338
- // text fields opted out via `.collab(false)`. `getTextBinding` is
339
- // optional on the contract — F1-era plugins that haven't implemented
340
- // it short-circuit the whole stash and every text field stays on the
341
- // LWW path. We stash only the non-null answers. Cleanup is owned by
342
- // `binding.destroy()` (expected to cascade into every issued
343
- // `TextBinding`).
344
- if (binding.getTextBinding) {
345
- const textStash = new Map<string, TextBinding>()
346
- for (const fieldName of Object.keys(valuesRef.current)) {
347
- if (fieldName.includes('.')) continue
348
- const tb = binding.getTextBinding(fieldName)
349
- if (tb) textStash.set(fieldName, tb)
350
- }
351
- if (textStash.size > 0) setTextBindings(textStash)
352
- }
353
-
354
259
  // Phase F.5 — build a `RowBindingApi` per top-level Repeater/Builder
355
260
  // field when the binding implements all three lifecycle methods. The
356
261
  // walk reads from formMeta (structural — fields exist regardless of
@@ -358,10 +263,9 @@ export function FormStateProvider({
358
263
  // the array name so `RepeaterInput` calls `rb.add(rowId, …)` rather
359
264
  // than `binding.addRow(name, rowId, …)`. Partial F.5 impls (e.g. a
360
265
  // 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.
266
+ // contract says all three or nothing.
363
267
  if (binding.addRow && binding.removeRow && binding.reorderRows) {
364
- const { addRow, removeRow, reorderRows, subscribeRows } = binding
268
+ const { addRow, removeRow, reorderRows, subscribeRows, getRowOrder } = binding
365
269
  const arrayNames = collectRowArrayFieldNames(formMetaRef.current)
366
270
  if (arrayNames.length > 0) {
367
271
  const rowStash = new Map<string, RowBindingApi>()
@@ -377,29 +281,19 @@ export function FormStateProvider({
377
281
  subscribe: subscribeRows
378
282
  ? (fn) => subscribeRows.call(binding, arrayName, fn)
379
283
  : () => () => {},
284
+ // `getRowOrder` is optional — bindings that ship row CRUD
285
+ // without a snapshot read (test stubs, older plugins) get a
286
+ // `[]` substitute. The renderer's reconciler treats empty as
287
+ // "no orphans known" and no-ops, which is the safest fallback.
288
+ current: getRowOrder
289
+ ? () => getRowOrder.call(binding, arrayName)
290
+ : () => [],
380
291
  })
381
292
  }
382
293
  setRowBindings(rowStash)
383
294
  }
384
295
  }
385
296
 
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
-
403
297
  // Subscribe to remote changes. Local writes ALSO trigger this
404
298
  // (Yjs observers fire on local transactions too) — the per-key
405
299
  // Object.is short-circuit below collapses them into no-op renders.
@@ -421,10 +315,7 @@ export function FormStateProvider({
421
315
  unsubscribe()
422
316
  binding.destroy()
423
317
  bindingRef.current = null
424
- setTextBindings(null)
425
318
  setRowBindings(null)
426
- setRowTextLeaves(null)
427
- setGetRowTextBinding(null)
428
319
  }
429
320
  // `valuesRef.current` is intentionally read once at mount — initial
430
321
  // values seed the binding; subsequent edits flow through `setValue`
@@ -657,11 +548,8 @@ export function FormStateProvider({
657
548
  formMeta,
658
549
  inFlight,
659
550
  fieldStatus,
660
- textBindings,
661
551
  rowBindings,
662
- rowTextLeaves,
663
- getRowTextBinding,
664
- }), [values, setValue, triggerLive, errors, applyErrors, formMeta, inFlight, fieldStatus, textBindings, rowBindings, rowTextLeaves, getRowTextBinding])
552
+ }), [values, setValue, triggerLive, errors, applyErrors, formMeta, inFlight, fieldStatus, rowBindings])
665
553
 
666
554
  return (
667
555
  <FormStateContext.Provider value={api}>
@@ -0,0 +1,23 @@
1
+ import React, { createContext, useContext } from 'react'
2
+
3
+ /**
4
+ * Phase 1 — row-text Tiptap-backed collab plan
5
+ * (`pilotiq-pro/docs/plans/collab-row-text-tiptap-backed.md`).
6
+ *
7
+ * Each Repeater / Builder row mounts a `<RowCoordsContext.Provider>`
8
+ * around its children so dotted-path text leaves can compose a
9
+ * fragment-key that includes the stable `rowId` (survives reorders).
10
+ * Top-level fields see `null` and fall through to bare-name fragment
11
+ * routing.
12
+ */
13
+ export interface RowCoords {
14
+ arrayName: string
15
+ rowIndex: number
16
+ rowId: string
17
+ }
18
+
19
+ export const RowCoordsContext = createContext<RowCoords | null>(null)
20
+
21
+ export function useRowCoords(): RowCoords | null {
22
+ return useContext(RowCoordsContext)
23
+ }
@@ -5,9 +5,11 @@ import { Button } from '../ui/button.js'
5
5
  import { SchemaRenderer } from '../SchemaRenderer.js'
6
6
  import { FormIdContext, useFormState, useRowBinding } from '../FormStateContext.js'
7
7
  import { findFieldMeta } from '../formStateHelpers.js'
8
+ import { RowCoordsContext } from '../RowCoordsContext.js'
8
9
  import { useIconFor } from '../icon-context.js'
9
10
  import { reorderRows, ExtraActionStrip, buildGridContainer } from './RepeaterInput.js'
10
11
  import { syncRowGates } from './syncRowGates.js'
12
+ import { consumeReconcileFlag, computeReconcilePlan } from './repeaterReconcile.js'
11
13
  import type { RowButtonsMeta } from '../../fields/RowButton.js'
12
14
  import {
13
15
  RowChromeIconButton,
@@ -191,11 +193,9 @@ export function BuilderInput({
191
193
  // the first event — without it, the picker dropdown choice doesn't
192
194
  // propagate until the user makes their first inner-field edit.
193
195
  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.
196
+ // Mirror row identities into the form's values map so dotted row-leaf
197
+ // consumers can resolve the row's `__id` via `rowIdAtIndex(ctx.values,
198
+ // name, i)`. Mirrors the same plumbing in RepeaterInput.
199
199
  const formStateForIds = useFormState()
200
200
  const ctxSetValue = formStateForIds?.setValue
201
201
  useEffect(() => {
@@ -253,6 +253,31 @@ export function BuilderInput({
253
253
  })
254
254
  })
255
255
  }, [rowBinding, blocksByName, meta.blocks])
256
+
257
+ // Phase A reconciliation for `Builder.relationship` PK-switch — mirrors
258
+ // the effect in `RepeaterInput`. See its comment + the plan doc:
259
+ // `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`.
260
+ useEffect(() => {
261
+ if (!rowBinding) return
262
+ if (!consumeReconcileFlag(formId)) return
263
+ const timer = setTimeout(() => {
264
+ const plan = computeReconcilePlan({
265
+ current: rowBinding.current(),
266
+ authoritative: initialRows.map(r => r.id),
267
+ })
268
+ for (const id of plan.toRemove) rowBinding.remove(id)
269
+ // For Builder, the row carries a block `type` discriminator; seed
270
+ // it on the add path so peers' picker dropdowns see the right
271
+ // block (matches the existing add path in `addBlock`). The block
272
+ // type comes from initialRows when the row is server-rendered.
273
+ for (const id of plan.toAdd) {
274
+ const row = initialRows.find(r => r.id === id)
275
+ rowBinding.add(id, row?.type ? { type: row.type } : {})
276
+ }
277
+ }, 1500)
278
+ return () => clearTimeout(timer)
279
+ // eslint-disable-next-line react-hooks/exhaustive-deps
280
+ }, [rowBinding, formId])
256
281
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() =>
257
282
  accordion ? {} : initSeedCollapsed(initialRows, formId, name, defaultCollapsed, collapsible),
258
283
  )
@@ -362,26 +387,23 @@ export function BuilderInput({
362
387
  }
363
388
 
364
389
  const moveRow = (id: string, dir: -1 | 1): void => {
365
- let newOrder: string[] | null = null
366
- setRows(prev => {
367
- const idx = prev.findIndex(r => r.id === id)
368
- if (idx < 0) return prev
369
- let next: RowState[]
370
- if (dir === -1) {
371
- let target = idx - 1
372
- while (target >= 0 && prev[target]?.hidden) target--
373
- if (target < 0) return prev
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)
380
- }
381
- if (next !== prev) newOrder = next.map(r => r.id)
382
- return next
383
- })
384
- if (newOrder !== null) rowBinding?.reorder(newOrder)
390
+ const idx = rows.findIndex(r => r.id === id)
391
+ if (idx < 0) return
392
+ let next: RowState[]
393
+ if (dir === -1) {
394
+ let target = idx - 1
395
+ while (target >= 0 && rows[target]?.hidden) target--
396
+ if (target < 0) return
397
+ next = reorderRows(rows, idx, target)
398
+ } else {
399
+ let target = idx + 1
400
+ while (target < rows.length && rows[target]?.hidden) target++
401
+ if (target >= rows.length) return
402
+ next = reorderRows(rows, idx, target + 1)
403
+ }
404
+ if (next === rows) return
405
+ setRows(next)
406
+ rowBinding?.reorder(next.map(r => r.id))
385
407
  }
386
408
 
387
409
  // ── DnD state (skipped when buttonsOnly) ────────────────
@@ -395,15 +417,16 @@ export function BuilderInput({
395
417
  } = useRowReorderDnd({
396
418
  enabled: dndEnabled,
397
419
  onDrop: (fromId, at) => {
398
- let newOrder: string[] | null = null
399
- setRows(prev => {
400
- const fromIdx = prev.findIndex(r => r.id === fromId)
401
- if (fromIdx < 0) return prev
402
- const next = reorderRows(prev, fromIdx, at)
403
- if (next !== prev) newOrder = next.map(r => r.id)
404
- return next
405
- })
406
- if (newOrder !== null) rowBinding?.reorder(newOrder)
420
+ // See RepeaterInput's matching onDrop comment — closure-mutation
421
+ // through setRows's updater is unreliable when other state updates
422
+ // are batched (useRowReorderDnd nulls dragId/dropAt right before
423
+ // calling this).
424
+ const fromIdx = rows.findIndex(r => r.id === fromId)
425
+ if (fromIdx < 0) return
426
+ const next = reorderRows(rows, fromIdx, at)
427
+ if (next === rows) return
428
+ setRows(next)
429
+ rowBinding?.reorder(next.map(r => r.id))
407
430
  },
408
431
  })
409
432
 
@@ -870,6 +893,15 @@ function BuilderRow({
870
893
  () => row.children.map(c => prefixFieldNames(c, dataPrefix)),
871
894
  [row.children, dataPrefix],
872
895
  )
896
+ // Row coords for dotted-path text leaves under this row — composes
897
+ // fragment-key `${arrayName}.${rowId}.${fieldName}` (Phase 1 of
898
+ // collab-row-text-tiptap-backed.md). `parseRowFieldPath` strips the
899
+ // Builder-specific `data` segment, so the coords use the array name +
900
+ // the row's stable id without referencing the dialect.
901
+ const rowCoords = useMemo(
902
+ () => ({ arrayName: name, rowIndex: index, rowId: row.id }),
903
+ [name, index, row.id],
904
+ )
873
905
 
874
906
  const RowIcon = useIconFor(showIcons ? block?.icon : undefined)
875
907
  const blockLabel = block?.label ?? row.type ?? 'Block'
@@ -878,11 +910,13 @@ function BuilderRow({
878
910
 
879
911
  if (row.hidden) {
880
912
  return (
881
- <div style={{ display: 'none' }} data-pilotiq-builder-row="hidden">
882
- <input type="hidden" name={`${name}.${index}.__id`} value={row.id} readOnly />
883
- <input type="hidden" name={`${name}.${index}.type`} value={row.type} readOnly />
884
- <SchemaRenderer elements={namespaced} />
885
- </div>
913
+ <RowCoordsContext.Provider value={rowCoords}>
914
+ <div style={{ display: 'none' }} data-pilotiq-builder-row="hidden">
915
+ <input type="hidden" name={`${name}.${index}.__id`} value={row.id} readOnly />
916
+ <input type="hidden" name={`${name}.${index}.type`} value={row.type} readOnly />
917
+ <SchemaRenderer elements={namespaced} />
918
+ </div>
919
+ </RowCoordsContext.Provider>
886
920
  )
887
921
  }
888
922
 
@@ -921,6 +955,7 @@ function BuilderRow({
921
955
  const innerColumns = block.columns && block.columns > 1 ? block.columns : 1
922
956
 
923
957
  return (
958
+ <RowCoordsContext.Provider value={rowCoords}>
924
959
  <div
925
960
  className={`rounded-md border bg-card transition-opacity ${isDragging ? 'opacity-50' : ''}`}
926
961
  data-pilotiq-builder-row=""
@@ -999,6 +1034,7 @@ function BuilderRow({
999
1034
  : <SchemaRenderer elements={namespaced} />}
1000
1035
  </div>
1001
1036
  </div>
1037
+ </RowCoordsContext.Provider>
1002
1038
  )
1003
1039
  }
1004
1040