@@ -554,44 +554,27 @@ async function markAllAsRead() {
554554
555555 // Immediate visual feedback: start overlay animation with stagger
556556 const items = [ ...notificationsList . querySelectorAll ( '.repo-group-header, .notification-item' ) ] ;
557- lastAnimationDuration =
558- Math . max ( items . length - 1 , 0 ) * ANIMATION_DURATION . STAGGER_DELAY + ANIMATION_DURATION . FADE_OUT ;
559- lastUserActionTime = Date . now ( ) ;
560-
561- // Add marking-read to all items first for ::after pseudo-element
562- items . forEach ( ( item ) => item . classList . add ( 'marking-read' ) ) ;
563- // eslint-disable-next-line no-unused-expressions
564- document . body . offsetHeight ; // Force reflow
565-
566- // Stagger fade-out and track timeout IDs for rollback
567- const staggerTimeouts = [ ] ;
568- items . forEach ( ( item , index ) => {
569- const timeoutId = setTimeout ( ( ) => {
570- item . classList . add ( 'fade-out' ) ;
571- } , index * ANIMATION_DURATION . STAGGER_DELAY ) ;
572- staggerTimeouts . push ( timeoutId ) ;
573- } ) ;
557+ const anim = beginStaggerAnimation ( items ) ;
574558
575559 function rollback ( ) {
576- staggerTimeouts . forEach ( ( id ) => clearTimeout ( id ) ) ;
577- removeOverlayFadeOut ( items ) ;
560+ anim . rollback ( ) ;
578561 markAllBtn . disabled = false ;
579562 markAllBtn . innerHTML = originalText ;
580563 }
581564
582565 try {
583566 const result = await sendMessage ( MESSAGE_TYPES . MARK_ALL_AS_READ ) ;
584567 if ( result . success ) {
585- // Success: clear pending animations, DOM and show empty state
586- staggerTimeouts . forEach ( ( id ) => clearTimeout ( id ) ) ;
568+ // Wait for stagger animation to finish before clearing DOM
569+ await anim . waitForCompletion ( ) ;
587570 clearNotificationCache ( ) ;
588- await new Promise ( requestAnimationFrame ) ;
589571 notificationsList . innerHTML = '' ;
590572 emptyState . hidden = false ;
591573 markAllBtn . disabled = true ;
592574 markAllBtn . innerHTML = originalText ;
593575 } else {
594576 rollback ( ) ;
577+ console . error ( 'Failed to mark all as read:' , result . error ) ;
595578 }
596579 } catch ( error ) {
597580 rollback ( ) ;
@@ -772,21 +755,75 @@ async function logout() {
772755 await showView ( 'login' ) ;
773756}
774757
758+ // Cap stagger to avoid long animation when many items are off-screen
759+ const MAX_STAGGER_COUNT = 20 ;
760+
761+ /**
762+ * Calculate total stagger animation duration for a given number of elements.
763+ */
764+ function calcStaggerDuration ( count ) {
765+ const capped = Math . min ( count , MAX_STAGGER_COUNT ) ;
766+ return Math . max ( capped - 1 , 0 ) * ANIMATION_DURATION . STAGGER_DELAY + ANIMATION_DURATION . FADE_OUT ;
767+ }
768+
775769/**
776- * Apply overlay fade-out animation via CSS classes.
777- * Uses ::after pseudo-element overlay to avoid compositing artifacts.
770+ * Wait until a stagger animation that started at `startTime` has finished.
771+ * Resolves immediately if the animation has already completed.
772+ */
773+ async function waitForAnimation ( startTime , duration ) {
774+ const remaining = Math . max ( 0 , duration - ( Date . now ( ) - startTime ) ) ;
775+ if ( remaining > 0 ) {
776+ await new Promise ( ( resolve ) => setTimeout ( resolve , remaining ) ) ;
777+ }
778+ }
779+
780+ /**
781+ * Start stagger animation on elements and return control handles.
782+ * Sets global timing state and returns rollback/wait helpers.
778783 * @param {HTMLElement[] } elements - Elements to animate
784+ * @returns {{ rollback: () => void, waitForCompletion: () => Promise<void> } }
779785 */
780- function applyOverlayFadeOut ( elements ) {
786+ function beginStaggerAnimation ( elements ) {
787+ lastAnimationDuration = calcStaggerDuration ( elements . length ) ;
788+ lastUserActionTime = Date . now ( ) ;
789+ const animationStart = lastUserActionTime ;
790+ const animationDuration = lastAnimationDuration ;
791+
792+ const timeoutIds = startStaggerFadeOut ( elements , ANIMATION_DURATION . STAGGER_DELAY ) ;
793+
794+ return {
795+ rollback ( ) {
796+ timeoutIds . forEach ( ( id ) => clearTimeout ( id ) ) ;
797+ removeOverlayFadeOut ( elements ) ;
798+ } ,
799+ waitForCompletion ( ) {
800+ return waitForAnimation ( animationStart , animationDuration ) ;
801+ } ,
802+ } ;
803+ }
804+
805+ /**
806+ * Start staggered overlay fade-out animation.
807+ * @param {HTMLElement[] } elements - Elements to animate
808+ * @param {number } staggerDelay - Delay in ms between each element's fade
809+ * @returns {number[] } Timeout IDs for cancellation
810+ */
811+ function startStaggerFadeOut ( elements , staggerDelay ) {
781812 for ( const el of elements ) {
782813 el . classList . add ( 'marking-read' ) ;
783814 }
784- // Force reflow so ::after pseudo-element starts at opacity 0
785815 // eslint-disable-next-line no-unused-expressions
786- document . body . offsetHeight ;
787- for ( const el of elements ) {
788- el . classList . add ( 'fade-out' ) ;
789- }
816+ document . body . offsetHeight ; // Force reflow
817+
818+ const timeoutIds = [ ] ;
819+ elements . forEach ( ( el , index ) => {
820+ const delay = Math . min ( index , MAX_STAGGER_COUNT - 1 ) * staggerDelay ;
821+ const id = setTimeout ( ( ) => {
822+ el . classList . add ( 'fade-out' ) ;
823+ } , delay ) ;
824+ timeoutIds . push ( id ) ;
825+ } ) ;
826+ return timeoutIds ;
790827}
791828
792829/**
@@ -806,27 +843,22 @@ function removeOverlayFadeOut(elements) {
806843async function handleMarkRepoAsRead ( repoFullName ) {
807844 const [ owner , repo ] = repoFullName . split ( '/' ) ;
808845
809- lastUserActionTime = Date . now ( ) ;
810-
811846 // Immediate visual feedback: start animation before API response
812847 const escapedRepo = CSS . escape ( repoFullName ) ;
813848 const repoHeader = document . querySelector ( `.repo-group-header[data-repo="${ escapedRepo } "]` ) ;
814849 const items = [ ...document . querySelectorAll ( `.notification-item[data-repo="${ escapedRepo } "]` ) ] ;
815850
816- const animationDuration = ANIMATION_DURATION . FADE_OUT ;
817- lastAnimationDuration = animationDuration ;
818-
819851 const allElements = repoHeader ? [ repoHeader , ...items ] : items ;
820- applyOverlayFadeOut ( allElements ) ;
852+
853+ const anim = beginStaggerAnimation ( allElements ) ;
821854
822855 try {
823856 const response = await sendMessage ( MESSAGE_TYPES . MARK_REPO_AS_READ , { owner, repo } ) ;
824857
825858 if ( response . success ) {
826- // Success: remove elements from DOM
827- await new Promise ( requestAnimationFrame ) ;
828- if ( repoHeader ) repoHeader . remove ( ) ;
829- items . forEach ( ( item ) => item . remove ( ) ) ;
859+ // Wait for stagger animation to finish before removing DOM
860+ await anim . waitForCompletion ( ) ;
861+ allElements . forEach ( ( el ) => el . remove ( ) ) ;
830862
831863 const remainingItems = notificationsList . querySelectorAll ( '.notification-item' ) ;
832864 if ( remainingItems . length === 0 ) {
@@ -842,13 +874,11 @@ async function handleMarkRepoAsRead(repoFullName) {
842874 console . error ( 'Failed to reload notifications:' , error ) ;
843875 }
844876 } else {
845- // Failure: rollback animation
846- removeOverlayFadeOut ( allElements ) ;
877+ anim . rollback ( ) ;
847878 console . error ( 'Failed to mark repo as read:' , response . error ) ;
848879 }
849880 } catch ( error ) {
850- // Failure: rollback animation
851- removeOverlayFadeOut ( allElements ) ;
881+ anim . rollback ( ) ;
852882 console . error ( 'Error marking repo as read:' , error ) ;
853883 }
854884}
0 commit comments