import { useRef, useEffect, useState } from 'react' import { Niivue } from '@niivue/niivue' interface NiiVueViewerProps { backgroundUrl: string overlayUrl?: string onError?: (error: string) => void } export function NiiVueViewer({ backgroundUrl, overlayUrl, onError }: NiiVueViewerProps) { const canvasRef = useRef(null) const nvRef = useRef(null) const onErrorRef = useRef(onError) const [loadError, setLoadError] = useState(null) // Keep onError ref current without triggering effect re-runs useEffect(() => { onErrorRef.current = onError }) // Effect 1: Mount/unmount - instantiate and cleanup NiiVue ONCE useEffect(() => { if (!canvasRef.current) return const nv = new Niivue({ backColor: [0.05, 0.05, 0.05, 1], show3Dcrosshair: true, crosshairColor: [1, 0, 0, 0.5], }) nv.attachToCanvas(canvasRef.current) nvRef.current = nv // Cleanup on unmount ONLY - CRITICAL: Release WebGL context // Browsers limit WebGL contexts (~16 in Chrome). Without cleanup, // navigating between cases will exhaust contexts and break the viewer. return () => { // Capture gl BEFORE cleanup (cleanup may null internal state) const gl = nv.gl try { // NiiVue's cleanup() releases event listeners and observers // See: https://niivue.github.io/niivue/devdocs/classes/Niivue.html#cleanup nv.cleanup() // Force WebGL context loss to free GPU memory immediately if (gl) { const ext = gl.getExtension('WEBGL_lose_context') ext?.loseContext() } } catch { // Ignore cleanup errors } nvRef.current = null } }, []) // Effect 2: URL changes - reload volumes on existing NiiVue instance // Uses isCurrent flag to ignore stale loads when URLs change rapidly useEffect(() => { const nv = nvRef.current if (!nv) return let isCurrent = true // Clear previous error before new load (valid pattern for async operations) // eslint-disable-next-line react-hooks/set-state-in-effect setLoadError(null) const volumes: Array<{ url: string; colormap: string; opacity: number }> = [ { url: backgroundUrl, colormap: 'gray', opacity: 1 }, ] if (overlayUrl) { volumes.push({ url: overlayUrl, colormap: 'red', opacity: 0.5, }) } // Load volumes with error handling - ignore stale results nv.loadVolumes(volumes).catch((err: unknown) => { if (!isCurrent) return // Ignore errors from stale loads const message = err instanceof Error ? err.message : 'Failed to load volume' setLoadError(message) onErrorRef.current?.(message) }) // Cleanup: mark this effect instance as stale return () => { isCurrent = false } }, [backgroundUrl, overlayUrl]) return (
{loadError && (
Failed to load volume: {loadError}
)}
Scroll: Navigate slices Drag: Adjust contrast Right-click: Pan
) }