fix(typst-preview): fix race condition and error-masking in WASM preview
Build and Deploy Verso / deploy (push) Successful in 12m35s
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:
@@ -27,19 +27,50 @@ const TypstWasmPreview: FC = () => {
|
||||
const { changedAt } = useLocalCompileContext()
|
||||
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 workerRef = useRef<Worker | null>(null)
|
||||
const rendererRef = useRef<TypstRenderer | null>(null)
|
||||
const isReadyRef = useRef(false)
|
||||
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 [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 worker = workerRef.current
|
||||
if (!worker || !isReadyRef.current) return
|
||||
const content = view?.state.doc.toString() ?? ''
|
||||
const content = viewRef.current?.state.doc.toString() ?? ''
|
||||
if (!content) return
|
||||
|
||||
if (compileTimerRef.current) clearTimeout(compileTimerRef.current)
|
||||
@@ -51,29 +82,33 @@ const TypstWasmPreview: FC = () => {
|
||||
mainFilePath: '/main.typ',
|
||||
})
|
||||
}, 400)
|
||||
}, [view])
|
||||
}, []) // stable — no closure over view
|
||||
|
||||
// Init renderer (main thread, needs DOM)
|
||||
// Init renderer (main thread, needs DOM). Stable dep: doRender.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const renderer = createTypstRenderer()
|
||||
renderer
|
||||
.init({ getModule: () => fetch(rendererWasmUrl) })
|
||||
.then(() => {
|
||||
if (!cancelled) rendererRef.current = renderer
|
||||
.then(async () => {
|
||||
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 => {
|
||||
if (!cancelled) {
|
||||
setStatus('error')
|
||||
setErrorMsg(`Renderer init failed: ${err}`)
|
||||
}
|
||||
if (cancelled) return
|
||||
setStatus('error')
|
||||
setErrorMsg(`Renderer init failed: ${err}`)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [doRender])
|
||||
|
||||
// Init worker and wire message handler
|
||||
// Init worker. Stable deps: triggerCompile, doRender.
|
||||
useEffect(() => {
|
||||
const worker = createTypstWorker()
|
||||
workerRef.current = worker
|
||||
@@ -86,34 +121,12 @@ const TypstWasmPreview: FC = () => {
|
||||
|
||||
if (msg.type === 'ready') {
|
||||
isReadyRef.current = true
|
||||
setStatus('ready')
|
||||
// Don't change status here — doRender will set 'ready' once rendered
|
||||
triggerCompile()
|
||||
}
|
||||
|
||||
if (msg.type === 'compiled') {
|
||||
const renderer = rendererRef.current
|
||||
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}`)
|
||||
}
|
||||
await doRender(msg.vectorData)
|
||||
}
|
||||
|
||||
if (msg.type === 'error') {
|
||||
@@ -128,7 +141,7 @@ const TypstWasmPreview: FC = () => {
|
||||
workerRef.current = null
|
||||
isReadyRef.current = false
|
||||
}
|
||||
}, [triggerCompile])
|
||||
}, [triggerCompile, doRender]) // both stable
|
||||
|
||||
// Re-compile when document changes
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user