@@ -377,48 +377,199 @@ describe('tooltip-position-manager', () => {
377377 expect ( result ) . toBeNull ( ) ;
378378 } ) ;
379379
380- it ( 'stops repositioning when target is removed from DOM after initial positioning' , ( ) => {
380+ it ( 'calls onDetach and cleans up when target is removed from DOM after initial positioning' , ( ) => {
381381 let rafCallback : FrameRequestCallback | null = null ;
382382 vi . stubGlobal ( 'requestAnimationFrame' , ( cb : FrameRequestCallback ) => {
383383 rafCallback = cb ;
384384 return 1 ;
385385 } ) ;
386386
387+ const onDetach = vi . fn ( ) ;
387388 const target = createTarget ( { } ) ;
388389 const tooltip = createTooltip ( { width : 120 , height : 50 } ) ;
389- handle = positionTooltip ( tooltip , '#target' , 'bottom' ) ;
390+ handle = positionTooltip ( tooltip , '#target' , 'bottom' , { onDetach } ) ;
390391
391392 target . remove ( ) ;
392393
393394 window . dispatchEvent ( new Event ( 'scroll' ) ) ;
394395 rafCallback ! ( 0 ) ;
395396
397+ expect ( onDetach ) . toHaveBeenCalledTimes ( 1 ) ;
396398 expect ( log ) . toHaveBeenCalledWith (
397399 'Tooltip or target element removed from DOM, cleaning up listeners'
398400 ) ;
399401 } ) ;
400402
401- it ( 'stops repositioning when tooltip is removed from DOM' , ( ) => {
403+ it ( 'calls onDetach and cleans up when tooltip is removed from DOM' , ( ) => {
402404 let rafCallback : FrameRequestCallback | null = null ;
403405 vi . stubGlobal ( 'requestAnimationFrame' , ( cb : FrameRequestCallback ) => {
404406 rafCallback = cb ;
405407 return 1 ;
406408 } ) ;
407409
410+ const onDetach = vi . fn ( ) ;
408411 createTarget ( { } ) ;
409412 const tooltip = createTooltip ( { width : 120 , height : 50 } ) ;
410- handle = positionTooltip ( tooltip , '#target' , 'bottom' ) ;
413+ handle = positionTooltip ( tooltip , '#target' , 'bottom' , { onDetach } ) ;
411414
412415 tooltip . remove ( ) ;
413416
414417 window . dispatchEvent ( new Event ( 'scroll' ) ) ;
415418 rafCallback ! ( 0 ) ;
416419
420+ expect ( onDetach ) . toHaveBeenCalledTimes ( 1 ) ;
421+ expect ( log ) . toHaveBeenCalledWith (
422+ 'Tooltip or target element removed from DOM, cleaning up listeners'
423+ ) ;
424+ } ) ;
425+
426+ it ( 'calls onDetach via MutationObserver when target is removed without scroll' , async ( ) => {
427+ const onDetach = vi . fn ( ) ;
428+ const target = createTarget ( { } ) ;
429+ const tooltip = createTooltip ( { width : 120 , height : 50 } ) ;
430+ handle = positionTooltip ( tooltip , '#target' , 'bottom' , { onDetach } ) ;
431+ expect ( handle ) . not . toBeNull ( ) ;
432+
433+ target . remove ( ) ;
434+
435+ // MutationObserver callbacks are microtasks — flush them
436+ await new Promise ( ( r ) => setTimeout ( r , 0 ) ) ;
437+
438+ expect ( onDetach ) . toHaveBeenCalledTimes ( 1 ) ;
439+ expect ( log ) . toHaveBeenCalledWith (
440+ 'Tooltip or target element removed from DOM, cleaning up listeners'
441+ ) ;
442+ } ) ;
443+
444+ it ( 'calls onDetach when target parent is replaced (SPA navigation)' , async ( ) => {
445+ const onDetach = vi . fn ( ) ;
446+ const container = document . createElement ( 'div' ) ;
447+ container . id = 'app-root' ;
448+ document . body . appendChild ( container ) ;
449+
450+ const target = document . createElement ( 'div' ) ;
451+ target . id = 'spa-target' ;
452+ container . appendChild ( target ) ;
453+ target . getBoundingClientRect = vi . fn (
454+ ( ) =>
455+ ( {
456+ top : 100 ,
457+ bottom : 140 ,
458+ left : 200 ,
459+ right : 280 ,
460+ width : 80 ,
461+ height : 40 ,
462+ x : 200 ,
463+ y : 100 ,
464+ toJSON : ( ) => ( { } ) ,
465+ } ) as DOMRect
466+ ) ;
467+
468+ const tooltip = createTooltip ( { width : 120 , height : 50 } ) ;
469+ handle = positionTooltip ( tooltip , '#spa-target' , 'bottom' , { onDetach } ) ;
470+ expect ( handle ) . not . toBeNull ( ) ;
471+
472+ container . innerHTML = '<div>new page content</div>' ;
473+
474+ await new Promise ( ( r ) => setTimeout ( r , 0 ) ) ;
475+
476+ expect ( onDetach ) . toHaveBeenCalledTimes ( 1 ) ;
477+ expect ( log ) . toHaveBeenCalledWith (
478+ 'Tooltip or target element removed from DOM, cleaning up listeners'
479+ ) ;
480+ } ) ;
481+
482+ it ( 'cleans up without error when onDetach is not provided' , ( ) => {
483+ let rafCallback : FrameRequestCallback | null = null ;
484+ vi . stubGlobal ( 'requestAnimationFrame' , ( cb : FrameRequestCallback ) => {
485+ rafCallback = cb ;
486+ return 1 ;
487+ } ) ;
488+
489+ const target = createTarget ( { } ) ;
490+ const tooltip = createTooltip ( { width : 120 , height : 50 } ) ;
491+ handle = positionTooltip ( tooltip , '#target' , 'bottom' ) ;
492+
493+ target . remove ( ) ;
494+
495+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
496+ expect ( ( ) => rafCallback ! ( 0 ) ) . not . toThrow ( ) ;
497+
417498 expect ( log ) . toHaveBeenCalledWith (
418499 'Tooltip or target element removed from DOM, cleaning up listeners'
419500 ) ;
420501 } ) ;
421502
503+ it ( 'does not swallow scroll repositioning when an unrelated DOM mutation fires' , ( ) => {
504+ const rafCallbacks : FrameRequestCallback [ ] = [ ] ;
505+ let nextRafId = 1 ;
506+ vi . stubGlobal ( 'requestAnimationFrame' , ( cb : FrameRequestCallback ) => {
507+ rafCallbacks . push ( cb ) ;
508+ return nextRafId ++ ;
509+ } ) ;
510+
511+ const target = createTarget ( { } ) ;
512+ const tooltip = createTooltip ( { width : 120 , height : 50 } ) ;
513+ handle = positionTooltip ( tooltip , '#target' , 'bottom' ) ;
514+
515+ const callCountBefore = ( target . getBoundingClientRect as ReturnType < typeof vi . fn > ) . mock
516+ . calls . length ;
517+
518+ // Trigger an unrelated DOM mutation (target is still attached)
519+ const unrelated = document . createElement ( 'span' ) ;
520+ document . body . appendChild ( unrelated ) ;
521+
522+ // Flush the MutationObserver microtask so its RAF is queued
523+ // MutationObserver uses a real microtask in jsdom, but we need to
524+ // manually invoke the RAF callback it scheduled.
525+ // Simulate: mutation observer queued a RAF
526+ const mutationRaf = rafCallbacks . pop ( ) ;
527+
528+ // Now a scroll fires while the mutation RAF is pending
529+ window . dispatchEvent ( new Event ( 'scroll' ) ) ;
530+
531+ // The scroll should have queued its own RAF (not been blocked)
532+ const scrollRaf = rafCallbacks . pop ( ) ;
533+ expect ( scrollRaf ) . toBeDefined ( ) ;
534+
535+ // Fire the mutation RAF — target is still attached, so it's a no-op
536+ if ( mutationRaf ) mutationRaf ( 0 ) ;
537+
538+ // Fire the scroll RAF — this must call update() and reposition
539+ scrollRaf ! ( 0 ) ;
540+
541+ const callCountAfter = ( target . getBoundingClientRect as ReturnType < typeof vi . fn > ) . mock . calls
542+ . length ;
543+ expect ( callCountAfter ) . toBeGreaterThan ( callCountBefore ) ;
544+
545+ unrelated . remove ( ) ;
546+ } ) ;
547+
548+ it ( 'disconnects the MutationObserver on cleanup' , async ( ) => {
549+ const disconnectSpy = vi . fn ( ) ;
550+ const OriginalObserver = globalThis . MutationObserver ;
551+ vi . stubGlobal (
552+ 'MutationObserver' ,
553+ class extends OriginalObserver {
554+ disconnect ( ) {
555+ disconnectSpy ( ) ;
556+ super . disconnect ( ) ;
557+ }
558+ }
559+ ) ;
560+
561+ createTarget ( { } ) ;
562+ const tooltip = createTooltip ( { width : 120 , height : 50 } ) ;
563+ handle = positionTooltip ( tooltip , '#target' , 'bottom' ) ;
564+
565+ expect ( disconnectSpy ) . not . toHaveBeenCalled ( ) ;
566+
567+ handle ! . cleanup ( ) ;
568+
569+ expect ( disconnectSpy ) . toHaveBeenCalledTimes ( 1 ) ;
570+ handle = null ;
571+ } ) ;
572+
422573 it ( 'cleanup is idempotent and can be called multiple times safely' , ( ) => {
423574 createTarget ( { } ) ;
424575 const tooltip = createTooltip ( { width : 120 , height : 50 } ) ;
0 commit comments