Skip to content

feat(files): extract PDF viewer behind SSR boundary and polish file preview#4316

Open
waleedlatif1 wants to merge 11 commits intostagingfrom
fix/files-mothership
Open

feat(files): extract PDF viewer behind SSR boundary and polish file preview#4316
waleedlatif1 wants to merge 11 commits intostagingfrom
fix/files-mothership

Conversation

@waleedlatif1
Copy link
Copy Markdown
Collaborator

@waleedlatif1 waleedlatif1 commented Apr 28, 2026

Summary

This PR delivers a batch of improvements to the Files module: a proper architectural fix for the SSR crash introduced by pdfjs-dist v5, several PDF viewer UX upgrades, and a set of correctness/cleanup fixes across the file preview stack.


Core architectural fix — PDF SSR boundary

pdfjs-dist v5 references DOMMatrix at module evaluation time. When any file that imports react-pdf is server-evaluated (even with 'use client'), Next.js crashes with DOMMatrix is not defined.

The previous workaround was a DOMMatrix polyfill injected in instrumentation.ts. This is removed. The correct fix:

  • Extract all react-pdf / pdfjs-dist code into a new pdf-viewer.tsx module
  • Import it exclusively via next/dynamic(() => import('./pdf-viewer'), { ssr: false })

next/dynamic({ ssr: false }) creates a hard bundle boundary — the imported module is never evaluated during SSR, regardless of 'use client'. 'use client' alone does not prevent SSR evaluation in the Next.js App Router.

PdfDocumentSource type is re-exported from pdf-viewer.tsx and imported with import type in file-viewer.tsx — zero runtime edge, type-only.


PDF viewer UX improvements

Cursor-anchored zoom

Previously, style.zoom magnified from the top-left origin, causing the content under the cursor to drift away. Now zoom anchors at the cursor (or viewport centre for toolbar buttons) using the canonical scroll-adjust formula:

ratio = newZoom / oldZoom
scrollLeft = (scrollLeft + anchorX - PADDING) * ratio + PADDING - anchorX
scrollTop  = (scrollTop  + anchorY - PADDING) * ratio + PADDING - anchorY

This is the same algorithm used by Google Maps, Figma, and pdfjs-viewer.

Horizontal scroll at zoom > 1×

Dropping flex-col from the scroll container lets the zoomed pages wrapper overflow naturally — a horizontal scrollbar appears when zoom causes pages to exceed container width. The pages wrapper becomes inline-block so its width is content-driven, not stretched by flex.

Loading skeleton

Replaced the conditional inline skeleton with an absolute inset-0 overlay. It fills the scroll container correctly in all layout contexts and disappears the moment the document loads.


Bug fixes

data-table.tsx — ref callback re-runs on every keystroke

setInputRef was an inline function. Since editValue state changes on every keystroke, DataTable re-renders, setInputRef gets a new function identity, React tears down the old ref (calls it with null) and mounts the new ref (calls it with the node), firing node.select() on every character typed — resetting the cursor selection.

Fixed by wrapping setInputRef in useCallback([], []) for a stable identity.

Shadow token syntax

shadow-[var(--shadow-medium)] and shadow-[var(--shadow-card)] bypass the Tailwind utility classes defined in tailwind.config.ts. Fixed to shadow-medium and shadow-card (5+ occurrences across file-viewer.tsx and pdf-viewer.tsx).


API route — withRouteHandler

The file content route was missing withRouteHandler, so logger calls did not include the automatic request ID from AsyncLocalStorage. Wrapped with withRouteHandler; removed manual generateRequestId() calls and [${requestId}] log prefixes. Also:

  • Added resourceName to audit record
  • Added encoding param support (base64 / utf-8)

React Query — cache-busting fix

useWorkspaceFileContent and useWorkspaceFileBinary query keys did not include the storage object key. When a file was re-uploaded (new storage key, same file ID), the old cached content was served. Fixed by including key in both query key tuples.


Other changes

Change File
Suspense boundaries for useSearchParams files/page.tsx, files/[fileId]/page.tsx
Remove unnecessary useCallback/useMemo in resource-content resource-content.tsx
Remove ==== separator comments lib/copilot/constants.ts
Add mmd to supported code extensions (Mermaid) lib/uploads/utils/validation.ts
Add https: to CSP img-src lib/core/security/csp.ts
New deps: pdfjs-dist 5.4.296, mermaid 11.14.0, monaco-editor 0.55.1, @monaco-editor/react 4.7.0 package.json

