Skip to content

Commit d7866dc

Browse files
authored
fix(files): support Safari < 17.4 in PDF preview (#4992)
* fix(files): support Safari < 17.4 in PDF preview 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 * 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. * fix(files): apply preview mode once deep-linked file record loads On a hard load of /files/<id> 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. * improvement(files): derive routed-file presence from existing selectedFile memo 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. * 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.
1 parent 1228ebd commit d7866dc

5 files changed

Lines changed: 138 additions & 18 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { resolvePreviewType } from './preview-panel'
2020
import {
2121
PREVIEW_LOADING_OVERLAY,
2222
PreviewError,
23+
PreviewErrorBoundary,
2324
PreviewLoadingFrame,
2425
resolvePreviewError,
2526
} from './preview-shared'
@@ -145,11 +146,9 @@ const IframePreview = memo(function IframePreview({
145146
}
146147

147148
return (
148-
<PdfViewerCore
149-
key={`${file.id}:${preview.dataUpdatedAt}`}
150-
source={bufferSource}
151-
filename={file.name}
152-
/>
149+
<PreviewErrorBoundary key={`${file.id}:${preview.dataUpdatedAt}`} label='PDF'>
150+
<PdfViewerCore source={bufferSource} filename={file.name} />
151+
</PreviewErrorBoundary>
153152
)
154153
})
155154

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
'use client'
22

3+
/**
4+
* Must precede the react-pdf import: pdf.js calls the polyfilled APIs while
5+
* its module evaluates, which throws on Safari < 17.4 without them.
6+
*/
7+
import '@/lib/core/utils/browser-polyfills'
38
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
49
import { createLogger } from '@sim/logger'
510
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
813
import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar'
914
import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
1015

16+
/**
17+
* The worker runs in its own context that browser-polyfills cannot reach, so
18+
* serve the legacy worker build, which bundles its own polyfills.
19+
*/
1120
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
12-
'pdfjs-dist/build/pdf.worker.min.mjs',
21+
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
1322
import.meta.url
1423
).href
1524

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
'use client'
22

3+
import { Component, type ErrorInfo, type ReactNode } from 'react'
4+
import { createLogger } from '@sim/logger'
35
import { cn } from '@/lib/core/utils/cn'
46

7+
const logger = createLogger('FilePreview')
8+
59
export function PreviewError({ label, error }: { label: string; error: string }) {
610
return (
711
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
@@ -13,6 +17,61 @@ export function PreviewError({ label, error }: { label: string; error: string })
1317
)
1418
}
1519

20+
interface PreviewErrorBoundaryProps {
21+
/** Format label shown in the fallback, e.g. "PDF". */
22+
label: string
23+
children: ReactNode
24+
}
25+
26+
interface PreviewErrorBoundaryState {
27+
hasError: boolean
28+
error?: Error
29+
}
30+
31+
/**
32+
* Error boundary for preview renderers. Catches render-time crashes (including
33+
* a preview module whose dynamic import rejected) and degrades to the standard
34+
* PreviewError fallback instead of unwinding to the route-level error boundary
35+
* and replacing the whole workspace view.
36+
*
37+
* Callers must `key` this boundary by the identity of the rendered content
38+
* (e.g. file id + data version) — the error state resets only via remount, so
39+
* keying the child alone would leave a tripped boundary stuck on the fallback.
40+
*/
41+
export class PreviewErrorBoundary extends Component<
42+
PreviewErrorBoundaryProps,
43+
PreviewErrorBoundaryState
44+
> {
45+
public state: PreviewErrorBoundaryState = {
46+
hasError: false,
47+
}
48+
49+
public static getDerivedStateFromError(error: Error): PreviewErrorBoundaryState {
50+
return { hasError: true, error }
51+
}
52+
53+
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
54+
logger.error('Preview crashed', {
55+
label: this.props.label,
56+
error: error.message,
57+
componentStack: errorInfo.componentStack,
58+
})
59+
}
60+
61+
public render() {
62+
if (this.state.hasError) {
63+
return (
64+
<PreviewError
65+
label={this.props.label}
66+
error={this.state.error?.message ?? 'An unexpected error occurred'}
67+
/>
68+
)
69+
}
70+
71+
return this.props.children
72+
}
73+
}
74+
1675
export function resolvePreviewError(
1776
fetchError: Error | null,
1877
renderError: string | null

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,25 +1293,31 @@ export function Files() {
12931293
closeListContextMenu()
12941294
}, [canEdit, uploading, closeListContextMenu])
12951295

