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 fa770dada1..2989806a92 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
@@ -20,6 +20,7 @@ import { resolvePreviewType } from './preview-panel'
import {
PREVIEW_LOADING_OVERLAY,
PreviewError,
+ PreviewErrorBoundary,
PreviewLoadingFrame,
resolvePreviewError,
} from './preview-shared'
@@ -145,11 +146,9 @@ const IframePreview = memo(function IframePreview({
}
return (
-
+
+
+
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx
index f45f19fafb..2dea41ff56 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx
@@ -1,5 +1,10 @@
'use client'
+/**
+ * Must precede the react-pdf import: pdf.js calls the polyfilled APIs while
+ * its module evaluates, which throws on Safari < 17.4 without them.
+ */
+import '@/lib/core/utils/browser-polyfills'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { pdfjs, Document as ReactPdfDocument, Page as ReactPdfPage } from 'react-pdf'
@@ -8,8 +13,12 @@ import { PREVIEW_LOADING_OVERLAY } from '@/app/workspace/[workspaceId]/files/com
import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar'
import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
+/**
+ * The worker runs in its own context that browser-polyfills cannot reach, so
+ * serve the legacy worker build, which bundles its own polyfills.
+ */
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
+ 'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
import.meta.url
).href
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx
index c04898fe39..f72d71a9a8 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx
@@ -1,7 +1,11 @@
'use client'
+import { Component, type ErrorInfo, type ReactNode } from 'react'
+import { createLogger } from '@sim/logger'
import { cn } from '@/lib/core/utils/cn'
+const logger = createLogger('FilePreview')
+
export function PreviewError({ label, error }: { label: string; error: string }) {
return (
@@ -13,6 +17,61 @@ export function PreviewError({ label, error }: { label: string; error: string })
)
}
+interface PreviewErrorBoundaryProps {
+ /** Format label shown in the fallback, e.g. "PDF". */
+ label: string
+ children: ReactNode
+}
+
+interface PreviewErrorBoundaryState {
+ hasError: boolean
+ error?: Error
+}
+
+/**
+ * Error boundary for preview renderers. Catches render-time crashes (including
+ * a preview module whose dynamic import rejected) and degrades to the standard
+ * PreviewError fallback instead of unwinding to the route-level error boundary
+ * and replacing the whole workspace view.
+ *
+ * Callers must `key` this boundary by the identity of the rendered content
+ * (e.g. file id + data version) — the error state resets only via remount, so
+ * keying the child alone would leave a tripped boundary stuck on the fallback.
+ */
+export class PreviewErrorBoundary extends Component<
+ PreviewErrorBoundaryProps,
+ PreviewErrorBoundaryState
+> {
+ public state: PreviewErrorBoundaryState = {
+ hasError: false,
+ }
+
+ public static getDerivedStateFromError(error: Error): PreviewErrorBoundaryState {
+ return { hasError: true, error }
+ }
+
+ public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ logger.error('Preview crashed', {
+ label: this.props.label,
+ error: error.message,
+ componentStack: errorInfo.componentStack,
+ })
+ }
+
+ public render() {
+ if (this.state.hasError) {
+ return (
+
+ )
+ }
+
+ return this.props.children
+ }
+}
+
export function resolvePreviewError(
fetchError: Error | null,
renderError: string | null
diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
index 2358d61182..b96ef517aa 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
@@ -1293,25 +1293,31 @@ export function Files() {
closeListContextMenu()
}, [canEdit, uploading, closeListContextMenu])
- const prevFileIdRef = useRef(fileIdFromRoute)
+ /**
+ * Tracks the route target whose preview mode has been applied. Starts at
+ * null (the list view) rather than the initial route id because on a hard
+ * load the files list may not have arrived when the mode initializer ran —
+ * a deep-linked previewable file would otherwise be locked into the code
+ * editor. The effect therefore defers until the routed file is resolvable:
+ * either its record exists, or the files query has settled (so a missing
+ * id decides 'editor' instead of waiting forever).
+ */
+ const appliedModeFileIdRef = useRef
(null)
+ const routedFileResolved = selectedFile != null || !isLoading
useEffect(() => {
- if (fileIdFromRoute === prevFileIdRef.current) return
- prevFileIdRef.current = fileIdFromRoute
+ if (fileIdFromRoute === appliedModeFileIdRef.current) return
const isJustCreated =
isNewFile || (fileIdFromRoute != null && justCreatedFileIdRef.current === fileIdFromRoute)
if (justCreatedFileIdRef.current && !isJustCreated) {
justCreatedFileIdRef.current = null
}
- const nextMode: PreviewMode = isJustCreated
- ? 'editor'
- : (() => {
- const file = fileIdFromRoute
- ? filesRef.current.find((f) => f.id === fileIdFromRoute)
- : null
- return file && isPreviewable(file) ? 'preview' : 'editor'
- })()
+ if (fileIdFromRoute != null && !routedFileResolved && !isJustCreated) return
+ appliedModeFileIdRef.current = fileIdFromRoute
+ const file = fileIdFromRoute ? selectedFileRef.current : null
+ const nextMode: PreviewMode =
+ !isJustCreated && file && isPreviewable(file) ? 'preview' : 'editor'
setPreviewMode((current) => (nextMode === current ? current : nextMode))
- }, [fileIdFromRoute, isNewFile])
+ }, [fileIdFromRoute, isNewFile, routedFileResolved])
useEffect(() => {
if (isNewFile && fileIdFromRoute) {
diff --git a/apps/sim/lib/core/utils/browser-polyfills.ts b/apps/sim/lib/core/utils/browser-polyfills.ts
new file mode 100644
index 0000000000..bd161d86ab
--- /dev/null
+++ b/apps/sim/lib/core/utils/browser-polyfills.ts
@@ -0,0 +1,47 @@
+/**
+ * Polyfills for `Promise.withResolvers` (Safari < 17.4, Chrome < 119) and
+ * `URL.parse` (Safari < 18, Chrome < 126), which pdf.js 5.x calls at
+ * module-evaluation time. Without them, importing `react-pdf`/`pdfjs-dist`
+ * throws before anything renders, so this module must be imported for its
+ * side effects BEFORE those imports. The pdf.js worker runs in a separate
+ * context these polyfills cannot reach; it is covered by serving pdf.js's
+ * self-polyfilling legacy worker build (see pdf-viewer.tsx).
+ *
+ * Typed locally because the repo TS lib is ES2022, which predates both APIs.
+ */
+
+interface PromiseWithResolversResult {
+ promise: Promise
+ resolve: (value: T | PromiseLike) => void
+ reject: (reason?: unknown) => void
+}
+
+const promiseCtor = Promise as typeof Promise & {
+ withResolvers?: () => PromiseWithResolversResult
+}
+
+if (typeof promiseCtor.withResolvers !== 'function') {
+ promiseCtor.withResolvers = (): PromiseWithResolversResult => {
+ let resolve!: (value: T | PromiseLike) => void
+ let reject!: (reason?: unknown) => void
+ const promise = new Promise((res, rej) => {
+ resolve = res
+ reject = rej
+ })
+ return { promise, resolve, reject }
+ }
+}
+
+const urlCtor = URL as typeof URL & {
+ parse?: (url: string | URL, base?: string | URL) => URL | null
+}
+
+if (typeof urlCtor.parse !== 'function') {
+ urlCtor.parse = (url: string | URL, base?: string | URL): URL | null => {
+ try {
+ return new URL(url, base)
+ } catch {
+ return null
+ }
+ }
+}