@pilotiq/pilotiq 0.13.0 → 0.14.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 (75) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +19 -0
  3. package/dist/Page.d.ts +40 -0
  4. package/dist/Page.d.ts.map +1 -1
  5. package/dist/Page.js +32 -0
  6. package/dist/Page.js.map +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/pageData/helpers.d.ts +16 -0
  11. package/dist/pageData/helpers.d.ts.map +1 -1
  12. package/dist/pageData/helpers.js +61 -1
  13. package/dist/pageData/helpers.js.map +1 -1
  14. package/dist/pageData/navigation.d.ts +15 -1
  15. package/dist/pageData/navigation.d.ts.map +1 -1
  16. package/dist/pageData/navigation.js +15 -0
  17. package/dist/pageData/navigation.js.map +1 -1
  18. package/dist/pageData.d.ts +1 -1
  19. package/dist/pageData.d.ts.map +1 -1
  20. package/dist/pageData.js +1 -1
  21. package/dist/pageData.js.map +1 -1
  22. package/dist/react/AppShell.d.ts +5 -0
  23. package/dist/react/AppShell.d.ts.map +1 -1
  24. package/dist/react/AppShell.js +2 -1
  25. package/dist/react/AppShell.js.map +1 -1
  26. package/dist/react/CustomPageWrapperGate.d.ts +33 -0
  27. package/dist/react/CustomPageWrapperGate.d.ts.map +1 -0
  28. package/dist/react/CustomPageWrapperGate.js +50 -0
  29. package/dist/react/CustomPageWrapperGate.js.map +1 -0
  30. package/dist/react/CustomPageWrapperRegistry.d.ts +34 -0
  31. package/dist/react/CustomPageWrapperRegistry.d.ts.map +1 -0
  32. package/dist/react/CustomPageWrapperRegistry.js +19 -0
  33. package/dist/react/CustomPageWrapperRegistry.js.map +1 -0
  34. package/dist/react/FormCollabBindingRegistry.d.ts +16 -0
  35. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
  36. package/dist/react/FormCollabBindingRegistry.js.map +1 -1
  37. package/dist/react/FormStateContext.d.ts.map +1 -1
  38. package/dist/react/FormStateContext.js +8 -1
  39. package/dist/react/FormStateContext.js.map +1 -1
  40. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  41. package/dist/react/fields/BuilderInput.js +64 -40
  42. package/dist/react/fields/BuilderInput.js.map +1 -1
  43. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  44. package/dist/react/fields/RepeaterInput.js +78 -43
  45. package/dist/react/fields/RepeaterInput.js.map +1 -1
  46. package/dist/react/fields/repeaterReconcile.d.ts +66 -0
  47. package/dist/react/fields/repeaterReconcile.d.ts.map +1 -0
  48. package/dist/react/fields/repeaterReconcile.js +96 -0
  49. package/dist/react/fields/repeaterReconcile.js.map +1 -0
  50. package/dist/react/index.d.ts +2 -0
  51. package/dist/react/index.d.ts.map +1 -1
  52. package/dist/react/index.js +2 -0
  53. package/dist/react/index.js.map +1 -1
  54. package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -1
  55. package/dist/react/schemaRenderer/form/FormRenderer.js +10 -0
  56. package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -1
  57. package/package.json +1 -1
  58. package/src/Page.test.ts +64 -0
  59. package/src/Page.ts +60 -0
  60. package/src/index.ts +1 -1
  61. package/src/pageData/helpers.ts +55 -1
  62. package/src/pageData/navigation.ts +31 -1
  63. package/src/pageData.test.ts +109 -0
  64. package/src/pageData.ts +1 -0
  65. package/src/react/AppShell.tsx +12 -1
  66. package/src/react/CustomPageWrapperGate.tsx +69 -0
  67. package/src/react/CustomPageWrapperRegistry.ts +45 -0
  68. package/src/react/FormCollabBindingRegistry.ts +17 -0
  69. package/src/react/FormStateContext.tsx +8 -1
  70. package/src/react/fields/BuilderInput.tsx +53 -29
  71. package/src/react/fields/RepeaterInput.tsx +66 -32
  72. package/src/react/fields/repeaterReconcile.test.ts +114 -0
  73. package/src/react/fields/repeaterReconcile.ts +104 -0
  74. package/src/react/index.ts +10 -0
  75. package/src/react/schemaRenderer/form/FormRenderer.tsx +10 -0
