11import * as React from 'react' ;
22import { createElement } from 'react' ;
33import { createRoot , type Root } from 'react-dom/client' ;
4- import { MemoryRouter } from 'react-router-dom' ;
4+ import { MemoryRouter , useLocation , useNavigate } from 'react-router-dom' ;
55import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest' ;
66import PostDesktop from '../post-desktop' ;
77import PostMobile from '../post-mobile' ;
@@ -52,12 +52,16 @@ const testState = vi.hoisted(() => ({
5252 accountCommentsByCid : { } as Record < string , TestComment | undefined > ,
5353 directoryEntryByAddress : { } as Record < string , { address : string ; directoryCode ?: string ; features ?: Record < string , unknown > ; title ?: string } | undefined > ,
5454 hasMoreReplies : false ,
55+ freshReplyInputs : [ ] as TestComment [ ] [ ] ,
5556 openReplyModalMock : vi . fn ( ) ,
5657 pseudonymityMode : 'none' ,
5758 replyComments : [ ] as Array < TestComment | undefined > ,
5859 setResetFunctionMock : vi . fn ( ) ,
5960 stateString : undefined as string | undefined ,
6061 virtuosoProps : [ ] as Array < { defaultItemHeight ?: number ; heightEstimates ?: number [ ] ; itemSize ?: unknown } > ,
62+ getVirtuosoStateMock : vi . fn ( ) ,
63+ virtuosoSnapshot : { ranges : [ 0 ] , scrollTop : 0 } ,
64+ restoredVirtuosoStates : [ ] as Array < { initialScrollTop ?: number ; restoreStateFrom ?: { ranges : number [ ] ; scrollTop : number } } > ,
6165} ) ) ;
6266
6367const getMockPreloadedReplies = ( comment ?: TestComment , sortType ?: string ) => {
@@ -120,20 +124,28 @@ vi.mock('react-virtuoso', () => ({
120124 heightEstimates,
121125 itemSize,
122126 itemContent,
127+ initialScrollTop,
128+ restoreStateFrom,
123129 } : {
124130 components ?: { Footer ?: React . ComponentType } ;
125131 data ?: TestComment [ ] ;
126132 defaultItemHeight ?: number ;
127133 heightEstimates ?: number [ ] ;
128134 itemSize ?: unknown ;
129135 itemContent : ( index : number , item : TestComment ) => React . ReactNode ;
136+ initialScrollTop ?: number ;
137+ restoreStateFrom ?: { ranges : number [ ] ; scrollTop : number } ;
130138 } ,
131139 ref : React . ForwardedRef < { getState : ( cb : ( snapshot : { ranges : number [ ] ; scrollTop : number } ) => void ) => void } > ,
132140 ) => {
133141 testState . virtuosoProps . push ( { defaultItemHeight, heightEstimates, itemSize } ) ;
142+ testState . restoredVirtuosoStates . push ( { initialScrollTop, restoreStateFrom } ) ;
134143
135144 React . useImperativeHandle ( ref , ( ) => ( {
136- getState : ( cb ) => cb ( { ranges : [ 0 ] , scrollTop : 0 } ) ,
145+ getState : ( cb ) => {
146+ testState . getVirtuosoStateMock ( ) ;
147+ cb ( testState . virtuosoSnapshot ) ;
148+ } ,
137149 } ) ) ;
138150
139151 return createElement (
@@ -358,7 +370,10 @@ vi.mock('../../hooks/use-progressive-render', () => ({
358370} ) ) ;
359371
360372vi . mock ( '../../hooks/use-fresh-replies' , ( ) => ( {
361- default : ( replies : TestComment [ ] ) => replies ,
373+ default : ( replies : TestComment [ ] ) => {
374+ testState . freshReplyInputs . push ( replies ) ;
375+ return replies ;
376+ } ,
362377} ) ) ;
363378
364379vi . mock ( '../../hooks/use-reply-height-estimates' , ( ) => ( {
@@ -378,7 +393,7 @@ vi.mock('../../lib/constants', () => ({
378393vi . mock ( '../../lib/utils/replies-preview-utils' , ( ) => ( {
379394 computeOmittedCount : ( ) => 0 ,
380395 filterRepliesForDisplay : ( replies : TestComment [ ] ) => replies ,
381- getPreviewDisplayReplies : ( replies : TestComment [ ] ) => replies ,
396+ getPreviewDisplayReplies : ( replies : TestComment [ ] ) => [ ... replies ] ,
382397 getTotalReplyCount : ( { replyCount } : { replyCount ?: number } ) => replyCount ?? 0 ,
383398 hasEnoughPreviewReplies : ( { replyCount, loadedCount, visibleCount } : { replyCount ?: number ; loadedCount : number ; visibleCount : number } ) =>
384399 loadedCount >= Math . min ( visibleCount , replyCount ?? visibleCount ) ,
@@ -483,10 +498,13 @@ describe('post community address compatibility', () => {
483498 'music-posting.eth' : { address : 'music-posting.eth' , features : { } } ,
484499 } ;
485500 testState . hasMoreReplies = false ;
501+ testState . freshReplyInputs = [ ] ;
486502 testState . pseudonymityMode = 'none' ;
487503 testState . replyComments = [ ] ;
488504 testState . stateString = undefined ;
489505 testState . virtuosoProps = [ ] ;
506+ testState . virtuosoSnapshot = { ranges : [ 0 ] , scrollTop : 0 } ;
507+ testState . restoredVirtuosoStates = [ ] ;
490508
491509 container = document . createElement ( 'div' ) ;
492510 document . body . appendChild ( container ) ;
@@ -733,6 +751,80 @@ describe('post community address compatibility', () => {
733751 } ) ;
734752 } ) ;
735753
754+ it . each ( [
755+ [ 'desktop' , PostDesktop ] ,
756+ [ 'mobile' , PostMobile ] ,
757+ ] as const ) ( 'saves %s reply sizes on departure and restores them on back without snapshotting scroll ticks' , async ( mode , PostComponent ) => {
758+ const post = { ...makeLegacyThread ( ) , cid : `snapshot-${ mode } ` } ;
759+ const NavigationHarness = ( ) => {
760+ const location = useLocation ( ) ;
761+ const navigate = useNavigate ( ) ;
762+ return createElement (
763+ React . Fragment ,
764+ { } ,
765+ createElement ( 'button' , { 'data-testid' : 'leave-thread' , onClick : ( ) => navigate ( '/' ) } , 'home' ) ,
766+ createElement ( 'button' , { 'data-testid' : 'back-to-thread' , onClick : ( ) => navigate ( - 1 ) } , 'back' ) ,
767+ location . pathname . includes ( '/thread/' ) ? createElement ( PostComponent , { post, showAllReplies : true } ) : null ,
768+ ) ;
769+ } ;
770+
771+ await renderWithRoute ( createElement ( NavigationHarness ) , `/mu/thread/${ post . cid } ` ) ;
772+ expect ( container . querySelector ( '[data-testid="virtuoso"]' ) ) . toBeNull ( ) ;
773+
774+ // Replies may become virtualized only after loading their first page.
775+ testState . hasMoreReplies = true ;
776+ await renderWithRoute ( createElement ( NavigationHarness ) , `/mu/thread/${ post . cid } ` ) ;
777+ expect ( container . querySelector ( '[data-testid="virtuoso"]' ) ) . toBeTruthy ( ) ;
778+
779+ await act ( async ( ) => {
780+ for ( let index = 0 ; index < 100 ; index += 1 ) window . dispatchEvent ( new Event ( 'scroll' ) ) ;
781+ } ) ;
782+ expect ( testState . getVirtuosoStateMock ) . not . toHaveBeenCalled ( ) ;
783+
784+ // Saving after a resize captures the latest item sizes too.
785+ testState . virtuosoSnapshot = { ranges : [ 1 , 4 ] , scrollTop : 480 } ;
786+ await act ( async ( ) => {
787+ window . dispatchEvent ( new Event ( 'resize' ) ) ;
788+ window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
789+ } ) ;
790+ expect ( testState . getVirtuosoStateMock ) . toHaveBeenCalledTimes ( 1 ) ;
791+
792+ testState . virtuosoSnapshot = { ranges : [ 2 , 5 ] , scrollTop : 1024 } ;
793+ await act ( async ( ) => container . querySelector < HTMLButtonElement > ( '[data-testid="leave-thread"]' ) ?. click ( ) ) ;
794+ expect ( testState . getVirtuosoStateMock ) . toHaveBeenCalledTimes ( 2 ) ;
795+ expect ( container . querySelector ( '[data-testid="virtuoso"]' ) ) . toBeNull ( ) ;
796+
797+ window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
798+ expect ( testState . getVirtuosoStateMock ) . toHaveBeenCalledTimes ( 2 ) ;
799+
800+ await act ( async ( ) => container . querySelector < HTMLButtonElement > ( '[data-testid="back-to-thread"]' ) ?. click ( ) ) ;
801+ expect ( testState . restoredVirtuosoStates . at ( - 1 ) ) . toEqual ( {
802+ initialScrollTop : 1024 ,
803+ restoreStateFrom : { ranges : [ 2 , 5 ] , scrollTop : 1024 } ,
804+ } ) ;
805+ } ) ;
806+
807+ it ( 'preserves unchanged desktop preview inputs and updates them when replies change' , async ( ) => {
808+ const post = makeLegacyThread ( ) ;
809+ const reply = post . replies ! . pages ! . new . comments ! [ 0 ] ;
810+ const replyPaginationOverride = { replies : [ reply ] } ;
811+
812+ await renderWithRoute ( createElement ( PostDesktop , { post, replyPaginationOverride } ) ) ;
813+ const firstPreview = testState . freshReplyInputs . at ( - 1 ) ;
814+ expect ( firstPreview ) . toEqual ( [ reply ] ) ;
815+ testState . freshReplyInputs = [ ] ;
816+
817+ await renderWithRoute ( createElement ( PostDesktop , { post, replyPaginationOverride } ) ) ;
818+ expect ( testState . freshReplyInputs . length ) . toBeGreaterThan ( 0 ) ;
819+ expect ( testState . freshReplyInputs . every ( ( replies ) => replies === firstPreview ) ) . toBe ( true ) ;
820+
821+ const updatedReply = { ...reply , cid : 'updated-reply' , content : 'Updated preview' } ;
822+ await renderWithRoute ( createElement ( PostDesktop , { post, replyPaginationOverride : { replies : [ updatedReply ] } } ) ) ;
823+ expect ( testState . freshReplyInputs . at ( - 1 ) ) . toEqual ( [ updatedReply ] ) ;
824+ expect ( testState . freshReplyInputs . at ( - 1 ) ) . not . toBe ( firstPreview ) ;
825+ expect ( container . textContent ) . toContain ( 'updated-reply' ) ;
826+ } ) ;
827+
736828 it ( 'keeps board-card Pretext heights when preview replies are rendered' , async ( ) => {
737829 await renderWithRoute ( createElement ( PostDesktop , { post : makeLegacyThread ( ) } ) ) ;
738830 expect ( container . querySelector ( '.postDesktop' ) ?. getAttribute ( 'data-pretext-height' ) ) . toBeTruthy ( ) ;
0 commit comments