@pilotiq/tiptap 3.10.0 → 3.10.2

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.
@@ -0,0 +1,112 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import React from 'react'
4
+ import { render, cleanup, waitFor } from '@testing-library/react'
5
+
6
+ import { TiptapEditor } from './TiptapEditor.js'
7
+
8
+ /**
9
+ * Behavioral coverage for the rich-text field renderer. The matching
10
+ * pure-data tests under `RichTextField.test.ts` cover `toMeta()` / option
11
+ * resolution; this file proves the React renderer actually mounts in our
12
+ * jsdom + RTL environment and produces the FormData wiring downstream
13
+ * consumers depend on.
14
+ *
15
+ * Scope is deliberately narrow — slash menu, floating toolbar, mention
16
+ * popover, side panel, and AI suggestion bridge all need additional
17
+ * fixtures (focus traps, document-level key handlers, context providers)
18
+ * and are covered by the playground + Playwright e2e suite. The asserts
19
+ * here are the ones that would catch a "renderer crashes at mount" or
20
+ * "hidden input wire-name drift" regression cheaply.
21
+ */
22
+ describe('TiptapEditor (DOM)', () => {
23
+ function renderEditor(opts: {
24
+ name: string
25
+ defaultValue?: unknown
26
+ placeholder?: string
27
+ }) {
28
+ const { name, defaultValue = '', placeholder = 'Write…' } = opts
29
+ return render(
30
+ <TiptapEditor
31
+ el={{ type: 'field', fieldType: 'richtext', name }}
32
+ name={name}
33
+ defaultValue={defaultValue}
34
+ required={false}
35
+ disabled={false}
36
+ placeholder={placeholder}
37
+ />,
38
+ )
39
+ }
40
+
41
+ it('mounts the editor on hydration and exposes the hidden FormData input', async () => {
42
+ const { container } = renderEditor({ name: 'bio' })
43
+ try {
44
+ // Initial render is the SSR placeholder gated on `mounted`. After
45
+ // the mount-effect flips, `ClientEditor` mounts → Tiptap defers
46
+ // editor construction to its own effect under `immediatelyRender:
47
+ // false` → the `.ProseMirror` contenteditable lands in the DOM.
48
+ await waitFor(() => {
49
+ assert.ok(
50
+ container.querySelector('.ProseMirror'),
51
+ 'ProseMirror contenteditable mounts after hydration',
52
+ )
53
+ })
54
+ const hidden = container.querySelector<HTMLInputElement>(
55
+ 'input[type="hidden"][name="bio"]',
56
+ )
57
+ assert.ok(hidden, 'hidden FormData input present alongside the editor')
58
+ } finally {
59
+ cleanup()
60
+ }
61
+ })
62
+
63
+ it('serializes a JSON `defaultValue` into the hidden input on first paint', async () => {
64
+ // Tiptap doc shape: paragraph with one text node. The renderer's
65
+ // `serializeForHidden` round-trips this verbatim under the default
66
+ // `storage: 'json'` setting, so the hidden input should hold the
67
+ // JSON string at the very first render (before the editor itself
68
+ // has even mounted — the SSR placeholder ships the same serialized
69
+ // value so submit-on-mount works).
70
+ const defaultValue = {
71
+ type: 'doc',
72
+ content: [
73
+ { type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
74
+ ],
75
+ }
76
+ const { container } = renderEditor({ name: 'body', defaultValue })
77
+ try {
78
+ const hidden = container.querySelector<HTMLInputElement>(
79
+ 'input[type="hidden"][name="body"]',
80
+ )
81
+ assert.ok(hidden, 'hidden input present')
82
+ const parsed = JSON.parse(hidden.value)
83
+ assert.equal(parsed.type, 'doc', 'value parses to a doc')
84
+ assert.equal(parsed.content[0].content[0].text, 'hello')
85
+ } finally {
86
+ cleanup()
87
+ }
88
+ })
89
+
90
+ it('uses the field `name` for the hidden input wire-name', async () => {
91
+ // Pilotiq forms post FormData keyed by field name; renaming the wire
92
+ // input here would silently drop the field on submit. The non-default
93
+ // `name` ("article_body" with an underscore) doubles as a regression
94
+ // guard for any future serializer that tries to clean / normalize
95
+ // names — the value the host passes in is the value posted back.
96
+ const { container } = renderEditor({ name: 'article_body' })
97
+ try {
98
+ await waitFor(() => {
99
+ assert.ok(
100
+ container.querySelector('.ProseMirror'),
101
+ 'editor mounted (post-hydration probe so the test isn\'t racing the SSR branch)',
102
+ )
103
+ })
104
+ const hidden = container.querySelector<HTMLInputElement>(
105
+ 'input[type="hidden"][name="article_body"]',
106
+ )
107
+ assert.ok(hidden, 'wire-name matches `name` prop verbatim')
108
+ } finally {
109
+ cleanup()
110
+ }
111
+ })
112
+ })
@@ -18,7 +18,7 @@ import type {
18
18
  CollabRoom,
19
19
  CollabExtensionFactory,
20
20
  } from '@pilotiq/pilotiq/react'
21
- import { useCollabRoom, getCollabExtensions, onProviderSynced, useRowCoords, parseRowFieldPath } from '@pilotiq/pilotiq/react'
21
+ import { useCollabRoom, getCollabExtensions, useCollabSeed, useRowCoords, parseRowFieldPath } from '@pilotiq/pilotiq/react'
22
22
  import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
23
23
  import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js'
24
24
  import { AiSuggestionBanner } from './AiSuggestionBanner.js'
@@ -449,47 +449,66 @@ function ClientEditor(props: ClientEditorProps) {
449
449
  // scratch on every keystroke.
450
450
  useEffect(() => { editorRef.current = editor ?? null }, [editor])
451
451
 
452
- // First-load seed when collab is active. Collaboration starts the
453
- // editor empty regardless of `defaultValue`; once the WebsocketProvider
454
- // syncs the room state from the server we check whether the field's
455
- // Y.XmlFragment was ever written. Empty + we have an initial value =
456
- // first session for this record — push the DB content into the ydoc
457
- // exactly once. Non-empty = the room already has authoritative state;
458
- // don't overwrite.
452
+ // Mirror `disabled` onto the live editor at runtime. `useEditor`'s
453
+ // `editable: !disabled` only fires at construction time, so a parent
454
+ // flipping read-only after mount (e.g. policy denial mid-edit, form
455
+ // submitting state) would silently no-op without this effect. Same
456
+ // shape MarkdownEditor.tsx and CollabTextRenderer.tsx already use.
459
457
  useEffect(() => {
460
- if (!editor || !collabActive || !room) return
461
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
462
- const provider = room.provider as any
463
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
464
- const ydoc = room.ydoc as any
465
- if (!provider || !ydoc) return
466
-
467
- const trySeed = () => {
468
- try {
469
- const fragment = ydoc.getXmlFragment(collabName)
470
- if (
471
- fragment &&
472
- fragment.length === 0 &&
473
- initialContent !== undefined &&
474
- initialContent !== null &&
475
- initialContent !== '' &&
476
- isTiptapShapedContent(initialContent)
477
- ) {
478
- // setContent dispatches a Tiptap transaction; the bound
479
- // y-prosemirror binding (inside Collaboration) mirrors it
480
- // into the fragment so every peer sees the seeded state.
481
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
482
- editor.commands.setContent(initialContent as any)
483
- }
484
- } catch { /* ignore — seed is best-effort */ }
485
- }
458
+ if (!editor) return
459
+ editor.setEditable(!disabled)
460
+ }, [editor, disabled])
486
461
 
487
- return onProviderSynced(provider, trySeed)
488
- // `initialContent` resolves once per mount (parsed from defaultValue
489
- // at the top of this body). The keyed remount above guarantees we
490
- // get a fresh closure per collab session.
491
- // eslint-disable-next-line react-hooks/exhaustive-deps
492
- }, [editor, collabActive, room, name])
462
+ // First-load seed when collab is active. Collaboration starts the
463
+ // editor empty regardless of `defaultValue`; once the room's first
464
+ // sync resolves, `useCollabSeed` runs the callback inside
465
+ // `ydoc.transact(..., 'pilotiq-collab-seed')`. We check whether the
466
+ // field's `Y.XmlFragment` was ever written — empty + we have an
467
+ // initial value = first session for this record — push the DB
468
+ // content into the ydoc exactly once. Non-empty = the room already
469
+ // has authoritative state; don't overwrite. Gating on `editor` keeps
470
+ // the effect from firing before Tiptap mounts its
471
+ // y-prosemirror binding (Tiptap v3 defers editor construction to
472
+ // first effect under `immediatelyRender: false`).
473
+ //
474
+ // Subscribe-after-sync mirror: after the seed branch (or no-op when
475
+ // the fragment already has content from a remote peer), serialize the
476
+ // editor's current state into the hidden FormData input. The
477
+ // debounced `onUpdate` path covers steady-state typing, but in the
478
+ // cold-mount case (fresh peer joining a populated doc) y-prosemirror's
479
+ // `ySyncPlugin` view hook may run `_forceRerender` before the React
480
+ // owner has installed the `update` listener — leaving the hidden
481
+ // input empty on submit. Idempotent: when `onUpdate` already
482
+ // propagated, this is a no-op `setSerialized(sameValue)`. Same shape
483
+ // as `CollabTextRenderer`'s post-sync mirror and
484
+ // `@pilotiq-pro/collab`'s `rowArrayBinding.subscribeRows` catch-up.
485
+ useCollabSeed(
486
+ editor && collabActive ? room : null,
487
+ collabName,
488
+ (doc) => {
489
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
490
+ const fragment = (doc as any).getXmlFragment(collabName)
491
+ if (
492
+ fragment &&
493
+ fragment.length === 0 &&
494
+ initialContent !== undefined &&
495
+ initialContent !== null &&
496
+ initialContent !== '' &&
497
+ isTiptapShapedContent(initialContent) &&
498
+ editor
499
+ ) {
500
+ // setContent dispatches a Tiptap transaction; the bound
501
+ // y-prosemirror binding (inside Collaboration) mirrors it
502
+ // into the fragment so every peer sees the seeded state.
503
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
504
+ editor.commands.setContent(initialContent as any)
505
+ }
506
+ if (editor) {
507
+ const value = storage === 'html' ? editor.getHTML() : JSON.stringify(editor.getJSON())
508
+ setSerialized(value)
509
+ }
510
+ },
511
+ )
493
512
 
