fix(typst-preview): use persistent session to avoid Rc ownership panics
Build and Deploy Verso / deploy (push) Successful in 11m4s

Every call to renderToSvg({ artifactContent }) internally routes through
runWithSession, which calls session.free() after fn resolves. Because the JS
GC may still hold a reference to the first RenderSession wrapper, the next
render's Rc::try_unwrap() panics with 'attempted to take ownership of Rust
value while it was borrowed'.

Fix: use renderer.createModule(data) once to create a persistent RenderSession
that is never freed during the component's lifetime. Subsequent renders call
session.manipulateData({ action: 'reset', data }) (synchronous, no ownership
transfer) + session.renderToSvg({ container }) which routes through
withinOptionSession's renderSession fast-path — bypassing runWithSession and
its session.free() entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-19 15:49:50 +00:00
parent eedf4b50f6
commit 4f5dad383b
@@ -8,7 +8,7 @@ import {
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { createTypstRenderer } from '@myriaddreamin/typst.ts' import { createTypstRenderer } from '@myriaddreamin/typst.ts'
import type { TypstRenderer } from '@myriaddreamin/typst.ts' import type { TypstRenderer, RenderSession } from '@myriaddreamin/typst.ts'
import rendererWasmUrl from '@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm' import rendererWasmUrl from '@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm'
import { useLocalCompileContext } from '@/shared/context/local-compile-context' import { useLocalCompileContext } from '@/shared/context/local-compile-context'
import { useEditorViewContext } from '@/features/ide-react/context/editor-view-context' import { useEditorViewContext } from '@/features/ide-react/context/editor-view-context'
@@ -37,13 +37,16 @@ const TypstWasmPreview: FC = () => {
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)
// Persistent render session — created once, reused for all subsequent renders
// to avoid the Rust borrow/ownership panics that occur when runWithSession
// creates and frees a session on every render
const sessionRef = useRef<RenderSession | 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 latest vector data // If 'compiled' arrives before renderer is ready, buffer the latest vector data
const pendingVectorRef = useRef<Uint8Array | null>(null) const pendingVectorRef = useRef<Uint8Array | null>(null)
// Prevent concurrent runWithSession calls which cause Rust aliasing errors // Prevent concurrent renders
const isRenderingRef = useRef(false) const isRenderingRef = useRef(false)
// Latest pending render while one is in progress
const renderQueueRef = useRef<Uint8Array | null>(null) const renderQueueRef = useRef<Uint8Array | null>(null)
const [status, setStatus] = useState<Status>('initializing') const [status, setStatus] = useState<Status>('initializing')
@@ -57,8 +60,6 @@ const TypstWasmPreview: FC = () => {
return return
} }
// If a render is in progress, queue this data and return —
// the in-progress render will pick it up when it finishes
if (isRenderingRef.current) { if (isRenderingRef.current) {
renderQueueRef.current = vectorData renderQueueRef.current = vectorData
return return
@@ -71,13 +72,20 @@ const TypstWasmPreview: FC = () => {
dataToRender = null dataToRender = null
try { try {
// Use RenderByContentOptions: pass artifactContent directly instead of let session = sessionRef.current
// using runWithSession + manipulateData, which causes Rust aliasing errors if (!session) {
await renderer.renderToSvg({ // createModule creates a persistent session loaded with the vector data.
format: 'vector', // It does NOT go through runWithSession, so there is no automatic free()
artifactContent: data, // and no Rc::try_unwrap ownership conflict between renders.
container, session = await (renderer as any).createModule(data) as RenderSession
}) sessionRef.current = session
} else {
// Reset existing session with new vector data — synchronous, no ownership transfer
session.manipulateData({ action: 'reset', data })
}
// renderToSvg with an existing renderSession bypasses runWithSession entirely
// (withinOptionSession short-circuits to fn(session) when renderSession is present)
await session.renderToSvg({ container })
pendingVectorRef.current = null pendingVectorRef.current = null
setStatus('ready') setStatus('ready')
setErrorMsg('') setErrorMsg('')
@@ -88,7 +96,6 @@ const TypstWasmPreview: FC = () => {
isRenderingRef.current = false isRenderingRef.current = false
} }
// Pick up any compile result that arrived while we were rendering
if (renderQueueRef.current) { if (renderQueueRef.current) {
dataToRender = renderQueueRef.current dataToRender = renderQueueRef.current
renderQueueRef.current = null renderQueueRef.current = null
@@ -135,6 +142,8 @@ const TypstWasmPreview: FC = () => {
}) })
return () => { return () => {
cancelled = true cancelled = true
sessionRef.current = null
rendererRef.current = null
} }
}, [doRender]) }, [doRender])