Skip to content

Commit 11747bd

Browse files
authored
feat(collab-doc): Hocuspocus binary persistence + Next 16 seed/persist fixes (#6059)
* fix(collab-doc): make server-side seed conversion work under Next 16 / Turbopack Opening a file left both collaborators read-only and stalled ~12s: the server-side seed (markdown -> Yjs, run through the headless editor engine) was failing, so the doc never seeded and the editor never left its readiness gate. Two root causes, both latent until a real build/runtime (typecheck + unit tests don't exercise either), surfaced by the Next 16 upgrade: 1. Build boundary: the server seed route imported the shared editor schema (`createMarkdownContentExtensions`), which pulled in the React node-view components (`useEffect`) -> 'client component in a Server Component'. Split each node's React-free schema into its own `*-schema.ts` (code-block, image, raw-markdown-snippet); the client editor still injects the React node views via the existing `nodeViews` param, unchanged. 2. Runtime DOM: the converter installs a jsdom `window` on `globalThis`, but Turbopack's server bundle gives bundled `@tiptap/core` a `window` that does NOT read `globalThis`, so `elementFromString` threw 'no window object available'. Externalize the `@tiptap/*` packages the converter uses (native Node require, so their `window` reads the real global) and fix the converter's DOM guard to gate on `window` (what TipTap checks) with no sticky flag. Verified: seed route returns 200 with the Yjs update; 514 collab-doc + editor tests pass; schema byte-identical after the split. * feat(collab-doc): persist the Yjs binary and load it on cold-start (Hocuspocus pattern) Adopt the industry-standard Hocuspocus store/load-document pattern so a cold room open loads the file's last-persisted Yjs binary directly instead of re-converting markdown -> Yjs on every open. Rebuilding the CRDT from markdown on each connect is the exact anti-pattern Tiptap/Yjs warn against (fresh client ids -> duplicated content); it also forced the fragile server-side headless-editor conversion on every open. Now conversion runs only on a genuine first open or an external markdown edit. - new table workspace_file_collab_state(file_id PK->workspace_files cascade, doc_state bytea, source_hash, updated_at): the Yjs binary + a hash of the markdown it was derived from (bounded <=~1MB by the 256KB round-trip gate). Mirrors Hocuspocus's extension-database (binary in a DB column). Migration 0275. - persist upserts the binary (tagged with the exact markdown just written) - cold-start seed returns the cached binary when its source_hash matches the file's current markdown; otherwise converts (and the next persist refreshes the cache) - also externalize yjs / y-protocols / lib0 alongside @tiptap: bundling loaded a second yjs copy, so @tiptap/y-tiptap's 'instanceof Y.XmlElement' failed on app-created nodes ('Unexpected case') during Yjs -> markdown Verified end-to-end: seed -> persist -> seed returns the exact persisted binary (a cache hit, no re-conversion). 18 collab-doc + 51 realtime file-doc tests pass. * fix(collab-doc): best-effort cache read + drop dead barrel - seed: a cache-read failure (transient DB error, not-yet-migrated cache table) no longer aborts a cold room open — the durable markdown is already in hand, so fall through to conversion. Symmetric with persist's best-effort cache write. Addresses the Cursor Bugbot finding on the read/write asymmetry. - remove the collab-doc index.ts barrel: nothing imported it (every consumer uses direct ./seed / ./merge / ./converter imports), so it was dead re-export surface. De-export COLLAB_DOC_FIELD accordingly — it is used only inside converter.ts.
1 parent 2c88758 commit 11747bd

18 files changed

Lines changed: 19201 additions & 684 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { JSONContent } from '@tiptap/core'
2+
import { CodeBlock } from '@tiptap/extension-code-block'
3+
4+
/**
5+
* React-free schema half of the code-block node. Lives apart from {@link ./code-block} (its React
6+
* node view) so the shared editor schema — `createMarkdownContentExtensions` in `./extensions` — can
7+
* be imported by server code (the collab-doc seed converter) without pulling a client component
8+
* (`useEffect`) into a Server Component module. The client editor injects the node-view variant
9+
* ({@link CodeBlockWithLanguage}) via `nodeViews`.
10+
*/
11+
12+
function codeBlockText(node: JSONContent): string {
13+
return (node.content ?? []).map((child) => child.text ?? '').join('')
14+
}
15+
16+
/** Fence sized to one backtick longer than the longest run inside the code (CommonMark rule). */
17+
function fenceFor(text: string): string {
18+
const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length))
19+
return '`'.repeat(Math.max(3, longestRun + 1))
20+
}
21+
22+
/**
23+
* Code block whose markdown serializer sizes the fence to the interior backtick runs, so a code
24+
* block that itself contains a ``` line round-trips instead of shattering. Shared by the test
25+
* (plain) and live ({@link CodeBlockWithLanguage}) paths.
26+
*/
27+
export const MarkdownCodeBlock = CodeBlock.extend({
28+
renderMarkdown: (node: JSONContent) => {
29+
const language = typeof node.attrs?.language === 'string' ? node.attrs.language : ''
30+
const text = codeBlockText(node)
31+
const fence = fenceFor(text)
32+
return `${fence}${language}\n${text}\n${fence}`
33+
},
34+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,11 @@ import {
88
DropdownMenuTrigger,
99
useCopyToClipboard,
1010
} from '@sim/emcn'
11-
import type { JSONContent } from '@tiptap/core'
12-
import { CodeBlock } from '@tiptap/extension-code-block'
1311
import type { ReactNodeViewProps } from '@tiptap/react'
1412
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
1513
import { Check, ChevronDown, Code, Copy, Eye, WrapText } from 'lucide-react'
1614
import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram'
15+
import { MarkdownCodeBlock } from './code-block-schema'
1716
import { detectLanguage } from './detect-language'
1817
import { useEditorEditable } from './use-editor-editable'
1918

@@ -228,30 +227,6 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
228227
)
229228
}
230229