Test plan

  • Open a PDF in the Files viewer — no SSR crash, loads correctly
  • Ctrl/⌘+wheel zoom over specific text — text stays under cursor as zoom changes
  • Zoom > 1× — horizontal scrollbar appears; shift+wheel or trackpad pans horizontally
  • Toolbar + / buttons zoom around the visible viewport centre
  • No "frame-only" flash at any zoom level
  • Page navigation (< / >) scrolls to correct page at any zoom level
  • Resize the side panel — pages re-fit width at 1× zoom
  • Edit a CSV cell — cursor does not reset to start on every keystroke
  • Re-upload a file — fresh content loads (no stale cache served)
  • Confirm no regressions in DOCX, XLSX, PPTX, image, audio, video preview

…review

## Core architectural fix

Move all react-pdf / pdfjs-dist code into a new pdf-viewer.tsx module and
import it exclusively via next/dynamic({ ssr: false }). pdfjs-dist v5
references DOMMatrix at module evaluation time, which crashed SSR. The
previous workaround (a DOMMatrix polyfill in instrumentation.ts) is removed
in favour of this proper hard module boundary.

## PDF viewer improvements

- Cursor-anchored zoom: Ctrl/⌘+wheel and trackpad-pinch now zoom toward the
  cursor instead of the top-left corner. Toolbar ± buttons anchor to the
  viewport centre. Uses the canonical scroll-adjust formula used by map and
  canvas viewers.
- Horizontal scroll: dropping flex-col from the scroll container lets the
  zoomed pages wrapper overflow naturally and produces a horizontal scrollbar
  at zoom > 1×.
- Loading skeleton: replaced the conditional inline skeleton with an
  absolute inset-0 overlay so it fills the scroll container correctly in all
  layout contexts.
- Shadow tokens: fixed shadow-[var(--shadow-medium)] and
  shadow-[var(--shadow-card)] to use the Tailwind utility classes
  shadow-medium and shadow-card directly.

## File viewer cleanup

- data-table.tsx: wrap setInputRef in useCallback([]) so the ref callback
  has a stable identity across renders. Previously the inline function got a
  new identity on every keystroke (because editValue state changed), causing
  React to teardown/remount the ref and re-run node.select() on every
  character typed.
- preview-panel.tsx: keep useMemo on ctxValue passed to Context.Provider —
  Context uses Object.is, so a new object every render causes unnecessary
  consumer re-renders.
- resource-content.tsx: remove unnecessary useCallback/useMemo wrappers on
  handlers and derived values that have no memoization observers.

## API route

- Wrap content route with withRouteHandler for automatic request-ID tracking
  via AsyncLocalStorage; remove manual generateRequestId() calls.
- Add resourceName to audit record; add encoding param support (base64 /
  utf-8).

## Query hooks

- Include key (storage object key) in both useWorkspaceFileContent and
  useWorkspaceFileBinary query key tuples so the cache is correctly busted
  when a file is re-uploaded with a new storage key.

## Other

- Add Suspense boundaries to files/page.tsx and files/[fileId]/page.tsx
  (required for useSearchParams inside the Files component).
- Add mmd to SUPPORTED_CODE_EXTENSIONS (Mermaid diagrams).
- Add https: to CSP img-src.
- Remove ==== separator comments from lib/copilot/constants.ts.
- New dependencies: pdfjs-dist 5.4.296, mermaid 11.14.0,
  monaco-editor 0.55.1, @monaco-editor/react 4.7.0.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Apr 28, 2026 7:22am

Request Review

@cursor
Copy link
Copy Markdown

cursor Bot commented Apr 28, 2026

PR Summary

Medium Risk
Touches core file editing/preview paths (new Monaco/PDF stacks, new media previewers, and XLSX writeback via base64), so regressions could affect viewing/saving files across formats despite limited backend surface-area changes.

Overview
Files preview/editor is significantly upgraded. The text editor switches to Monaco (client-only) with improved language detection/theme syncing and streaming behavior, and PDF preview is moved into a new pdf-viewer.tsx loaded via next/dynamic({ ssr: false }) to avoid SSR crashes while adding page controls/zoom and better loading skeletons.