494
513
  // Cross-package suggestion bridge — sync the host's
495
514
  // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
@@ -37,6 +37,7 @@ import { useEditorState } from '@tiptap/react'
37
37
  import {
38
38
  registerPendingSuggestionApplier,
39
39
  usePendingSuggestionsForField,
40
+ useFormId,
40
41
  type PendingSuggestion,
41
42
  type PendingSuggestionApplier,
42
43
  } from '@pilotiq/pilotiq/react'
@@ -86,6 +87,15 @@ export function useAiInlineDiff(
86
87
  options: UseAiInlineDiffOptions,
87
88
  ): void {
88
89
  const { list } = usePendingSuggestionsForField(fieldName)
90
+ // Scope the applier registration by the surrounding form's id so
91
+ // multi-form pages route suggestions to the editor instance inside the
92
+ // matching form — without this, two editors on different forms but
93
+ // sharing a field name (e.g. two "summary" RichTextFields, one in the
94
+ // main edit form + one in a Replicate modal) would race on
95
+ // `registerPendingSuggestionApplier(undefined, …)` and the last-mounted
96
+ // editor would steal every approval. Falls back to wildcard scope
97
+ // (`undefined`) when no form is up-tree.
98
+ const formId = useFormId()
89
99
 
90
100
  const parseRef = useRef(options.parseSuggestion)
91
101
  useEffect(() => { parseRef.current = options.parseSuggestion }, [options.parseSuggestion])
@@ -95,6 +105,22 @@ export function useAiInlineDiff(
95
105
  // suggestions.
96
106
  const startedRef = useRef<Set<string>>(new Set())
97
107
 
108
+ // Re-evaluate the suggestion queue when the editor's doc shape
109
+ // changes. Specifically guards against the seed race: collab-enabled
110
+ // markdown/richtext editors mount empty and seed their content
111
+ // asynchronously after the Yjs provider syncs. A suggestion arriving
112
+ // during that window (or before the first user keystroke) sees an
113
+ // empty doc, `planReplaceBlock` returns null for any blockIndex >= 1,
114
+ // the effect bails — and never re-runs because `list` hasn't changed
115
+ // and React doesn't track ProseMirror's doc state. Watching
116
+ // `doc.childCount` flips the diff-start effect from "ran once at
117
+ // suggestion-push time" to "re-runs when the doc reaches usable
118
+ // shape," which closes the silent no-preview gap.
119
+ const childCount = useEditorState({
120
+ editor,
121
+ selector: ({ editor: ed }) => ed?.state.doc.childCount ?? 0,
122
+ }) ?? 0
123
+
98
124
  // Context → editor: start the diff for each new whole-field /
99
125
  // surgical-block suggestion. `meta.surgical` (if present) routes to a
100
126
  // precise PM transaction; otherwise we treat the suggested value as a
@@ -159,7 +185,7 @@ export function useAiInlineDiff(
159
185
  for (const id of Array.from(startedRef.current)) {
160
186
  if (!contextIds.has(id)) startedRef.current.delete(id)
161
187
  }
162
- }, [editor, list])
188
+ }, [editor, list, childCount])
163
189
 
