@pilotiq/pilotiq 0.16.0 → 0.18.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.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +36 -0
- package/dist/react/CollabRoomContext.d.ts +27 -0
- package/dist/react/CollabRoomContext.d.ts.map +1 -1
- package/dist/react/CollabRoomContext.js +30 -0
- package/dist/react/CollabRoomContext.js.map +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -2
- package/dist/react/index.js.map +1 -1
- package/package.json +1 -1
- package/src/pageData.test.ts +95 -0
- package/src/react/CollabRoomContext.ts +44 -0
- package/src/react/index.ts +4 -1
- package/src/react/onProviderSynced.test.ts +90 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
|
|
2
|
-
> @pilotiq/pilotiq@0.
|
|
2
|
+
> @pilotiq/pilotiq@0.18.0 build /home/runner/work/pilotiq/pilotiq/packages/pilotiq
|
|
3
3
|
> tsc -p tsconfig.build.json && pnpm run copy-assets
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
> @pilotiq/pilotiq@0.
|
|
6
|
+
> @pilotiq/pilotiq@0.18.0 copy-assets /home/runner/work/pilotiq/pilotiq/packages/pilotiq
|
|
7
7
|
> mkdir -p dist/styles && cp -R src/styles/*.css dist/styles/
|
|
8
8
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# @pilotiq/pilotiq
|
|
2
2
|
|
|
3
|
+
## 0.18.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1b8c1bc: feat(pilotiq): extract `onProviderSynced(provider, fn)` helper for the seed-on-synced collab lifecycle pattern
|
|
8
|
+
|
|
9
|
+
Adapter packages that bind to a collab room (Tiptap-backed editors, the CodeMirror collab adapter) all need the same choreography on mount: if the provider's already streamed in the initial room state, run the seed callback now; otherwise register `provider.once('synced', fn)` and clean up via `provider.off?.('synced', fn)`. That gate was implemented separately in 4 renderers (`CollabTextRenderer`, `MarkdownEditor`, `TiptapEditor` in `@pilotiq/tiptap`; `CollabCodeMirrorEditor` in `@pilotiq/codemirror`).
|
|
10
|
+
|
|
11
|
+
This change extracts the pattern into a single helper in `@pilotiq/pilotiq/react` so future bug fixes in the gate logic (StrictMode double-fire, missing-off-method providers, etc.) fix in one place and so adapters from outside this monorepo can adopt the same pattern with one import.
|
|
12
|
+
|
|
13
|
+
**New public surface on `@pilotiq/pilotiq/react`:**
|
|
14
|
+
|
|
15
|
+
- `onProviderSynced(provider, fn): () => void` — runs `fn` synchronously if `provider.synced`, otherwise registers `provider.once('synced', fn)`. Returns a cleanup that safely unregisters via `try { provider.off?.('synced', fn) } catch {}`. Null/undefined provider returns a no-op cleanup.
|
|
16
|
+
- `SyncedProviderLike` — structural type with `synced?: boolean`, `once?(event: 'synced', fn): void`, `off?(event: 'synced', fn): void`. No yjs / y-websocket peer dep — callers cast their concrete provider via `provider as SyncedProviderLike`.
|
|
17
|
+
|
|
18
|
+
**Adapter package changes (patch-grade):**
|
|
19
|
+
|
|
20
|
+
- `@pilotiq/tiptap`: `CollabTextRenderer`, `MarkdownEditor`, and `TiptapEditor` each replace their ~10-line gate block with `return onProviderSynced(provider, trySeed)` (still inside the existing `useEffect`).
|
|
21
|
+
- `@pilotiq/codemirror`: `CollabCodeMirrorEditor` stores the cleanup and invokes it alongside `view.destroy()` inside the mount effect's combined cleanup.
|
|
22
|
+
|
|
23
|
+
Behavior is unchanged — no double-fire risk, no missed-cleanup risk, no API changes for callers of any of the affected renderers.
|
|
24
|
+
|
|
25
|
+
Test coverage: 6 new unit tests in `packages/pilotiq/src/react/onProviderSynced.test.ts` cover synced-now, defer-until-synced, cleanup-before-synced, null provider, off-throws, and provider-missing-once/off.
|
|
26
|
+
|
|
27
|
+
## 0.17.0
|
|
28
|
+
|
|
29
|
+
### Minor Changes
|
|
30
|
+
|
|
31
|
+
- 1559a62: CodeEditorField now binds to `y-codemirror.next` when a `<RecordCollabRoom>` is mounted up-tree (parallel to `@pilotiq/tiptap`'s collab plain-text path). Each `CodeEditorField` opens a doc-root `Y.Text` keyed by either the bare field name (top-level) or `${arrayName}.${rowId}.${fieldName}` (Repeater / Builder row leaves). Opt out per-field with `.collab(false)`.
|
|
32
|
+
|
|
33
|
+
Adds optional peer deps `y-codemirror.next ^0.3` + `yjs ^13` on `@pilotiq/codemirror` (under `peerDependenciesMeta.optional` — panels without `@pilotiq-pro/collab` installed continue to work as before).
|
|
34
|
+
|
|
35
|
+
Also re-exports `useRowCoords`, `RowCoordsContext`, `parseRowFieldPath`, and `ParsedRowFieldPath` from `@pilotiq/pilotiq/react` so adapter packages (codemirror today, others later) can compose row-field collab keys consistently.
|
|
36
|
+
|
|
37
|
+
**Relationship-row code editors:** `y-codemirror.next` binds against `Y.Text`, not `Y.XmlFragment`. `@pilotiq-pro/collab`'s `rowArrayBinding.renameRow` rekeys both share types alongside one another (`applyDelta(toDelta())` for `Y.Text`, `child.clone()` for `Y.XmlFragment`), so on PK-switch (UUID → DB PK after first save) a row-leaf `CodeEditorField`'s text content carries over to the new composite key on peer B without falling back to the DB column. Trade-off is rename-by-recreate (fresh CRDT identity → discards concurrent-edit history on the renamed row's code-editor leaves), same posture as the `Y.XmlFragment` branch. Requires `@pilotiq-pro/collab` ≥ the patch that ships this rekey (`pilotiq-pro@5fae624`, 2026-05-17).
|
|
38
|
+
|
|
3
39
|
## 0.16.0
|
|
4
40
|
|
|
5
41
|
### Minor Changes
|
|
@@ -34,4 +34,31 @@ export interface CollabRoom {
|
|
|
34
34
|
export declare const CollabRoomContext: import("react").Context<CollabRoom | null>;
|
|
35
35
|
/** Read the active collab room for the surrounding record, or `null`. */
|
|
36
36
|
export declare function useCollabRoom(): CollabRoom | null;
|
|
37
|
+
/**
|
|
38
|
+
* Minimal structural shape every collab provider exposes for the
|
|
39
|
+
* "initial room state has streamed in" signal. Kept structural so callers
|
|
40
|
+
* (`@pilotiq/tiptap`, `@pilotiq/codemirror`, future adapters) can pass
|
|
41
|
+
* `provider as unknown as SyncedProviderLike` without taking a hard peer
|
|
42
|
+
* dep on yjs / y-websocket / y-webrtc.
|
|
43
|
+
*/
|
|
44
|
+
export interface SyncedProviderLike {
|
|
45
|
+
synced?: boolean;
|
|
46
|
+
once?(event: 'synced', fn: () => void): void;
|
|
47
|
+
off?(event: 'synced', fn: () => void): void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Run `fn` once the collab provider's initial room state has streamed in.
|
|
51
|
+
* If the provider is already synced, `fn` fires synchronously; otherwise
|
|
52
|
+
* it's registered via `provider.once('synced', fn)`. The returned cleanup
|
|
53
|
+
* unregisters the once handler safely (idempotent + try/catch) so callers
|
|
54
|
+
* can wire it directly into a React effect's cleanup return.
|
|
55
|
+
*
|
|
56
|
+
* Useful for the brand-new-record seed pattern: editors mounting against
|
|
57
|
+
* a freshly-created record want to push the SSR-rendered default into
|
|
58
|
+
* the empty `Y.Text` / `Y.XmlFragment` exactly once after sync, before
|
|
59
|
+
* the user types. Race caveat: two peers simultaneously mounting against
|
|
60
|
+
* a brand-new record can both see `length === 0` and both seed —
|
|
61
|
+
* accepted today across every adapter's seed path.
|
|
62
|
+
*/
|
|
63
|
+
export declare function onProviderSynced(provider: SyncedProviderLike | null | undefined, fn: () => void): () => void;
|
|
37
64
|
//# sourceMappingURL=CollabRoomContext.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CollabRoomContext.d.ts","sourceRoot":"","sources":["../../src/react/CollabRoomContext.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,UAAU;IACzB,gDAAgD;IAChD,IAAI,EAAO,OAAO,CAAA;IAClB,4DAA4D;IAC5D,QAAQ,EAAG,OAAO,CAAA;IAClB,+EAA+E;IAC/E,IAAI,CAAC,EAAE;QACL,IAAI,CAAC,EAAG,MAAM,CAAA;QACd,KAAK,CAAC,EAAE,MAAM,CAAA;KACf,CAAA;CACF;AAED;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,4CAAyC,CAAA;AAEvE,yEAAyE;AACzE,wBAAgB,aAAa,IAAI,UAAU,GAAG,IAAI,CAEjD"}
|
|
1
|
+
{"version":3,"file":"CollabRoomContext.d.ts","sourceRoot":"","sources":["../../src/react/CollabRoomContext.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,UAAU;IACzB,gDAAgD;IAChD,IAAI,EAAO,OAAO,CAAA;IAClB,4DAA4D;IAC5D,QAAQ,EAAG,OAAO,CAAA;IAClB,+EAA+E;IAC/E,IAAI,CAAC,EAAE;QACL,IAAI,CAAC,EAAG,MAAM,CAAA;QACd,KAAK,CAAC,EAAE,MAAM,CAAA;KACf,CAAA;CACF;AAED;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,4CAAyC,CAAA;AAEvE,yEAAyE;AACzE,wBAAgB,aAAa,IAAI,UAAU,GAAG,IAAI,CAEjD;AAED;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,IAAI,CAAC,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI,CAAA;IAC5C,GAAG,CAAC,CAAC,KAAK,EAAG,QAAQ,EAAE,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI,CAAA;CAC7C;AAID;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,kBAAkB,GAAG,IAAI,GAAG,SAAS,EAC/C,EAAE,EAAQ,MAAM,IAAI,GACnB,MAAM,IAAI,CAUZ"}
|
|
@@ -9,4 +9,34 @@ export const CollabRoomContext = createContext(null);
|
|
|
9
9
|
export function useCollabRoom() {
|
|
10
10
|
return useContext(CollabRoomContext);
|
|
11
11
|
}
|
|
12
|
+
const NOOP_CLEANUP = () => { };
|
|
13
|
+
/**
|
|
14
|
+
* Run `fn` once the collab provider's initial room state has streamed in.
|
|
15
|
+
* If the provider is already synced, `fn` fires synchronously; otherwise
|
|
16
|
+
* it's registered via `provider.once('synced', fn)`. The returned cleanup
|
|
17
|
+
* unregisters the once handler safely (idempotent + try/catch) so callers
|
|
18
|
+
* can wire it directly into a React effect's cleanup return.
|
|
19
|
+
*
|
|
20
|
+
* Useful for the brand-new-record seed pattern: editors mounting against
|
|
21
|
+
* a freshly-created record want to push the SSR-rendered default into
|
|
22
|
+
* the empty `Y.Text` / `Y.XmlFragment` exactly once after sync, before
|
|
23
|
+
* the user types. Race caveat: two peers simultaneously mounting against
|
|
24
|
+
* a brand-new record can both see `length === 0` and both seed —
|
|
25
|
+
* accepted today across every adapter's seed path.
|
|
26
|
+
*/
|
|
27
|
+
export function onProviderSynced(provider, fn) {
|
|
28
|
+
if (!provider)
|
|
29
|
+
return NOOP_CLEANUP;
|
|
30
|
+
if (provider.synced) {
|
|
31
|
+
fn();
|
|
32
|
+
return NOOP_CLEANUP;
|
|
33
|
+
}
|
|
34
|
+
provider.once?.('synced', fn);
|
|
35
|
+
return () => {
|
|
36
|
+
try {
|
|
37
|
+
provider.off?.('synced', fn);
|
|
38
|
+
}
|
|
39
|
+
catch { /* ignore */ }
|
|
40
|
+
};
|
|
41
|
+
}
|
|
12
42
|
//# sourceMappingURL=CollabRoomContext.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CollabRoomContext.js","sourceRoot":"","sources":["../../src/react/CollabRoomContext.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,OAAO,CAAA;AA+BjD;;;;GAIG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,aAAa,CAAoB,IAAI,CAAC,CAAA;AAEvE,yEAAyE;AACzE,MAAM,UAAU,aAAa;IAC3B,OAAO,UAAU,CAAC,iBAAiB,CAAC,CAAA;AACtC,CAAC"}
|
|
1
|
+
{"version":3,"file":"CollabRoomContext.js","sourceRoot":"","sources":["../../src/react/CollabRoomContext.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,OAAO,CAAA;AA+BjD;;;;GAIG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,aAAa,CAAoB,IAAI,CAAC,CAAA;AAEvE,yEAAyE;AACzE,MAAM,UAAU,aAAa;IAC3B,OAAO,UAAU,CAAC,iBAAiB,CAAC,CAAA;AACtC,CAAC;AAeD,MAAM,YAAY,GAAG,GAAS,EAAE,GAAE,CAAC,CAAA;AAEnC;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,gBAAgB,CAC9B,QAA+C,EAC/C,EAAoB;IAEpB,IAAI,CAAC,QAAQ;QAAE,OAAO,YAAY,CAAA;IAClC,IAAI,QAAQ,CAAC,MAAM,EAAE,CAAC;QACpB,EAAE,EAAE,CAAA;QACJ,OAAO,YAAY,CAAA;IACrB,CAAC;IACD,QAAQ,CAAC,IAAI,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAA;IAC7B,OAAO,GAAG,EAAE;QACV,IAAI,CAAC;YAAC,QAAQ,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC;IAC7D,CAAC,CAAA;AACH,CAAC"}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { registerFieldLabelSlot, getFieldLabelSlot, type FieldLabelSlotProps } f
|
|
|
6
6
|
export { registerPendingSuggestionOverlay, getPendingSuggestionOverlay, type PendingSuggestionOverlayProps, } from './PendingSuggestionOverlayRegistry.js';
|
|
7
7
|
export { PendingSuggestionsContext, usePendingSuggestions, usePendingSuggestionsForField, type PendingSuggestion, type PendingSuggestionOrigin, type PendingSuggestionsApi, } from './PendingSuggestionsContext.js';
|
|
8
8
|
export { registerPendingSuggestionApplier, getPendingSuggestionApplier, type PendingSuggestionApplier, } from './PendingSuggestionApplierRegistry.js';
|
|
9
|
-
export { CollabRoomContext, useCollabRoom, type CollabRoom, } from './CollabRoomContext.js';
|
|
9
|
+
export { CollabRoomContext, useCollabRoom, onProviderSynced, type CollabRoom, type SyncedProviderLike, } from './CollabRoomContext.js';
|
|
10
10
|
export { registerCollabExtensions, getCollabExtensions, type CollabExtensionFactory, type CollabExtensionFactoryArgs, } from './CollabExtensionFactoryRegistry.js';
|
|
11
11
|
export { registerCollabTextRenderer, getCollabTextRenderer, type CollabTextRenderer, type CollabTextRendererProps, } from './CollabTextRendererRegistry.js';
|
|
12
12
|
export { registerMarkdownEditor, getMarkdownEditor, type MarkdownEditor, type MarkdownEditorProps, } from './MarkdownEditorRegistry.js';
|
|
@@ -20,7 +20,8 @@ export { CustomPageWrapperGate, type CustomPageWrapperGateProps, type PageCollab
|
|
|
20
20
|
export { parseRecordPageUrl, parseRecordEditUrl, type RecordPageIdentity, type RecordPageRole, type RecordEditIdentity, } from './parseRecordEditUrl.js';
|
|
21
21
|
export { registerWidgetRenderer, getWidgetRenderer, type WidgetRendererProps, } from './widgetRegistry.js';
|
|
22
22
|
export { FormStateProvider, useFieldState, useFormState, useRowBinding, type FormStateApi, type FormStateProviderProps, type UseFieldStateResult, } from './FormStateContext.js';
|
|
23
|
-
export { parseFormDataToNested } from './formStateHelpers.js';
|
|
23
|
+
export { parseFormDataToNested, parseRowFieldPath, type ParsedRowFieldPath } from './formStateHelpers.js';
|
|
24
|
+
export { RowCoordsContext, useRowCoords, type RowCoords } from './RowCoordsContext.js';
|
|
24
25
|
export { NavigateProvider, useNavigate, type NavigateFn } from './navigate.js';
|
|
25
26
|
export { ToasterProvider, useToast } from './Toaster.js';
|
|
26
27
|
export { WidgetDataProvider, useInitialWidgetData, useWidgetData, type WidgetState, type WidgetMetaLike, type WidgetDataProviderProps, } from './WidgetDataContext.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,eAAe,CAAA;AAC5D,OAAO,EACL,yBAAyB,EACzB,oBAAoB,EACpB,UAAU,EACV,KAAK,iBAAiB,GACvB,MAAM,mBAAmB,CAAA;AAE1B,OAAO,EACL,cAAc,EACd,KAAK,mBAAmB,EACxB,UAAU,EACV,KAAK,eAAe,GACrB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,KAAK,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAChG,OAAO,EAAE,sBAAsB,EAAE,iBAAiB,EAAE,KAAK,mBAAmB,EAAE,MAAM,6BAA6B,CAAA;AACjH,OAAO,EACL,gCAAgC,EAChC,2BAA2B,EAC3B,KAAK,6BAA6B,GACnC,MAAM,uCAAuC,CAAA;AAC9C,OAAO,EACL,yBAAyB,EACzB,qBAAqB,EACrB,6BAA6B,EAC7B,KAAK,iBAAiB,EACtB,KAAK,uBAAuB,EAC5B,KAAK,qBAAqB,GAC3B,MAAM,gCAAgC,CAAA;AACvC,OAAO,EACL,gCAAgC,EAChC,2BAA2B,EAC3B,KAAK,wBAAwB,GAC9B,MAAM,uCAAuC,CAAA;AAC9C,OAAO,EACL,iBAAiB,EACjB,aAAa,EACb,KAAK,UAAU,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"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"}
|
package/dist/react/index.js
CHANGED
|
@@ -6,7 +6,7 @@ export { registerFieldLabelSlot, getFieldLabelSlot } from './FieldLabelSlotRegis
|
|
|
6
6
|
export { registerPendingSuggestionOverlay, getPendingSuggestionOverlay, } from './PendingSuggestionOverlayRegistry.js';
|
|
7
7
|
export { PendingSuggestionsContext, usePendingSuggestions, usePendingSuggestionsForField, } from './PendingSuggestionsContext.js';
|
|
8
8
|
export { registerPendingSuggestionApplier, getPendingSuggestionApplier, } from './PendingSuggestionApplierRegistry.js';
|
|
9
|
-
export { CollabRoomContext, useCollabRoom, } from './CollabRoomContext.js';
|
|
9
|
+
export { CollabRoomContext, useCollabRoom, onProviderSynced, } from './CollabRoomContext.js';
|
|
10
10
|
export { registerCollabExtensions, getCollabExtensions, } from './CollabExtensionFactoryRegistry.js';
|
|
11
11
|
export { registerCollabTextRenderer, getCollabTextRenderer, } from './CollabTextRendererRegistry.js';
|
|
12
12
|
export { registerMarkdownEditor, getMarkdownEditor, } from './MarkdownEditorRegistry.js';
|
|
@@ -20,7 +20,8 @@ export { CustomPageWrapperGate, } from './CustomPageWrapperGate.js';
|
|
|
20
20
|
export { parseRecordPageUrl, parseRecordEditUrl, } from './parseRecordEditUrl.js';
|
|
21
21
|
export { registerWidgetRenderer, getWidgetRenderer, } from './widgetRegistry.js';
|
|
22
22
|
export { FormStateProvider, useFieldState, useFormState, useRowBinding, } from './FormStateContext.js';
|
|
23
|
-
export { parseFormDataToNested } from './formStateHelpers.js';
|
|
23
|
+
export { parseFormDataToNested, parseRowFieldPath } from './formStateHelpers.js';
|
|
24
|
+
export { RowCoordsContext, useRowCoords } from './RowCoordsContext.js';
|
|
24
25
|
export { NavigateProvider, useNavigate } from './navigate.js';
|
|
25
26
|
export { ToasterProvider, useToast } from './Toaster.js';
|
|
26
27
|
export { WidgetDataProvider, useInitialWidgetData, useWidgetData, } from './WidgetDataContext.js';
|
package/dist/react/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAsB,MAAM,eAAe,CAAA;AAC5D,OAAO,EACL,yBAAyB,EACzB,oBAAoB,EACpB,UAAU,GAEX,MAAM,mBAAmB,CAAA;AAE1B,OAAO,EACL,cAAc,EAEd,UAAU,GAEX,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,qBAAqB,EAAE,gBAAgB,EAA2B,MAAM,eAAe,CAAA;AAChG,OAAO,EAAE,sBAAsB,EAAE,iBAAiB,EAA4B,MAAM,6BAA6B,CAAA;AACjH,OAAO,EACL,gCAAgC,EAChC,2BAA2B,GAE5B,MAAM,uCAAuC,CAAA;AAC9C,OAAO,EACL,yBAAyB,EACzB,qBAAqB,EACrB,6BAA6B,GAI9B,MAAM,gCAAgC,CAAA;AACvC,OAAO,EACL,gCAAgC,EAChC,2BAA2B,GAE5B,MAAM,uCAAuC,CAAA;AAC9C,OAAO,EACL,iBAAiB,EACjB,aAAa,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/pageData.test.ts
CHANGED
|
@@ -987,6 +987,101 @@ describe('formStateData (Plan #5)', () => {
|
|
|
987
987
|
const formMeta = result.form as { values?: Record<string, unknown> }
|
|
988
988
|
assert.equal(formMeta.values?.['slug'], 'hello-world')
|
|
989
989
|
})
|
|
990
|
+
|
|
991
|
+
// Regression lock — reactive `itemHidden` end-to-end. Server-side resolve
|
|
992
|
+
// alone is covered in `RepeaterField.test.ts` / `BuilderField.test.ts`, and
|
|
993
|
+
// the client-side row-gate sync is covered in `syncRowGates.test.ts`. This
|
|
994
|
+
// covers the wire between them: applyStateUpdate of a row-leaf dotted
|
|
995
|
+
// path, then full resolveSchema, with the `itemHidden` rule reading the
|
|
996
|
+
// updated row value. If this regresses, peer A typing into a `live()`
|
|
997
|
+
// inner field would never flip the row's chrome on a real form.
|
|
998
|
+
|
|
999
|
+
it('re-evaluates Repeater itemHidden after a live() inner-leaf cycle', async () => {
|
|
1000
|
+
class TestPage extends Page {
|
|
1001
|
+
static override slug = 'demo'
|
|
1002
|
+
static override schema() {
|
|
1003
|
+
return [Form.make().formId('the-form').schema([
|
|
1004
|
+
Repeater.make('items')
|
|
1005
|
+
.schema([
|
|
1006
|
+
TextField.make('mode').live(),
|
|
1007
|
+
TextField.make('label'),
|
|
1008
|
+
])
|
|
1009
|
+
.itemHidden(({ values }) => (values as Record<string, unknown>)['mode'] === 'hidden'),
|
|
1010
|
+
])]
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
1014
|
+
|
|
1015
|
+
// Before: row is visible.
|
|
1016
|
+
const visible = await formStateData(
|
|
1017
|
+
panel,
|
|
1018
|
+
{ kind: 'page', pageSlug: 'demo' },
|
|
1019
|
+
{ formId: 'the-form', changed: 'items.0.mode', values: { items: [{ mode: 'visible', label: 'one' }] } },
|
|
1020
|
+
)
|
|
1021
|
+
if (visible === null || !visible.ok) throw new Error('expected ok result')
|
|
1022
|
+
const visibleMeta = visible.form as { children: Array<{ rows: Array<{ id: string; hidden?: boolean }> }> }
|
|
1023
|
+
assert.equal(visibleMeta.children[0]?.rows[0]?.hidden, undefined)
|
|
1024
|
+
|
|
1025
|
+
// After: same row, `mode` flipped to `'hidden'` — itemHidden re-evaluates.
|
|
1026
|
+
const hidden = await formStateData(
|
|
1027
|
+
panel,
|
|
1028
|
+
{ kind: 'page', pageSlug: 'demo' },
|
|
1029
|
+
{ formId: 'the-form', changed: 'items.0.mode', values: { items: [{ mode: 'hidden', label: 'one' }] } },
|
|
1030
|
+
)
|
|
1031
|
+
if (hidden === null || !hidden.ok) throw new Error('expected ok result')
|
|
1032
|
+
const hiddenMeta = hidden.form as { children: Array<{ rows: Array<{ id: string; hidden?: boolean }> }> }
|
|
1033
|
+
assert.equal(hiddenMeta.children[0]?.rows[0]?.hidden, true)
|
|
1034
|
+
// Row id stays stable across the cycle — syncRowGates on the client
|
|
1035
|
+
// matches on `id`, so an unstable id would silently skip the hidden
|
|
1036
|
+
// flip.
|
|
1037
|
+
assert.equal(hiddenMeta.children[0]?.rows[0]?.id, visibleMeta.children[0]?.rows[0]?.id)
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1040
|
+
it('re-evaluates Builder itemHidden after a live() block-leaf cycle', async () => {
|
|
1041
|
+
class TestPage extends Page {
|
|
1042
|
+
static override slug = 'demo'
|
|
1043
|
+
static override schema() {
|
|
1044
|
+
return [Form.make().formId('the-form').schema([
|
|
1045
|
+
Builder.make('content')
|
|
1046
|
+
.blocks([
|
|
1047
|
+
Block.make('heading').schema([
|
|
1048
|
+
TextField.make('text').live(),
|
|
1049
|
+
TextField.make('anchor'),
|
|
1050
|
+
]),
|
|
1051
|
+
])
|
|
1052
|
+
.itemHidden(({ values }) => (values as Record<string, unknown>)['text'] === 'skip'),
|
|
1053
|
+
])]
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
|
|
1057
|
+
|
|
1058
|
+
const keep = await formStateData(
|
|
1059
|
+
panel,
|
|
1060
|
+
{ kind: 'page', pageSlug: 'demo' },
|
|
1061
|
+
{
|
|
1062
|
+
formId: 'the-form',
|
|
1063
|
+
changed: 'content.0.data.text',
|
|
1064
|
+
values: { content: [{ type: 'heading', data: { text: 'keep', anchor: '' } }] },
|
|
1065
|
+
},
|
|
1066
|
+
)
|
|
1067
|
+
if (keep === null || !keep.ok) throw new Error('expected ok result')
|
|
1068
|
+
const keepMeta = keep.form as { children: Array<{ rows: Array<{ id: string; hidden?: boolean }> }> }
|
|
1069
|
+
assert.equal(keepMeta.children[0]?.rows[0]?.hidden, undefined)
|
|
1070
|
+
|
|
1071
|
+
const skip = await formStateData(
|
|
1072
|
+
panel,
|
|
1073
|
+
{ kind: 'page', pageSlug: 'demo' },
|
|
1074
|
+
{
|
|
1075
|
+
formId: 'the-form',
|
|
1076
|
+
changed: 'content.0.data.text',
|
|
1077
|
+
values: { content: [{ type: 'heading', data: { text: 'skip', anchor: '' } }] },
|
|
1078
|
+
},
|
|
1079
|
+
)
|
|
1080
|
+
if (skip === null || !skip.ok) throw new Error('expected ok result')
|
|
1081
|
+
const skipMeta = skip.form as { children: Array<{ rows: Array<{ id: string; hidden?: boolean }> }> }
|
|
1082
|
+
assert.equal(skipMeta.children[0]?.rows[0]?.hidden, true)
|
|
1083
|
+
assert.equal(skipMeta.children[0]?.rows[0]?.id, keepMeta.children[0]?.rows[0]?.id)
|
|
1084
|
+
})
|
|
990
1085
|
})
|
|
991
1086
|
|
|
992
1087
|
describe('mentionResolveData (async mention items)', () => {
|
|
@@ -40,3 +40,47 @@ export const CollabRoomContext = createContext<CollabRoom | null>(null)
|
|
|
40
40
|
export function useCollabRoom(): CollabRoom | null {
|
|
41
41
|
return useContext(CollabRoomContext)
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Minimal structural shape every collab provider exposes for the
|
|
46
|
+
* "initial room state has streamed in" signal. Kept structural so callers
|
|
47
|
+
* (`@pilotiq/tiptap`, `@pilotiq/codemirror`, future adapters) can pass
|
|
48
|
+
* `provider as unknown as SyncedProviderLike` without taking a hard peer
|
|
49
|
+
* dep on yjs / y-websocket / y-webrtc.
|
|
50
|
+
*/
|
|
51
|
+
export interface SyncedProviderLike {
|
|
52
|
+
synced?: boolean
|
|
53
|
+
once?(event: 'synced', fn: () => void): void
|
|
54
|
+
off?(event: 'synced', fn: () => void): void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const NOOP_CLEANUP = (): void => {}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run `fn` once the collab provider's initial room state has streamed in.
|
|
61
|
+
* If the provider is already synced, `fn` fires synchronously; otherwise
|
|
62
|
+
* it's registered via `provider.once('synced', fn)`. The returned cleanup
|
|
63
|
+
* unregisters the once handler safely (idempotent + try/catch) so callers
|
|
64
|
+
* can wire it directly into a React effect's cleanup return.
|
|
65
|
+
*
|
|
66
|
+
* Useful for the brand-new-record seed pattern: editors mounting against
|
|
67
|
+
* a freshly-created record want to push the SSR-rendered default into
|
|
68
|
+
* the empty `Y.Text` / `Y.XmlFragment` exactly once after sync, before
|
|
69
|
+
* the user types. Race caveat: two peers simultaneously mounting against
|
|
70
|
+
* a brand-new record can both see `length === 0` and both seed —
|
|
71
|
+
* accepted today across every adapter's seed path.
|
|
72
|
+
*/
|
|
73
|
+
export function onProviderSynced(
|
|
74
|
+
provider: SyncedProviderLike | null | undefined,
|
|
75
|
+
fn: () => void,
|
|
76
|
+
): () => void {
|
|
77
|
+
if (!provider) return NOOP_CLEANUP
|
|
78
|
+
if (provider.synced) {
|
|
79
|
+
fn()
|
|
80
|
+
return NOOP_CLEANUP
|
|
81
|
+
}
|
|
82
|
+
provider.once?.('synced', fn)
|
|
83
|
+
return () => {
|
|
84
|
+
try { provider.off?.('synced', fn) } catch { /* ignore */ }
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -35,7 +35,9 @@ export {
|
|
|
35
35
|
export {
|
|
36
36
|
CollabRoomContext,
|
|
37
37
|
useCollabRoom,
|
|
38
|
+
onProviderSynced,
|
|
38
39
|
type CollabRoom,
|
|
40
|
+
type SyncedProviderLike,
|
|
39
41
|
} from './CollabRoomContext.js'
|
|
40
42
|
export {
|
|
41
43
|
registerCollabExtensions,
|
|
@@ -117,7 +119,8 @@ export {
|
|
|
117
119
|
type UseFieldStateResult,
|
|
118
120
|
} from './FormStateContext.js'
|
|
119
121
|
|
|
120
|
-
export { parseFormDataToNested } from './formStateHelpers.js'
|
|
122
|
+
export { parseFormDataToNested, parseRowFieldPath, type ParsedRowFieldPath } from './formStateHelpers.js'
|
|
123
|
+
export { RowCoordsContext, useRowCoords, type RowCoords } from './RowCoordsContext.js'
|
|
121
124
|
|
|
122
125
|
export { NavigateProvider, useNavigate, type NavigateFn } from './navigate.js'
|
|
123
126
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { onProviderSynced, type SyncedProviderLike } from './CollabRoomContext.js'
|
|
5
|
+
|
|
6
|
+
/** Bare provider double — captures `once` registrations and supports
|
|
7
|
+
* flipping `synced` so we can assert the deferred path. */
|
|
8
|
+
function mockProvider(initialSynced = false): SyncedProviderLike & {
|
|
9
|
+
fire(): void
|
|
10
|
+
registered: number
|
|
11
|
+
unregistered: number
|
|
12
|
+
offThrows: boolean
|
|
13
|
+
} {
|
|
14
|
+
let synced = initialSynced
|
|
15
|
+
let pending: (() => void) | null = null
|
|
16
|
+
const inst = {
|
|
17
|
+
get synced() { return synced },
|
|
18
|
+
set synced(v: boolean) { synced = v },
|
|
19
|
+
once(event: 'synced', fn: () => void) {
|
|
20
|
+
assert.equal(event, 'synced', 'only synced is registered')
|
|
21
|
+
pending = fn
|
|
22
|
+
inst.registered++
|
|
23
|
+
},
|
|
24
|
+
off(event: 'synced', fn: () => void) {
|
|
25
|
+
assert.equal(event, 'synced', 'only synced is unregistered')
|
|
26
|
+
if (inst.offThrows) throw new Error('boom')
|
|
27
|
+
if (pending === fn) pending = null
|
|
28
|
+
inst.unregistered++
|
|
29
|
+
},
|
|
30
|
+
fire() { if (pending) { const f = pending; pending = null; f() } },
|
|
31
|
+
registered: 0,
|
|
32
|
+
unregistered: 0,
|
|
33
|
+
offThrows: false,
|
|
34
|
+
}
|
|
35
|
+
return inst
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('onProviderSynced', () => {
|
|
39
|
+
it('fires fn synchronously when provider is already synced', () => {
|
|
40
|
+
const provider = mockProvider(true)
|
|
41
|
+
let calls = 0
|
|
42
|
+
const cleanup = onProviderSynced(provider, () => { calls++ })
|
|
43
|
+
assert.equal(calls, 1, 'fn ran synchronously')
|
|
44
|
+
assert.equal(provider.registered, 0, 'once never registered')
|
|
45
|
+
cleanup()
|
|
46
|
+
assert.equal(provider.unregistered, 0, 'no-op cleanup did not call off')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('defers fn until synced event when provider not yet synced', () => {
|
|
50
|
+
const provider = mockProvider(false)
|
|
51
|
+
let calls = 0
|
|
52
|
+
const cleanup = onProviderSynced(provider, () => { calls++ })
|
|
53
|
+
assert.equal(calls, 0, 'fn not yet called')
|
|
54
|
+
assert.equal(provider.registered, 1, 'once was registered')
|
|
55
|
+
provider.fire()
|
|
56
|
+
assert.equal(calls, 1, 'fn ran after synced fired')
|
|
57
|
+
cleanup()
|
|
58
|
+
assert.equal(provider.unregistered, 1, 'cleanup called off')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('cleanup unregisters the once handler when called before synced fires', () => {
|
|
62
|
+
const provider = mockProvider(false)
|
|
63
|
+
let calls = 0
|
|
64
|
+
const cleanup = onProviderSynced(provider, () => { calls++ })
|
|
65
|
+
cleanup()
|
|
66
|
+
assert.equal(provider.unregistered, 1, 'off was called')
|
|
67
|
+
provider.fire()
|
|
68
|
+
assert.equal(calls, 0, 'fn never fired after cleanup')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns a no-op cleanup when provider is null or undefined', () => {
|
|
72
|
+
const noopCleanup = onProviderSynced(null, () => { throw new Error('unreachable') })
|
|
73
|
+
assert.doesNotThrow(() => noopCleanup())
|
|
74
|
+
const noopCleanup2 = onProviderSynced(undefined, () => { throw new Error('unreachable') })
|
|
75
|
+
assert.doesNotThrow(() => noopCleanup2())
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('swallows errors thrown by provider.off so cleanup never breaks an effect', () => {
|
|
79
|
+
const provider = mockProvider(false)
|
|
80
|
+
provider.offThrows = true
|
|
81
|
+
const cleanup = onProviderSynced(provider, () => {})
|
|
82
|
+
assert.doesNotThrow(cleanup, 'cleanup did not propagate the throw')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('gracefully handles a provider missing once/off methods', () => {
|
|
86
|
+
const sparse: SyncedProviderLike = { synced: false }
|
|
87
|
+
const cleanup = onProviderSynced(sparse, () => {})
|
|
88
|
+
assert.doesNotThrow(cleanup, 'cleanup is safe when off is undefined')
|
|
89
|
+
})
|
|
90
|
+
})
|