@@ -364,4 +364,135 @@ describe('Saga-style Effects Scenarios', () => {
364364
365365 expect ( canceledCheck ) . toBe ( true )
366366 } )
367+
368+ test ( 'long-running listener with immediate unsubscribe is cancelable' , async ( ) => {
369+ let runCount = 0
370+ let abortCount = 0
371+
372+ startListening ( {
373+ actionCreator : increment ,
374+ effect : async ( action , listenerApi ) => {
375+ runCount ++
376+
377+ // Stop listening for this action
378+ listenerApi . unsubscribe ( )
379+
380+ try {
381+ // Wait indefinitely
382+ await listenerApi . condition ( ( ) => false )
383+ } catch ( err ) {
384+ if ( err instanceof TaskAbortError ) {
385+ abortCount ++
386+ }
387+ }
388+ } ,
389+ } )
390+
391+ // First action starts the listener, which unsubscribes
392+ store . dispatch ( increment ( ) )
393+ expect ( runCount ) . toBe ( 1 )
394+
395+ // Verify that the first action unsubscribed the listener
396+ store . dispatch ( increment ( ) )
397+ expect ( runCount ) . toBe ( 1 )
398+
399+ // Now call clearListeners, which should abort the running effect, even
400+ // though the listener is no longer subscribed
401+ listenerMiddleware . clearListeners ( )
402+ await delay ( 0 )
403+
404+ expect ( abortCount ) . toBe ( 1 )
405+ } )
406+
407+ test ( 'long-running listener with unsubscribe race is cancelable' , async ( ) => {
408+ let runCount = 0
409+ let abortCount = 0
410+
411+ startListening ( {
412+ actionCreator : increment ,
413+ effect : async ( action , listenerApi ) => {
414+ runCount ++
415+
416+ if ( runCount === 2 ) {
417+ // On the second run, stop listening for this action
418+ listenerApi . unsubscribe ( )
419+ return
420+ }
421+
422+ try {
423+ // Wait indefinitely
424+ await listenerApi . condition ( ( ) => false )
425+ } catch ( err ) {
426+ if ( err instanceof TaskAbortError ) {
427+ abortCount ++
428+ }
429+ }
430+ } ,
431+ } )
432+
433+ // First action starts the hanging effect
434+ store . dispatch ( increment ( ) )
435+ expect ( runCount ) . toBe ( 1 )
436+
437+ // Second action starts the fast effect, which unsubscribes
438+ store . dispatch ( increment ( ) )
439+ expect ( runCount ) . toBe ( 2 )
440+
441+ // Third action should be a noop
442+ store . dispatch ( increment ( ) )
443+ expect ( runCount ) . toBe ( 2 )
444+
445+ // The hanging effect should still be hanging
446+ expect ( abortCount ) . toBe ( 0 )
447+
448+ // Now call clearListeners, which should abort the hanging effect, even
449+ // though the listener is no longer subscribed
450+ listenerMiddleware . clearListeners ( )
451+ await delay ( 0 )
452+
453+ expect ( abortCount ) . toBe ( 1 )
454+ } )
455+
456+ test ( 'long-running listener with immediate unsubscribe and forked child is cancelable' , async ( ) => {
457+ let outerAborted = false
458+ let innerAborted = false
459+
460+ startListening ( {
461+ actionCreator : increment ,
462+ effect : async ( action , listenerApi ) => {
463+ // Stop listening for this action
464+ listenerApi . unsubscribe ( )
465+
466+ const pollingTask = listenerApi . fork ( async ( forkApi ) => {
467+ try {
468+ // Cancellation-aware indefinite pause
469+ await forkApi . pause ( new Promise ( ( ) => { } ) )
470+ } catch ( err ) {
471+ if ( err instanceof TaskAbortError ) {
472+ innerAborted = true
473+ }
474+ }
475+ } )
476+
477+ try {
478+ // Wait indefinitely
479+ await listenerApi . condition ( ( ) => false )
480+ pollingTask . cancel ( )
481+ } catch ( err ) {
482+ if ( err instanceof TaskAbortError ) {
483+ outerAborted = true
484+ }
485+ }
486+ } ,
487+ } )
488+
489+ store . dispatch ( increment ( ) )
490+ await delay ( 0 )
491+
492+ listenerMiddleware . clearListeners ( )
493+ await delay ( 0 )
494+
495+ expect ( outerAborted ) . toBe ( true )
496+ expect ( innerAborted ) . toBe ( true )
497+ } )
367498} )
0 commit comments