@@ -83,6 +83,73 @@ describe('applyFillPipeline', () => {
83
83
  const values = await applyFillPipeline(form, { id: 1 })
84
84
  assert.deepEqual(values, { id: 1, async: true })
85
85
  })
86
+
87
+ it('parses JSON-string values on Repeater slots into arrays', async () => {
88
+ const form = Form.make().schema([
89
+ TextField.make('title'),
90
+ Repeater.make('metadata').schema([TextField.make('heading')]),
91
+ ])
92
+ const record = {
93
+ id: 1,
94
+ title: 'Hello',
95
+ metadata: '[{"__id":"row-1","heading":"a"},{"__id":"row-2","heading":"b"}]',
96
+ }
97
+ const values = await applyFillPipeline(form, record)
98
+ assert.deepEqual(values['metadata'], [
99
+ { __id: 'row-1', heading: 'a' },
100
+ { __id: 'row-2', heading: 'b' },
101
+ ])
102
+ assert.equal(values['title'], 'Hello')
103
+ })
104
+
105
+ it('parses JSON-string values on Builder slots into arrays', async () => {
106
+ const form = Form.make().schema([
107
+ Builder.make('content').blocks([
108
+ Block.make('heading').schema([TextField.make('text')]),
109
+ ]),
110
+ ])
111
+ const record = {
112
+ content: '[{"__id":"row-1","type":"heading","data":{"text":"hi"}}]',
113
+ }
114
+ const values = await applyFillPipeline(form, record)
115
+ assert.deepEqual(values['content'], [
116
+ { __id: 'row-1', type: 'heading', data: { text: 'hi' } },
117
+ ])
118
+ })
119
+
120
+ it('leaves non-JSON strings on array-field slots untouched', async () => {
121
+ const form = Form.make().schema([
122
+ Repeater.make('tags').schema([TextField.make('label')]),
123
+ ])
124
+ const record = { tags: 'not-json' }
125
+ const values = await applyFillPipeline(form, record)
126
+ assert.equal(values['tags'], 'not-json')
127
+ })
128
+
129
+ it('leaves JSON strings that deserialize to non-arrays untouched', async () => {
130
+ const form = Form.make().schema([
131
+ Repeater.make('tags').schema([TextField.make('label')]),
132
+ ])
133
+ const record = { tags: '{"not":"an-array"}' }
134
+ const values = await applyFillPipeline(form, record)
135
+ assert.equal(values['tags'], '{"not":"an-array"}')
136
+ })
137
+
138
+ it('passes through already-parsed arrays unchanged', async () => {
139
+ const form = Form.make().schema([
140
+ Repeater.make('metadata').schema([TextField.make('heading')]),
141
+ ])
142
+ const rows = [{ __id: 'row-1', heading: 'a' }]
143
+ const values = await applyFillPipeline(form, { metadata: rows })
144
+ assert.equal(values['metadata'], rows)
145
+ })
146
+
147
+ it('ignores top-level non-array fields whose value happens to be a JSON-string', async () => {
148
+ const form = Form.make().schema([TextField.make('title')])
149
+ const record = { title: '[1,2,3]' }
150
+ const values = await applyFillPipeline(form, record)
151
+ assert.equal(values['title'], '[1,2,3]')
152
+ })
86
153
  })
87
154
 
88
155
  describe('resolveActiveTab', () => {
@@ -1339,3 +1406,45 @@ describe('panelInfo — recordCollab map (resource collab opt-in)', () => {
1339
1406
  })
1340
1407
  })
1341
1408
  })