Previews are expanded and polished: adds Mermaid (.mmd / text/x-mermaid) rendering (including markdown codeblock diagrams), adds audio/video previewers, and updates image/PPTX/DOCX/XLSX preview UX/skeletons; DataTable now supports inline cell/header editing and exposes an imperative commitEdit for save flows.

Editing and persistence are extended: XLSX sheets become editable and can be saved back to storage (base64-encoded) via the existing content API; the PUT content route and useUpdateWorkspaceFileContent now accept an optional encoding field. React Query cache keys for file content/binary now include the storage key to prevent stale content after re-uploads, and the Files page adds Suspense boundaries plus drag-and-drop upload and improved “new file” routing/focus behavior.

Reviewed by Cursor Bugbot for commit 9f34a27. Configure here.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 28, 2026

Greptile Summary

This PR ships a batch of improvements to the Files module: it correctly fixes the pdfjs-dist v5 SSR crash via next/dynamic({ ssr: false }) boundaries (removing the DOMMatrix polyfill from instrumentation.ts), upgrades the code editor from Prism/react-simple-code-editor to Monaco, adds Mermaid preview, GitHub-style callout blocks, audio/video preview categories, cursor-anchored PDF zoom, and several correctness fixes (React Query cache-busting, withRouteHandler wrapping, base64 encoding support).

  • P1 — claimed fix not applied: The PR description explicitly states the CSV cursor-reset bug is fixed by wrapping setInputRef in useCallback([], []), but useCallback is not imported and the wrapper is absent in data-table.tsx. The bug (cursor jumping to position 0 on every keystroke) is still present.

Confidence Score: 4/5

Safe to merge after adding useCallback to setInputRef in data-table.tsx; all other changes are architecturally sound.

One P1 defect: the cursor-reset bug in the CSV editor is not actually fixed despite being listed as a fix in the PR description. All other changes (SSR boundary, Monaco migration, cache-busting, route handler) are correct and well-structured.

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx — setInputRef must be wrapped in useCallback

Important Files Changed

Filename Overview
apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx Major rewrite adds inline cell/header editing with forwardRef+imperative handle; the claimed cursor-stability fix (useCallback on setInputRef) is missing — cursor still resets on every keystroke.
apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx New SSR-safe PDF viewer with cursor-anchored zoom, IntersectionObserver page tracking, and an absolute-overlay loading skeleton; minor useMemo dependency concern on file.
apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx Replaces react-simple-code-editor/Prism with Monaco Editor (dynamic import, SSR:false) and PdfViewerCore (dynamic import, SSR:false); adds audio/video preview categories; removes useCodeRendererForCodeFiles prop.
apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx Adds Mermaid preview type, GitHub-style callout blocks via a custom remark plugin, gray-matter front-matter stripping, and inline code syntax highlighting with copy button.
apps/sim/hooks/queries/workspace-files.ts Adds storage key to both useWorkspaceFileContent and useWorkspaceFileBinary query keys for correct cache-busting on re-upload; changes upload mutation from onSuccess to onSettled.
apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts Wraps PUT handler with withRouteHandler, removes manual request ID, adds base64 encoding support, and adds resourceName to audit record.
apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx Removes unnecessary useCallback/useMemo wrappers on event handlers and simple derived values; removes useCodeRendererForCodeFiles prop usage.
apps/sim/instrumentation.ts Removes the DOMMatrix polyfill (now unnecessary after PDF SSR boundary fix); only meaningful change is adding a blank line.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[FileViewer - file-viewer.tsx] -->|next/dynamic ssr:false| B[MonacoEditor]
    A -->|next/dynamic ssr:false| C[PdfViewerCore - pdf-viewer.tsx]
    A --> D[PreviewPanel - preview-panel.tsx]
    A --> E[DataTable - data-table.tsx]

    D -->|previewType=markdown| F[MarkdownPreview + Callouts]
    D -->|previewType=mermaid| G[MermaidFilePreview]
    D -->|previewType=csv| H[CsvPreview → DataTable]
    D -->|previewType=svg| I[SvgPreview]
    D -->|previewType=html| J[HtmlPreview]

    C -->|zoom wheel| K[applyZoomAt - cursor-anchored scroll]
    C -->|IntersectionObserver| L[currentPage tracking]

    M[useWorkspaceFileContent] -->|queryKey includes key| N[Cache busted on re-upload]
    M2[useWorkspaceFileBinary] -->|queryKey includes key| N

    style E fill:#f87171,color:#fff
    style C fill:#86efac,color:#000
    style B fill:#86efac,color:#000
