From affb1d87aac1bfbf8200f540917de2a3f6157c5d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Aug 2026 14:57:55 -0700 Subject: [PATCH 1/2] fix(files): reserve embedded-image space on direct file-view loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An embedded image in a markdown file reshifted on every open: it loaded ~2.3s in with no reserved box and shoved everything below it down (CLS ~0.17). The intrinsic dimensions ARE stored server-side, but the image node view never read them at render. useWorkspaceImageDimensionsAdapter read the active files list via queryClient.getQueryData — non-reactively — so on a cold file-view load it returned null at first render and, because the adapter identity was stable, never re-checked when the list later resolved. Read the list via a reactive useWorkspaceFiles subscription instead: the adapter re-runs the image node view's memoized dimension read when the list resolves, so it reserves the box from the stored dimensions before the (slower) image download finishes. The query key is shared, so it dedupes with surrounding views. Gate it behind `enabled` (driven by the absence of a caller-supplied contentSource) so the public share page — which passes a share token as workspaceId — doesn't fire a 404. Verified in a CLS harness: dims present -> 0 shift; dims absent -> 0.20. --- .../components/file-viewer/file-viewer.tsx | 5 +++- apps/sim/hooks/queries/workspace-files.ts | 28 ++++++++++++------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index fe61b735738..8b729a1257d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -131,7 +131,10 @@ interface FileViewerProps { export function FileViewer(props: FileViewerProps) { const { contentSource, workspaceId } = props - const imageDimensions = useWorkspaceImageDimensionsAdapter(workspaceId) + // A caller-supplied contentSource means the adapter is unused (and its `workspaceId` may be a share token). + const imageDimensions = useWorkspaceImageDimensionsAdapter(workspaceId, { + enabled: !contentSource, + }) const source = useMemo( () => contentSource ?? createWorkspaceFileContentSource(workspaceId, imageDimensions), [contentSource, workspaceId, imageDimensions] diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 65469cbe202..8a5b503cd8c 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -142,20 +142,28 @@ export function useWorkspaceFiles( } /** - * Back the file content source's image-dimension capability with workspace file metadata. Reads intrinsic - * dimensions synchronously from the already-loaded active file list (so a stored image reserves its box on - * the first render), and persists the browser's measured dimensions when they're absent or disagree with - * what's stored — an overwrite, so a stale value (left over after a content swap, or a non-EXIF-corrected - * one) self-corrects rather than sticking. The write is fire-and-forget and de-duped (an exact-match cache - * check plus mismatch-only reporting from the caller), so it never storms, never blocks render, and never - * touches the collaborative document. + * Back the file content source's image-dimension capability with workspace file metadata. Subscribes to + * the active file list ({@link useWorkspaceFiles}) and reads each image's stored intrinsic dimensions from + * it, so a stored image reserves its box before it downloads. A reactive read (not a one-shot + * `getQueryData`), so it also works on a cold direct file-view load where the list isn't cached until after + * the image first renders: the subscription re-runs the node view's dimension read once the list resolves. + * Persists the browser's measured dimensions when they're absent or disagree with what's stored — an + * overwrite, so a stale value (left over after a content swap, or a non-EXIF-corrected one) self-corrects + * rather than sticking. The write is fire-and-forget and de-duped (an exact-match cache check plus + * mismatch-only reporting from the caller), so it never storms, never blocks render, and never touches the + * collaborative document. `options.enabled` turns the subscription off for callers that supply their own + * content source (the public share page, whose `workspaceId` is a share token that would 404). */ -export function useWorkspaceImageDimensionsAdapter(workspaceId: string): ImageDimensionsSource { +export function useWorkspaceImageDimensionsAdapter( + workspaceId: string, + options?: { enabled?: boolean } +): ImageDimensionsSource { const queryClient = useQueryClient() + const { data: files } = useWorkspaceFiles(workspaceId, 'active', options) return useMemo(() => { const listKey = workspaceFilesKeys.list(workspaceId, 'active') const findRecord = (src: string | undefined): WorkspaceFileRecord | undefined => - findWorkspaceFileBySrc(queryClient.getQueryData(listKey), src) + findWorkspaceFileBySrc(files, src) return { getImageDimensions: (src) => { const record = findRecord(src) @@ -194,7 +202,7 @@ export function useWorkspaceImageDimensionsAdapter(workspaceId: string): ImageDi .catch(() => {}) }, } - }, [queryClient, workspaceId]) + }, [files, queryClient, workspaceId]) } /** From 08682dab8ccab1433bb3794a6d5b59951c6e9627 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Aug 2026 14:57:55 -0700 Subject: [PATCH 2/2] fix(files): stop the duplicate 'mention' extension-name warning The @-mention menu extension and the mention node were both named 'mention', so TipTap logged "Duplicate extension names found: ['mention']" on every editor (twice under the collaborative placeholder + live pair). Rename the menu extension to 'mentionMenu' (the node keeps 'mention', its persisted doc-node type) and move its editor.storage.mention -> editor.storage.mentionMenu. --- .../mention/mention-chip.test.tsx | 2 +- .../rich-markdown-editor/mention/mention-chip.tsx | 2 +- .../rich-markdown-editor/mention/mention.ts | 12 ++++++------ .../mention/use-editor-mentions.ts | 10 +++++----- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx index b3e08f18da1..f17fa37ebd7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx @@ -26,7 +26,7 @@ function fakeNode(attrs: Record) { } function fakeEditor(): Editor { - return { storage: { mention: { navigable: false } } } as unknown as Editor + return { storage: { mentionMenu: { navigable: false } } } as unknown as Editor } let container: HTMLDivElement | null = null diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx index cecd097fd81..30c27c1bed7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx @@ -38,7 +38,7 @@ export function MentionChipView({ node, editor }: ReactNodeViewProps) { const { kind, id, label } = node.attrs as MentionAttrs const Icon = mentionIcon(kind, id, label) as StyleableIcon | undefined const iconStyle = Icon ? getBareIconStyle(Icon) : undefined - const navigable = editor.storage.mention?.navigable === true + const navigable = editor.storage.mentionMenu?.navigable === true const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined const path = navigable && workspaceId ? simLinkPath(workspaceId, kind, id) : null diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts index bb4b621d78d..1ab92bd938a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts @@ -25,7 +25,7 @@ export interface MentionStorage { declare module '@tiptap/core' { interface Storage { - mention: MentionStorage + mentionMenu: MentionStorage } } @@ -35,10 +35,10 @@ declare module '@tiptap/core' { * entity inserts it as a portable `sim:/` markdown link (same wire format as the chat * composer's `chip-clipboard-codec`), so it round-trips natively through the editor's link + markdown * machinery. The plugin's `items` is an empty gate; the real list is sourced reactively from the store - * inside {@link MentionList}, populated by the host via the extension's `mention` storage. + * inside {@link MentionList}, populated by the host via the extension's `mentionMenu` storage. */ export const Mention = Extension.create, MentionStorage>({ - name: 'mention', + name: 'mentionMenu', addStorage() { return { store: createMentionStore(), onOpen: null, enabled: true, navigable: false } @@ -53,7 +53,7 @@ export const Mention = Extension.create, MentionStorage>({ allowSpaces: false, startOfLine: false, allow: ({ editor, range }) => { - if (!editor.storage.mention.enabled) return false + if (!editor.storage.mentionMenu.enabled) return false if (editor.isActive('codeBlock') || editor.isActive('link') || editor.isActive('code')) { return false } @@ -78,10 +78,10 @@ export const Mention = Extension.create, MentionStorage>({ mapProps: (props) => ({ query: props.query, command: props.command, - store: props.editor.storage.mention.store, + store: props.editor.storage.mentionMenu.store, editor: props.editor, }), - onOpen: (props) => props.editor.storage.mention?.onOpen?.(), + onOpen: (props) => props.editor.storage.mentionMenu?.onOpen?.(), }), }), ] diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts index 5ac2ce036fc..72ebc583a18 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts @@ -27,15 +27,15 @@ export function useEditorMentions( useEffect(() => { if (!editor) return const taggingOn = Boolean(workspaceId) && !disableTagging - editor.storage.mention.enabled = taggingOn - editor.storage.mention.navigable = navigable - editor.storage.mention.onOpen = taggingOn ? () => setActive(true) : null + editor.storage.mentionMenu.enabled = taggingOn + editor.storage.mentionMenu.navigable = navigable + editor.storage.mentionMenu.onOpen = taggingOn ? () => setActive(true) : null return () => { - editor.storage.mention.onOpen = null + editor.storage.mentionMenu.onOpen = null } }, [editor, workspaceId, navigable, disableTagging]) useEffect(() => { - editor?.storage.mention.store.set(items) + editor?.storage.mentionMenu.store.set(items) }, [editor, items]) }