1409
+
1410
+ describe('panelInfo — pageCollab map (custom-page collab opt-in)', () => {
1411
+ it('absent when no page opts in', async () => {
1412
+ class Analytics extends Page {
1413
+ static override slug = 'analytics'
1414
+ static override label = 'Analytics'
1415
+ }
1416
+ const info = await panelInfo(Pilotiq.make('T').path('/admin').pages([Analytics]))
1417
+ assert.equal((info as { pageCollab?: unknown }).pageCollab, undefined)
1418
+ })
1419
+
1420
+ it('emits an entry per opted-in custom page keyed by URL slug', async () => {
1421
+ class Settings extends Page {
1422
+ static override slug = 'settings'
1423
+ static override label = 'Settings'
1424
+ static override collab = { room: 'settings-general' }
1425
+ }
1426
+ class Analytics extends Page {
1427
+ static override slug = 'analytics'
1428
+ static override label = 'Analytics'
1429
+ // No collab — should NOT appear in the map.
1430
+ }
1431
+ const info = await panelInfo(Pilotiq.make('T').path('/admin').pages([Settings, Analytics]))
1432
+ const map = (info as { pageCollab?: Record<string, unknown> }).pageCollab
1433
+ assert.deepEqual(map, {
1434
+ settings: { room: 'settings-general', presence: true },
1435
+ })
1436
+ })
1437
+
1438
+ it('object form can suppress presence', async () => {
1439
+ class Settings extends Page {
1440
+ static override slug = 'settings'
1441
+ static override label = 'Settings'
1442
+ static override collab = { room: 'settings', presence: false }
1443
+ }
1444
+ const info = await panelInfo(Pilotiq.make('T').path('/admin').pages([Settings]))
1445
+ const map = (info as { pageCollab?: Record<string, unknown> }).pageCollab
1446
+ assert.deepEqual(map, {
1447
+ settings: { room: 'settings', presence: false },
1448
+ })
1449
+ })
1450
+ })
package/src/pageData.ts CHANGED
@@ -27,6 +27,7 @@ export {
27
27
  applyRelationshipBuilderFill,
28
28
  applyRelationshipRepeaterFill,
29
29
  callPageSchema,
30
+ normalizeArrayFieldStrings,
30
31
  resolveServerDataElements,
31
32
  tagActionDispatch,
32
33
  tagCellEditUrls,
@@ -11,6 +11,7 @@ import { RightPanelRegistryProvider } from './right-panel-registry.js'
11
11
  import { RightSidebarProvider, useRightSidebarOptional } from './RightSidebarContext.js'
12
12
  import { RightSidebar } from './RightSidebar.js'
13
13
  import { RecordWrapperGate, type RecordCollabMap } from './RecordWrapperGate.js'
14
+ import { CustomPageWrapperGate, type PageCollabMap } from './CustomPageWrapperGate.js'
14
15
  import { useIsMobile } from './hooks/use-mobile.js'
15
16
  import type { NavItem, UserMenuMeta, DatabaseNotificationsMeta, RightSidebarMeta } from '../pageData.js'
16
17
  import type { RenderHookMap } from '../RenderHook.js'
@@ -39,6 +40,10 @@ export interface AppShellProps {
39
40
  * decide whether to mount the plugin-registered RecordWrapper on
40
41
  * a record edit/view URL. Absent when no resource opted in. */
41
42
  recordCollab?: RecordCollabMap
43
+ /** Per-custom-page collab opt-in map — read by `CustomPageWrapperGate`
44
+ * to decide whether to mount the plugin-registered CustomPageWrapper
45
+ * on a custom-page URL. Absent when no page opted in. */
46
+ pageCollab?: PageCollabMap
42
47
  /** Pre-resolved render-hook slots for the panel chrome (body /
43
48
  * topbar / sidebar / user-menu / footer / head). Sparse map —
44
49
  * slots with no registered entries are absent. Built by
@@ -137,7 +142,13 @@ export function AppShell({ layout = 'sidebar', notifications, componentRegistry,
137
142
  {...(props.currentPath !== undefined ? { currentPath: props.currentPath } : {})}
138
143
  {...(props.panel.recordCollab !== undefined ? { recordCollab: props.panel.recordCollab } : {})}
139
144
  >
140
- {props.children}
145
+ <CustomPageWrapperGate
146
+ basePath={props.basePath}
147
+ {...(props.currentPath !== undefined ? { currentPath: props.currentPath } : {})}
148
+ {...(props.panel.pageCollab !== undefined ? { pageCollab: props.panel.pageCollab } : {})}
149
+ >
150
+ {props.children}
151
+ </CustomPageWrapperGate>
141
152
  </RecordWrapperGate>
142
153
  ),
143
154
  }
@@ -0,0 +1,69 @@
1
+ import { type ReactNode } from 'react'
2
+ import { getCustomPageWrapper } from './CustomPageWrapperRegistry.js'
3
+ import type { PageCollabConfig } from '../Page.js'
4
+
5
+ /** Per-custom-page collab opt-in keyed by URL slug (`P.getSlug()` for
6
+ * non-clustered, `${cluster.slug}/${P.getSlug()}` for clustered). Built
7
+ * server-side by `panelInfo()` as `pageCollab`. */
8
+ export type PageCollabMap = Record<string, PageCollabConfig>
9
+
10
+ export interface CustomPageWrapperGateProps {
11
+ currentPath?: string
12
+ basePath: string
13
+ /** Per-page opt-in map. Absent means no custom page opted in — gate
14
+ * always passes through. */
15
+ pageCollab?: PageCollabMap
16
+ children: ReactNode
17
+ }
18
+
19
+ /**
20
+ * Strip `basePath` from `currentPath` and return the remaining slash-
21
+ * joined tail. Returns `null` when the path is empty / doesn't start
22
+ * with `basePath` / has no tail. Mirrors `parseRecordPageUrl`'s
23
+ * normalization (trailing slash on either side is tolerated).
24
+ */
25
+ function pageSlugFromUrl(currentPath: string, basePath: string): string | null {
26
+ if (!currentPath) return null
27
+ const trimmedPath = currentPath.replace(/\/+$/, '')
28
+ const trimmedBase = basePath.replace(/\/+$/, '')
29
+
30
+ if (trimmedBase !== '' && !trimmedPath.startsWith(trimmedBase)) return null
31
+
32
+ const tail = trimmedPath.slice(trimmedBase.length).replace(/^\/+/, '')
33
+ if (tail.length === 0) return null
34
+ return tail
35
+ }
36
+
37
+ /**
38
+ * Conditionally wraps the page tree with the plugin-registered
39
+ * `CustomPageWrapper` when the current URL resolves to a custom page
40
+ * (a `Page` subclass with `static collab = { room: '…' }`).
41
+ * Pass-through in every other case:
42
+ *
43
+ * - no plugin registered a wrapper (`getCustomPageWrapper() === null`)
44
+ * - `currentPath` not yet known on the very first SSR render
45
+ * - `pageCollab` map absent (no page opted in)
46
+ * - the URL tail doesn't match any registered page slug (resource
47
+ * list/edit/view pages, dashboard, theme editor, etc.)
48
+ *
49
+ * Mounted inside `AppShell` around the page content area, beside
50
+ * `RecordWrapperGate`. The two gates are mutually exclusive in
51
+ * practice — record routes have 3+ segments ending in /edit or /view,
52
+ * custom-page routes are 1-2 segments matching a registered page slug.
53
+ */
54
+ export function CustomPageWrapperGate({ currentPath, basePath, pageCollab, children }: CustomPageWrapperGateProps) {
55
+ const Wrapper = getCustomPageWrapper()
56
+ if (!Wrapper || !currentPath || !pageCollab) return <>{children}</>
57
+
58
+ const slug = pageSlugFromUrl(currentPath, basePath)
59
+ if (!slug) return <>{children}</>
60
+
61
+ const cfg = pageCollab[slug]
62
+ if (!cfg) return <>{children}</>
63
+
64
+ return (
65
+ <Wrapper pageSlug={slug} room={cfg.room} presence={cfg.presence}>
66
+ {children}
67
+ </Wrapper>
68
+ )
69
+ }
@@ -0,0 +1,45 @@
1
+ import type { ComponentType, ReactNode } from 'react'
2
+
3
+ /**
4
+ * Props the custom-page wrapper receives from `CustomPageWrapperGate`
5
+ * when the current URL resolves to an opted-in custom page (a `Page`
6
+ * subclass with `static collab = { room: '…' }`).
7
+ *
8
+ * The wrapper owns whatever page-scoped context the plugin provides —
9
+ * `@pilotiq-pro/collab` mounts a collab room here so every collab
10
+ * field inside the page tree shares one Y.Doc + WS connection. Other
11
+ * plugins could mount per-page presence, audit logging, etc.
12
+ *
13
+ * `pageSlug` is the gate's URL slug (cluster-prefixed for clustered
14
+ * pages). `room` is the literal `room` value the page declared on
15
+ * `static collab` — opaque to pilotiq; the plugin is free to namespace
16
+ * it internally before opening the WS.
17
+ */
18
+ export interface CustomPageWrapperProps {
19
+ pageSlug: string
20
+ room: string
21
+ presence: boolean
22
+ children: ReactNode
23
+ }
24
+
25
+ let _component: ComponentType<CustomPageWrapperProps> | null = null
26
+
27
+ /**
28
+ * Register a component that wraps the page tree on every opted-in
29
+ * custom-page route. Called once at boot by a plugin (e.g.
30
+ * `@pilotiq-pro/collab`). No-op when no plugin registers —
31
+ * `CustomPageWrapperGate` passes through unchanged.
32
+ */
33
+ export function registerCustomPageWrapper(C: ComponentType<CustomPageWrapperProps>): void {
34
+ _component = C
35
+ }
36
+
37
+ /** Returns the registered wrapper component, or `null`. */
38
+ export function getCustomPageWrapper(): ComponentType<CustomPageWrapperProps> | null {
39
+ return _component
40
+ }
41
+
42
+ /** Test-only — drops any registered wrapper so tests stay isolated. */
43
+ export function _resetCustomPageWrapper(): void {
44
+ _component = null
45
+ }
@@ -101,6 +101,18 @@ export interface FormCollabBinding {
101
101
  * renderer's optimistic update converge).
102
102
  */
103
103
  subscribeRows?(arrayName: string, fn: (event: RowsEvent) => void): () => void
104
+
105
+ /**
106
+ * Snapshot the current row id order for an array. Unlike `subscribeRows`,
107
+ * which only fires on delta events, this returns whatever is in the CRDT
108
+ * right now — used by the renderer's mount-time reconciler to detect
109
+ * orphaned rows (CRDT carries a UUID forward after the parent save
110
+ * switched the row's `__id` to a DB PK; see
111
+ * `docs/plans/repeater-relationship-pk-switch.md` Phase A).
112
+ *
113
+ * Empty array when the binding has no state for `arrayName` yet.
114
+ */
115
+ getRowOrder?(arrayName: string): string[]
104
116
  }
105
117
 
106
118
  /**
@@ -134,6 +146,11 @@ export interface RowBindingApi {
134
146
  * rowId presence in its current state. Optional on the underlying
135
147
  * contract — `null` when the binding lacks `subscribeRows`. */
136
148
  subscribe(fn: (event: RowsEvent) => void): () => void
149
+ /** Snapshot the array's current row id order. Empty array when the
150
+ * binding has no state yet OR when the underlying binding doesn't
151
+ * implement `getRowOrder` (in which case mount-time reconciliation
152
+ * no-ops). */
153
+ current(): string[]
137
154
  }
138
155
 
139
156
  /**
@@ -265,7 +265,7 @@ export function FormStateProvider({
265
265
  // binding that has addRow but not reorderRows) skip the stash — the
266
266
  // contract says all three or nothing.
267
267
  if (binding.addRow && binding.removeRow && binding.reorderRows) {
268
- const { addRow, removeRow, reorderRows, subscribeRows } = binding
268
+ const { addRow, removeRow, reorderRows, subscribeRows, getRowOrder } = binding
269
269
  const arrayNames = collectRowArrayFieldNames(formMetaRef.current)
270
270
  if (arrayNames.length > 0) {
271
271
  const rowStash = new Map<string, RowBindingApi>()
@@ -281,6 +281,13 @@ export function FormStateProvider({
281
281
  subscribe: subscribeRows
282
282
  ? (fn) => subscribeRows.call(binding, arrayName, fn)
283
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
+ : () => [],
284
291
  })
285
292
  }
286
293
  setRowBindings(rowStash)
@@ -9,6 +9,7 @@ import { RowCoordsContext } from '../RowCoordsContext.js'
9
9
  import { useIconFor } from '../icon-context.js'
10
10
  import { reorderRows, ExtraActionStrip, buildGridContainer } from './RepeaterInput.js'
11
11
  import { syncRowGates } from './syncRowGates.js'
12
+ import { consumeReconcileFlag, computeReconcilePlan } from './repeaterReconcile.js'
12
13
  import type { RowButtonsMeta } from '../../fields/RowButton.js'
13
14
  import {
14
15
  RowChromeIconButton,
@@ -252,6 +253,31 @@ export function BuilderInput({
252
253
  })
253
254
  })
254
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])
255
281
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() =>
256
282
  accordion ? {} : initSeedCollapsed(initialRows, formId, name, defaultCollapsed, collapsible),
257
283
  )
@@ -361,26 +387,23 @@ export function BuilderInput({
361
387
  }
362
388
 
363
389
  const moveRow = (id: string, dir: -1 | 1): void => {
364
- let newOrder: string[] | null = null
365
- setRows(prev => {
366
- const idx = prev.findIndex(r => r.id === id)
367
- if (idx < 0) return prev
368
- let next: RowState[]
369
- if (dir === -1) {
370
- let target = idx - 1
371
- while (target >= 0 && prev[target]?.hidden) target--
372
- if (target < 0) return prev
373
- next = reorderRows(prev, idx, target)
374
- } else {
375
- let target = idx + 1
376
- while (target < prev.length && prev[target]?.hidden) target++
377
- if (target >= prev.length) return prev
378
- next = reorderRows(prev, idx, target + 1)
379
- }
380
- if (next !== prev) newOrder = next.map(r => r.id)
381
- return next
382
- })
383
- 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))
384
407
  }
385
408
 
386
409
  // ── DnD state (skipped when buttonsOnly) ────────────────
@@ -394,15 +417,16 @@ export function BuilderInput({
394
417
  } = useRowReorderDnd({
395
418
  enabled: dndEnabled,
396
419
  onDrop: (fromId, at) => {
397
- let newOrder: string[] | null = null
398
- setRows(prev => {
399
- const fromIdx = prev.findIndex(r => r.id === fromId)
400
- if (fromIdx < 0) return prev
401
- const next = reorderRows(prev, fromIdx, at)
402
- if (next !== prev) newOrder = next.map(r => r.id)
403
- return next
404
- })
405
- 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))
406
430
  },
407
431
  })
408
432
 
@@ -21,6 +21,7 @@ import {
21
21
  DEFAULT_DELETE,
22
22
  } from './rowChromeButton.js'
23
23
  import { syncRowGates } from './syncRowGates.js'
24
+ import { consumeReconcileFlag, computeReconcilePlan } from './repeaterReconcile.js'
24
25
  import {
25
26
  generateRowId, makeAccordionStorage, makeCollapsedStorage,
26
27
  } from './rowState.js'
@@ -304,6 +305,37 @@ export function RepeaterInput({
304
305
  })
305
306
  })
306
307
  }, [rowBinding, meta.template])
308
+
309
+ // Phase A reconciliation for `Repeater.relationship` PK-switch — when
310
+ // the surrounding form just submitted in this tab AND we're inside a
311
+ // collab room with a row binding, snapshot the CRDT order after a
312
+ // short settle (long enough for WS sync to deliver any persisted
313
+ // state) and reconcile against `initialRows`. Drops orphan UUIDs
314
+ // whose rows just persisted under a fresh DB PK; idempotent + no-op
315
+ // for non-relationship Repeaters where `__id` stays UUID across
316
+ // save+reload. Plan:
317
+ // `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`.
318
+ useEffect(() => {
319
+ if (!rowBinding) return
320
+ if (!consumeReconcileFlag(formId)) return
321
+ // Give WS sync time to deliver any persisted rows before reading
322
+ // current(). 1500ms is conservative; typical sync settles in <300ms.
323
+ // The reconciler is one-shot per submit, so we accept the brief
324
+ // visual flicker over a tighter timer that might fire pre-sync.
325
+ const timer = setTimeout(() => {
326
+ const plan = computeReconcilePlan({
327
+ current: rowBinding.current(),
328
+ authoritative: initialRows.map(r => r.id),
329
+ })
330
+ for (const id of plan.toRemove) rowBinding.remove(id)
331
+ for (const id of plan.toAdd) rowBinding.add(id, {})
332
+ }, 1500)
333
+ return () => clearTimeout(timer)
334
+ // initialRows is a stable useMemo([]) ref so it's safe to omit. We
335
+ // intentionally key only on rowBinding + formId — the reconciler is
336
+ // tied to the submit lifecycle, not to row-state changes.
337
+ // eslint-disable-next-line react-hooks/exhaustive-deps
338
+ }, [rowBinding, formId])
307
339
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() =>
308
340
  accordion ? {} : initSeedCollapsed(initialRows, formId, name, defaultCollapsed, collapsible),
309
341
  )
@@ -394,29 +426,26 @@ export function RepeaterInput({
394
426
  }
395
427
 
396
428
  const moveRow = (id: string, dir: -1 | 1): void => {
397
- let newOrder: string[] | null = null
398
- setRows(prev => {
399
- const idx = prev.findIndex(r => r.id === id)
400
- if (idx < 0) return prev
401
- // Skip past hidden neighbours so reorder operates between visible
402
- // rows. Hidden rows hold their absolute slot — the visible row hops
403
- // over them.
404
- let next: RowState[]
405
- if (dir === -1) {
406
- let target = idx - 1
407
- while (target >= 0 && prev[target]?.hidden) target--
408
- if (target < 0) return prev
409
- next = reorderRows(prev, idx, target)
410
- } else {
411
- let target = idx + 1
412
- while (target < prev.length && prev[target]?.hidden) target++
413
- if (target >= prev.length) return prev
414
- next = reorderRows(prev, idx, target + 1)
415
- }
416
- if (next !== prev) newOrder = next.map(r => r.id)
417
- return next
418
- })
419
- if (newOrder !== null) rowBinding?.reorder(newOrder)
429
+ const idx = rows.findIndex(r => r.id === id)
430
+ if (idx < 0) return
431
+ // Skip past hidden neighbours so reorder operates between visible
432
+ // rows. Hidden rows hold their absolute slot — the visible row hops
433
+ // over them.
434
+ let next: RowState[]
435
+ if (dir === -1) {
436
+ let target = idx - 1
437
+ while (target >= 0 && rows[target]?.hidden) target--
438
+ if (target < 0) return
439
+ next = reorderRows(rows, idx, target)
440
+ } else {
441
+ let target = idx + 1
442
+ while (target < rows.length && rows[target]?.hidden) target++
443
+ if (target >= rows.length) return
444
+ next = reorderRows(rows, idx, target + 1)
445
+ }
446
+ if (next === rows) return
447
+ setRows(next)
448
+ rowBinding?.reorder(next.map(r => r.id))
420
449
  }
421
450
 
422
451
  // ── DnD state ───────────────────────────────────────────
@@ -429,15 +458,20 @@ export function RepeaterInput({
429
458
  } = useRowReorderDnd({
430
459
  enabled: reorderable && !disabled,
431
460
  onDrop: (fromId, at) => {
432
- let newOrder: string[] | null = null
433
- setRows(prev => {
434
- const fromIdx = prev.findIndex(r => r.id === fromId)
435
- if (fromIdx < 0) return prev
436
- const next = reorderRows(prev, fromIdx, at)
437
- if (next !== prev) newOrder = next.map(r => r.id)
438
- return next
439
- })
440
- if (newOrder !== null) rowBinding?.reorder(newOrder)
461
+ // Compute next from the current `rows` directly. The previous
462
+ // setRows(updater) + closure-mutation pattern relied on React
463
+ // running the updater synchronously inside setState — which only
464
+ // happens when no other update is queued. `useRowReorderDnd`'s
465
+ // handleDrop sets dragId/dropAt to null right before calling
466
+ // this callback, so the updater runs in commit phase and the
467
+ // outer `newOrder` stayed null past the `if` check, silently
468
+ // skipping the rowBinding.reorder broadcast.
469
+ const fromIdx = rows.findIndex(r => r.id === fromId)
470
+ if (fromIdx < 0) return
471
+ const next = reorderRows(rows, fromIdx, at)
472
+ if (next === rows) return
473
+ setRows(next)
474
+ rowBinding?.reorder(next.map(r => r.id))
441
475
  },
442
476
  })
443
477