Loading

Reviews (3): Last reviewed commit: "refactor(files): cleanup anti-patterns a..." | Re-trigger Greptile

Comment thread apps/sim/lib/core/security/csp.ts Outdated
Comment thread apps/sim/app/workspace/[workspaceId]/files/files.tsx Outdated
Comment thread apps/sim/app/workspace/[workspaceId]/files/page.tsx
Comment thread apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx Outdated
…eleton tokens

- Use toError() from @sim/utils/errors across all catch blocks in
  file-viewer.tsx, preview-panel.tsx, and route.ts instead of the
  prohibited `err instanceof Error ? err.message : fallback` pattern
- Fix loading skeleton in files.tsx: bg-white → bg-[var(--surface-2)]
  and shadow-[var(--shadow-medium)] → shadow-medium
- csp.ts: revert bare https: from img-src — it defeats the existing
  domain allowlist and opens info-leakage vectors
- files/page.tsx + files/[fileId]/page.tsx: add explicit fallback={null}
  to <Suspense> to make intent clear (React defaults to null, but
  omitting it looks like an oversight)
- preview-panel.tsx: restore pre passthrough in STATIC_MARKDOWN_COMPONENTS
  so Streamdown's wrapping <pre> doesn't nest inside the custom code
  block <div>, which produced invalid HTML and broken styling
- file-viewer.tsx: add 'webm' to VIDEO_PREVIEWABLE_EXTENSIONS to match
  'video/webm' in VIDEO_PREVIEWABLE_MIME_TYPES
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx Outdated
The bundle was regenerated non-deterministically during development (same
pptxgenjs 4.0.1, different variable names in minifier output). No functional
change — restore the prior version to keep the diff clean.
…c workbook mutation

Three bugs from Cursor Bugbot follow-up review:

1. Stale closure in handleEditorMount (Medium): useCallback([], []) captured
   content='' at first render. When Monaco mounts after content loads (e.g.
   switching from preview to editor mode), lastSyncedContentRef was never
   initialized and external content changes stopped syncing. Fixed by keeping
   a contentRef updated on every render and reading it inside handleEditorMount.

2. XLSX Ctrl+S discards active cell edit (Medium): handleSave read from
   workbookRef.current before DataTable's in-progress editValue was committed.
   Fixed by exposing commitEdit() from DataTable via useImperativeHandle
   (using an always-current editStateRef so the handle stays stable) and
   calling it at the top of handleSave.

3. Async workbook mutation fragility (Low): handleCellChange / handleHeaderChange
   updated the workbook inside import('xlsx').then(), creating microtask-order
   coupling with handleSave. Fixed by caching the xlsx module in xlsxModuleRef
   on first parse and using it synchronously in both handlers.
Six-pass cleanup over the file-viewer directory:

Effects (you-might-not-need-an-effect):
- AudioPreview, VideoPreview: replace reset useEffect with key={file.id} so
  the component remounts on file change — React's canonical solution
- DocxPreview: same key-prop fix; removes a 5-setState reset effect that was
  also clearing containerRef.current.innerHTML unnecessarily

Callbacks (you-might-not-need-a-callback):
- handleEditorMount, handleEditorChange: remove useCallback — MonacoEditor is
  dynamic(), not React.memo, so reference stability has no observer
- markSavedContent: remove useCallback — called only through an onSaveRef,
  never directly observed
- DataTable.setInputRef: remove useCallback — callback refs on native elements
  are called regardless of reference identity

Design tokens (emcn-design-review):
- VideoPreview: bg-black → bg-[var(--surface-inverted)]
- HtmlPreview iframe: bg-white → bg-[var(--surface-2)]

useMemo, useState, and react-query passes found no issues.
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9f34a27. Configure here.

node.focus()
node.select()
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ref callback missing useCallback causes cursor reset on keystroke

High Severity

setInputRef is declared as a plain inline function instead of being wrapped in useCallback. The PR description explicitly states this was "fixed by wrapping setInputRef in useCallback([], []) for a stable identity," but the actual code omits the wrapper. Since editValue state changes on every keystroke, DataTableBase re-renders, setInputRef gets a new identity, React tears down the old ref and mounts the new one, calling node.focus() and node.select() on every character typed — resetting the cursor selection to "select all" on each keystroke.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9f34a27. Configure here.

