@@ -906,8 +906,38 @@ export const useOpeningDrillController = (
906906 // Background analysis: run Maia + Stockfish on positions as they are played
907907 // so that post-drill analysis is already complete (or nearly so) when the
908908 // drill ends.
909- const backgroundAnalysisQueueRef = useRef < Set < string > > ( new Set ( ) )
909+ //
910+ // We use a ref-based queue so the async loop persists across re-renders.
911+ // The useEffect only *enqueues* new nodes; the loop processes them
912+ // independently of the React lifecycle to avoid being killed on every move.
913+ const backgroundQueueRef = useRef < GameNode [ ] > ( [ ] )
914+ const backgroundRunningRef = useRef ( false )
915+ const backgroundCancelledRef = useRef ( false )
916+
917+ // The long-lived loop that drains the queue
918+ const runBackgroundLoop = useCallback ( async ( ) => {
919+ if ( backgroundRunningRef . current ) return
920+ backgroundRunningRef . current = true
910921
922+ try {
923+ while ( backgroundQueueRef . current . length > 0 ) {
924+ if ( backgroundCancelledRef . current ) break
925+ const node = backgroundQueueRef . current [ 0 ]
926+
927+ await ensureMaiaForNode ( node )
928+ if ( backgroundCancelledRef . current ) break
929+
930+ await ensureStockfishForNode ( node )
931+
932+ // Only remove from queue after both analyses complete (or if cancelled)
933+ backgroundQueueRef . current . shift ( )
934+ }
935+ } finally {
936+ backgroundRunningRef . current = false
937+ }
938+ } , [ ensureMaiaForNode , ensureStockfishForNode ] )
939+
940+ // Enqueue new drill nodes whenever the tree grows
911941 useEffect ( ( ) => {
912942 if ( ! currentDrillGame || isAnalyzingDrill ) return
913943
@@ -918,9 +948,11 @@ export const useOpeningDrillController = (
918948 : 0
919949 const drillNodes = mainLine . slice ( startIndex )
920950
921- // Find nodes that still need analysis and haven't been queued yet
922- const nodesNeedingWork = drillNodes . filter ( ( node ) => {
923- if ( backgroundAnalysisQueueRef . current . has ( node . fen ) ) return false
951+ // Track FENs already in the queue to avoid duplicates
952+ const queuedFens = new Set ( backgroundQueueRef . current . map ( ( n ) => n . fen ) )
953+
954+ const newNodes = drillNodes . filter ( ( node ) => {
955+ if ( queuedFens . has ( node . fen ) ) return false
924956 const hasMaia =
925957 node . analysis . maia &&
926958 MAIA_MODELS . every ( ( model ) => node . analysis . maia ?. [ model ] )
@@ -930,36 +962,27 @@ export const useOpeningDrillController = (
930962 return ! hasMaia || ! hasStockfish
931963 } )
932964
933- if ( nodesNeedingWork . length === 0 ) return
934-
935- let cancelled = false
936-
937- const runBackgroundAnalysis = async ( ) => {
938- for ( const node of nodesNeedingWork ) {
939- if ( cancelled ) break
940- backgroundAnalysisQueueRef . current . add ( node . fen )
941- await ensureMaiaForNode ( node )
942- if ( cancelled ) break
943- // Let Stockfish finish its current evaluation even if new moves come in
944- await ensureStockfishForNode ( node )
945- }
946- }
947-
948- runBackgroundAnalysis ( )
949-
950- return ( ) => {
951- cancelled = true
965+ if ( newNodes . length > 0 ) {
966+ backgroundQueueRef . current . push ( ...newNodes )
967+ runBackgroundLoop ( )
952968 }
953969 } , [
954970 currentDrillGame ,
955971 gameTree ,
956972 isAnalyzingDrill ,
957- ensureMaiaForNode ,
958- ensureStockfishForNode ,
959- // Re-run when tree grows (new moves played)
973+ runBackgroundLoop ,
960974 treeController . currentNode ,
961975 ] )
962976
977+ // Cancel background analysis when drill session resets
978+ useEffect ( ( ) => {
979+ backgroundCancelledRef . current = false
980+ return ( ) => {
981+ backgroundCancelledRef . current = true
982+ backgroundQueueRef . current = [ ]
983+ }
984+ } , [ currentDrillGame ] )
985+
963986 const ensureDrillAnalysis = useCallback (
964987 async ( drillGame : OpeningDrillGame ) : Promise < boolean > => {
965988 const mainLine = drillGame . tree . getMainLine ( )
0 commit comments