From 32b7d87352be4128556a582620765089de2d633d Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Thu, 16 Jul 2026 16:13:15 +0200 Subject: [PATCH 01/17] feat: add streaming-reveal extension to @tiptap/ai-toolkit --- .changeset/new-pots-admire.md | 5 + packages/ai-toolkit/package.json | 24 +- .../ai-toolkit/src/ai-insert-reveal.spec.ts | 65 +++++ packages/ai-toolkit/src/ai-insert-reveal.ts | 235 ++++++++++++++++++ packages/ai-toolkit/tsup.config.ts | 2 +- 5 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 .changeset/new-pots-admire.md create mode 100644 packages/ai-toolkit/src/ai-insert-reveal.spec.ts create mode 100644 packages/ai-toolkit/src/ai-insert-reveal.ts diff --git a/.changeset/new-pots-admire.md b/.changeset/new-pots-admire.md new file mode 100644 index 0000000000..f8776959fd --- /dev/null +++ b/.changeset/new-pots-admire.md @@ -0,0 +1,5 @@ +--- +"@tiptap/ai-toolkit": minor +--- + +Add the `AiInsertReveal` extension, exported from `@tiptap/ai-toolkit/streaming-reveal`, to fade in text as the AI streams it into a collaborative document. diff --git a/packages/ai-toolkit/package.json b/packages/ai-toolkit/package.json index 107c4947c6..2bc6ca18c0 100644 --- a/packages/ai-toolkit/package.json +++ b/packages/ai-toolkit/package.json @@ -39,6 +39,14 @@ }, "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./streaming-reveal": { + "types": { + "import": "./dist/streaming-reveal.d.ts", + "require": "./dist/streaming-reveal.d.cts" + }, + "import": "./dist/streaming-reveal.js", + "require": "./dist/streaming-reveal.cjs" } }, "scripts": { @@ -51,10 +59,22 @@ }, "devDependencies": { "@tiptap/core": "workspace:^", - "@tiptap/pm": "workspace:^" + "@tiptap/pm": "workspace:^", + "@tiptap/y-tiptap": "^3.0.7", + "yjs": "^13.6.23" }, "peerDependencies": { "@tiptap/core": "^3.0.1", - "@tiptap/pm": "^3.0.1" + "@tiptap/pm": "^3.0.1", + "@tiptap/y-tiptap": "^3.0.0", + "yjs": "^13.6.23" + }, + "peerDependenciesMeta": { + "@tiptap/y-tiptap": { + "optional": true + }, + "yjs": { + "optional": true + } } } diff --git a/packages/ai-toolkit/src/ai-insert-reveal.spec.ts b/packages/ai-toolkit/src/ai-insert-reveal.spec.ts new file mode 100644 index 0000000000..9c8cbb1690 --- /dev/null +++ b/packages/ai-toolkit/src/ai-insert-reveal.spec.ts @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom + +import { Editor } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import { describe, expect, it } from 'vitest' + +import { AiInsertReveal } from './ai-insert-reveal.js' + +/** + * Creates an editor with the {@link AiInsertReveal} extension and no + * collaboration, to prove the extension loads and degrades gracefully when the + * y-sync plugin it reads is absent. + * + * @return Promise resolving once the editor create lifecycle has finished. + */ +function createEditor(): Promise { + return new Promise(resolve => { + const editor = new Editor({ + element: document.createElement('div'), + extensions: [StarterKit, AiInsertReveal], + content: { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }], + }, + onCreate: () => { + resolve(editor) + }, + }) + }) +} + +describe('AiInsertReveal', () => { + it('is a named Tiptap extension', () => { + expect(AiInsertReveal.name).toBe('aiInsertReveal') + }) + + it('registers and degrades to a no-op when no collaboration y-sync plugin is present', async () => { + const editor = await createEditor() + + expect(editor.extensionManager.extensions.some(e => e.name === 'aiInsertReveal')).toBe(true) + // Without a y-sync plugin the decorations source resolves to nothing, so the + // editor renders normally rather than throwing. + expect(editor.getText()).toBe('Hello') + + editor.destroy() + }) + + it('applies configured className and durationMs', async () => { + const editor = await new Promise(resolve => { + const created = new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit, + AiInsertReveal.configure({ className: 'custom-reveal', durationMs: 300 }), + ], + onCreate: () => resolve(created), + }) + }) + + const reveal = editor.extensionManager.extensions.find(e => e.name === 'aiInsertReveal') + expect(reveal?.options).toMatchObject({ className: 'custom-reveal', durationMs: 300 }) + + editor.destroy() + }) +}) diff --git a/packages/ai-toolkit/src/ai-insert-reveal.ts b/packages/ai-toolkit/src/ai-insert-reveal.ts new file mode 100644 index 0000000000..da158273b0 --- /dev/null +++ b/packages/ai-toolkit/src/ai-insert-reveal.ts @@ -0,0 +1,235 @@ +import { Extension } from '@tiptap/core' +import type { Node as PMNode } from '@tiptap/pm/model' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { relativePositionToAbsolutePosition, ySyncPluginKey } from '@tiptap/y-tiptap' +import * as Y from 'yjs' + +/** + * Configuration for {@link AiInsertReveal}. + */ +export type AiInsertRevealOptions = { + /** + * CSS class applied to each freshly-inserted run. Style this class in your app + * to define the fade (see the package docs for a default). Change it to run + * more than one reveal effect, or to avoid a class-name collision. + */ + className: string + /** + * How long (ms) each run keeps its reveal decoration before it is dropped. + * Keep it at or above your CSS animation duration so the fade can finish. + */ + durationMs: number +} + +/** + * Upper bound (chars) on a single revealed run. A normal streamed token is a + * few chars; this only guards against a mis-resolved relative position yielding + * an absurd range (e.g. spanning the whole document). + */ +const MAX_REVEAL_RANGE = 400 + +/** + * One freshly-inserted text run, anchored by Yjs relative positions so it stays + * valid across the whole-document rebuild that y-tiptap applies on every remote + * sync (a plain ProseMirror position would be collapsed by that rebuild). + */ +type RevealEntry = { + start: Y.RelativePosition + end: Y.RelativePosition + at: number +} + +/** Minimal shape of the y-sync plugin state we read. */ +type YSyncState = { + doc: Y.Doc + type: Y.XmlFragment + binding: { mapping: Map, PMNode | PMNode[]> } | null +} + +const aiInsertRevealKey = new PluginKey('aiInsertReveal') + +/** + * Fades in text that arrives from a remote peer (the Server AI Toolkit streaming + * into the shared Y.Doc), one run per token, without mutating the document. + * + * It reads the authored signal directly: each remote Yjs transaction carries a + * delta describing exactly what was inserted and where. Those ranges are stored + * as relative positions and re-resolved to absolute positions on every render to + * drive view-only inline decorations. It does no document diffing and adds no + * marks, so it is inert to accept/reject and to persistence. Local edits are + * ignored via `transaction.local`, so a user typing sees no fade. + * + * Requires the Collaboration extension (its y-sync plugin) to be present. The + * actual fade is defined in CSS on the configured `className` (default + * `ai-insert-reveal`); without that stylesheet the decorations are added but + * invisible. + */ +export const AiInsertReveal = Extension.create({ + name: 'aiInsertReveal', + + addOptions() { + return { + className: 'ai-insert-reveal', + durationMs: 550, + } + }, + + addProseMirrorPlugins() { + const { className, durationMs } = this.options + + // Runs in insertion (time) order, so expired entries are always a prefix. + const entries: RevealEntry[] = [] + + const dropExpired = (now: number) => { + let firstActive = 0 + while (firstActive < entries.length && now - entries[firstActive].at >= durationMs) { + firstActive += 1 + } + if (firstActive > 0) entries.splice(0, firstActive) + } + + return [ + new Plugin({ + key: aiInsertRevealKey, + + props: { + decorations: state => { + if (entries.length === 0) return null + const ystate = ySyncPluginKey.getState(state) as YSyncState | undefined + if (!ystate?.binding) return null + + const now = Date.now() + const decorations: Decoration[] = [] + for (const entry of entries) { + const age = now - entry.at + if (age >= durationMs) continue + + const from = relativePositionToAbsolutePosition( + ystate.doc, + ystate.type, + entry.start, + ystate.binding.mapping, + ) + const to = relativePositionToAbsolutePosition( + ystate.doc, + ystate.type, + entry.end, + ystate.binding.mapping, + ) + if (from === null || to === null) continue + + const a = Math.min(from, to) + const b = Math.max(from, to) + if (a >= b || b - a > MAX_REVEAL_RANGE) continue + + // Seed the CSS animation from the run's real age so a re-render + // (y-tiptap rebuilds the doc on every token) resumes the fade at + // the correct point instead of restarting it. + decorations.push( + Decoration.inline(a, b, { + class: className, + style: `animation-delay: -${Math.round(age)}ms`, + }), + ) + } + + return decorations.length ? DecorationSet.create(state.doc, decorations) : null + }, + }, + + view: view => { + const initialState = ySyncPluginKey.getState(view.state) as YSyncState | undefined + const fragment = initialState?.type ?? null + + let raf: number | null = null + let pruneTimer: ReturnType | null = null + + const rerender = () => { + if (view.isDestroyed) return + // Empty transaction: re-runs `decorations` so new entries paint (and + // expired ones are dropped). Kept out of the undo history. + view.dispatch(view.state.tr.setMeta('addToHistory', false)) + } + + // Coalesce bursts of tokens landing in the same frame into one render. + const scheduleRerender = () => { + if (raf !== null) return + raf = requestAnimationFrame(() => { + raf = null + rerender() + }) + } + + // After the stream pauses, one delayed render removes the last runs' + // decorations once their fades have completed. + const schedulePrune = () => { + if (pruneTimer !== null) clearTimeout(pruneTimer) + pruneTimer = setTimeout(() => { + pruneTimer = null + dropExpired(Date.now()) + rerender() + }, durationMs + 80) + } + + const onChange = ( + events: Array>>, + transaction: Y.Transaction, + ) => { + // Only remote edits, i.e. the AI. The local user's own typing is a + // local transaction and must not fade. + if (transaction.local) return + + const now = Date.now() + dropExpired(now) + + let captured = false + for (const event of events) { + const target = event.target + if (!(target instanceof Y.XmlText)) continue + + let index = 0 + for (const op of event.delta) { + if (typeof op.retain === 'number') { + index += op.retain + } else if (typeof op.insert === 'string') { + const length = op.insert.length + if (length > 0) { + entries.push({ + start: Y.createRelativePositionFromTypeIndex(target, index), + // Anchor the end to the run's last char (assoc < 0) so the + // next token appended here starts its own run instead of + // extending this one. + end: Y.createRelativePositionFromTypeIndex(target, index + length, -1), + at: now, + }) + captured = true + } + index += length + } else if (op.insert !== undefined) { + index += 1 + } + } + } + + if (captured) { + scheduleRerender() + schedulePrune() + } + } + + fragment?.observeDeep(onChange) + + return { + destroy: () => { + fragment?.unobserveDeep(onChange) + if (raf !== null) cancelAnimationFrame(raf) + if (pruneTimer !== null) clearTimeout(pruneTimer) + entries.length = 0 + }, + } + }, + }), + ] + }, +}) diff --git a/packages/ai-toolkit/tsup.config.ts b/packages/ai-toolkit/tsup.config.ts index 03b7c8d0b6..d09a9166d3 100644 --- a/packages/ai-toolkit/tsup.config.ts +++ b/packages/ai-toolkit/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/index.ts'], + entry: { index: 'src/index.ts', 'streaming-reveal': 'src/ai-insert-reveal.ts' }, tsconfig: '../../tsconfig.build.json', outDir: 'dist', dts: true, From 4b1c45ea4cf7a9a753e4082adb6ee6c9a3553444 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Fri, 17 Jul 2026 11:12:01 +0200 Subject: [PATCH 02/17] cover AiInsertReveal behavior and simplify its plugin functions --- .../ai-toolkit/src/ai-insert-reveal.spec.ts | 126 ++++++++++++++++++ packages/ai-toolkit/src/ai-insert-reveal.ts | 125 ++++++++++------- 2 files changed, 205 insertions(+), 46 deletions(-) diff --git a/packages/ai-toolkit/src/ai-insert-reveal.spec.ts b/packages/ai-toolkit/src/ai-insert-reveal.spec.ts index 9c8cbb1690..1db90e8a1b 100644 --- a/packages/ai-toolkit/src/ai-insert-reveal.spec.ts +++ b/packages/ai-toolkit/src/ai-insert-reveal.spec.ts @@ -1,8 +1,10 @@ // @vitest-environment happy-dom import { Editor } from '@tiptap/core' +import { Collaboration } from '@tiptap/extension-collaboration' import StarterKit from '@tiptap/starter-kit' import { describe, expect, it } from 'vitest' +import * as Y from 'yjs' import { AiInsertReveal } from './ai-insert-reveal.js' @@ -29,6 +31,71 @@ function createEditor(): Promise { }) } +/** + * Creates a collaborative editor bound to a fresh Y.Doc with {@link AiInsertReveal}, + * seeded with a single `Hello` paragraph. + * + * @param options - Optional reveal configuration forwarded to `configure`. + * @return Promise resolving to the editor and its backing Y.Doc. + */ +function createCollabEditor(options?: { + durationMs?: number +}): Promise<{ editor: Editor; ydoc: Y.Doc }> { + const ydoc = new Y.Doc() + return new Promise(resolve => { + new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit.configure({ undoRedo: false }), + Collaboration.configure({ document: ydoc }), + options?.durationMs === undefined + ? AiInsertReveal + : AiInsertReveal.configure({ durationMs: options.durationMs }), + ], + onCreate: ({ editor }) => { + // Collaboration ignores the `content` prop (the empty Y.Doc wins when the + // y-sync plugin binds), so seed the shared doc with a local edit instead. + editor.commands.setContent('

Hello

') + resolve({ editor, ydoc }) + }, + }) + }) +} + +/** + * Applies a remote (non-local) insert into the first paragraph's text, mimicking + * an AI streaming into the shared document from another peer. Uses a second Y.Doc + * synced from `ydoc` so the resulting transaction has `local === false`. + */ +function remoteInsert(ydoc: Y.Doc, index: number, text: string): void { + const remote = new Y.Doc() + Y.applyUpdate(remote, Y.encodeStateAsUpdate(ydoc)) + const paragraph = remote.getXmlFragment('default').get(0) as Y.XmlElement + const xmlText = paragraph.get(0) as Y.XmlText + xmlText.insert(index, text) + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(ydoc))) +} + +/** + * Collects the reveal decorations currently produced by the extension, resolved + * against the editor's live state (mirrors what the view renders). + */ +function revealDecorations(editor: Editor): Array<{ from: number; to: number; style: string }> { + for (const plugin of editor.state.plugins) { + // biome-ignore lint/suspicious/noExplicitAny: reading the decoration prop generically + const set = (plugin as any).props?.decorations?.call(plugin, editor.state) + // biome-ignore lint/suspicious/noExplicitAny: DecorationSet.find returns internal decoration objects + const found = (set?.find?.() ?? []).filter( + (d: any) => d.type?.attrs?.class === 'ai-insert-reveal', + ) + if (found.length > 0) { + // biome-ignore lint/suspicious/noExplicitAny: decoration internals + return found.map((d: any) => ({ from: d.from, to: d.to, style: d.type.attrs.style ?? '' })) + } + } + return [] +} + describe('AiInsertReveal', () => { it('is a named Tiptap extension', () => { expect(AiInsertReveal.name).toBe('aiInsertReveal') @@ -62,4 +129,63 @@ describe('AiInsertReveal', () => { editor.destroy() }) + + it('reveals a remote insert as a decoration over exactly the inserted run', async () => { + const { editor, ydoc } = await createCollabEditor() + + remoteInsert(ydoc, 5, ' WORLD') + + expect(editor.getText()).toBe('Hello WORLD') + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + // The decoration spans exactly the 6 inserted characters. + expect(decorations[0].to - decorations[0].from).toBe(' WORLD'.length) + // The age-seeded animation-delay is present so the fade survives re-renders. + expect(decorations[0].style).toMatch(/animation-delay: -\d+ms/) + + editor.destroy() + }) + + it("does not reveal the local user's own typing", async () => { + const { editor } = await createCollabEditor() + + // A local transaction (transaction.local === true) must be ignored. + editor.commands.insertContentAt(6, 'X') + + expect(editor.getText()).toBe('HelloX') + expect(revealDecorations(editor)).toHaveLength(0) + + editor.destroy() + }) + + it('drops the reveal once its duration has elapsed', async () => { + const { editor, ydoc } = await createCollabEditor({ durationMs: 30 }) + + remoteInsert(ydoc, 5, ' WORLD') + expect(revealDecorations(editor)).toHaveLength(1) + + await new Promise(resolve => setTimeout(resolve, 60)) + expect(revealDecorations(editor)).toHaveLength(0) + + editor.destroy() + }) + + it('ignores an insert larger than the max reveal range', async () => { + const { editor, ydoc } = await createCollabEditor() + + remoteInsert(ydoc, 5, 'x'.repeat(401)) + + expect(editor.getText()).toBe(`Hello${'x'.repeat(401)}`) + expect(revealDecorations(editor)).toHaveLength(0) + + editor.destroy() + }) + + it('tears down cleanly after a reveal without throwing', async () => { + const { editor, ydoc } = await createCollabEditor() + remoteInsert(ydoc, 5, ' WORLD') + expect(revealDecorations(editor)).toHaveLength(1) + + expect(() => editor.destroy()).not.toThrow() + }) }) diff --git a/packages/ai-toolkit/src/ai-insert-reveal.ts b/packages/ai-toolkit/src/ai-insert-reveal.ts index da158273b0..7b48524d6d 100644 --- a/packages/ai-toolkit/src/ai-insert-reveal.ts +++ b/packages/ai-toolkit/src/ai-insert-reveal.ts @@ -49,6 +49,77 @@ type YSyncState = { const aiInsertRevealKey = new PluginKey('aiInsertReveal') +/** + * Resolves one reveal entry to an absolute range, or null if it has expired, its + * anchors no longer resolve (the run's text was deleted by a concurrent edit), or + * the span is empty or implausibly large. Returns the run's `age` so the caller + * can seed the fade from it. + */ +function resolveRevealRange( + ystate: YSyncState, + entry: RevealEntry, + now: number, + durationMs: number, +): { from: number; to: number; age: number } | null { + if (!ystate.binding) return null + + const age = now - entry.at + if (age >= durationMs) return null + + const from = relativePositionToAbsolutePosition( + ystate.doc, + ystate.type, + entry.start, + ystate.binding.mapping, + ) + const to = relativePositionToAbsolutePosition( + ystate.doc, + ystate.type, + entry.end, + ystate.binding.mapping, + ) + if (from === null || to === null) return null + + const a = Math.min(from, to) + const b = Math.max(from, to) + if (a >= b || b - a > MAX_REVEAL_RANGE) return null + + return { from: a, to: b, age } +} + +/** + * Extracts the freshly-inserted text runs carried by one remote Yjs event, + * anchored by relative positions. Returns an empty array when the event's target + * is not a text node or carries no string inserts. + */ +function collectInsertedRuns(event: Y.YEvent>, now: number): RevealEntry[] { + const target = event.target + if (!(target instanceof Y.XmlText)) return [] + + const runs: RevealEntry[] = [] + let index = 0 + for (const op of event.delta) { + if (typeof op.retain === 'number') { + index += op.retain + } else if (typeof op.insert === 'string') { + const length = op.insert.length + if (length > 0) { + runs.push({ + start: Y.createRelativePositionFromTypeIndex(target, index), + // Anchor the end to the run's last char (assoc < 0) so the next token + // appended here starts its own run instead of extending this one. + end: Y.createRelativePositionFromTypeIndex(target, index + length, -1), + at: now, + }) + } + index += length + } else if (op.insert !== undefined) { + index += 1 + } + } + return runs +} + /** * Fades in text that arrives from a remote peer (the Server AI Toolkit streaming * into the shared Y.Doc), one run per token, without mutating the document. @@ -102,34 +173,16 @@ export const AiInsertReveal = Extension.create({ const now = Date.now() const decorations: Decoration[] = [] for (const entry of entries) { - const age = now - entry.at - if (age >= durationMs) continue - - const from = relativePositionToAbsolutePosition( - ystate.doc, - ystate.type, - entry.start, - ystate.binding.mapping, - ) - const to = relativePositionToAbsolutePosition( - ystate.doc, - ystate.type, - entry.end, - ystate.binding.mapping, - ) - if (from === null || to === null) continue - - const a = Math.min(from, to) - const b = Math.max(from, to) - if (a >= b || b - a > MAX_REVEAL_RANGE) continue + const range = resolveRevealRange(ystate, entry, now, durationMs) + if (range === null) continue // Seed the CSS animation from the run's real age so a re-render // (y-tiptap rebuilds the doc on every token) resumes the fade at // the correct point instead of restarting it. decorations.push( - Decoration.inline(a, b, { + Decoration.inline(range.from, range.to, { class: className, - style: `animation-delay: -${Math.round(age)}ms`, + style: `animation-delay: -${Math.round(range.age)}ms`, }), ) } @@ -185,30 +238,10 @@ export const AiInsertReveal = Extension.create({ let captured = false for (const event of events) { - const target = event.target - if (!(target instanceof Y.XmlText)) continue - - let index = 0 - for (const op of event.delta) { - if (typeof op.retain === 'number') { - index += op.retain - } else if (typeof op.insert === 'string') { - const length = op.insert.length - if (length > 0) { - entries.push({ - start: Y.createRelativePositionFromTypeIndex(target, index), - // Anchor the end to the run's last char (assoc < 0) so the - // next token appended here starts its own run instead of - // extending this one. - end: Y.createRelativePositionFromTypeIndex(target, index + length, -1), - at: now, - }) - captured = true - } - index += length - } else if (op.insert !== undefined) { - index += 1 - } + const runs = collectInsertedRuns(event, now) + if (runs.length > 0) { + entries.push(...runs) + captured = true } } From 37c55cac4d80a5e28497dbb3506cdbd73243a70a Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Fri, 17 Jul 2026 11:55:16 +0200 Subject: [PATCH 03/17] refactor: name the reveal source after its streaming-reveal entry --- .../src/{ai-insert-reveal.spec.ts => streaming-reveal.spec.ts} | 2 +- .../ai-toolkit/src/{ai-insert-reveal.ts => streaming-reveal.ts} | 0 packages/ai-toolkit/tsup.config.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/ai-toolkit/src/{ai-insert-reveal.spec.ts => streaming-reveal.spec.ts} (99%) rename packages/ai-toolkit/src/{ai-insert-reveal.ts => streaming-reveal.ts} (100%) diff --git a/packages/ai-toolkit/src/ai-insert-reveal.spec.ts b/packages/ai-toolkit/src/streaming-reveal.spec.ts similarity index 99% rename from packages/ai-toolkit/src/ai-insert-reveal.spec.ts rename to packages/ai-toolkit/src/streaming-reveal.spec.ts index 1db90e8a1b..54ca2defd0 100644 --- a/packages/ai-toolkit/src/ai-insert-reveal.spec.ts +++ b/packages/ai-toolkit/src/streaming-reveal.spec.ts @@ -6,7 +6,7 @@ import StarterKit from '@tiptap/starter-kit' import { describe, expect, it } from 'vitest' import * as Y from 'yjs' -import { AiInsertReveal } from './ai-insert-reveal.js' +import { AiInsertReveal } from './streaming-reveal.js' /** * Creates an editor with the {@link AiInsertReveal} extension and no diff --git a/packages/ai-toolkit/src/ai-insert-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts similarity index 100% rename from packages/ai-toolkit/src/ai-insert-reveal.ts rename to packages/ai-toolkit/src/streaming-reveal.ts diff --git a/packages/ai-toolkit/tsup.config.ts b/packages/ai-toolkit/tsup.config.ts index d09a9166d3..c3269074b0 100644 --- a/packages/ai-toolkit/tsup.config.ts +++ b/packages/ai-toolkit/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: { index: 'src/index.ts', 'streaming-reveal': 'src/ai-insert-reveal.ts' }, + entry: { index: 'src/index.ts', 'streaming-reveal': 'src/streaming-reveal.ts' }, tsconfig: '../../tsconfig.build.json', outDir: 'dist', dts: true, From c3a99c6718310e2335f076d3da1bfb8039a0919b Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Fri, 17 Jul 2026 12:23:45 +0200 Subject: [PATCH 04/17] chore: update lockfile for the streaming-reveal deps --- pnpm-lock.yaml | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca182adb0b..5afd25efdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,7 +150,7 @@ importers: version: 2.15.0(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) '@hocuspocus/transformer': specifier: ^2.15.0 - version: 2.15.2(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23) + version: 2.15.2(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23) '@lexical/react': specifier: ^0.36.2 version: 0.36.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(yjs@13.6.23) @@ -411,6 +411,12 @@ importers: '@tiptap/pm': specifier: workspace:^ version: link:../pm + '@tiptap/y-tiptap': + specifier: ^3.0.7 + version: 3.0.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) + yjs: + specifier: ^13.6.23 + version: 13.6.23 packages/core: devDependencies: @@ -3476,10 +3482,10 @@ packages: peerDependencies: '@tiptap/pm': ^2.7.0 - '@tiptap/core@3.27.4': - resolution: {integrity: sha512-8W/GwlEn0JwNdpyVfTWcXwHYUpj9BWwO++YxtizmgjJzlwigSh7/xLVJMwVykuQHQ2fCq5rkUvmBRtpHOMLUQA==} + '@tiptap/core@3.28.0': + resolution: {integrity: sha512-gUuD5WAYfbDxNSSJya/emh2KSzXZXLUYKW4fEnc1AQ5FE2twzh4LJ9UlKFIawigrUCAksWI5Fy1hRbv+5m4ZdQ==} peerDependencies: - '@tiptap/pm': 3.27.4 + '@tiptap/pm': 3.28.0 '@tiptap/extension-blockquote@2.14.0': resolution: {integrity: sha512-AwqPP0jLYNioKxakiVw0vlfH/ceGFbV+SGoqBbPSGFPRdSbHhxHDNBlTtiThmT3N2PiVwXAD9xislJV+WY4GUA==} @@ -3584,8 +3590,8 @@ packages: '@tiptap/pm@2.14.0': resolution: {integrity: sha512-cnsfaIlvTFCDtLP/A2Fd3LmpttgY0O/tuTM2fC71vetONz83wUTYT+aD9uvxdX0GkSocoh840b0TsEazbBxhpA==} - '@tiptap/pm@3.27.4': - resolution: {integrity: sha512-UB8lcyomfWk7YGI2PZKNqcYXfyRA+PFj+QntlsUXyrsiA5JJIaE8SHKYjxKlGG/xtW3EtPm1b0p38T9Mk4xiFw==} + '@tiptap/pm@3.28.0': + resolution: {integrity: sha512-ALcpwZMUdat9gjJKlpscpoqXStoLhU246LPEVBDvJdIsoUKvUu3MrzfXik2Y8mtSGfhjtm9O2TRkWxQiFVMwsQ==} '@tiptap/starter-kit@2.14.0': resolution: {integrity: sha512-Z1bKAfHl14quRI3McmdU+bs675jp6/iexEQTI9M9oHa6l3McFF38g9N3xRpPPX02MX83DghsUPupndUW/yJvEQ==} @@ -8678,10 +8684,10 @@ snapshots: - bufferutil - utf-8-validate - '@hocuspocus/transformer@2.15.2(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23)': + '@hocuspocus/transformer@2.15.2(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23)': dependencies: - '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) - '@tiptap/pm': 3.27.4 + '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/pm': 3.28.0 '@tiptap/starter-kit': 2.14.0 y-prosemirror: 1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) yjs: 13.6.23 @@ -9361,9 +9367,9 @@ snapshots: dependencies: '@tiptap/pm': 2.14.0 - '@tiptap/core@3.27.4(@tiptap/pm@3.27.4)': + '@tiptap/core@3.28.0(@tiptap/pm@3.28.0)': dependencies: - '@tiptap/pm': 3.27.4 + '@tiptap/pm': 3.28.0 '@tiptap/extension-blockquote@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))': dependencies: @@ -9467,7 +9473,7 @@ snapshots: prosemirror-transform: 1.12.0 prosemirror-view: 1.41.9 - '@tiptap/pm@3.27.4': + '@tiptap/pm@3.28.0': dependencies: prosemirror-changeset: 2.4.1 prosemirror-commands: 1.7.1 From 357755108f41443300e67a5781b0bf7a3e556b5d Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Fri, 17 Jul 2026 13:09:45 +0200 Subject: [PATCH 05/17] refactor: split streaming-reveal into single-purpose helpers --- packages/ai-toolkit/src/streaming-reveal.ts | 116 +++++++++++--------- 1 file changed, 64 insertions(+), 52 deletions(-) diff --git a/packages/ai-toolkit/src/streaming-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts index 7b48524d6d..81fefadb5f 100644 --- a/packages/ai-toolkit/src/streaming-reveal.ts +++ b/packages/ai-toolkit/src/streaming-reveal.ts @@ -50,10 +50,9 @@ type YSyncState = { const aiInsertRevealKey = new PluginKey('aiInsertReveal') /** - * Resolves one reveal entry to an absolute range, or null if it has expired, its - * anchors no longer resolve (the run's text was deleted by a concurrent edit), or - * the span is empty or implausibly large. Returns the run's `age` so the caller - * can seed the fade from it. + * Resolves one reveal entry to an absolute range, or null if it has expired or no + * longer maps to a valid span. Returns the run's `age` so the caller can seed the + * fade from it. */ function resolveRevealRange( ystate: YSyncState, @@ -61,11 +60,20 @@ function resolveRevealRange( now: number, durationMs: number, ): { from: number; to: number; age: number } | null { - if (!ystate.binding) return null - const age = now - entry.at if (age >= durationMs) return null + const span = resolveSpan(ystate, entry) + return span === null ? null : { ...span, age } +} + +/** + * Resolves a reveal entry's relative-position anchors to an absolute, ordered, + * plausibly-sized span, or null if the run was deleted or its span is invalid. + */ +function resolveSpan(ystate: YSyncState, entry: RevealEntry): { from: number; to: number } | null { + if (!ystate.binding) return null + const from = relativePositionToAbsolutePosition( ystate.doc, ystate.type, @@ -78,13 +86,14 @@ function resolveRevealRange( entry.end, ystate.binding.mapping, ) - if (from === null || to === null) return null + return from === null || to === null ? null : orderedSpan(from, to) +} +/** Orders two positions and rejects an empty or implausibly large span. */ +function orderedSpan(from: number, to: number): { from: number; to: number } | null { const a = Math.min(from, to) const b = Math.max(from, to) - if (a >= b || b - a > MAX_REVEAL_RANGE) return null - - return { from: a, to: b, age } + return a >= b || b - a > MAX_REVEAL_RANGE ? null : { from: a, to: b } } /** @@ -99,27 +108,39 @@ function collectInsertedRuns(event: Y.YEvent>, now: numb const runs: RevealEntry[] = [] let index = 0 for (const op of event.delta) { - if (typeof op.retain === 'number') { - index += op.retain - } else if (typeof op.insert === 'string') { - const length = op.insert.length - if (length > 0) { - runs.push({ - start: Y.createRelativePositionFromTypeIndex(target, index), - // Anchor the end to the run's last char (assoc < 0) so the next token - // appended here starts its own run instead of extending this one. - end: Y.createRelativePositionFromTypeIndex(target, index + length, -1), - at: now, - }) - } - index += length - } else if (op.insert !== undefined) { - index += 1 - } + const { advance, inserted } = scanDeltaOp(op) + if (inserted > 0) runs.push(makeRun(target, index, inserted, now)) + index += advance } return runs } +/** + * Reads one delta op for the run scan: how far it advances the cursor, and the + * length of a string insert (0 for any non-string-insert op). + */ +function scanDeltaOp(op: { retain?: number; insert?: unknown }): { + advance: number + inserted: number +} { + if (typeof op.retain === 'number') return { advance: op.retain, inserted: 0 } + if (typeof op.insert === 'string') + return { advance: op.insert.length, inserted: op.insert.length } + return op.insert === undefined ? { advance: 0, inserted: 0 } : { advance: 1, inserted: 0 } +} + +/** + * Builds one reveal entry. The end is anchored to the run's last char (assoc < 0) + * so the next token appended here starts its own run instead of extending this one. + */ +function makeRun(target: Y.XmlText, index: number, length: number, now: number): RevealEntry { + return { + start: Y.createRelativePositionFromTypeIndex(target, index), + end: Y.createRelativePositionFromTypeIndex(target, index + length, -1), + at: now, + } +} + /** * Fades in text that arrives from a remote peer (the Server AI Toolkit streaming * into the shared Y.Doc), one run per token, without mutating the document. @@ -166,28 +187,26 @@ export const AiInsertReveal = Extension.create({ props: { decorations: state => { - if (entries.length === 0) return null const ystate = ySyncPluginKey.getState(state) as YSyncState | undefined - if (!ystate?.binding) return null + if (entries.length === 0 || !ystate?.binding) return null const now = Date.now() - const decorations: Decoration[] = [] - for (const entry of entries) { - const range = resolveRevealRange(ystate, entry, now, durationMs) - if (range === null) continue - + const decorations = entries + .map(entry => resolveRevealRange(ystate, entry, now, durationMs)) + .filter((range): range is NonNullable => range !== null) // Seed the CSS animation from the run's real age so a re-render - // (y-tiptap rebuilds the doc on every token) resumes the fade at - // the correct point instead of restarting it. - decorations.push( + // (y-tiptap rebuilds the doc on every token) resumes the fade at the + // correct point instead of restarting it. + .map(range => Decoration.inline(range.from, range.to, { class: className, style: `animation-delay: -${Math.round(range.age)}ms`, }), ) - } - return decorations.length ? DecorationSet.create(state.doc, decorations) : null + // An empty set is equivalent to null here (no decorations rendered); + // the early return above covers the common no-entries case. + return DecorationSet.create(state.doc, decorations) }, }, @@ -236,19 +255,12 @@ export const AiInsertReveal = Extension.create({ const now = Date.now() dropExpired(now) - let captured = false - for (const event of events) { - const runs = collectInsertedRuns(event, now) - if (runs.length > 0) { - entries.push(...runs) - captured = true - } - } - - if (captured) { - scheduleRerender() - schedulePrune() - } + const runs = events.flatMap(event => collectInsertedRuns(event, now)) + if (runs.length === 0) return + + entries.push(...runs) + scheduleRerender() + schedulePrune() } fragment?.observeDeep(onChange) From d1b3421cdd6abdf86fd5dc1b5e4a611f72d98999 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Fri, 17 Jul 2026 15:39:03 +0200 Subject: [PATCH 06/17] docs: remove inert biome-ignore directives and trim redundant comments --- .../ai-toolkit/src/streaming-reveal.spec.ts | 4 --- packages/ai-toolkit/src/streaming-reveal.ts | 29 +++---------------- 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/packages/ai-toolkit/src/streaming-reveal.spec.ts b/packages/ai-toolkit/src/streaming-reveal.spec.ts index 54ca2defd0..81c6698a03 100644 --- a/packages/ai-toolkit/src/streaming-reveal.spec.ts +++ b/packages/ai-toolkit/src/streaming-reveal.spec.ts @@ -82,14 +82,11 @@ function remoteInsert(ydoc: Y.Doc, index: number, text: string): void { */ function revealDecorations(editor: Editor): Array<{ from: number; to: number; style: string }> { for (const plugin of editor.state.plugins) { - // biome-ignore lint/suspicious/noExplicitAny: reading the decoration prop generically const set = (plugin as any).props?.decorations?.call(plugin, editor.state) - // biome-ignore lint/suspicious/noExplicitAny: DecorationSet.find returns internal decoration objects const found = (set?.find?.() ?? []).filter( (d: any) => d.type?.attrs?.class === 'ai-insert-reveal', ) if (found.length > 0) { - // biome-ignore lint/suspicious/noExplicitAny: decoration internals return found.map((d: any) => ({ from: d.from, to: d.to, style: d.type.attrs.style ?? '' })) } } @@ -138,7 +135,6 @@ describe('AiInsertReveal', () => { expect(editor.getText()).toBe('Hello WORLD') const decorations = revealDecorations(editor) expect(decorations).toHaveLength(1) - // The decoration spans exactly the 6 inserted characters. expect(decorations[0].to - decorations[0].from).toBe(' WORLD'.length) // The age-seeded animation-delay is present so the fade survives re-renders. expect(decorations[0].style).toMatch(/animation-delay: -\d+ms/) diff --git a/packages/ai-toolkit/src/streaming-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts index 81fefadb5f..d3043f89d9 100644 --- a/packages/ai-toolkit/src/streaming-reveal.ts +++ b/packages/ai-toolkit/src/streaming-reveal.ts @@ -11,8 +11,7 @@ import * as Y from 'yjs' export type AiInsertRevealOptions = { /** * CSS class applied to each freshly-inserted run. Style this class in your app - * to define the fade (see the package docs for a default). Change it to run - * more than one reveal effect, or to avoid a class-name collision. + * to define the fade (see the package docs for a default). */ className: string /** @@ -49,11 +48,6 @@ type YSyncState = { const aiInsertRevealKey = new PluginKey('aiInsertReveal') -/** - * Resolves one reveal entry to an absolute range, or null if it has expired or no - * longer maps to a valid span. Returns the run's `age` so the caller can seed the - * fade from it. - */ function resolveRevealRange( ystate: YSyncState, entry: RevealEntry, @@ -67,10 +61,6 @@ function resolveRevealRange( return span === null ? null : { ...span, age } } -/** - * Resolves a reveal entry's relative-position anchors to an absolute, ordered, - * plausibly-sized span, or null if the run was deleted or its span is invalid. - */ function resolveSpan(ystate: YSyncState, entry: RevealEntry): { from: number; to: number } | null { if (!ystate.binding) return null @@ -89,18 +79,12 @@ function resolveSpan(ystate: YSyncState, entry: RevealEntry): { from: number; to return from === null || to === null ? null : orderedSpan(from, to) } -/** Orders two positions and rejects an empty or implausibly large span. */ function orderedSpan(from: number, to: number): { from: number; to: number } | null { const a = Math.min(from, to) const b = Math.max(from, to) return a >= b || b - a > MAX_REVEAL_RANGE ? null : { from: a, to: b } } -/** - * Extracts the freshly-inserted text runs carried by one remote Yjs event, - * anchored by relative positions. Returns an empty array when the event's target - * is not a text node or carries no string inserts. - */ function collectInsertedRuns(event: Y.YEvent>, now: number): RevealEntry[] { const target = event.target if (!(target instanceof Y.XmlText)) return [] @@ -115,10 +99,6 @@ function collectInsertedRuns(event: Y.YEvent>, now: numb return runs } -/** - * Reads one delta op for the run scan: how far it advances the cursor, and the - * length of a string insert (0 for any non-string-insert op). - */ function scanDeltaOp(op: { retain?: number; insert?: unknown }): { advance: number inserted: number @@ -126,12 +106,13 @@ function scanDeltaOp(op: { retain?: number; insert?: unknown }): { if (typeof op.retain === 'number') return { advance: op.retain, inserted: 0 } if (typeof op.insert === 'string') return { advance: op.insert.length, inserted: op.insert.length } + // A non-string insert (embed) advances one position but is not a revealed run. return op.insert === undefined ? { advance: 0, inserted: 0 } : { advance: 1, inserted: 0 } } /** - * Builds one reveal entry. The end is anchored to the run's last char (assoc < 0) - * so the next token appended here starts its own run instead of extending this one. + * The end anchor binds to the run's last char (assoc < 0) so a token appended + * here starts its own run instead of extending this one. */ function makeRun(target: Y.XmlText, index: number, length: number, now: number): RevealEntry { return { @@ -204,8 +185,6 @@ export const AiInsertReveal = Extension.create({ }), ) - // An empty set is equivalent to null here (no decorations rendered); - // the early return above covers the common no-entries case. return DecorationSet.create(state.doc, decorations) }, }, From 326505ef384d84a34e7c1a179669279fe2df5745 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Fri, 17 Jul 2026 16:59:13 +0200 Subject: [PATCH 07/17] chore: drop the unrelated core/pm bump from the lockfile --- pnpm-lock.yaml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5afd25efdf..48345b1acc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,7 +150,7 @@ importers: version: 2.15.0(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) '@hocuspocus/transformer': specifier: ^2.15.0 - version: 2.15.2(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23) + version: 2.15.2(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23) '@lexical/react': specifier: ^0.36.2 version: 0.36.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(yjs@13.6.23) @@ -3482,10 +3482,10 @@ packages: peerDependencies: '@tiptap/pm': ^2.7.0 - '@tiptap/core@3.28.0': - resolution: {integrity: sha512-gUuD5WAYfbDxNSSJya/emh2KSzXZXLUYKW4fEnc1AQ5FE2twzh4LJ9UlKFIawigrUCAksWI5Fy1hRbv+5m4ZdQ==} + '@tiptap/core@3.27.4': + resolution: {integrity: sha512-8W/GwlEn0JwNdpyVfTWcXwHYUpj9BWwO++YxtizmgjJzlwigSh7/xLVJMwVykuQHQ2fCq5rkUvmBRtpHOMLUQA==} peerDependencies: - '@tiptap/pm': 3.28.0 + '@tiptap/pm': 3.27.4 '@tiptap/extension-blockquote@2.14.0': resolution: {integrity: sha512-AwqPP0jLYNioKxakiVw0vlfH/ceGFbV+SGoqBbPSGFPRdSbHhxHDNBlTtiThmT3N2PiVwXAD9xislJV+WY4GUA==} @@ -3590,8 +3590,8 @@ packages: '@tiptap/pm@2.14.0': resolution: {integrity: sha512-cnsfaIlvTFCDtLP/A2Fd3LmpttgY0O/tuTM2fC71vetONz83wUTYT+aD9uvxdX0GkSocoh840b0TsEazbBxhpA==} - '@tiptap/pm@3.28.0': - resolution: {integrity: sha512-ALcpwZMUdat9gjJKlpscpoqXStoLhU246LPEVBDvJdIsoUKvUu3MrzfXik2Y8mtSGfhjtm9O2TRkWxQiFVMwsQ==} + '@tiptap/pm@3.27.4': + resolution: {integrity: sha512-UB8lcyomfWk7YGI2PZKNqcYXfyRA+PFj+QntlsUXyrsiA5JJIaE8SHKYjxKlGG/xtW3EtPm1b0p38T9Mk4xiFw==} '@tiptap/starter-kit@2.14.0': resolution: {integrity: sha512-Z1bKAfHl14quRI3McmdU+bs675jp6/iexEQTI9M9oHa6l3McFF38g9N3xRpPPX02MX83DghsUPupndUW/yJvEQ==} @@ -8684,10 +8684,10 @@ snapshots: - bufferutil - utf-8-validate - '@hocuspocus/transformer@2.15.2(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23)': + '@hocuspocus/transformer@2.15.2(@tiptap/core@3.27.4(@tiptap/pm@3.27.4))(@tiptap/pm@3.27.4)(y-prosemirror@1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23))(yjs@13.6.23)': dependencies: - '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) - '@tiptap/pm': 3.28.0 + '@tiptap/core': 3.27.4(@tiptap/pm@3.27.4) + '@tiptap/pm': 3.27.4 '@tiptap/starter-kit': 2.14.0 y-prosemirror: 1.3.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) yjs: 13.6.23 @@ -9367,9 +9367,9 @@ snapshots: dependencies: '@tiptap/pm': 2.14.0 - '@tiptap/core@3.28.0(@tiptap/pm@3.28.0)': + '@tiptap/core@3.27.4(@tiptap/pm@3.27.4)': dependencies: - '@tiptap/pm': 3.28.0 + '@tiptap/pm': 3.27.4 '@tiptap/extension-blockquote@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))': dependencies: @@ -9473,7 +9473,7 @@ snapshots: prosemirror-transform: 1.12.0 prosemirror-view: 1.41.9 - '@tiptap/pm@3.28.0': + '@tiptap/pm@3.27.4': dependencies: prosemirror-changeset: 2.4.1 prosemirror-commands: 1.7.1 From 14db45b2e5a7f04c015d3e9d5c997c766a845ae4 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Wed, 22 Jul 2026 14:56:57 +0200 Subject: [PATCH 08/17] pnpm lock --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0367c2bfa8..c2be625c4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -414,7 +414,7 @@ importers: version: link:../pm '@tiptap/y-tiptap': specifier: ^3.0.7 - version: 3.0.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) + version: 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) yjs: specifier: ^13.6.23 version: 13.6.23 From d6da91e70b03e236db456901be5998020e9e62c4 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Thu, 23 Jul 2026 14:17:12 +0200 Subject: [PATCH 09/17] docs: tighten streaming-reveal comments per review --- packages/ai-toolkit/src/streaming-reveal.ts | 31 ++++++++------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/packages/ai-toolkit/src/streaming-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts index d3043f89d9..4959ed2f5b 100644 --- a/packages/ai-toolkit/src/streaming-reveal.ts +++ b/packages/ai-toolkit/src/streaming-reveal.ts @@ -22,9 +22,10 @@ export type AiInsertRevealOptions = { } /** - * Upper bound (chars) on a single revealed run. A normal streamed token is a - * few chars; this only guards against a mis-resolved relative position yielding - * an absurd range (e.g. spanning the whole document). + * Upper bound (chars) on one revealed run: the text from a single streamed + * insert, usually a token of a few chars. This only guards against a + * mis-resolved relative position yielding an absurd range (e.g. spanning the + * whole document). */ const MAX_REVEAL_RANGE = 400 @@ -123,20 +124,11 @@ function makeRun(target: Y.XmlText, index: number, length: number, now: number): } /** - * Fades in text that arrives from a remote peer (the Server AI Toolkit streaming - * into the shared Y.Doc), one run per token, without mutating the document. - * - * It reads the authored signal directly: each remote Yjs transaction carries a - * delta describing exactly what was inserted and where. Those ranges are stored - * as relative positions and re-resolved to absolute positions on every render to - * drive view-only inline decorations. It does no document diffing and adds no - * marks, so it is inert to accept/reject and to persistence. Local edits are - * ignored via `transaction.local`, so a user typing sees no fade. - * - * Requires the Collaboration extension (its y-sync plugin) to be present. The - * actual fade is defined in CSS on the configured `className` (default - * `ai-insert-reveal`); without that stylesheet the decorations are added but - * invisible. + * Fades in text inserted by remote Yjs transactions using view-only inline + * decorations anchored by relative positions. It never mutates the document, + * so it stays inert to accept/reject and to persistence, and local edits are + * ignored so a user's own typing does not fade. Requires the Collaboration + * extension and CSS on the configured `className` (default `ai-insert-reveal`). */ export const AiInsertReveal = Extension.create({ name: 'aiInsertReveal', @@ -175,9 +167,8 @@ export const AiInsertReveal = Extension.create({ const decorations = entries .map(entry => resolveRevealRange(ystate, entry, now, durationMs)) .filter((range): range is NonNullable => range !== null) - // Seed the CSS animation from the run's real age so a re-render - // (y-tiptap rebuilds the doc on every token) resumes the fade at the - // correct point instead of restarting it. + // y-tiptap rebuilds the whole doc per token, restarting the CSS + // animation; offset by the run's age to resume the fade instead. .map(range => Decoration.inline(range.from, range.to, { class: className, From 696d2cf0f62e97d1edea3aa2a392d3bf4ff59029 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Fri, 24 Jul 2026 10:29:53 +0200 Subject: [PATCH 10/17] docs: shorten remaining streaming-reveal comments --- .../ai-toolkit/src/streaming-reveal.spec.ts | 27 +++------------- packages/ai-toolkit/src/streaming-reveal.ts | 31 +++++-------------- 2 files changed, 11 insertions(+), 47 deletions(-) diff --git a/packages/ai-toolkit/src/streaming-reveal.spec.ts b/packages/ai-toolkit/src/streaming-reveal.spec.ts index 81c6698a03..678cae0d2d 100644 --- a/packages/ai-toolkit/src/streaming-reveal.spec.ts +++ b/packages/ai-toolkit/src/streaming-reveal.spec.ts @@ -8,13 +8,7 @@ import * as Y from 'yjs' import { AiInsertReveal } from './streaming-reveal.js' -/** - * Creates an editor with the {@link AiInsertReveal} extension and no - * collaboration, to prove the extension loads and degrades gracefully when the - * y-sync plugin it reads is absent. - * - * @return Promise resolving once the editor create lifecycle has finished. - */ +/** Creates an editor with {@link AiInsertReveal} but no collaboration (no y-sync plugin). */ function createEditor(): Promise { return new Promise(resolve => { const editor = new Editor({ @@ -31,13 +25,7 @@ function createEditor(): Promise { }) } -/** - * Creates a collaborative editor bound to a fresh Y.Doc with {@link AiInsertReveal}, - * seeded with a single `Hello` paragraph. - * - * @param options - Optional reveal configuration forwarded to `configure`. - * @return Promise resolving to the editor and its backing Y.Doc. - */ +/** Creates a collaborative editor (fresh Y.Doc, {@link AiInsertReveal}) seeded with one `Hello` paragraph. */ function createCollabEditor(options?: { durationMs?: number }): Promise<{ editor: Editor; ydoc: Y.Doc }> { @@ -62,11 +50,7 @@ function createCollabEditor(options?: { }) } -/** - * Applies a remote (non-local) insert into the first paragraph's text, mimicking - * an AI streaming into the shared document from another peer. Uses a second Y.Doc - * synced from `ydoc` so the resulting transaction has `local === false`. - */ +/** Applies a remote insert via a synced second Y.Doc. */ function remoteInsert(ydoc: Y.Doc, index: number, text: string): void { const remote = new Y.Doc() Y.applyUpdate(remote, Y.encodeStateAsUpdate(ydoc)) @@ -76,10 +60,7 @@ function remoteInsert(ydoc: Y.Doc, index: number, text: string): void { Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(ydoc))) } -/** - * Collects the reveal decorations currently produced by the extension, resolved - * against the editor's live state (mirrors what the view renders). - */ +/** Collects the extension's current reveal decorations from the editor's live state. */ function revealDecorations(editor: Editor): Array<{ from: number; to: number; style: string }> { for (const plugin of editor.state.plugins) { const set = (plugin as any).props?.decorations?.call(plugin, editor.state) diff --git a/packages/ai-toolkit/src/streaming-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts index 4959ed2f5b..4d0244e471 100644 --- a/packages/ai-toolkit/src/streaming-reveal.ts +++ b/packages/ai-toolkit/src/streaming-reveal.ts @@ -9,10 +9,7 @@ import * as Y from 'yjs' * Configuration for {@link AiInsertReveal}. */ export type AiInsertRevealOptions = { - /** - * CSS class applied to each freshly-inserted run. Style this class in your app - * to define the fade (see the package docs for a default). - */ + /** CSS class on each revealed run; style it in your app to define the fade. */ className: string /** * How long (ms) each run keeps its reveal decoration before it is dropped. @@ -21,19 +18,10 @@ export type AiInsertRevealOptions = { durationMs: number } -/** - * Upper bound (chars) on one revealed run: the text from a single streamed - * insert, usually a token of a few chars. This only guards against a - * mis-resolved relative position yielding an absurd range (e.g. spanning the - * whole document). - */ +/** Limits each revealed streamed insert to guard against mis-resolved positions spanning the document. */ const MAX_REVEAL_RANGE = 400 -/** - * One freshly-inserted text run, anchored by Yjs relative positions so it stays - * valid across the whole-document rebuild that y-tiptap applies on every remote - * sync (a plain ProseMirror position would be collapsed by that rebuild). - */ +/** Anchors each inserted run with Yjs relative positions so it survives y-tiptap document rebuilds. */ type RevealEntry = { start: Y.RelativePosition end: Y.RelativePosition @@ -111,10 +99,7 @@ function scanDeltaOp(op: { retain?: number; insert?: unknown }): { return op.insert === undefined ? { advance: 0, inserted: 0 } : { advance: 1, inserted: 0 } } -/** - * The end anchor binds to the run's last char (assoc < 0) so a token appended - * here starts its own run instead of extending this one. - */ +/** End anchor uses assoc < 0 so an appended token starts a new run, not extends this one. */ function makeRun(target: Y.XmlText, index: number, length: number, now: number): RevealEntry { return { start: Y.createRelativePositionFromTypeIndex(target, index), @@ -124,11 +109,9 @@ function makeRun(target: Y.XmlText, index: number, length: number, now: number): } /** - * Fades in text inserted by remote Yjs transactions using view-only inline - * decorations anchored by relative positions. It never mutates the document, - * so it stays inert to accept/reject and to persistence, and local edits are - * ignored so a user's own typing does not fade. Requires the Collaboration - * extension and CSS on the configured `className` (default `ai-insert-reveal`). + * Fades in remote Yjs inserts with view-only decorations. It never mutates the + * document, so it is inert to accept/reject and persistence, and ignores local edits. + * Requires Collaboration and CSS for `className` (default `ai-insert-reveal`). */ export const AiInsertReveal = Extension.create({ name: 'aiInsertReveal', From 6711609fe2ab3552586d0752e80df7961d1cb462 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Fri, 7 Aug 2026 16:51:59 +0200 Subject: [PATCH 11/17] fix(ai-toolkit): clamp streaming-reveal spans instead of dropping them --- .../ai-toolkit/src/streaming-reveal.spec.ts | 63 +++++++++++++++++-- packages/ai-toolkit/src/streaming-reveal.ts | 45 +++++++++---- 2 files changed, 90 insertions(+), 18 deletions(-) diff --git a/packages/ai-toolkit/src/streaming-reveal.spec.ts b/packages/ai-toolkit/src/streaming-reveal.spec.ts index 678cae0d2d..1d70424daa 100644 --- a/packages/ai-toolkit/src/streaming-reveal.spec.ts +++ b/packages/ai-toolkit/src/streaming-reveal.spec.ts @@ -60,10 +60,13 @@ function remoteInsert(ydoc: Y.Doc, index: number, text: string): void { Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(ydoc))) } -/** Collects the extension's current reveal decorations from the editor's live state. */ -function revealDecorations(editor: Editor): Array<{ from: number; to: number; style: string }> { +/** Collects the extension's current reveal decorations, resolved against `state`. */ +function revealDecorations( + editor: Editor, + state: unknown = editor.state, +): Array<{ from: number; to: number; style: string }> { for (const plugin of editor.state.plugins) { - const set = (plugin as any).props?.decorations?.call(plugin, editor.state) + const set = (plugin as any).props?.decorations?.call(plugin, state) const found = (set?.find?.() ?? []).filter( (d: any) => d.type?.attrs?.class === 'ai-insert-reveal', ) @@ -123,6 +126,39 @@ describe('AiInsertReveal', () => { editor.destroy() }) + it('reveals an insert at the very end of the document', async () => { + const { editor, ydoc } = await createCollabEditor() + + remoteInsert(ydoc, 5, '!') + + expect(editor.getText()).toBe('Hello!') + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + expect(decorations[0].to - decorations[0].from).toBe(1) + expect(decorations[0].to).toBeLessThanOrEqual(editor.state.doc.content.size) + + editor.destroy() + }) + + it('clamps a decoration that resolves past the end of the document', async () => { + const { editor, ydoc } = await createCollabEditor() + + remoteInsert(ydoc, 5, ' WORLD') + expect(revealDecorations(editor)).toHaveLength(1) + + // Y.Doc can be ahead of the PM doc mid-sync, so resolve against a shorter doc. + const shortDoc = editor.schema.nodeFromJSON({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello!' }] }], + }) + const decorations = revealDecorations(editor, { ...editor.state, doc: shortDoc }) + + expect(decorations).toHaveLength(1) + expect(decorations[0].to).toBe(shortDoc.content.size) + + editor.destroy() + }) + it("does not reveal the local user's own typing", async () => { const { editor } = await createCollabEditor() @@ -147,13 +183,30 @@ describe('AiInsertReveal', () => { editor.destroy() }) - it('ignores an insert larger than the max reveal range', async () => { + it('clamps a run longer than the max reveal range to that many characters', async () => { const { editor, ydoc } = await createCollabEditor() remoteInsert(ydoc, 5, 'x'.repeat(401)) expect(editor.getText()).toBe(`Hello${'x'.repeat(401)}`) - expect(revealDecorations(editor)).toHaveLength(0) + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + // The whole run inserts; only the fade is capped (MAX_REVEAL_RANGE = 400). + expect(decorations[0].to - decorations[0].from).toBe(400) + + editor.destroy() + }) + + it('drops a run whose resolved span no longer matches its inserted length', async () => { + const { editor, ydoc } = await createCollabEditor() + + remoteInsert(ydoc, 5, ' WORLD') + // 'XYZ' lands inside the first run, so its span drifts from 6 to 9 and drops as stale. + remoteInsert(ydoc, 8, 'XYZ') + + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + expect(decorations[0].to - decorations[0].from).toBe(3) editor.destroy() }) diff --git a/packages/ai-toolkit/src/streaming-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts index 4d0244e471..63ca91e3c1 100644 --- a/packages/ai-toolkit/src/streaming-reveal.ts +++ b/packages/ai-toolkit/src/streaming-reveal.ts @@ -18,14 +18,16 @@ export type AiInsertRevealOptions = { durationMs: number } -/** Limits each revealed streamed insert to guard against mis-resolved positions spanning the document. */ +/** Caps the fade to a run's first N chars; the rest reveals instantly. */ const MAX_REVEAL_RANGE = 400 -/** Anchors each inserted run with Yjs relative positions so it survives y-tiptap document rebuilds. */ +/** Yjs relative positions so each run survives y-tiptap doc rebuilds. */ type RevealEntry = { start: Y.RelativePosition end: Y.RelativePosition at: number + /** Inserted char count; a resolved span that drifts from it is stale. */ + length: number } /** Minimal shape of the y-sync plugin state we read. */ @@ -42,15 +44,20 @@ function resolveRevealRange( entry: RevealEntry, now: number, durationMs: number, + docSize: number, ): { from: number; to: number; age: number } | null { const age = now - entry.at if (age >= durationMs) return null - const span = resolveSpan(ystate, entry) + const span = resolveSpan(ystate, entry, docSize) return span === null ? null : { ...span, age } } -function resolveSpan(ystate: YSyncState, entry: RevealEntry): { from: number; to: number } | null { +function resolveSpan( + ystate: YSyncState, + entry: RevealEntry, + docSize: number, +): { from: number; to: number } | null { if (!ystate.binding) return null const from = relativePositionToAbsolutePosition( @@ -65,13 +72,23 @@ function resolveSpan(ystate: YSyncState, entry: RevealEntry): { from: number; to entry.end, ystate.binding.mapping, ) - return from === null || to === null ? null : orderedSpan(from, to) + return from === null || to === null ? null : clampSpan(from, to, entry.length, docSize) } -function orderedSpan(from: number, to: number): { from: number; to: number } | null { - const a = Math.min(from, to) - const b = Math.max(from, to) - return a >= b || b - a > MAX_REVEAL_RANGE ? null : { from: a, to: b } +/** Drops a span that drifted from its insert length; a stale mapping, not a real run. */ +function clampSpan( + from: number, + to: number, + length: number, + docSize: number, +): { from: number; to: number } | null { + const lo = Math.min(from, to) + const hi = Math.max(from, to) + if (hi - lo !== length) return null + + const start = Math.max(0, lo) + const end = Math.min(docSize, hi, start + MAX_REVEAL_RANGE) + return start >= end ? null : { from: start, to: end } } function collectInsertedRuns(event: Y.YEvent>, now: number): RevealEntry[] { @@ -99,12 +116,13 @@ function scanDeltaOp(op: { retain?: number; insert?: unknown }): { return op.insert === undefined ? { advance: 0, inserted: 0 } : { advance: 1, inserted: 0 } } -/** End anchor uses assoc < 0 so an appended token starts a new run, not extends this one. */ +/** End anchor assoc < 0 so an appended token starts a new run, not extends it. */ function makeRun(target: Y.XmlText, index: number, length: number, now: number): RevealEntry { return { start: Y.createRelativePositionFromTypeIndex(target, index), end: Y.createRelativePositionFromTypeIndex(target, index + length, -1), at: now, + length, } } @@ -148,7 +166,9 @@ export const AiInsertReveal = Extension.create({ const now = Date.now() const decorations = entries - .map(entry => resolveRevealRange(ystate, entry, now, durationMs)) + .map(entry => + resolveRevealRange(ystate, entry, now, durationMs, state.doc.content.size), + ) .filter((range): range is NonNullable => range !== null) // y-tiptap rebuilds the whole doc per token, restarting the CSS // animation; offset by the run's age to resume the fade instead. @@ -201,8 +221,7 @@ export const AiInsertReveal = Extension.create({ events: Array>>, transaction: Y.Transaction, ) => { - // Only remote edits, i.e. the AI. The local user's own typing is a - // local transaction and must not fade. + // Local typing must not fade; the rest is any remote peer, not only the AI. if (transaction.local) return const now = Date.now() From ddbf14d428caa7969b161ac174a45d5cc0f9aa6d Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Mon, 10 Aug 2026 11:33:47 +0200 Subject: [PATCH 12/17] feat: reveal only the AI's streamed inserts --- .changeset/new-pots-admire.md | 2 +- .../ai-toolkit/src/streaming-reveal.spec.ts | 242 +++++++++++++++--- packages/ai-toolkit/src/streaming-reveal.ts | 165 ++++++++++-- 3 files changed, 352 insertions(+), 57 deletions(-) diff --git a/.changeset/new-pots-admire.md b/.changeset/new-pots-admire.md index f8776959fd..0ce736ea24 100644 --- a/.changeset/new-pots-admire.md +++ b/.changeset/new-pots-admire.md @@ -2,4 +2,4 @@ "@tiptap/ai-toolkit": minor --- -Add the `AiInsertReveal` extension, exported from `@tiptap/ai-toolkit/streaming-reveal`, to fade in text as the AI streams it into a collaborative document. +Add the `AiInsertReveal` extension, exported from `@tiptap/ai-toolkit/streaming-reveal`, to fade in text as the AI streams it into a collaborative document. Pass your collaboration provider to it so only the AI's inserts are revealed, never another collaborator's. diff --git a/packages/ai-toolkit/src/streaming-reveal.spec.ts b/packages/ai-toolkit/src/streaming-reveal.spec.ts index 1d70424daa..6e965c2a2c 100644 --- a/packages/ai-toolkit/src/streaming-reveal.spec.ts +++ b/packages/ai-toolkit/src/streaming-reveal.spec.ts @@ -3,17 +3,41 @@ import { Editor } from '@tiptap/core' import { Collaboration } from '@tiptap/extension-collaboration' import StarterKit from '@tiptap/starter-kit' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import * as Y from 'yjs' import { AiInsertReveal } from './streaming-reveal.js' +const AI_CLIENT_ID = 111111 +const HUMAN_CLIENT_ID = 222222 + +/** The awareness payload the Tiptap AI server publishes while it streams. */ +const AI_USER = { name: 'AI', color: '#8B5CF6', aiInstanceId: 'ai-instance-1' } + +/** Stands in for a collab provider, exposing only the awareness surface we read. */ +function createProvider() { + const states = new Map>() + const listeners = new Set<() => void>() + + return { + awareness: { + getStates: () => states, + on: (_event: string, listener: () => void) => listeners.add(listener), + off: (_event: string, listener: () => void) => listeners.delete(listener), + }, + announce(clientId: number, user: Record) { + states.set(clientId, { user }) + listeners.forEach(listener => listener()) + }, + } +} + /** Creates an editor with {@link AiInsertReveal} but no collaboration (no y-sync plugin). */ function createEditor(): Promise { return new Promise(resolve => { const editor = new Editor({ element: document.createElement('div'), - extensions: [StarterKit, AiInsertReveal], + extensions: [StarterKit, AiInsertReveal.configure({ provider: createProvider() })], content: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }], @@ -25,39 +49,68 @@ function createEditor(): Promise { }) } -/** Creates a collaborative editor (fresh Y.Doc, {@link AiInsertReveal}) seeded with one `Hello` paragraph. */ +/** A remote peer that keeps its clientID across writes. */ +function createPeer(ydoc: Y.Doc, clientID: number): Y.Doc { + const peer = new Y.Doc() + peer.clientID = clientID + Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc)) + return peer +} + +/** Creates a collaborative editor seeded with one `Hello` paragraph, plus two remote peers. */ function createCollabEditor(options?: { durationMs?: number -}): Promise<{ editor: Editor; ydoc: Y.Doc }> { +}): Promise<{ editor: Editor; ydoc: Y.Doc; ai: Y.Doc; human: Y.Doc }> { const ydoc = new Y.Doc() + const provider = createProvider() + provider.announce(AI_CLIENT_ID, AI_USER) + provider.announce(HUMAN_CLIENT_ID, { name: 'Someone else', color: '#0EA5E9' }) + return new Promise(resolve => { new Editor({ element: document.createElement('div'), extensions: [ StarterKit.configure({ undoRedo: false }), Collaboration.configure({ document: ydoc }), - options?.durationMs === undefined - ? AiInsertReveal - : AiInsertReveal.configure({ durationMs: options.durationMs }), + AiInsertReveal.configure({ + provider, + ...(options?.durationMs === undefined ? {} : { durationMs: options.durationMs }), + }), ], onCreate: ({ editor }) => { // Collaboration ignores the `content` prop (the empty Y.Doc wins when the // y-sync plugin binds), so seed the shared doc with a local edit instead. editor.commands.setContent('

Hello

') - resolve({ editor, ydoc }) + resolve({ + editor, + ydoc, + ai: createPeer(ydoc, AI_CLIENT_ID), + human: createPeer(ydoc, HUMAN_CLIENT_ID), + }) }, }) }) } -/** Applies a remote insert via a synced second Y.Doc. */ -function remoteInsert(ydoc: Y.Doc, index: number, text: string): void { - const remote = new Y.Doc() - Y.applyUpdate(remote, Y.encodeStateAsUpdate(ydoc)) - const paragraph = remote.getXmlFragment('default').get(0) as Y.XmlElement +/** Applies an insert from `peer`, so it lands as a remote transaction authored by it. */ +function remoteInsert(peer: Y.Doc, ydoc: Y.Doc, index: number, text: string): void { + Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(peer))) + const paragraph = peer.getXmlFragment('default').get(0) as Y.XmlElement const xmlText = paragraph.get(0) as Y.XmlText xmlText.insert(index, text) - Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(ydoc))) + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(ydoc))) +} + +/** Applies a whole new block from `peer`, the way the AI writes a heading. */ +function remoteInsertBlock(peer: Y.Doc, ydoc: Y.Doc, text: string): void { + Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(peer))) + const fragment = peer.getXmlFragment('default') + const block = new Y.XmlElement('paragraph') + const blockText = new Y.XmlText() + block.insert(0, [blockText]) + fragment.insert(0, [block]) + if (text.length > 0) blockText.insert(0, text) + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(ydoc))) } /** Collects the extension's current reveal decorations, resolved against `state`. */ @@ -99,7 +152,11 @@ describe('AiInsertReveal', () => { element: document.createElement('div'), extensions: [ StarterKit, - AiInsertReveal.configure({ className: 'custom-reveal', durationMs: 300 }), + AiInsertReveal.configure({ + className: 'custom-reveal', + durationMs: 300, + provider: createProvider(), + }), ], onCreate: () => resolve(created), }) @@ -112,9 +169,9 @@ describe('AiInsertReveal', () => { }) it('reveals a remote insert as a decoration over exactly the inserted run', async () => { - const { editor, ydoc } = await createCollabEditor() + const { editor, ydoc, ai } = await createCollabEditor() - remoteInsert(ydoc, 5, ' WORLD') + remoteInsert(ai, ydoc, 5, ' WORLD') expect(editor.getText()).toBe('Hello WORLD') const decorations = revealDecorations(editor) @@ -126,10 +183,49 @@ describe('AiInsertReveal', () => { editor.destroy() }) + it('reveals a block that arrives as a whole new node', async () => { + const { editor, ydoc, ai } = await createCollabEditor() + + remoteInsertBlock(ai, ydoc, 'A brand new title') + + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + expect(decorations[0].to - decorations[0].from).toBe('A brand new title'.length) + + editor.destroy() + }) + + it('reveals text streamed into a block that was created empty', async () => { + const { editor, ydoc, ai } = await createCollabEditor() + + remoteInsertBlock(ai, ydoc, '') + remoteInsert(ai, ydoc, 0, 'Streamed title') + + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + expect(decorations[0].to - decorations[0].from).toBe('Streamed title'.length) + + editor.destroy() + }) + + it('merges touching runs that share an animation offset into one decoration', async () => { + const { editor, ydoc, ai } = await createCollabEditor() + + remoteInsert(ai, ydoc, 5, ' one') + remoteInsert(ai, ydoc, 9, ' two') + + expect(editor.getText()).toBe('Hello one two') + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + expect(decorations[0].to - decorations[0].from).toBe(' one two'.length) + + editor.destroy() + }) + it('reveals an insert at the very end of the document', async () => { - const { editor, ydoc } = await createCollabEditor() + const { editor, ydoc, ai } = await createCollabEditor() - remoteInsert(ydoc, 5, '!') + remoteInsert(ai, ydoc, 5, '!') expect(editor.getText()).toBe('Hello!') const decorations = revealDecorations(editor) @@ -141,9 +237,9 @@ describe('AiInsertReveal', () => { }) it('clamps a decoration that resolves past the end of the document', async () => { - const { editor, ydoc } = await createCollabEditor() + const { editor, ydoc, ai } = await createCollabEditor() - remoteInsert(ydoc, 5, ' WORLD') + remoteInsert(ai, ydoc, 5, ' WORLD') expect(revealDecorations(editor)).toHaveLength(1) // Y.Doc can be ahead of the PM doc mid-sync, so resolve against a shorter doc. @@ -159,6 +255,92 @@ describe('AiInsertReveal', () => { editor.destroy() }) + it("does not reveal another collaborator's remote insert", async () => { + const { editor, ydoc, human } = await createCollabEditor() + + remoteInsert(human, ydoc, 5, ' WORLD') + + // The insert lands like any remote edit, it just must not fade. + expect(editor.getText()).toBe('Hello WORLD') + expect(revealDecorations(editor)).toHaveLength(0) + + editor.destroy() + }) + + it('reveals the AI while a collaborator types alongside it', async () => { + const { editor, ydoc, ai, human } = await createCollabEditor() + + remoteInsert(human, ydoc, 5, ' HUMAN') + remoteInsert(ai, ydoc, 11, ' AI') + + expect(editor.getText()).toBe('Hello HUMAN AI') + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + expect(decorations[0].to - decorations[0].from).toBe(' AI'.length) + + editor.destroy() + }) + + it('reveals a run that arrived before awareness identified the AI', async () => { + const ydoc = new Y.Doc() + const provider = createProvider() + + const editor = await new Promise(resolve => { + new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit.configure({ undoRedo: false }), + Collaboration.configure({ document: ydoc }), + AiInsertReveal.configure({ provider }), + ], + onCreate: ({ editor: created }) => { + created.commands.setContent('

Hello

') + resolve(created) + }, + }) + }) + + // The AI's first tokens can land before its awareness entry does. + remoteInsert(createPeer(ydoc, AI_CLIENT_ID), ydoc, 5, ' WORLD') + expect(revealDecorations(editor)).toHaveLength(0) + + provider.announce(AI_CLIENT_ID, AI_USER) + + expect(revealDecorations(editor)).toHaveLength(1) + + editor.destroy() + }) + + it('warns and reveals nothing when no provider is configured', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const ydoc = new Y.Doc() + + const editor = await new Promise(resolve => { + new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit.configure({ undoRedo: false }), + Collaboration.configure({ document: ydoc }), + AiInsertReveal, + ], + onCreate: ({ editor: created }) => { + created.commands.setContent('

Hello

') + resolve(created) + }, + }) + }) + + remoteInsert(createPeer(ydoc, AI_CLIENT_ID), ydoc, 5, ' WORLD') + + // Without awareness the AI cannot be identified, so it fails closed. + expect(editor.getText()).toBe('Hello WORLD') + expect(revealDecorations(editor)).toHaveLength(0) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('"provider" option is required')) + + warn.mockRestore() + editor.destroy() + }) + it("does not reveal the local user's own typing", async () => { const { editor } = await createCollabEditor() @@ -172,9 +354,9 @@ describe('AiInsertReveal', () => { }) it('drops the reveal once its duration has elapsed', async () => { - const { editor, ydoc } = await createCollabEditor({ durationMs: 30 }) + const { editor, ydoc, ai } = await createCollabEditor({ durationMs: 30 }) - remoteInsert(ydoc, 5, ' WORLD') + remoteInsert(ai, ydoc, 5, ' WORLD') expect(revealDecorations(editor)).toHaveLength(1) await new Promise(resolve => setTimeout(resolve, 60)) @@ -184,9 +366,9 @@ describe('AiInsertReveal', () => { }) it('clamps a run longer than the max reveal range to that many characters', async () => { - const { editor, ydoc } = await createCollabEditor() + const { editor, ydoc, ai } = await createCollabEditor() - remoteInsert(ydoc, 5, 'x'.repeat(401)) + remoteInsert(ai, ydoc, 5, 'x'.repeat(401)) expect(editor.getText()).toBe(`Hello${'x'.repeat(401)}`) const decorations = revealDecorations(editor) @@ -198,11 +380,11 @@ describe('AiInsertReveal', () => { }) it('drops a run whose resolved span no longer matches its inserted length', async () => { - const { editor, ydoc } = await createCollabEditor() + const { editor, ydoc, ai } = await createCollabEditor() - remoteInsert(ydoc, 5, ' WORLD') + remoteInsert(ai, ydoc, 5, ' WORLD') // 'XYZ' lands inside the first run, so its span drifts from 6 to 9 and drops as stale. - remoteInsert(ydoc, 8, 'XYZ') + remoteInsert(ai, ydoc, 8, 'XYZ') const decorations = revealDecorations(editor) expect(decorations).toHaveLength(1) @@ -212,8 +394,8 @@ describe('AiInsertReveal', () => { }) it('tears down cleanly after a reveal without throwing', async () => { - const { editor, ydoc } = await createCollabEditor() - remoteInsert(ydoc, 5, ' WORLD') + const { editor, ydoc, ai } = await createCollabEditor() + remoteInsert(ai, ydoc, 5, ' WORLD') expect(revealDecorations(editor)).toHaveLength(1) expect(() => editor.destroy()).not.toThrow() diff --git a/packages/ai-toolkit/src/streaming-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts index 63ca91e3c1..9c48736650 100644 --- a/packages/ai-toolkit/src/streaming-reveal.ts +++ b/packages/ai-toolkit/src/streaming-reveal.ts @@ -16,11 +16,22 @@ export type AiInsertRevealOptions = { * Keep it at or above your CSS animation duration so the fade can finish. */ durationMs: number + /** + * The collaboration provider, e.g. `HocuspocusProvider` or `TiptapCloudProvider`. + * Its awareness is what identifies the AI, so nothing is revealed without it. + */ + provider: any } /** Caps the fade to a run's first N chars; the rest reveals instantly. */ const MAX_REVEAL_RANGE = 400 +/** + * Rounding (ms) for the animation offset. ProseMirror compares decoration attrs by + * value, so an offset that changed every render would rebuild every run's DOM node. + */ +const REVEAL_AGE_STEP = 100 + /** Yjs relative positions so each run survives y-tiptap doc rebuilds. */ type RevealEntry = { start: Y.RelativePosition @@ -28,6 +39,8 @@ type RevealEntry = { at: number /** Inserted char count; a resolved span that drifts from it is stale. */ length: number + /** Author, resolved against awareness at render time rather than on arrival. */ + client: number } /** Minimal shape of the y-sync plugin state we read. */ @@ -91,20 +104,77 @@ function clampSpan( return start >= end ? null : { from: start, to: end } } -function collectInsertedRuns(event: Y.YEvent>, now: number): RevealEntry[] { - const target = event.target - if (!(target instanceof Y.XmlText)) return [] +/** Merges touching runs with the same offset, so a stream paints a few spans, not one per token. */ +function mergeRuns( + ranges: Array<{ from: number; to: number; age: number }>, +): Array<{ from: number; to: number; delay: number }> { + const merged: Array<{ from: number; to: number; delay: number }> = [] + + for (const range of ranges) { + const delay = Math.round(range.age / REVEAL_AGE_STEP) * REVEAL_AGE_STEP + const last = merged[merged.length - 1] + + if (last !== undefined && last.delay === delay && last.to === range.from) { + last.to = range.to + } else { + merged.push({ from: range.from, to: range.to, delay }) + } + } + return merged +} + +/** The Tiptap AI server tags the identity it publishes with an instance id. */ +function isAiUser(user: Record | undefined): boolean { + return Boolean(user?.aiInstanceId) +} + +/** Clients whose clock advanced in this transaction, i.e. who authored it. */ +function transactionAuthors(transaction: Y.Transaction): number[] { + const authors: number[] = [] + transaction.afterState.forEach((clock, client) => { + if ((transaction.beforeState.get(client) ?? 0) < clock) authors.push(client) + }) + return authors +} + +function collectInsertedRuns( + event: Y.YEvent>, + now: number, + client: number, +): RevealEntry[] { + const target = event.target const runs: RevealEntry[] = [] - let index = 0 + + if (target instanceof Y.XmlText) { + let index = 0 + for (const op of event.delta) { + const { advance, inserted } = scanDeltaOp(op) + if (inserted > 0) runs.push(makeRun(target, index, inserted, now, client)) + index += advance + } + return runs + } + + // Whole nodes arrive when the AI writes a heading or a fresh block, so their + // text never shows up as an insert into existing content. for (const op of event.delta) { - const { advance, inserted } = scanDeltaOp(op) - if (inserted > 0) runs.push(makeRun(target, index, inserted, now)) - index += advance + if (!Array.isArray(op.insert)) continue + for (const node of op.insert) collectNodeText(node, now, client, runs) } return runs } +function collectNodeText(node: unknown, now: number, client: number, runs: RevealEntry[]): void { + if (node instanceof Y.XmlText) { + if (node.length > 0) runs.push(makeRun(node, 0, node.length, now, client)) + return + } + if (node instanceof Y.XmlElement) { + for (const child of node.toArray()) collectNodeText(child, now, client, runs) + } +} + function scanDeltaOp(op: { retain?: number; insert?: unknown }): { advance: number inserted: number @@ -116,20 +186,30 @@ function scanDeltaOp(op: { retain?: number; insert?: unknown }): { return op.insert === undefined ? { advance: 0, inserted: 0 } : { advance: 1, inserted: 0 } } -/** End anchor assoc < 0 so an appended token starts a new run, not extends it. */ -function makeRun(target: Y.XmlText, index: number, length: number, now: number): RevealEntry { +/** + * End anchor assoc < 0 so an appended token starts a new run, not extends it. + * At index 0 the start needs the same assoc, or it resolves to nothing. + */ +function makeRun( + target: Y.XmlText, + index: number, + length: number, + now: number, + client: number, +): RevealEntry { return { - start: Y.createRelativePositionFromTypeIndex(target, index), + start: Y.createRelativePositionFromTypeIndex(target, index, index === 0 ? -1 : 0), end: Y.createRelativePositionFromTypeIndex(target, index + length, -1), at: now, length, + client, } } /** - * Fades in remote Yjs inserts with view-only decorations. It never mutates the - * document, so it is inert to accept/reject and persistence, and ignores local edits. - * Requires Collaboration and CSS for `className` (default `ai-insert-reveal`). + * Fades in text the AI streams into the document, using view-only decorations. + * It never mutates the document, so it is inert to accept/reject and persistence. + * Requires Collaboration, a provider, and CSS for `className` (default `ai-insert-reveal`). */ export const AiInsertReveal = Extension.create({ name: 'aiInsertReveal', @@ -138,15 +218,28 @@ export const AiInsertReveal = Extension.create({ return { className: 'ai-insert-reveal', durationMs: 550, + provider: null, + } + }, + + onCreate() { + if (!this.options.provider) { + console.warn( + '[tiptap warn]: The "provider" option is required for "AiInsertReveal" to tell the AI apart from other collaborators. Nothing is revealed without it.', + ) } }, addProseMirrorPlugins() { - const { className, durationMs } = this.options + const { className, durationMs, provider } = this.options // Runs in insertion (time) order, so expired entries are always a prefix. const entries: RevealEntry[] = [] + // Never pruned: the AI clears its awareness entry when it stops, and its first + // tokens can arrive before that entry lands. + const aiClients = new Set() + const dropExpired = (now: number) => { let firstActive = 0 while (firstActive < entries.length && now - entries[firstActive].at >= durationMs) { @@ -162,22 +255,24 @@ export const AiInsertReveal = Extension.create({ props: { decorations: state => { const ystate = ySyncPluginKey.getState(state) as YSyncState | undefined - if (entries.length === 0 || !ystate?.binding) return null + if (entries.length === 0 || aiClients.size === 0 || !ystate?.binding) return null const now = Date.now() - const decorations = entries + const ranges = entries + .filter(entry => aiClients.has(entry.client)) .map(entry => resolveRevealRange(ystate, entry, now, durationMs, state.doc.content.size), ) .filter((range): range is NonNullable => range !== null) - // y-tiptap rebuilds the whole doc per token, restarting the CSS - // animation; offset by the run's age to resume the fade instead. - .map(range => - Decoration.inline(range.from, range.to, { - class: className, - style: `animation-delay: -${Math.round(range.age)}ms`, - }), - ) + + // y-tiptap rebuilds the whole doc per token, restarting the CSS + // animation; offset by the run's age to resume the fade instead. + const decorations = mergeRuns(ranges).map(range => + Decoration.inline(range.from, range.to, { + class: className, + style: `animation-delay: -${range.delay}ms`, + }), + ) return DecorationSet.create(state.doc, decorations) }, @@ -186,6 +281,18 @@ export const AiInsertReveal = Extension.create({ view: view => { const initialState = ySyncPluginKey.getState(view.state) as YSyncState | undefined const fragment = initialState?.type ?? null + const awareness = provider?.awareness ?? null + + const trackAiClients = () => { + const known = aiClients.size + awareness + ?.getStates() + .forEach((state: Record | undefined, clientId: number) => { + if (isAiUser(state?.user)) aiClients.add(clientId) + }) + // Runs that arrived before the AI was identified can now be revealed. + if (aiClients.size > known) scheduleRerender() + } let raf: number | null = null let pruneTimer: ReturnType | null = null @@ -221,13 +328,16 @@ export const AiInsertReveal = Extension.create({ events: Array>>, transaction: Y.Transaction, ) => { - // Local typing must not fade; the rest is any remote peer, not only the AI. if (transaction.local) return + // A batch mixing several authors cannot be attributed run by run. + const authors = transactionAuthors(transaction) + if (authors.length !== 1) return + const now = Date.now() dropExpired(now) - const runs = events.flatMap(event => collectInsertedRuns(event, now)) + const runs = events.flatMap(event => collectInsertedRuns(event, now, authors[0])) if (runs.length === 0) return entries.push(...runs) @@ -235,10 +345,13 @@ export const AiInsertReveal = Extension.create({ schedulePrune() } + trackAiClients() + awareness?.on('change', trackAiClients) fragment?.observeDeep(onChange) return { destroy: () => { + awareness?.off('change', trackAiClients) fragment?.unobserveDeep(onChange) if (raf !== null) cancelAnimationFrame(raf) if (pruneTimer !== null) clearTimeout(pruneTimer) From 571bb78f3e3e47801446905467274aaa2f3e59ee Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Mon, 10 Aug 2026 11:52:59 +0200 Subject: [PATCH 13/17] fix: resolve the duplicate y-tiptap instance that broke the reveal tests --- packages/ai-toolkit/{src => __tests__}/streaming-reveal.spec.ts | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename packages/ai-toolkit/{src => __tests__}/streaming-reveal.spec.ts (99%) diff --git a/packages/ai-toolkit/src/streaming-reveal.spec.ts b/packages/ai-toolkit/__tests__/streaming-reveal.spec.ts similarity index 99% rename from packages/ai-toolkit/src/streaming-reveal.spec.ts rename to packages/ai-toolkit/__tests__/streaming-reveal.spec.ts index 6e965c2a2c..554c281a32 100644 --- a/packages/ai-toolkit/src/streaming-reveal.spec.ts +++ b/packages/ai-toolkit/__tests__/streaming-reveal.spec.ts @@ -6,7 +6,7 @@ import StarterKit from '@tiptap/starter-kit' import { describe, expect, it, vi } from 'vitest' import * as Y from 'yjs' -import { AiInsertReveal } from './streaming-reveal.js' +import { AiInsertReveal } from '../src/streaming-reveal.js' const AI_CLIENT_ID = 111111 const HUMAN_CLIENT_ID = 222222 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60b1f7f7b9..5e26a46664 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -414,7 +414,7 @@ importers: version: link:../pm '@tiptap/y-tiptap': specifier: ^3.0.7 - version: 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) + version: 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) yjs: specifier: ^13.6.23 version: 13.6.23 From c71e7f25882c6ecc3db54f710f90ca8b5cb2bfb3 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Mon, 10 Aug 2026 13:20:54 +0200 Subject: [PATCH 14/17] fix: stop revealing a document that arrives as one sync --- .../__tests__/streaming-reveal.spec.ts | 112 +++++++++++++++++- packages/ai-toolkit/src/streaming-reveal.ts | 44 ++++++- 2 files changed, 149 insertions(+), 7 deletions(-) diff --git a/packages/ai-toolkit/__tests__/streaming-reveal.spec.ts b/packages/ai-toolkit/__tests__/streaming-reveal.spec.ts index 554c281a32..96d11d4feb 100644 --- a/packages/ai-toolkit/__tests__/streaming-reveal.spec.ts +++ b/packages/ai-toolkit/__tests__/streaming-reveal.spec.ts @@ -3,7 +3,7 @@ import { Editor } from '@tiptap/core' import { Collaboration } from '@tiptap/extension-collaboration' import StarterKit from '@tiptap/starter-kit' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import * as Y from 'yjs' import { AiInsertReveal } from '../src/streaming-reveal.js' @@ -131,18 +131,25 @@ function revealDecorations( } describe('AiInsertReveal', () => { + afterEach(() => { + vi.useRealTimers() + }) + it('is a named Tiptap extension', () => { expect(AiInsertReveal.name).toBe('aiInsertReveal') }) - it('registers and degrades to a no-op when no collaboration y-sync plugin is present', async () => { + it('warns and degrades to a no-op when no collaboration y-sync plugin is present', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const editor = await createEditor() expect(editor.extensionManager.extensions.some(e => e.name === 'aiInsertReveal')).toBe(true) // Without a y-sync plugin the decorations source resolves to nothing, so the // editor renders normally rather than throwing. expect(editor.getText()).toBe('Hello') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Collaboration extension')) + warn.mockRestore() editor.destroy() }) @@ -210,6 +217,8 @@ describe('AiInsertReveal', () => { it('merges touching runs that share an animation offset into one decoration', async () => { const { editor, ydoc, ai } = await createCollabEditor() + // Only the clock is faked: the editor is created through a real `setTimeout`. + vi.useFakeTimers({ toFake: ['Date'] }) remoteInsert(ai, ydoc, 5, ' one') remoteInsert(ai, ydoc, 9, ' two') @@ -311,6 +320,102 @@ describe('AiInsertReveal', () => { editor.destroy() }) + it('reveals a multi-block structure the AI writes as one node', async () => { + const { editor, ydoc, ai } = await createCollabEditor() + + Y.applyUpdate(ai, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(ai))) + const list = new Y.XmlElement('bulletList') + for (let item = 0; item < 3; item++) { + const listItem = new Y.XmlElement('listItem') + const paragraph = new Y.XmlElement('paragraph') + const text = new Y.XmlText() + paragraph.insert(0, [text]) + listItem.insert(0, [paragraph]) + list.insert(list.length, [listItem]) + text.insert(0, `Item ${item}`) + } + ai.getXmlFragment('default').insert(0, [list]) + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ai, Y.encodeStateVector(ydoc))) + + // One structure, so the block cap must not mistake it for a document sync. + expect(revealDecorations(editor)).toHaveLength(3) + + editor.destroy() + }) + + it('reveals nothing when a whole AI-authored document arrives at once', async () => { + const ydoc = new Y.Doc() + const provider = createProvider() + provider.announce(AI_CLIENT_ID, AI_USER) + + const editor = await new Promise(resolve => { + const created = new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit.configure({ undoRedo: false }), + Collaboration.configure({ document: ydoc }), + AiInsertReveal.configure({ provider }), + ], + onCreate: () => resolve(created), + }) + }) + + // Joining a room hands over everything the AI wrote before, in one update. + const server = new Y.Doc() + server.clientID = AI_CLIENT_ID + const fragment = server.getXmlFragment('default') + for (let block = 0; block < 5; block++) { + const element = new Y.XmlElement('paragraph') + const text = new Y.XmlText() + element.insert(0, [text]) + fragment.insert(fragment.length, [element]) + text.insert(0, `Paragraph ${block} written earlier by the AI.`) + } + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(server)) + + expect(editor.getText().length).toBeGreaterThan(0) + expect(revealDecorations(editor)).toHaveLength(0) + + editor.destroy() + }) + + it('warns when the provider has no awareness', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const editor = await new Promise(resolve => { + const created = new Editor({ + element: document.createElement('div'), + extensions: [StarterKit, AiInsertReveal.configure({ provider: {} })], + onCreate: () => resolve(created), + }) + }) + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no awareness')) + + warn.mockRestore() + editor.destroy() + }) + + it('warns when durationMs cannot hold a reveal', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const editor = await new Promise(resolve => { + const created = new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit, + AiInsertReveal.configure({ provider: createProvider(), durationMs: 0 }), + ], + onCreate: () => resolve(created), + }) + }) + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('durationMs')) + + warn.mockRestore() + editor.destroy() + }) + it('warns and reveals nothing when no provider is configured', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const ydoc = new Y.Doc() @@ -355,11 +460,12 @@ describe('AiInsertReveal', () => { it('drops the reveal once its duration has elapsed', async () => { const { editor, ydoc, ai } = await createCollabEditor({ durationMs: 30 }) + vi.useFakeTimers({ toFake: ['Date'] }) remoteInsert(ai, ydoc, 5, ' WORLD') expect(revealDecorations(editor)).toHaveLength(1) - await new Promise(resolve => setTimeout(resolve, 60)) + vi.setSystemTime(Date.now() + 60) expect(revealDecorations(editor)).toHaveLength(0) editor.destroy() diff --git a/packages/ai-toolkit/src/streaming-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts index 9c48736650..5c85bd9935 100644 --- a/packages/ai-toolkit/src/streaming-reveal.ts +++ b/packages/ai-toolkit/src/streaming-reveal.ts @@ -20,7 +20,16 @@ export type AiInsertRevealOptions = { * The collaboration provider, e.g. `HocuspocusProvider` or `TiptapCloudProvider`. * Its awareness is what identifies the AI, so nothing is revealed without it. */ - provider: any + provider: RevealProvider | null +} + +/** The awareness surface this extension reads from the provider. */ +export type RevealProvider = { + awareness?: { + getStates: () => Map | undefined> + on: (event: 'change', listener: () => void) => void + off: (event: 'change', listener: () => void) => void + } } /** Caps the fade to a run's first N chars; the rest reveals instantly. */ @@ -32,6 +41,14 @@ const MAX_REVEAL_RANGE = 400 */ const REVEAL_AGE_STEP = 100 +/** + * Blocks a single update may add before it is taken for a document sync rather + * than live streaming; without this, joining a room fades everything the AI has + * written so far. Counted in blocks so one structure it just wrote, such as a + * list, still reveals as a whole. + */ +const MAX_BLOCKS_PER_UPDATE = 2 + /** Yjs relative positions so each run survives y-tiptap doc rebuilds. */ type RevealEntry = { start: Y.RelativePosition @@ -88,7 +105,7 @@ function resolveSpan( return from === null || to === null ? null : clampSpan(from, to, entry.length, docSize) } -/** Drops a span that drifted from its insert length; a stale mapping, not a real run. */ +/** Drops a span whose width drifted from the insert, be it a stale mapping or an edit inside the run. */ function clampSpan( from: number, to: number, @@ -160,6 +177,7 @@ function collectInsertedRuns( // text never shows up as an insert into existing content. for (const op of event.delta) { if (!Array.isArray(op.insert)) continue + if (op.insert.length > MAX_BLOCKS_PER_UPDATE) continue for (const node of op.insert) collectNodeText(node, now, client, runs) } return runs @@ -223,10 +241,22 @@ export const AiInsertReveal = Extension.create({ }, onCreate() { - if (!this.options.provider) { + const { provider, durationMs } = this.options + + if (!provider) { console.warn( '[tiptap warn]: The "provider" option is required for "AiInsertReveal" to tell the AI apart from other collaborators. Nothing is revealed without it.', ) + } else if (!provider.awareness) { + console.warn( + '[tiptap warn]: The provider passed to "AiInsertReveal" has no awareness, so the AI cannot be identified. Nothing is revealed.', + ) + } + + if (durationMs <= 0) { + console.warn( + `[tiptap warn]: "AiInsertReveal" needs a positive "durationMs" to hold a reveal, got ${durationMs}. Nothing is revealed.`, + ) } }, @@ -255,7 +285,7 @@ export const AiInsertReveal = Extension.create({ props: { decorations: state => { const ystate = ySyncPluginKey.getState(state) as YSyncState | undefined - if (entries.length === 0 || aiClients.size === 0 || !ystate?.binding) return null + if (entries.length === 0 || !ystate?.binding) return null const now = Date.now() const ranges = entries @@ -283,6 +313,12 @@ export const AiInsertReveal = Extension.create({ const fragment = initialState?.type ?? null const awareness = provider?.awareness ?? null + if (fragment === null) { + console.warn( + '[tiptap warn]: "AiInsertReveal" needs the Collaboration extension to see incoming text. Nothing is revealed.', + ) + } + const trackAiClients = () => { const known = aiClients.size awareness From c03e2d5f0bc7437a0387bc3a15f56411a7d1f6d6 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Mon, 10 Aug 2026 15:07:32 +0200 Subject: [PATCH 15/17] docs: tighten streaming-reveal comments --- packages/ai-toolkit/src/streaming-reveal.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/ai-toolkit/src/streaming-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts index 5c85bd9935..1404418c0b 100644 --- a/packages/ai-toolkit/src/streaming-reveal.ts +++ b/packages/ai-toolkit/src/streaming-reveal.ts @@ -35,18 +35,10 @@ export type RevealProvider = { /** Caps the fade to a run's first N chars; the rest reveals instantly. */ const MAX_REVEAL_RANGE = 400 -/** - * Rounding (ms) for the animation offset. ProseMirror compares decoration attrs by - * value, so an offset that changed every render would rebuild every run's DOM node. - */ +/** ProseMirror compares decoration attrs by value, so an unrounded offset rebuilds the DOM. */ const REVEAL_AGE_STEP = 100 -/** - * Blocks a single update may add before it is taken for a document sync rather - * than live streaming; without this, joining a room fades everything the AI has - * written so far. Counted in blocks so one structure it just wrote, such as a - * list, still reveals as a whole. - */ +/** Above this, an update is a document sync, such as joining a room, not live streaming. */ const MAX_BLOCKS_PER_UPDATE = 2 /** Yjs relative positions so each run survives y-tiptap doc rebuilds. */ @@ -105,7 +97,7 @@ function resolveSpan( return from === null || to === null ? null : clampSpan(from, to, entry.length, docSize) } -/** Drops a span whose width drifted from the insert, be it a stale mapping or an edit inside the run. */ +/** Drops a span whose width drifted from the insert, so stale mappings never paint. */ function clampSpan( from: number, to: number, @@ -335,8 +327,7 @@ export const AiInsertReveal = Extension.create({ const rerender = () => { if (view.isDestroyed) return - // Empty transaction: re-runs `decorations` so new entries paint (and - // expired ones are dropped). Kept out of the undo history. + // Empty transaction: re-runs `decorations` so new entries paint and expired ones drop. view.dispatch(view.state.tr.setMeta('addToHistory', false)) } From a4bc46543fe90465bbfd53d81e244e8d71310c66 Mon Sep 17 00:00:00 2001 From: Baris Ozdemirci Date: Thu, 20 Aug 2026 14:02:04 +0200 Subject: [PATCH 16/17] docs(ai-toolkit): trim reveal changeset and fix a stale test comment --- .changeset/new-pots-admire.md | 2 +- packages/ai-toolkit/__tests__/streaming-reveal.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/new-pots-admire.md b/.changeset/new-pots-admire.md index 0ce736ea24..09e7097fac 100644 --- a/.changeset/new-pots-admire.md +++ b/.changeset/new-pots-admire.md @@ -2,4 +2,4 @@ "@tiptap/ai-toolkit": minor --- -Add the `AiInsertReveal` extension, exported from `@tiptap/ai-toolkit/streaming-reveal`, to fade in text as the AI streams it into a collaborative document. Pass your collaboration provider to it so only the AI's inserts are revealed, never another collaborator's. +Add the `AiInsertReveal` extension (`@tiptap/ai-toolkit/streaming-reveal`) to fade in text as the AI streams it into a collaborative document. diff --git a/packages/ai-toolkit/__tests__/streaming-reveal.spec.ts b/packages/ai-toolkit/__tests__/streaming-reveal.spec.ts index 96d11d4feb..48d9a0ea56 100644 --- a/packages/ai-toolkit/__tests__/streaming-reveal.spec.ts +++ b/packages/ai-toolkit/__tests__/streaming-reveal.spec.ts @@ -101,7 +101,7 @@ function remoteInsert(peer: Y.Doc, ydoc: Y.Doc, index: number, text: string): vo Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(ydoc))) } -/** Applies a whole new block from `peer`, the way the AI writes a heading. */ +/** Applies a whole new block from `peer`, the way the AI writes a fresh paragraph. */ function remoteInsertBlock(peer: Y.Doc, ydoc: Y.Doc, text: string): void { Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(peer))) const fragment = peer.getXmlFragment('default') From 965a880c1f0af94b992722d1fdfbc53029d20e90 Mon Sep 17 00:00:00 2001 From: Dominik Biedebach <6538827+bdbch@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:01:30 +0200 Subject: [PATCH 17/17] fix(core): preserve JSX child boundaries (#7491) * Fix JSX runtime for nested sibling children Spread children arrays into the returned DOMOutputSpec so sibling elements are spread as separate children instead of nested arrays. Treat a single DOMOutputSpec array (starts with a string) as a single child, filter out null/undefined entries, and treat empty arrays as no children. Add unit tests covering nested siblings and edge cases, and include a changeset. * Improve DOMOutputSpec detection in JSX runtime Add tests covering DOMOutputSpec edge cases: tag+0, tag+attrs+0, array of DOMOutputSpec arrays, and nested child DOMOutputSpec. Update h() detection to treat the second element as valid when it is undefined, 0, an attrs object, or a nested DOMOutputSpecArray. * test: add edge case tests for content hole (0) handling * fix: handle string as second element in DOMOutputSpec detection * Preserve JSX boundaries for DOM output specs * Flatten nested Fragment children in JSX runtime Flatten nested Fragment children and skip null/undefined values when creating JSX elements. Introduce JSXChild type and update Fragment and h to use the flattened children. Add unit test for spreading a nested Fragment between sibling elements. * refactor(core): remove unused multipleChildren parameter --- .changeset/fix-jsx-runtime-nested-siblings.md | 5 + packages/core/__tests__/jsx-runtime.spec.ts | 353 +++++++++++++++++- packages/core/src/jsx-runtime.ts | 94 ++++- 3 files changed, 421 insertions(+), 31 deletions(-) create mode 100644 .changeset/fix-jsx-runtime-nested-siblings.md diff --git a/.changeset/fix-jsx-runtime-nested-siblings.md b/.changeset/fix-jsx-runtime-nested-siblings.md new file mode 100644 index 0000000000..79ad9be201 --- /dev/null +++ b/.changeset/fix-jsx-runtime-nested-siblings.md @@ -0,0 +1,5 @@ +--- +'@tiptap/core': patch +--- + +Fix JSX runtime to properly render nested sibling elements by spreading children arrays into DOMOutputSpec diff --git a/packages/core/__tests__/jsx-runtime.spec.ts b/packages/core/__tests__/jsx-runtime.spec.ts index ec9631ef89..ef460aa298 100644 --- a/packages/core/__tests__/jsx-runtime.spec.ts +++ b/packages/core/__tests__/jsx-runtime.spec.ts @@ -1,22 +1,349 @@ import { describe, expect, it } from 'vitest' -import { jsx, jsxs } from '../src/jsx-runtime.js' +import { Fragment, h, jsx, jsxs } from '../src/jsx-runtime.js' -describe('JSX runtime', () => { - it('keeps text and element siblings separate', () => { - const element = jsx('strong', { children: 'text' }) +describe('JSX Runtime', () => { + describe('basic functionality', () => { + it('should render a simple element tag', () => { + const result = h('div', {}) - expect(jsxs('p', { children: ['Before ', element] })).toEqual([ - 'p', - {}, - 'Before ', - ['strong', {}, 'text'], - ]) + expect(result).toEqual(['div', {}]) + }) + + it('should render an element with attributes', () => { + const result = h('div', { class: 'container', id: 'main' }) + + expect(result).toEqual(['div', { class: 'container', id: 'main' }]) + }) + + it('should render slot tag as 0 (content hole)', () => { + const result = h('slot', {}) + + expect(result).toBe(0) + }) + + it('should handle function components', () => { + const Component = (props: any) => ['span', props] + const result = h(Component, { class: 'test' }) + + expect(result).toEqual(['span', { class: 'test' }]) + }) + + it('should keep a function component as a single child', () => { + const Component = (props: any) => ['span', props] + const component = h(Component, { class: 'test' }) + const result = h('div', { children: [component, 'text'] }) + + expect(result).toEqual(['div', {}, ['span', { class: 'test' }], 'text']) + }) + + it('should throw error for svg elements', () => { + expect(() => h('svg', {})).toThrow( + 'SVG elements are not supported in the JSX syntax, use the array syntax instead', + ) + }) + }) + + describe('single child rendering', () => { + it('should render element with single text child', () => { + const result = h('div', { children: 'Hello World' }) + + expect(result).toEqual(['div', {}, 'Hello World']) + }) + + it('should render element with single element child', () => { + const child = h('span', { children: 'text' }) + const result = h('div', { children: child }) + + expect(result).toEqual(['div', {}, ['span', {}, 'text']]) + }) + + it('should render element with single child and attributes', () => { + const child = h('span', { children: 'text' }) + const result = h('div', { class: 'container', children: child }) + + expect(result).toEqual(['div', { class: 'container' }, ['span', {}, 'text']]) + }) + }) + + describe('nested sibling rendering', () => { + it('should render multiple sibling elements by spreading them', () => { + // Simulates JSX:
AB
+ const child1 = h('span', { children: 'A' }) + const child2 = h('span', { children: 'B' }) + const result = h('div', { children: [child1, child2] }) + + // Expected: ["div", {}, ["span", {}, "A"], ["span", {}, "B"]] + // NOT: ["div", {}, [["span", {}, "A"], ["span", {}, "B"]]] + expect(result).toEqual(['div', {}, ['span', {}, 'A'], ['span', {}, 'B']]) + }) + + it('should render three sibling elements correctly', () => { + const child1 = h('span', { children: 'First' }) + const child2 = h('span', { children: 'Second' }) + const child3 = h('span', { children: 'Third' }) + const result = h('div', { children: [child1, child2, child3] }) + + expect(result).toEqual([ + 'div', + {}, + ['span', {}, 'First'], + ['span', {}, 'Second'], + ['span', {}, 'Third'], + ]) + }) + + it('should render elements with attributes and multiple children', () => { + // Simulates JSX:
AB
+ const child1 = h('span', { children: 'A' }) + const child2 = h('span', { children: 'B' }) + const result = h('div', { class: 'container', children: [child1, child2] }) + + expect(result).toEqual(['div', { class: 'container' }, ['span', {}, 'A'], ['span', {}, 'B']]) + }) + + it('should render nested structures with multiple siblings at each level', () => { + // Simulates complex nested JSX + const innerChild1 = h('span', { children: 'Title' }) + const innerChild2 = h('span', { children: 'Value' }) + const middleChild = h('div', { class: 'stat', children: [innerChild1, innerChild2] }) + + const result = h('div', { class: 'stats', children: [middleChild] }) + + expect(result).toEqual([ + 'div', + { class: 'stats' }, + ['div', { class: 'stat' }, ['span', {}, 'Title'], ['span', {}, 'Value']], + ]) + }) + + it('should handle the issue example: stats card with nested siblings', () => { + // This is the exact scenario from GitHub issue #6949 + const statTitle = jsx('div', { class: 'stat-title', children: 'Title' }) + const statValue = jsx('div', { class: 'stat-value', children: '1,000' }) + const stat = jsxs('div', { class: 'stat', children: [statTitle, statValue] }) + const result = jsx('div', { class: 'stats', children: stat }) + + expect(result).toEqual([ + 'div', + { class: 'stats' }, + [ + 'div', + { class: 'stat' }, + ['div', { class: 'stat-title' }, 'Title'], + ['div', { class: 'stat-value' }, '1,000'], + ], + ]) + }) + }) + + describe('edge cases', () => { + it('should handle empty children array', () => { + const result = h('div', { children: [] }) + + expect(result).toEqual(['div', {}]) + }) + + it('should handle undefined children', () => { + const result = h('div', { children: undefined }) + + expect(result).toEqual(['div', {}]) + }) + + it('should handle null children', () => { + const result = h('div', { children: null }) + + expect(result).toEqual(['div', {}]) + }) + + it('should filter out null and undefined from children array', () => { + const child1 = h('span', { children: 'A' }) + const child2 = h('span', { children: 'B' }) + const result = h('div', { children: [child1, null, child2, undefined] }) + + expect(result).toEqual(['div', {}, ['span', {}, 'A'], ['span', {}, 'B']]) + }) + + it('should handle array with all null/undefined children', () => { + const result = h('div', { children: [null, undefined, null] }) + + expect(result).toEqual(['div', {}]) + }) + }) + + describe('Fragment component', () => { + it('should return children array from Fragment', () => { + const child1 = h('span', { children: 'A' }) + const child2 = h('span', { children: 'B' }) + const result = Fragment({ children: [child1, child2] }) + + expect(result).toEqual([ + ['span', {}, 'A'], + ['span', {}, 'B'], + ]) + }) + + it('should work with Fragment as a parent component', () => { + const child1 = h('div', { children: 'First' }) + const child2 = h('div', { children: 'Second' }) + const fragmentResult = Fragment({ children: [child1, child2] }) + + expect(fragmentResult).toEqual([ + ['div', {}, 'First'], + ['div', {}, 'Second'], + ]) + }) + + it('should spread fragment children when nested', () => { + const child1 = h('span', { children: 'A' }) + const child2 = h('span', { children: 'B' }) + const fragment = Fragment({ children: [child1, child2] }) + const result = h('div', { children: fragment }) + + expect(result).toEqual(['div', {}, ['span', {}, 'A'], ['span', {}, 'B']]) + }) + + it('should spread a Fragment between sibling elements', () => { + const before = h('span', { children: 'Before' }) + const nestedFragment = Fragment({ + children: [h('span', { children: 'Inside A' }), h('span', { children: 'Inside B' })], + }) + const fragment = Fragment({ children: [nestedFragment] }) + const after = h('span', { children: 'After' }) + const result = h('div', { children: [before, fragment, after] }) + + expect(result).toEqual([ + 'div', + {}, + ['span', {}, 'Before'], + ['span', {}, 'Inside A'], + ['span', {}, 'Inside B'], + ['span', {}, 'After'], + ]) + }) + }) + + describe('content hole (slot) integration', () => { + it('should render element with slot (0) as child', () => { + const slot = h('slot', {}) + const result = h('div', { children: slot }) + + expect(result).toEqual(['div', {}, 0]) + }) + + it('should render element with attributes and slot', () => { + const slot = h('slot', {}) + const result = h('div', { class: 'content', children: slot }) + + expect(result).toEqual(['div', { class: 'content' }, 0]) + }) + + it('should handle slot in nested structure', () => { + const slot = h('slot', {}) + const inner = h('div', { class: 'inner', children: slot }) + const result = h('div', { class: 'outer', children: inner }) + + expect(result).toEqual(['div', { class: 'outer' }, ['div', { class: 'inner' }, 0]]) + }) + }) + + describe('mixed content', () => { + it('should handle mixed text and element children', () => { + const element = h('strong', { children: 'bold' }) + const result = h('p', { children: [element] }) + + expect(result).toEqual(['p', {}, ['strong', {}, 'bold']]) + }) + + it('should handle multiple mixed content types', () => { + const bold = h('strong', { children: 'important' }) + const italic = h('em', { children: 'emphasis' }) + const result = h('p', { children: [bold, italic] }) + + expect(result).toEqual(['p', {}, ['strong', {}, 'important'], ['em', {}, 'emphasis']]) + }) + + it('should keep text and element siblings separate', () => { + const element = jsx('strong', { children: 'bold' }) + const result = jsxs('p', { children: ['text', element] }) + + expect(result).toEqual(['p', {}, 'text', ['strong', {}, 'bold']]) + }) + + it('should keep text and slot siblings separate', () => { + const slot = jsx('slot', {}) + const result = jsxs('div', { children: ['text', slot] }) + + expect(result).toEqual(['div', {}, 'text', 0]) + }) }) - it('keeps text and slot siblings separate', () => { - const slot = jsx('slot', {}) + describe('real-world scenarios', () => { + it('should handle complex node rendering structure', () => { + // Simulates a complex custom node like a callout with icon and content + const icon = h('span', { class: 'icon', children: '📝' }) + const title = h('div', { class: 'title', children: 'Note' }) + const content = h('div', { class: 'content', children: h('slot', {}) }) + const header = h('div', { class: 'header', children: [icon, title] }) + + const result = h('div', { class: 'callout', children: [header, content] }) + + expect(result).toEqual([ + 'div', + { class: 'callout' }, + [ + 'div', + { class: 'header' }, + ['span', { class: 'icon' }, '📝'], + ['div', { class: 'title' }, 'Note'], + ], + ['div', { class: 'content' }, 0], + ]) + }) + + it('should handle deeply nested sibling structures', () => { + // Level 3 + const leaf1 = h('span', { children: 'A' }) + const leaf2 = h('span', { children: 'B' }) + + // Level 2 + const branch = h('div', { class: 'branch', children: [leaf1, leaf2] }) + + // Level 1 + const root = h('div', { class: 'root', children: [branch] }) + + expect(root).toEqual([ + 'div', + { class: 'root' }, + ['div', { class: 'branch' }, ['span', {}, 'A'], ['span', {}, 'B']], + ]) + }) + }) + + describe('JSX boundary handling', () => { + it('should spread array of DOMOutputSpecArrays, not treat as single child', () => { + // Array of multiple DOMOutputSpecArrays should be spread + const children = [ + ['span', {}, 'A'], + ['span', {}, 'B'], + ] as any + const result = h('div', { children }) + + expect(result).toEqual(['div', {}, ['span', {}, 'A'], ['span', {}, 'B']]) + }) + + it('should handle array starting with 0 as multiple children', () => { + const children = [0, 'text'] as any + const result = h('div', { children }) + + expect(result).toEqual(['div', {}, 0, 'text']) + }) + + it('should handle single 0 (content hole) as child', () => { + // Single 0 is the content hole marker + const result = h('div', { children: 0 }) - expect(jsxs('p', { children: ['Before ', slot] })).toEqual(['p', {}, 'Before ', 0]) + expect(result).toEqual(['div', {}, 0]) + }) }) }) diff --git a/packages/core/src/jsx-runtime.ts b/packages/core/src/jsx-runtime.ts index ba5013b785..6ed2e117e3 100644 --- a/packages/core/src/jsx-runtime.ts +++ b/packages/core/src/jsx-runtime.ts @@ -13,13 +13,43 @@ export type DOMOutputSpecArray = | [string, Attributes, DOMOutputSpecArray | 0] | [string, DOMOutputSpecArray] +// Child lists and DOM output specs are both arrays, so track JSX boundaries by identity. +const jsxElements = new WeakSet() +const jsxFragments = new WeakSet() + +/** Create a new JSX element from the given spec */ +function createJSXElement(spec: unknown[]): DOMOutputSpecArray { + const element = spec as DOMOutputSpecArray + + jsxElements.add(element) + + return element +} + +/** Check if a spec is a JSX element */ +function isJSXElement(value: unknown): value is DOMOutputSpecArray { + return Array.isArray(value) && jsxElements.has(value as DOMOutputSpecArray) +} + +function flattenFragmentChildren(children: unknown[]): unknown[] { + return children.flatMap(child => { + if (child == null) { + return [] + } + + if (Array.isArray(child) && jsxFragments.has(child) && !isJSXElement(child)) { + return flattenFragmentChildren(child) + } + + return [child] + }) +} + // JSX types for Tiptap's JSX runtime // These types only apply when using @jsxImportSource @tiptap/core -// oxlint-disable-next-lineno-namespace -export namespace JSX { +export declare namespace JSX { export type Element = DOMOutputSpecArray export interface IntrinsicElements { - // oxlint-disable-next-lineno-explicit-any [key: string]: any } export interface ElementChildrenAttribute { @@ -27,21 +57,21 @@ export namespace JSX { } } +type JSXChild = DOMOutputSpecElement | string | null | undefined | JSXChild[] + export type JSXRenderer = ( tag: 'slot' | string | ((props?: Attributes) => DOMOutputSpecArray | DOMOutputSpecElement), props?: Attributes, - ...children: JSXRenderer[] + ...children: JSXChild[] ) => DOMOutputSpecArray | DOMOutputSpecElement -export function Fragment(props: { children: JSXRenderer[] }) { +export function Fragment(props: { children: JSXChild[] }) { + jsxFragments.add(props.children) + return props.children } -function render( - tag: Parameters[0], - attributes: Attributes | undefined, - hasMultipleChildren: boolean, -) { +function render(tag: Parameters[0], attributes: Attributes | undefined) { // Treat the slot tag as the Prosemirror hole to render content into if (tag === 'slot') { return 0 @@ -49,7 +79,13 @@ function render( // If the tag is a function, call it with the props if (tag instanceof Function) { - return tag(attributes) + const result = tag(attributes) + + if (Array.isArray(result) && !isJSXElement(result) && !jsxFragments.has(result)) { + return createJSXElement(result) + } + + return result } const { children, ...rest } = attributes ?? {} @@ -60,24 +96,46 @@ function render( ) } - if (hasMultipleChildren && Array.isArray(children)) { - return [tag, rest, ...children] as DOMOutputSpecArray + // Handle children array by spreading elements + if (Array.isArray(children)) { + if (isJSXElement(children)) { + return createJSXElement([tag, rest, children]) + } + + if (children.length === 0) { + // Empty array means no children + return createJSXElement([tag, rest]) + } + + const flattenedChildren = flattenFragmentChildren(children) + + if (flattenedChildren.length === 0) { + return createJSXElement([tag, rest]) + } + + // Spread children into the result array + return createJSXElement([tag, rest, ...flattenedChildren]) + } + + // Single child or no children + if (children !== undefined && children !== null) { + return createJSXElement([tag, rest, children]) } - return [tag, rest, children] + return createJSXElement([tag, rest]) } -export const h: JSXRenderer = (tag, attributes) => render(tag, attributes, false) +export const h: JSXRenderer = (tag, attributes) => render(tag, attributes) -export const jsxs: JSXRenderer = (tag, attributes) => render(tag, attributes, true) +export const jsxs: JSXRenderer = (tag, attributes) => render(tag, attributes) export const jsxDEV = ( tag: Parameters[0], attributes?: Attributes, _key?: unknown, - isStaticChildren?: boolean, + _isStaticChildren?: boolean, ) => { - return render(tag, attributes, Boolean(isStaticChildren)) + return render(tag, attributes) } // See