return () => {
resizeObserver.disconnect()
}
}, [shouldUseCodeRenderer])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale saveImmediately captured in Monaco Ctrl+S command

Low Severity

handleEditorMount is a plain function that captures saveImmediately via closure when calling editor.addCommand. While saveImmediately from useAutosave has a stable identity (it uses useCallback with stable deps and reads from refs internally), the onSave passed to useAutosave is also a plain function that changes identity every render. This works only because useAutosave stores onSave in a ref (onSaveRef). The coupling is fragile — handleEditorMount relies on an implementation detail of useAutosave for correctness.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9f34a27. Configure here.

const handleSave = useCallback(async () => {
dataTableRef.current?.commitEdit()
const wb = workbookRef.current
if (!wb || isSavingRef.current) return
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

XLSX handleSave calls commitEdit then reads workbook synchronously but state update is async

Medium Severity

handleSave calls dataTableRef.current?.commitEdit() and immediately reads workbookRef.current on the next line. While commitEdit synchronously calls editConfig.onCellChange/onHeaderChange (which update the workbook ref synchronously), it also calls setEditingCell(null). This triggers an async React state update. If handleSave is called via Ctrl+S while a cell is being edited and onBlur fires simultaneously, there's a potential race where commitEdit runs twice — once from the imperative handle and once from the blur handler — potentially double-applying the edit.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9f34a27. Configure here.

Comment on lines +52 to +56
const setInputRef = (node: HTMLInputElement | null) => {
if (node) {
node.focus()
node.select()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 setInputRef still unstable — cursor resets on every keystroke

setInputRef is an inline arrow function. Every time editValue state changes (each character typed), DataTableBase re-renders and React sees a new function reference for the ref prop. React tears down the old ref (calls it with null) and mounts the new one (calls it with the node), triggering node.focus(); node.select() on every keystroke — exactly the bug this PR claims to fix.

The PR description explicitly states the fix is useCallback([], []), but useCallback is not even imported and the wrapper was never applied.

Add useCallback to the import on line 3 and wrap the callback:

import { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from 'react'
Suggested change
const setInputRef = (node: HTMLInputElement | null) => {
if (node) {
node.focus()
node.select()
}
const setInputRef = useCallback((node: HTMLInputElement | null) => {
if (node) {
node.focus()
node.select()
}
}, [])

… theme

Define sim-dark and sim-light Monaco themes using Sim's exact design tokens
instead of the default vs/vs-dark which looked identical to stock VSCode.

Chrome changes (both themes):
- Background, gutter use --bg (not VSCode's near-black / pure-white defaults)
- Line numbers use --text-muted instead of VSCode gray
- Cursor switches to --brand-secondary (#33b4ff)
- Selection highlight is brand blue at 15% opacity
- Scrollbar shadow removed, track uses surface tokens
- Bracket match, word highlight, find match all keyed to brand blue
- Suggestion/hover widgets use --surface-2 / --border tokens
- All hardcoded shadows removed (scrollbar.shadow = transparent)

Syntax token changes (inherit: true — base handles unlisted tokens):
- Comments: muted gray + italic (vs VSCode's bright green)
- Strings: #3ab872 dark / #16825d light (vs VSCode orange-red)
- Numbers: warm amber / warm orange (both readable on their backgrounds)
- Keywords: #33b4ff dark / #0078d4 light (brand blue family)
- Types: complementary blue-gray / purple
…ace-1

- Monaco cursor: #33b4ff (brand blue) → #e6e6e6 dark / #1a1a1a light
  (text cursor should be neutral, not loud)
- VideoPreview background: var(--surface-inverted) → var(--surface-1)
  (consistent with PDF viewer, fits workspace context over cinema-black)
…ommit in DataTable

Wrap setInputRef in useCallback([], []) so React doesn't tear down and
re-mount the input ref on every keystroke. Without stable identity, every
editValue state change caused node.focus()/node.select() to fire, resetting
the cursor selection to "select all" on each character typed.

Add isCommittedRef to guard both the imperative commitEdit handle and the
inline commitEdit (called by onBlur) against double-application. The ref is
cleared in startEdit and set to true on the first commit, so a concurrent
onBlur cannot re-apply the same edit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant