From 1d06dd9224a4f0e32906f106f6118f6ebe41ee4d Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 11 Jun 2026 21:12:30 -0700 Subject: [PATCH 1/5] fix(files): support Safari < 17.4 in PDF preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pdf.js 5.x calls Promise.withResolvers (Safari >= 17.4) and URL.parse (Safari >= 18) at module-evaluation time, so on older engines importing react-pdf threw an uncaught TypeError that unwound to the workspace error boundary — every PDF preview (chat and Files tab) rendered as "Something went wrong" for those users. - Polyfill both APIs in a side-effect module imported before react-pdf - Serve the legacy pdf.js worker build, which self-polyfills (the worker context is unreachable from main-thread polyfills) - Wrap the PDF preview in an error boundary so a viewer crash degrades to the standard preview fallback instead of replacing the workspace --- .../components/file-viewer/file-viewer.tsx | 13 +++-- .../components/file-viewer/pdf-viewer.tsx | 11 +++- .../components/file-viewer/preview-shared.tsx | 51 +++++++++++++++++++ apps/sim/lib/core/utils/browser-polyfills.ts | 47 +++++++++++++++++ 4 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 apps/sim/lib/core/utils/browser-polyfills.ts 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..717552657a 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,13 @@ 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..fd79e87966 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 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,53 @@ 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. + */ +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) { + logger.error('Preview crashed', { label: this.props.label, error: error.message }) + } + + 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/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 + } + } +} From e913d69bd1db30165cc6669b3893ef2a5884e45e Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 11 Jun 2026 21:19:46 -0700 Subject: [PATCH 2/5] fix(files): reset preview error boundary on content change, log component stack Key the boundary (not the child) by file id + data version so a tripped boundary remounts and retries when the preview content updates, and include React's componentStack in crash logs. --- .../files/components/file-viewer/file-viewer.tsx | 8 ++------ .../components/file-viewer/preview-shared.tsx | 14 +++++++++++--- 2 files changed, 13 insertions(+), 9 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 717552657a..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 @@ -146,12 +146,8 @@ const IframePreview = memo(function IframePreview({ } return ( - - + + ) }) 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 fd79e87966..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,6 +1,6 @@ 'use client' -import { Component, type ReactNode } from 'react' +import { Component, type ErrorInfo, type ReactNode } from 'react' import { createLogger } from '@sim/logger' import { cn } from '@/lib/core/utils/cn' @@ -33,6 +33,10 @@ interface PreviewErrorBoundaryState { * 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, @@ -46,8 +50,12 @@ export class PreviewErrorBoundary extends Component< return { hasError: true, error } } - public componentDidCatch(error: Error) { - logger.error('Preview crashed', { label: this.props.label, error: error.message }) + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + logger.error('Preview crashed', { + label: this.props.label, + error: error.message, + componentStack: errorInfo.componentStack, + }) } public render() { From df46f601cd0fc1d58514b3dbb551b22a9c5b14d9 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 11 Jun 2026 21:25:33 -0700 Subject: [PATCH 3/5] fix(files): apply preview mode once deep-linked file record loads On a hard load of /files/ the preview-mode initializer ran before the files list arrived, fell back to the code editor, and the route-change effect never corrected it (the route id never changed). Previewable files (html, markdown, csv, svg, mermaid) opened as source instead of the rendered preview. Defer recording the applied route target until the file record exists so the mode is applied as soon as the list loads, without clobbering manual mode toggles afterwards. --- .../app/workspace/[workspaceId]/files/files.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 2358d61182..1a27d87abb 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -1293,15 +1293,24 @@ export function Files() { closeListContextMenu() }, [canEdit, uploading, closeListContextMenu]) - const prevFileIdRef = useRef(fileIdFromRoute) + /** + * Tracks the route target whose preview mode has been applied. Starts unset + * (rather than at 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. + */ + const appliedModeFileIdRef = useRef(undefined) + const routedFileLoaded = + fileIdFromRoute != null && files.some((file) => file.id === fileIdFromRoute) 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 } + if (fileIdFromRoute != null && !routedFileLoaded && !isJustCreated) return + appliedModeFileIdRef.current = fileIdFromRoute const nextMode: PreviewMode = isJustCreated ? 'editor' : (() => { @@ -1311,7 +1320,7 @@ export function Files() { return file && isPreviewable(file) ? 'preview' : 'editor' })() setPreviewMode((current) => (nextMode === current ? current : nextMode)) - }, [fileIdFromRoute, isNewFile]) + }, [fileIdFromRoute, isNewFile, routedFileLoaded]) useEffect(() => { if (isNewFile && fileIdFromRoute) { From fe60590f1f7825d124fbda4f6d91b123f789fd70 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 11 Jun 2026 21:45:50 -0700 Subject: [PATCH 4/5] improvement(files): derive routed-file presence from existing selectedFile memo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local review follow-ups: reuse the memoized selectedFile/selectedFileRef instead of a second O(n) scan per render, and collapse the applied-mode ref to string | null — initializing at null (the list view) preserves the pre-existing fresh-mount behavior while still deferring deep-link mode application until the file record loads. --- .../workspace/[workspaceId]/files/files.tsx | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 1a27d87abb..94b575be23 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -1294,14 +1294,14 @@ export function Files() { }, [canEdit, uploading, closeListContextMenu]) /** - * Tracks the route target whose preview mode has been applied. Starts unset - * (rather than at 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. + * 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 record exists. */ - const appliedModeFileIdRef = useRef(undefined) - const routedFileLoaded = - fileIdFromRoute != null && files.some((file) => file.id === fileIdFromRoute) + const appliedModeFileIdRef = useRef(null) + const routedFileLoaded = selectedFile != null useEffect(() => { if (fileIdFromRoute === appliedModeFileIdRef.current) return const isJustCreated = @@ -1311,14 +1311,9 @@ export function Files() { } if (fileIdFromRoute != null && !routedFileLoaded && !isJustCreated) return appliedModeFileIdRef.current = fileIdFromRoute - const nextMode: PreviewMode = isJustCreated - ? 'editor' - : (() => { - const file = fileIdFromRoute - ? filesRef.current.find((f) => f.id === fileIdFromRoute) - : null - return file && isPreviewable(file) ? 'preview' : 'editor' - })() + const file = fileIdFromRoute ? selectedFileRef.current : null + const nextMode: PreviewMode = + !isJustCreated && file && isPreviewable(file) ? 'preview' : 'editor' setPreviewMode((current) => (nextMode === current ? current : nextMode)) }, [fileIdFromRoute, isNewFile, routedFileLoaded]) From 0b409f9183877498ae29d135244474af15eaf5c5 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 11 Jun 2026 21:53:40 -0700 Subject: [PATCH 5/5] fix(files): settle preview mode when a deep-linked file id is missing Gate the mode effect on the files query having resolved (record found or initial load finished) rather than on the record existing, so an invalid or deleted deep-link id decides 'editor' once instead of deferring indefinitely. --- apps/sim/app/workspace/[workspaceId]/files/files.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 94b575be23..b96ef517aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -1298,10 +1298,12 @@ export function Files() { * 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 record exists. + * 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 routedFileLoaded = selectedFile != null + const routedFileResolved = selectedFile != null || !isLoading useEffect(() => { if (fileIdFromRoute === appliedModeFileIdRef.current) return const isJustCreated = @@ -1309,13 +1311,13 @@ export function Files() { if (justCreatedFileIdRef.current && !isJustCreated) { justCreatedFileIdRef.current = null } - if (fileIdFromRoute != null && !routedFileLoaded && !isJustCreated) return + 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, routedFileLoaded]) + }, [fileIdFromRoute, isNewFile, routedFileResolved]) useEffect(() => { if (isNewFile && fileIdFromRoute) {