diff --git a/services/web/frontend/js/features/typst-preview/components/typst-wasm-preview.tsx b/services/web/frontend/js/features/typst-preview/components/typst-wasm-preview.tsx index c454c1f9dc..b3c0a5dada 100644 --- a/services/web/frontend/js/features/typst-preview/components/typst-wasm-preview.tsx +++ b/services/web/frontend/js/features/typst-preview/components/typst-wasm-preview.tsx @@ -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(null) const workerRef = useRef(null) const rendererRef = useRef(null) const isReadyRef = useRef(false) const compileTimerRef = useRef | null>(null) + // If 'compiled' arrives before renderer is ready, buffer the vector data + const pendingVectorRef = useRef(null) const [status, setStatus] = useState('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(() => {