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 + } + } +}