231-
function codeBlockText(node: JSONContent): string {
232-
return (node.content ?? []).map((child) => child.text ?? '').join('')
233-
}
234-
235-
/** Fence sized to one backtick longer than the longest run inside the code (CommonMark rule). */
236-
function fenceFor(text: string): string {
237-
const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length))
238-
return '`'.repeat(Math.max(3, longestRun + 1))
239-
}
240-
241-
/**
242-
* Code block whose markdown serializer sizes the fence to the interior backtick runs, so a code
243-
* block that itself contains a ``` line round-trips instead of shattering. Shared by the test
244-
* (plain) and live ({@link CodeBlockWithLanguage}) paths.
245-
*/
246-
export const MarkdownCodeBlock = CodeBlock.extend({
247-
renderMarkdown: (node: JSONContent) => {
248-
const language = typeof node.attrs?.language === 'string' ? node.attrs.language : ''
249-
const text = codeBlockText(node)
250-
const fence = fenceFor(text)
251-
return `${fence}${language}\n${text}\n${fence}`
252-
},
253-
})
254-
255230
/**
256231
* Code block with hover-revealed controls (language picker, line-wrap toggle, copy). The
257232
* `language` attribute drives {@link CodeBlockHighlight}'s Prism highlighting and serializes to

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,18 @@ import {
1111
} from '@tiptap/extension-table'
1212
import { Markdown } from '@tiptap/markdown'
1313
import StarterKit from '@tiptap/starter-kit'
14-
import { MarkdownCodeBlock } from './code-block'
14+
import { MarkdownCodeBlock } from './code-block-schema'
1515
import { Highlight } from './highlight'
16-
import { MarkdownImage } from './image'
16+
import { MarkdownImage } from './image-schema'
1717
import { MarkdownLinkInputRule } from './link-input-rule'
1818
import { MarkdownMention } from './mention/mention-node'
1919
import { SIM_LINK_SCHEME } from './mention/sim-link'
20-
import { FootnoteDef, FootnoteRef, RawHtmlBlock, RawInlineHtml } from './raw-markdown-snippet'
20+
import {
21+
FootnoteDef,
22+
FootnoteRef,
23+
RawHtmlBlock,
24+
RawInlineHtml,
25+
} from './raw-markdown-snippet-schema'
2126

2227
/**
2328
* The `@`-mention link scheme, registered on the Link mark — without it the schema strips the
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import type { JSONContent } from '@tiptap/core'
2+
import { Image } from '@tiptap/extension-image'
3+
4+
/**
5+
* React-free schema half of the image node. Lives apart from {@link ./image} (its React resize node
6+
* view) so the shared editor schema — `createMarkdownContentExtensions` in `./extensions` — can be
7+
* imported by server code (the collab-doc seed converter) without pulling a client component
8+
* (`useEffect`) into a Server Component module. The client editor injects the node-view variant
9+
* ({@link ResizableImage}) via `nodeViews`.
10+
*/
11+
12+
/**
13+
* A markdown linked image `[![alt](src "t")](href "t2")` — an image wrapped in a link, the canonical
14+
* form of a README badge. `@tiptap/markdown` parses this as a link mark over an image node, but an
15+
* image node can't carry inline marks, so the wrapping link is silently dropped. We instead tokenize
16+
* the whole construct ourselves and hang the link target on the image node's `href` attribute, so it
17+
* round-trips losslessly (and the file stays editable rather than opening read-only).
18+
*/
19+
const LINKED_IMAGE_RE =
20+
/^\[!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/
21+
22+
/** Escape a value for safe interpolation into a double-quoted HTML attribute. */
23+
function escapeAttr(value: string): string {
24+
return value
25+
.replace(/&/g, '&amp;')
26+
.replace(/"/g, '&quot;')
27+
.replace(/</g, '&lt;')
28+
.replace(/>/g, '&gt;')
29+
}
30+
31+
/**
32+
* Serialize an image to markdown when it has no explicit size, and to an HTML `<img>` tag when
33+
* it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to
34+
* preserve its dimensions. Unsized images stay clean `![alt](src)`. An image with an `href` is
35+
* wrapped in a markdown link so a linked badge round-trips as `[![alt](src)](href)`.
36+
*
37+
* A *sized **and** linked* image is the one case markdown can't represent: the linked-image tokenizer
38+
* only recognizes `[![alt](src)](href)`, so emitting `[<img …>](href)` would silently drop the link on
39+
* reparse (and the round-trip-safety probe wouldn't catch it). We keep the link and fall back to the
40+
* unsized `[![alt](src)](href)` form — the link matters more than the exact dimensions for a badge.
41+
*/
42+
function imageMarkdown(node: JSONContent): string {
43+
const attrs = node.attrs ?? {}
44+
const src = typeof attrs.src === 'string' ? attrs.src : ''
45+
const alt = typeof attrs.alt === 'string' ? attrs.alt : ''
46+
const title = typeof attrs.title === 'string' ? attrs.title : ''
47+
const href = typeof attrs.href === 'string' ? attrs.href : ''
48+
const hrefTitle = typeof attrs.hrefTitle === 'string' ? attrs.hrefTitle : ''
49+
const width = attrs.width
50+
const height = attrs.height
51+
let image: string
52+
if ((width || height) && !href) {
53+
const parts = [`src="${escapeAttr(src)}"`]
54+
if (alt) parts.push(`alt="${escapeAttr(alt)}"`)
55+
if (title) parts.push(`title="${escapeAttr(title)}"`)
56+
if (width) parts.push(`width="${escapeAttr(String(width))}"`)
57+
if (height) parts.push(`height="${escapeAttr(String(height))}"`)
58+
image = `<img ${parts.join(' ')}>`
59+
} else {
60+
// Escape so an alt with `]`/`[` or a title with `"` can't break out of the `![…](… "…")` syntax
61+
// and corrupt the round-trip; a src with spaces/parens goes in angle brackets (CommonMark).
62+
const titlePart = title ? ` "${title.replace(/["\\]/g, '\\$&')}"` : ''
63+
const safeSrc = /[\s()]/.test(src) ? `<${src}>` : src
64+
image = `![${alt.replace(/[\\[\]]/g, '\\$&')}](${safeSrc}${titlePart})`
65+
}
66+
if (!href) return image
67+
// Escape `"`/`\` so an href title can't break out of the `[…](href "title")` syntax (mirrors the
68+
// image title escaping above).
69+
const hrefTitlePart = hrefTitle ? ` "${hrefTitle.replace(/["\\]/g, '\\$&')}"` : ''
70+
return `[${image}](${href}${hrefTitlePart})`
71+
}
72+
73+
interface MarkdownImageToken {
74+
/** Set only by our linked-image tokenizer; absent on the built-in `![](src)` token. */
75+
src?: string
76+
alt?: string
77+
title?: string | null
78+
/** Built-in image token holds the source URL here; our linked token holds the link target. */
79+
href?: string
80+
hrefTitle?: string | null
81+
/** Built-in image token holds the alt text here. */
82+
text?: string
83+
}
84+
85+
/** Map both the built-in image token and our linked-image token onto the image node's attributes. */
86+
function parseImageToken(token: MarkdownImageToken): JSONContent {
87+
const isLinked = typeof token.src === 'string'
88+
return {
89+
type: 'image',
90+
attrs: isLinked
91+
? {
92+
src: token.src,
93+
alt: token.alt ?? '',
94+
title: token.title ?? null,
95+
href: token.href ?? null,
96+
hrefTitle: token.hrefTitle ?? null,
97+
}
98+
: {
99+
src: token.href ?? '',
100+
alt: token.text ?? '',
101+
title: token.title ?? null,
102+
href: null,
103+
hrefTitle: null,
104+
},
105+
}
106+
}
107+
108+
const widthAttr = {
109+
default: null,
110+
parseHTML: (element: HTMLElement) => element.getAttribute('width'),
111+
renderHTML: (attributes: Record<string, unknown>) =>
112+
attributes.width ? { width: String(attributes.width) } : {},
113+
}
114+
115+
const heightAttr = {
116+
default: null,
117+
parseHTML: (element: HTMLElement) => element.getAttribute('height'),
118+
renderHTML: (attributes: Record<string, unknown>) =>
119+
attributes.height ? { height: String(attributes.height) } : {},
120+
}
121+
122+
/** Link target of a linked image — markdown-only state, never emitted as an HTML `<img>` attribute. */
123+
const hrefAttr = { default: null, rendered: false }
124+
const hrefTitleAttr = { default: null, rendered: false }
125+
126+
/**
127+
* Image node that carries optional `width`/`height` (serialized as an HTML `<img>` tag) and an
128+
* optional `href`/`hrefTitle` (a wrapping markdown link, for badges). Shared by the headless
129+
* round-trip path (no node view) and the live {@link ResizableImage}.
130+
*/
131+
export const MarkdownImage = Image.extend({
132+
addAttributes() {
133+
return {
134+
...this.parent?.(),
135+
width: widthAttr,
136+
height: heightAttr,
137+
href: hrefAttr,
138+
hrefTitle: hrefTitleAttr,
139+
}
140+
},
141+
markdownTokenizer: {
142+
name: 'image',
143+
level: 'inline',
144+
start: (src: string) => src.indexOf('[!['),
145+
tokenize: (src: string): (MarkdownImageToken & { type: string; raw: string }) | undefined => {
146+
const match = LINKED_IMAGE_RE.exec(src)
147+
if (!match) return undefined
148+
return {
149+
type: 'image',
150+
raw: match[0],
151+
alt: match[1] ?? '',
152+
src: match[2],
153+
title: match[3] ?? null,
154+
href: match[4],
155+
hrefTitle: match[5] ?? null,
156+
}
157+
},
158+
},
159+
parseMarkdown: parseImageToken,
160+
renderMarkdown: imageMarkdown,
161+
})

0 commit comments

Comments
 (0)