164
190
  // Cross-tree applier — two paths:
165
191
  //
@@ -192,8 +218,8 @@ export function useAiInlineDiff(
192
218
  return true
193
219
  })
194
220
  }
195
- return registerPendingSuggestionApplier(undefined, fieldName, applier)
196
- }, [editor, fieldName])
221
+ return registerPendingSuggestionApplier(formId, fieldName, applier)
222
+ }, [editor, fieldName, formId])
197
223
  }
198
224
 
199
225
  function hasEditorRange(s: PendingSuggestion): boolean {
@@ -3,6 +3,7 @@ import type { Editor } from '@tiptap/core'
3
3
  import {
4
4
  registerPendingSuggestionApplier,
5
5
  usePendingSuggestionsForField,
6
+ useFormId,
6
7
  type PendingSuggestion,
7
8
  type PendingSuggestionApplier,
8
9
  } from '@pilotiq/pilotiq/react'
@@ -74,6 +75,12 @@ export function useAiSuggestionBridge(
74
75
  options: UseAiSuggestionBridgeOptions = {},
75
76
  ): void {
76
77
  const { list, dismiss } = usePendingSuggestionsForField(fieldName)
78
+ // Scope the applier under the surrounding form's id — same reasoning
79
+ // as `useAiInlineDiff`: two editors with the same field name across
80
+ // different forms (main edit form vs. a Replicate modal, say) would
81
+ // otherwise race on `registerPendingSuggestionApplier(undefined, …)`
82
+ // and the last-mounted editor would steal every approval.
83
+ const formId = useFormId()
77
84
 
78
85
  // Hold the latest `dismiss` in a ref so the editor-side listener — which
79
86
  // installs once per editor — always reaches the up-to-date context API.
@@ -201,12 +208,13 @@ export function useAiSuggestionBridge(
201
208
  apply(suggestion.suggestedValue)
202
209
  }
203
210
  }
204
- // Editor renderers don't currently have access to a `formId` here;
205
- // pass `undefined` so the wildcard form scope resolves. Phase 8.5+
206
- // can thread `formId` via the bridge call site if a future multi-
207
- // form richtext consumer needs it.
208
- return registerPendingSuggestionApplier(undefined, fieldName, applier)
209
- }, [editor, fieldName])
211
+ // formId comes from `useFormId()` on the form-context side scopes
212
+ // the registration per-form so multi-form pages route approvals to
213
+ // the matching editor. Falls back to wildcard (`undefined`) when no
214
+ // FormRenderer is up-tree (modal action schemas, action-modal forms
215
+ // mounted outside the main page form).
216
+ return registerPendingSuggestionApplier(formId, fieldName, applier)
217
+ }, [editor, fieldName, formId])
210
218
  }
