1- import { useLayoutEffect , useRef } from "react" ;
1+ import { useCallback , useLayoutEffect , useRef , useState } from "react" ;
22
33import { useMeasure , useRafLoop } from "react-use" ;
44import { Box , Flex } from "@radix-ui/themes" ;
55import type { FlexProps } from "@radix-ui/themes" ;
66import { useShredsChartScale } from "../useShredsChartScale" ;
77import { useSetAtom } from "jotai" ;
8- import { minDirtySlotByChartAtom } from "../atoms" ;
8+ import { isWebgl2SupportedAtom , minDirtySlotByChartAtom } from "../atoms" ;
99import type { RendererObj , TsRange } from "./chartUtils" ;
1010import { setUpRenderer , draw } from "./chartUtils" ;
1111import ShredsSlotLabels from "../ShredsSlotLabels" ;
1212import { MChartAxes , xAxisHeight } from "./ChartAxes" ;
1313import { createLabelsState , type LabelsState } from "../utils" ;
14+ import { disposeWebglResources } from "../webglUtils" ;
1415
1516const REDRAW_INTERVAL_MS = 15 ;
17+ /**
18+ * How long to wait for the GPU to restore a lost WebGL context before falling back to the canvas chart.
19+ * Context loss is usually transient (tab backgrounded, GPU reset, driver hiccup) and the browser restores it within
20+ * 1 ~ 2 frames.
21+ */
22+ const CONTEXT_RESTORE_TIMEOUT_MS = 10_000 ;
1623
17- type FlexPropsSubset = Pick < FlexProps , "height" | "minHeight" | "flexGrow" > ;
18-
19- interface ShredsChartProps {
24+ interface ShredsChartProps
25+ extends Pick < FlexProps , "height" | "minHeight" | "flexGrow" > {
2026 chartId : string ;
2127}
22- export default function ShredsChart ( {
28+ export default function ShredsChart ( props : ShredsChartProps ) {
29+ const [ key , setKey ] = useState ( 0 ) ;
30+ const triggerRemount = useCallback ( ( ) => {
31+ setKey ( ( prev ) => prev + 1 ) ;
32+ } , [ setKey ] ) ;
33+ return (
34+ < ShredsChartInner key = { key } { ...props } triggerRemount = { triggerRemount } />
35+ ) ;
36+ }
37+
38+ interface ShredsChartInnerProps extends ShredsChartProps {
39+ triggerRemount : ( ) => void ;
40+ }
41+
42+ export function ShredsChartInner ( {
2343 chartId,
44+ triggerRemount,
2445 ...flexProps
25- } : ShredsChartProps & FlexPropsSubset ) {
46+ } : ShredsChartInnerProps ) {
2647 const setMinDirtySlotByChart = useSetAtom ( minDirtySlotByChartAtom ) ;
48+ const setWebgl2Supported = useSetAtom ( isWebgl2SupportedAtom ) ;
2749
2850 const prevTimeDiffsRef = useRef < number [ ] > ( [ ] ) ;
2951 const lastRedrawRef = useRef ( 0 ) ;
@@ -41,9 +63,31 @@ export default function ShredsChart({
4163 prevLabels : createLabelsState ( ) ,
4264 tempNewLabels : createLabelsState ( ) ,
4365 } ) ;
66+ /**
67+ * timeout to use canvas chart fallback when context is lost and not restored
68+ */
69+ const contextLostTimeoutRef = useRef < ReturnType < typeof setTimeout > > ( ) ;
4470
4571 const scale = useShredsChartScale ( ) ;
4672
73+ const handleContextLost = useCallback (
74+ ( event : Event ) => {
75+ // preventDefault so that the browser fires webglcontextrestored
76+ event . preventDefault ( ) ;
77+
78+ clearTimeout ( contextLostTimeoutRef . current ) ;
79+ contextLostTimeoutRef . current = setTimeout ( ( ) => {
80+ setWebgl2Supported ( false ) ;
81+ } , CONTEXT_RESTORE_TIMEOUT_MS ) ;
82+ } ,
83+ [ setWebgl2Supported ] ,
84+ ) ;
85+
86+ const handleContextRestored = useCallback ( ( ) => {
87+ clearTimeout ( contextLostTimeoutRef . current ) ;
88+ triggerRemount ( ) ;
89+ } , [ triggerRemount ] ) ;
90+
4791 useLayoutEffect ( ( ) => {
4892 // setup dirty slot tracking
4993 setMinDirtySlotByChart ( ( prev ) => {
@@ -58,28 +102,54 @@ export default function ShredsChart({
58102 return prev ;
59103 } ) ;
60104
61- // dispose of WebGL resources
62- // NOTE: keep sharedMaterial and unitQuad to be reused across mounts
105+ const isContextLost = contextLostTimeoutRef . current != null ;
106+ clearTimeout ( contextLostTimeoutRef . current ) ;
107+
63108 const obj = rendererRef . current ;
64109 if ( ! obj ) return ;
65110
66- for ( const slotMesh of obj . meshes . values ( ) ) {
67- slotMesh . mesh . geometry . dispose ( ) ;
68- }
69- for ( const slotMesh of obj . availableMeshes ) {
70- slotMesh . mesh . geometry . dispose ( ) ;
111+ obj . renderer . domElement . removeEventListener (
112+ "webglcontextlost" ,
113+ handleContextLost ,
114+ ) ;
115+ obj . renderer . domElement . removeEventListener (
116+ "webglcontextrestored" ,
117+ handleContextRestored ,
118+ ) ;
119+
120+ // When the context is lost, its GPU objects are already gone so skip GL cleanup
121+ if ( ! isContextLost ) {
122+ for ( const slotMesh of obj . meshes . values ( ) ) {
123+ slotMesh . mesh . geometry . dispose ( ) ;
124+ }
125+ for ( const slotMesh of obj . availableMeshes ) {
126+ slotMesh . mesh . geometry . dispose ( ) ;
127+ }
128+ // dispose this chart's own unitQuad / sharedMaterial
129+ disposeWebglResources ( obj . resources ) ;
130+
131+ obj . renderer . dispose ( ) ;
132+ // force release of GL context on repeated mount / unmounts
133+ obj . renderer . forceContextLoss ( ) ;
71134 }
72- obj . renderer . dispose ( ) ;
73- // force release of GL context on repeated mount / unmounts
74- obj . renderer . forceContextLoss ( ) ;
135+
75136 obj . renderer . domElement . remove ( ) ;
76137 rendererRef . current = undefined ;
77138 } ;
78- } , [ chartId , setMinDirtySlotByChart ] ) ;
139+ } , [
140+ chartId ,
141+ handleContextLost ,
142+ handleContextRestored ,
143+ setMinDirtySlotByChart ,
144+ ] ) ;
79145
80146 // handle chart resize
81147 useLayoutEffect ( ( ) => {
82- if ( ! rendererRef . current ) return ;
148+ // skip while the context is lost. Remount on restore will handle resizing
149+ if ( ! rendererRef . current || contextLostTimeoutRef . current != null ) return ;
150+
151+ // skip until valid size is initialized
152+ if ( width <= 0 || height <= 0 ) return ;
83153 const { renderer } = rendererRef . current ;
84154 renderer . setSize ( width , height ) ;
85155 draw (
@@ -95,6 +165,13 @@ export default function ShredsChart({
95165 } , [ scale , width , height , chartId ] ) ;
96166
97167 useRafLoop ( function drawShredsLoop ( time : number ) {
168+ // Don't draw while waiting for context restore.
169+ // but keep canvas mounted to listen for restore event.
170+ if ( contextLostTimeoutRef . current != null ) return ;
171+
172+ // skip until valid size is initialized
173+ if ( width <= 0 || height <= 0 ) return ;
174+
98175 if (
99176 lastRedrawRef . current == null ||
100177 time - lastRedrawRef . current >= REDRAW_INTERVAL_MS
@@ -105,7 +182,10 @@ export default function ShredsChart({
105182 if ( ! rendererObj ) return ;
106183
107184 rendererRef . current = rendererObj ;
108- containerRef . current ?. replaceChildren ( rendererObj . renderer . domElement ) ;
185+ const canvas = rendererObj . renderer . domElement ;
186+ canvas . addEventListener ( "webglcontextlost" , handleContextLost ) ;
187+ canvas . addEventListener ( "webglcontextrestored" , handleContextRestored ) ;
188+ containerRef . current ?. replaceChildren ( canvas ) ;
109189 } else {
110190 draw (
111191 chartId ,
0 commit comments