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 { 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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user