211
219
 
212
220
  // Re-export the pending-suggestion type for consumers that import the hook
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Phase 6e test setup — boots jsdom into globalThis so React Testing
3
+ * Library + Tiptap render against a real-ish DOM. Loaded via the test
4
+ * script's `--import` flag *before* any test file is imported, so React
5
+ * sees the DOM globals at module-load time (RTL's `cleanup()` and
6
+ * `render()` both expect `document` + `window` to exist as globals).
7
+ *
8
+ * We register a minimal subset of DOM globals: the rest hang off
9
+ * `window`, which is how the browser code under test reaches them. The
10
+ * `assign` helper preserves `globalThis`'s native bindings (e.g. Node's
11
+ * `URL`) when jsdom exports a shadowing constructor that breaks
12
+ * downstream code (Tiptap uses `URL` in extension-link's autolink
13
+ * heuristic).
14
+ */
15
+ import { JSDOM } from 'jsdom'
16
+
17
+ const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
18
+ url: 'http://localhost/',
19
+ pretendToBeVisual: true,
20
+ })
21
+
22
+ const window = dom.window as unknown as Window & typeof globalThis
23
+
24
+ // Properties that Tiptap, React 19, and RTL touch directly via the
25
+ // global namespace (rather than via the captured `window` reference).
26
+ // Keep this list tight — every override is a place where jsdom and Node
27
+ // can diverge subtly.
28
+ const globals: Record<string, unknown> = {
29
+ window,
30
+ document: window.document,
31
+ navigator: window.navigator,
32
+ HTMLElement: window.HTMLElement,
33
+ HTMLInputElement: window.HTMLInputElement,
34
+ HTMLTextAreaElement: window.HTMLTextAreaElement,
35
+ HTMLAnchorElement: window.HTMLAnchorElement,
36
+ HTMLDivElement: window.HTMLDivElement,
37
+ HTMLSpanElement: window.HTMLSpanElement,
38
+ Element: window.Element,
39
+ Node: window.Node,
40
+ Text: window.Text,
41
+ Event: window.Event,
42
+ MouseEvent: window.MouseEvent,
43
+ KeyboardEvent: window.KeyboardEvent,
44
+ CustomEvent: window.CustomEvent,
45
+ DocumentFragment: window.DocumentFragment,
46
+ Range: window.Range,
47
+ Selection: window.Selection,
48
+ MutationObserver: window.MutationObserver,
49
+ IntersectionObserver: window.IntersectionObserver,
50
+ ResizeObserver: window.ResizeObserver ?? class { observe() {} unobserve() {} disconnect() {} },
51
+ DOMRect: window.DOMRect,
52
+ getComputedStyle: window.getComputedStyle.bind(window),
53
+ requestAnimationFrame: (cb: FrameRequestCallback) => setTimeout(() => cb(performance.now()), 16) as unknown as number,
54
+ cancelAnimationFrame: (id: number) => clearTimeout(id),
55
+ }
56
+
57
+ for (const [k, v] of Object.entries(globals)) {
58
+ Object.defineProperty(globalThis, k, { value: v, writable: true, configurable: true })
59
+ }
60
+
61
+ // React 19 + RTL require `IS_REACT_ACT_ENVIRONMENT` so `act()` warnings
62
+ // don't fire on every render. Without it, Tiptap's mount cascade
63
+ // produces dozens of warnings that swamp real test failures.
64
+ ;(globalThis as Record<string, unknown>)['IS_REACT_ACT_ENVIRONMENT'] = true