Skip to content

Commit edfdb0d

Browse files
committed
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
1 parent 1228ebd commit edfdb0d

4 files changed

Lines changed: 112 additions & 6 deletions

File tree

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

Lines changed: 8 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,13 @@ 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 label='PDF'>
150+
<PdfViewerCore
151+
key={`${file.id}:${preview.dataUpdatedAt}`}
152+
source={bufferSource}
153+
filename={file.name}
154+
/>
155+
</PreviewErrorBoundary>
153156
)
154157
})
155158

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

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

3+
// Must precede the react-pdf import: pdf.js calls the polyfilled APIs while
4+
// its module evaluates, which throws on Safari < 17.4 without them.
5+
import '@/lib/core/utils/browser-polyfills'
36
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
47
import { createLogger } from '@sim/logger'
58
import { pdfjs, Document as ReactPdfDocument, Page as ReactPdfPage } from 'react-pdf'
@@ -8,8 +11,10 @@ import { PREVIEW_LOADING_OVERLAY } from '@/app/workspace/[workspaceId]/files/com
811
import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar'
912
import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
1013

14+
// The worker runs in its own context that browser-polyfills cannot reach, so
15+
// serve the legacy worker build, which bundles its own polyfills.
1116
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
12-
'pdfjs-dist/build/pdf.worker.min.mjs',
17+
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
1318
import.meta.url
1419
).href
1520

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

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

3+
import { Component, 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,53 @@ 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+
export class PreviewErrorBoundary extends Component<
38+
PreviewErrorBoundaryProps,
39+
PreviewErrorBoundaryState
40+
> {
41+
public state: PreviewErrorBoundaryState = {
42+
hasError: false,
43+
}
44+
45+
public static getDerivedStateFromError(error: Error): PreviewErrorBoundaryState {
46+
return { hasError: true, error }
47+
}
48+
49+
public componentDidCatch(error: Error) {
50+
logger.error('Preview crashed', { label: this.props.label, error: error.message })
51+
}
52+
53+
public render() {
54+
if (this.state.hasError) {
55+
return (
56+
<PreviewError
57+
label={this.props.label}
58+
error={this.state.error?.message ?? 'An unexpected error occurred'}
59+
/>
60+
)
61+
}
62+
63+
return this.props.children
64+
}
65+
}
66+
1667
export function resolvePreviewError(
1768
fetchError: Error | null,
1869
renderError: string | null
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)