From b5cc8f6937be3b6f05f2bc89749baff370feb407 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Aug 2026 10:36:26 -0700 Subject: [PATCH 1/2] fix(files): render the file-viewer placeholder through the live node views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collaborative markdown viewer painted a static generateHTML placeholder while the Yjs doc seeded, then swapped to the live editor. generateHTML runs only schema renderHTML — never the React node views or the ProseMirror decoration plugins — so every node whose live appearance comes from a node view or a decoration rendered differently in the placeholder and visibly repainted on the swap: syntax highlighting popped in, mention-chip icons shifted their labels, mermaid blocks jumped from source to diagram, and media embeds appeared out of nowhere. Render the placeholder through a read-only editor that shares the live editor's extension set instead. It uses the same node views and decoration plugins, so the placeholder is pixel-identical to the live editor and the swap neither repaints nor reflows — highlighting, mention icons, images, mermaid (via its existing SVG cache), and embeds (which already reserve their aspect-ratio box) all render up front. The placeholder editor carries no Collaboration extension, Y.Doc, or awareness, so it structurally cannot write to the shared document, preserving the seed-only-on-server invariant; editable={false} disables every editing affordance. --- .../rich-markdown-editor.tsx | 57 ++++++++++++------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 83730f5465e..e7ec9b7c36c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -3,7 +3,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { cn, toast } from '@sim/emcn' import { FILE_DOC_SEED, type JoinFileDocError } from '@sim/realtime-protocol/file-doc' -import { type Extensions, generateHTML, type JSONContent } from '@tiptap/core' +import type { Extensions, JSONContent } from '@tiptap/core' import { isChangeOrigin } from '@tiptap/extension-collaboration' import { Fragment, Slice } from '@tiptap/pm/model' import { NodeSelection } from '@tiptap/pm/state' @@ -81,6 +81,34 @@ const STREAM_REPARSE_THROTTLE_MS = 120 /** Debounce before naming a still-untitled file after its leading heading, so it fires once typing settles. */ const DERIVE_TITLE_DEBOUNCE_MS = 600 +/** + * Read-only editor that renders the already-fetched markdown while a collaborative doc waits for its + * server seed, so the pane shows content instantly instead of blocking blank on the socket round-trip + * (the seed IS the same markdown, so the swap on `collabReady` is seamless). It shares the live + * editor's extension set ({@link EXTENSIONS}) — and therefore its node views and decoration plugins + * (syntax highlighting, mention chips, images, mermaid diagrams, media embeds) — so the content is + * pixel-identical to the live editor and the swap neither repaints nor reflows. It carries no + * Collaboration extension, Y.Doc, or awareness, so it structurally cannot write to the shared document + * (a client seed would duplicate it), and `editable={false}` disables every editing affordance. Mounted + * only while the placeholder shows, so no second editor lingers once the live one takes over. + */ +function ReadOnlyPlaceholder({ content }: { content: JSONContent }) { + const editor = useEditor({ + extensions: EXTENSIONS, + editable: false, + immediatelyRender: false, + shouldRerenderOnTransaction: false, + content, + editorProps: { attributes: { class: 'rich-markdown-prose' } }, + }) + return ( + + ) +} + interface RichMarkdownEditorProps { file: WorkspaceFileRecord workspaceId: string @@ -332,16 +360,12 @@ export function LoadedRichMarkdownEditor({ : parseMarkdownToDoc(splitFrontmatter(content).body) ) /** - * A read-only placeholder rendered from the already-fetched markdown while a collaborative doc waits - * for its server seed, so the pane shows content instantly instead of blocking blank on the socket - * round-trip (the seed IS the same markdown, so the swap on {@link collabReady} is seamless). Static - * HTML — it holds no editor, doc, or awareness, so it structurally cannot write to the Y.Doc, which - * is the invariant that keeps seeding out of the client (a client seed duplicates the doc). + * The already-fetched markdown, parsed once, for the read-only {@link ReadOnlyPlaceholder} shown while + * a collaborative doc waits for its server seed. Held only when collaborating; the local path seeds + * the live editor directly, so it needs no placeholder. */ - const [placeholderHtml] = useState(() => - collaborationEnabled - ? generateHTML(parseMarkdownToDoc(splitFrontmatter(content).body), EXTENSIONS) - : null + const [placeholderContent] = useState(() => + collaborationEnabled ? parseMarkdownToDoc(splitFrontmatter(content).body) : null ) /** * The body currently shown in the editor: seeded from a settled mount, updated on local edits (via @@ -1197,21 +1221,14 @@ export function LoadedRichMarkdownEditor({ if (images.length > 0) void insertImagesRef.current(images, at) }} /> - {showPlaceholder && placeholderHtml && ( - // Instant read-only content while the collaborative doc seeds, swapped for the live editor - // once ready. The `ProseMirror` class is load-bearing: it gives the placeholder the same base - // text layout as the live editable (prosemirror-view sets `white-space: break-spaces` and - // disables ligatures), so a line wraps identically and never re-wraps on the swap. -
+ {showPlaceholder && placeholderContent && ( + )}
From 98ed8bc38fe64640ed6a8cec93ca70830aeec6a5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Aug 2026 10:52:51 -0700 Subject: [PATCH 2/2] fix(files): address review on the placeholder editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Give ReadOnlyPlaceholder a named props interface (repo component convention). - Render the placeholder synchronously (immediatelyRender: true) so it paints instantly like the static HTML it replaced instead of blanking for a frame while the editor mounts — safe because this surface is client-only, never SSR'd. - Hoist the editor reading-column classes into a shared EDITOR_SURFACE_CLASS so the placeholder and live editor stay geometrically identical (drop the now redundant placeholderContent term from the live editor's hidden class). --- .../rich-markdown-editor.tsx | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index e7ec9b7c36c..447c8aebb2e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -81,6 +81,14 @@ const STREAM_REPARSE_THROTTLE_MS = 120 /** Debounce before naming a still-untitled file after its leading heading, so it fires once typing settles. */ const DERIVE_TITLE_DEBOUNCE_MS = 600 +/** + * The editor's reading column — the centered, padded surface both the live editor and the read-only + * {@link ReadOnlyPlaceholder} render into, so the two are geometrically identical and the placeholder → + * live swap never reflows. Shared as one constant to keep them in lockstep. + */ +const EDITOR_SURFACE_CLASS = + 'mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white' + /** * Read-only editor that renders the already-fetched markdown while a collaborative doc waits for its * server seed, so the pane shows content instantly instead of blocking blank on the socket round-trip @@ -92,21 +100,23 @@ const DERIVE_TITLE_DEBOUNCE_MS = 600 * (a client seed would duplicate it), and `editable={false}` disables every editing affordance. Mounted * only while the placeholder shows, so no second editor lingers once the live one takes over. */ -function ReadOnlyPlaceholder({ content }: { content: JSONContent }) { +interface ReadOnlyPlaceholderProps { + content: JSONContent +} + +function ReadOnlyPlaceholder({ content }: ReadOnlyPlaceholderProps) { const editor = useEditor({ extensions: EXTENSIONS, editable: false, - immediatelyRender: false, + // Render synchronously on first paint (safe — this surface is client-only, never SSR'd) so the + // placeholder appears instantly like the static HTML it replaced, instead of blanking for a frame + // while the editor mounts. + immediatelyRender: true, shouldRerenderOnTransaction: false, content, editorProps: { attributes: { class: 'rich-markdown-prose' } }, }) - return ( - - ) + return } interface RichMarkdownEditorProps { @@ -1226,10 +1236,7 @@ export function LoadedRichMarkdownEditor({ )} )