1296-
const prevFileIdRef = useRef(fileIdFromRoute)
1296+
/**
1297+
* Tracks the route target whose preview mode has been applied. Starts at
1298+
* null (the list view) rather than the initial route id because on a hard
1299+
* load the files list may not have arrived when the mode initializer ran —
1300+
* a deep-linked previewable file would otherwise be locked into the code
1301+
* editor. The effect therefore defers until the routed file is resolvable:
1302+
* either its record exists, or the files query has settled (so a missing
1303+
* id decides 'editor' instead of waiting forever).
1304+
*/
1305+
const appliedModeFileIdRef = useRef<string | null>(null)
1306+
const routedFileResolved = selectedFile != null || !isLoading
12971307
useEffect(() => {
1298-
if (fileIdFromRoute === prevFileIdRef.current) return
1299-
prevFileIdRef.current = fileIdFromRoute
1308+
if (fileIdFromRoute === appliedModeFileIdRef.current) return
13001309
const isJustCreated =
13011310
isNewFile || (fileIdFromRoute != null && justCreatedFileIdRef.current === fileIdFromRoute)
13021311
if (justCreatedFileIdRef.current && !isJustCreated) {
13031312
justCreatedFileIdRef.current = null
13041313
}
1305-
const nextMode: PreviewMode = isJustCreated
1306-
? 'editor'
1307-
: (() => {
1308-
const file = fileIdFromRoute
1309-
? filesRef.current.find((f) => f.id === fileIdFromRoute)
1310-
: null
1311-
return file && isPreviewable(file) ? 'preview' : 'editor'
1312-
})()
1314+
if (fileIdFromRoute != null && !routedFileResolved && !isJustCreated) return
1315+
appliedModeFileIdRef.current = fileIdFromRoute
1316+
const file = fileIdFromRoute ? selectedFileRef.current : null
1317+
const nextMode: PreviewMode =
1318+
!isJustCreated && file && isPreviewable(file) ? 'preview' : 'editor'
13131319
setPreviewMode((current) => (nextMode === current ? current : nextMode))
1314-
}, [fileIdFromRoute, isNewFile])
1320+
}, [fileIdFromRoute, isNewFile, routedFileResolved])
13151321

13161322
useEffect(() => {
13171323
if (isNewFile && fileIdFromRoute) {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Polyfills for `Promise.withResolvers` (Safari < 17.4, Chrome < 119) and
3+
* `URL.parse` (Safari < 18, Chrome < 126), which pdf.js 5.x calls at
4+
* module-evaluation time. Without them, importing `react-pdf`/`pdfjs-dist`
5+
* throws before anything renders, so this module must be imported for its
6+
* side effects BEFORE those imports. The pdf.js worker runs in a separate
7+
* context these polyfills cannot reach; it is covered by serving pdf.js's
8+
* self-polyfilling legacy worker build (see pdf-viewer.tsx).
9+
*
10+
* Typed locally because the repo TS lib is ES2022, which predates both APIs.
11+
*/
12+
13+
interface PromiseWithResolversResult<T> {
14+
promise: Promise<T>
15+
resolve: (value: T | PromiseLike<T>) => void
16+
reject: (reason?: unknown) => void
17+
}
18+
19+
const promiseCtor = Promise as typeof Promise & {
20+
withResolvers?: <T>() => PromiseWithResolversResult<T>
21+
}
22+
23+
if (typeof promiseCtor.withResolvers !== 'function') {
24+
promiseCtor.withResolvers = <T>(): PromiseWithResolversResult<T> => {
25+
let resolve!: (value: T | PromiseLike<T>) => void
26+
let reject!: (reason?: unknown) => void
27+
const promise = new Promise<T>((res, rej) => {
28+
resolve = res
29+
reject = rej
30+
})
31+
return { promise, resolve, reject }
32+
}
33+
}
34+
35+
const urlCtor = URL as typeof URL & {
36+
parse?: (url: string | URL, base?: string | URL) => URL | null
37+
}
38+
39+
if (typeof urlCtor.parse !== 'function') {
40+
urlCtor.parse = (url: string | URL, base?: string | URL): URL | null => {
41+
try {
42+
return new URL(url, base)
43+
} catch {
44+
return null
45+
}
46+
}
47+
}

0 commit comments

Comments
 (0)