fix(typst-preview): fix race condition and error-masking in WASM preview
Build and Deploy Verso / deploy (push) Successful in 12m35s

Two bugs were causing a brief red error then blank screen:

1. triggerCompile closed over `view` in useCallback deps, so every time
   the CodeMirror view reference changed, useEffect terminated and
   recreated the entire worker. Fixed by reading view via a ref, making
   triggerCompile stable (empty dep array).

2. When 'compiled' arrived before the renderer WASM finished loading,
   the old code called setStatus('ready') to silently skip rendering —
   this cleared any existing error and left a blank screen. Fixed by
   buffering the vectorData in pendingVectorRef and flushing it once
   the renderer is ready.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-19 14:05:41 +00:00
parent 200bff4ecb
commit 8515a899ac
@@ -27,19 +27,50 @@ const TypstWasmPreview: FC = () => {
const { changedAt } = useLocalCompileContext() const { changedAt } = useLocalCompileContext()
const { view } = useEditorViewContext() const { view } = useEditorViewContext()
// Keep view in a ref so triggerCompile never has stale deps
// (avoids recreating the worker every time the view reference changes)
const viewRef = useRef(view)
useEffect(() => {
viewRef.current = view
}, [view])
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const workerRef = useRef<Worker | null>(null) const workerRef = useRef<Worker | null>(null)
const rendererRef = useRef<TypstRenderer | null>(null) const rendererRef = useRef<TypstRenderer | null>(null)
const isReadyRef = useRef(false) const isReadyRef = useRef(false)
const compileTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const compileTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// If 'compiled' arrives before renderer is ready, buffer the vector data
const pendingVectorRef = useRef<Uint8Array | null>(null)
const [status, setStatus] = useState<Status>('initializing') const [status, setStatus] = useState<Status>('initializing')
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
const doRender = useCallback(async (vectorData: Uint8Array) => {
const renderer = rendererRef.current
const container = containerRef.current
if (!renderer || !container) {
pendingVectorRef.current = vectorData
return
}
try {
await renderer.runWithSession(async session => {
session.manipulateData({ action: 'reset', data: vectorData })
await renderer.renderToSvg({ renderSession: session, container })
})
pendingVectorRef.current = null
setStatus('ready')
setErrorMsg('')
} catch (e) {
setStatus('error')
setErrorMsg(`Render failed: ${e}`)
}
}, [])
// triggerCompile has no deps — reads view through viewRef
const triggerCompile = useCallback(() => { const triggerCompile = useCallback(() => {
const worker = workerRef.current const worker = workerRef.current
if (!worker || !isReadyRef.current) return if (!worker || !isReadyRef.current) return
const content = view?.state.doc.toString() ?? '' const content = viewRef.current?.state.doc.toString() ?? ''
if (!content) return if (!content) return
if (compileTimerRef.current) clearTimeout(compileTimerRef.current) if (compileTimerRef.current) clearTimeout(compileTimerRef.current)
@@ -51,29 +82,33 @@ const TypstWasmPreview: FC = () => {
mainFilePath: '/main.typ', mainFilePath: '/main.typ',
}) })
}, 400) }, 400)
}, [view]) }, []) // stable — no closure over view
// Init renderer (main thread, needs DOM) // Init renderer (main thread, needs DOM). Stable dep: doRender.
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
const renderer = createTypstRenderer() const renderer = createTypstRenderer()
renderer renderer
.init({ getModule: () => fetch(rendererWasmUrl) }) .init({ getModule: () => fetch(rendererWasmUrl) })
.then(() => { .then(async () => {
if (!cancelled) rendererRef.current = renderer if (cancelled) return
rendererRef.current = renderer
// Flush any compile result that arrived while renderer was loading
if (pendingVectorRef.current) {
await doRender(pendingVectorRef.current)
}
}) })
.catch(err => { .catch(err => {
if (!cancelled) { if (cancelled) return
setStatus('error') setStatus('error')
setErrorMsg(`Renderer init failed: ${err}`) setErrorMsg(`Renderer init failed: ${err}`)
}
}) })
return () => { return () => {
cancelled = true cancelled = true
} }
}, []) }, [doRender])
// Init worker and wire message handler // Init worker. Stable deps: triggerCompile, doRender.
useEffect(() => { useEffect(() => {
const worker = createTypstWorker() const worker = createTypstWorker()
workerRef.current = worker workerRef.current = worker
@@ -86,34 +121,12 @@ const TypstWasmPreview: FC = () => {
if (msg.type === 'ready') { if (msg.type === 'ready') {
isReadyRef.current = true isReadyRef.current = true
setStatus('ready') // Don't change status here — doRender will set 'ready' once rendered
triggerCompile() triggerCompile()
} }
if (msg.type === 'compiled') { if (msg.type === 'compiled') {
const renderer = rendererRef.current await doRender(msg.vectorData)
const container = containerRef.current
if (!renderer || !container) {
setStatus('ready')
return
}
try {
await renderer.runWithSession(async session => {
session.manipulateData({
action: 'reset',
data: msg.vectorData,
})
await renderer.renderToSvg({
renderSession: session,
container,
})
})
setStatus('ready')
setErrorMsg('')
} catch (e) {
setStatus('error')
setErrorMsg(`Render failed: ${e}`)
}
} }
if (msg.type === 'error') { if (msg.type === 'error') {
@@ -128,7 +141,7 @@ const TypstWasmPreview: FC = () => {
workerRef.current = null workerRef.current = null
isReadyRef.current = false isReadyRef.current = false
} }
}, [triggerCompile]) }, [triggerCompile, doRender]) // both stable
// Re-compile when document changes // Re-compile when document changes
useEffect(() => { useEffect(() => {