@@ -50,7 +50,78 @@ afterAll(() => {
5050 document . body . innerHTML = "" ;
5151} ) ;
5252
53- describe ( "Timeline row virtualization" , ( ) => {
53+ /**
54+ * The virtualized list only mounts rows/clips after its ResizeObserver and the
55+ * follow-up layout effect have both flushed, which is more than one React tick.
56+ * A fixed number of flushes is a coin flip once the rest of the suite is
57+ * competing for workers, so wait for the DOM the assertions actually need.
58+ */
59+ async function settleUntil ( predicate : ( ) => boolean , tries = 60 ) : Promise < void > {
60+ for ( let attempt = 0 ; attempt < tries ; attempt ++ ) {
61+ if ( predicate ( ) ) return ;
62+ await act ( async ( ) => {
63+ await new Promise < void > ( ( resolve ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
64+ } ) ;
65+ }
66+ }
67+
68+ // Every test here mounts 500-1000 timeline elements and settles the virtualizer,
69+ // which lands right on the 5s default once the rest of the suite is competing
70+ // for workers. The generous ceiling is a flake guard, not an expected runtime.
71+ describe ( "Timeline row virtualization" , { timeout : 30_000 } , ( ) => {
72+ it ( "defers rich clip content while scrolling without replacing the clip shell" , async ( ) => {
73+ const [ { Timeline } , { usePlayerStore } ] = await Promise . all ( [
74+ import ( "./Timeline" ) ,
75+ import ( "../store/playerStore" ) ,
76+ ] ) ;
77+ usePlayerStore . setState ( {
78+ duration : 60 ,
79+ timelineReady : true ,
80+ selectedElementId : "clip-0" ,
81+ elements : [ { id : "clip-0" , label : "Clip 0" , tag : "div" , start : 0 , duration : 10 , track : 0 } ] ,
82+ } ) ;
83+
84+ const host = document . createElement ( "div" ) ;
85+ document . body . append ( host ) ;
86+ const root = createRoot ( host ) ;
87+ try {
88+ await act ( async ( ) =>
89+ root . render (
90+ React . createElement ( Timeline , {
91+ renderClipContent : ( ) => React . createElement ( "span" , { "data-rich-content" : true } ) ,
92+ } ) ,
93+ ) ,
94+ ) ;
95+ await act ( async ( ) => { } ) ;
96+ await act ( async ( ) => {
97+ await new Promise ( ( resolve ) => setTimeout ( resolve , 110 ) ) ;
98+ } ) ;
99+
100+ const scroller = host . querySelector < HTMLElement > ( "[data-timeline-scroll-viewport]" ) ;
101+ const clip = host . querySelector < HTMLElement > ( '[data-el-id="clip-0"]' ) ;
102+ expect ( scroller ) . not . toBeNull ( ) ;
103+ expect ( clip ) . not . toBeNull ( ) ;
104+ expect ( clip ?. title ) . toBe ( "Clip 0 • 0.0s – 10.0s" ) ;
105+ expect ( host . querySelector ( "[data-rich-content]" ) ) . not . toBeNull ( ) ;
106+
107+ await act ( async ( ) => {
108+ scroller ?. dispatchEvent ( new Event ( "scroll" ) ) ;
109+ await new Promise < void > ( ( resolve ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
110+ } ) ;
111+ expect ( host . querySelector ( '[data-el-id="clip-0"]' ) ) . toBe ( clip ) ;
112+ expect ( host . querySelector ( "[data-rich-content]" ) ) . toBeNull ( ) ;
113+
114+ await act ( async ( ) => {
115+ await new Promise ( ( resolve ) => setTimeout ( resolve , 110 ) ) ;
116+ } ) ;
117+ expect ( host . querySelector ( '[data-el-id="clip-0"]' ) ) . toBe ( clip ) ;
118+ expect ( host . querySelector ( "[data-rich-content]" ) ) . not . toBeNull ( ) ;
119+ } finally {
120+ act ( ( ) => root . unmount ( ) ) ;
121+ usePlayerStore . getState ( ) . reset ( ) ;
122+ }
123+ } ) ;
124+
54125 it ( "mounts a bounded list range over the full geometry height" , async ( ) => {
55126 const [ { Timeline } , { usePlayerStore } , { getTimelineCanvasHeight, TRACK_H } ] =
56127 await Promise . all ( [
@@ -61,6 +132,8 @@ describe("Timeline row virtualization", () => {
61132 usePlayerStore . setState ( {
62133 duration : 60 ,
63134 timelineReady : true ,
135+ // Repeated fixture shape intentionally contrasts row and clip windowing scales.
136+ // fallow-ignore-next-line code-duplication
64137 elements : Array . from ( { length : 1_000 } , ( _ , track ) => ( {
65138 id : `clip-${ track } ` ,
66139 tag : "div" ,
@@ -76,6 +149,11 @@ describe("Timeline row virtualization", () => {
76149 await act ( async ( ) => root . render ( React . createElement ( Timeline , { sessionEpoch : 3 } ) ) ) ;
77150 await act ( async ( ) => { } ) ;
78151
152+ await settleUntil (
153+ ( ) =>
154+ ( host . querySelector ( '[role="list"]' ) ?. querySelectorAll ( '[role="listitem"]' ) . length ?? 0 ) >
155+ 0 ,
156+ ) ;
79157 const list = host . querySelector < HTMLElement > ( '[role="list"]' ) ;
80158 const rows = list ?. querySelectorAll ( '[role="listitem"]' ) ?? [ ] ;
81159 expect ( rows . length ) . toBeGreaterThan ( 0 ) ;
@@ -103,5 +181,77 @@ describe("Timeline row virtualization", () => {
103181
104182 act ( ( ) => root . unmount ( ) ) ;
105183 usePlayerStore . getState ( ) . reset ( ) ;
106- } , 10_000 ) ;
184+ } ) ;
185+
186+ it ( "windows clips and ruler cells while retaining an off-window selected clip" , async ( ) => {
187+ const [ { Timeline } , { usePlayerStore } , { TIMELINE_VIEWPORT_BUDGETS } ] = await Promise . all ( [
188+ import ( "./Timeline" ) ,
189+ import ( "../store/playerStore" ) ,
190+ import ( "../lib/timelineViewportBudgets" ) ,
191+ ] ) ;
192+ usePlayerStore . setState ( {
193+ duration : 1_000 ,
194+ timelineReady : true ,
195+ zoomMode : "manual" ,
196+ manualZoomPercent : 2_000 ,
197+ selectedElementId : "clip-490" ,
198+ selectedElementIds : new Set ( [ "clip-490" ] ) ,
199+ // Repeated fixture shape intentionally contrasts row and clip windowing scales.
200+ // fallow-ignore-next-line code-duplication
201+ elements : Array . from ( { length : 500 } , ( _ , index ) => ( {
202+ id : `clip-${ index } ` ,
203+ tag : "div" ,
204+ start : index * 2 ,
205+ duration : 1 ,
206+ track : 0 ,
207+ } ) ) ,
208+ } ) ;
209+
210+ const host = document . createElement ( "div" ) ;
211+ document . body . append ( host ) ;
212+ const root = createRoot ( host ) ;
213+ await act ( async ( ) => root . render ( React . createElement ( Timeline , { sessionEpoch : 4 } ) ) ) ;
214+ await act ( async ( ) => { } ) ;
215+
216+ await settleUntil ( ( ) => host . querySelectorAll ( "[data-clip]" ) . length > 1 ) ;
217+ const initialClips = [ ...host . querySelectorAll < HTMLElement > ( "[data-clip]" ) ] ;
218+ const initialGridCells = host . querySelectorAll ( "[data-timeline-grid-cell]" ) ;
219+ expect ( initialClips . length ) . toBeGreaterThan ( 1 ) ;
220+ expect ( initialClips . length ) . toBeLessThanOrEqual (
221+ TIMELINE_VIEWPORT_BUDGETS . maxMountedClipRootsPerRow + 1 ,
222+ ) ;
223+ expect ( initialGridCells . length ) . toBeLessThan ( 100 ) ;
224+ expect ( host . querySelector ( '[data-el-id="clip-490"]' ) ) . not . toBeNull ( ) ;
225+ const initialWindowIds = initialClips . map ( ( clip ) => clip . dataset . elId ) ;
226+
227+ const scroller = host . querySelector < HTMLElement > ( "[data-timeline-scroll-viewport]" ) ;
228+ expect ( scroller ) . not . toBeNull ( ) ;
229+ if ( scroller ) {
230+ scroller . scrollLeft = 8_000 ;
231+ await act ( async ( ) => {
232+ scroller . dispatchEvent ( new Event ( "scroll" ) ) ;
233+ await new Promise < void > ( ( resolve ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
234+ } ) ;
235+ }
236+
237+ const scrolledClips = [ ...host . querySelectorAll < HTMLElement > ( "[data-clip]" ) ] ;
238+ expect ( scrolledClips . map ( ( clip ) => clip . dataset . elId ) ) . not . toEqual ( initialWindowIds ) ;
239+ expect ( scrolledClips . length ) . toBeLessThanOrEqual (
240+ TIMELINE_VIEWPORT_BUDGETS . maxMountedClipRootsPerRow + 1 ,
241+ ) ;
242+ expect ( host . querySelector ( '[data-el-id="clip-490"]' ) ) . not . toBeNull ( ) ;
243+ expect ( host . querySelectorAll ( "[data-timeline-grid-cell]" ) . length ) . toBeLessThan ( 100 ) ;
244+
245+ await act ( async ( ) => usePlayerStore . getState ( ) . requestClipReveal ( "clip-300" ) ) ;
246+ await act ( async ( ) => {
247+ await new Promise < void > ( ( resolve ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
248+ } ) ;
249+ await act ( async ( ) => { } ) ;
250+ expect ( usePlayerStore . getState ( ) . clipRevealRequest ) . toBeNull ( ) ;
251+ expect ( document . activeElement ?. getAttribute ( "data-el-id" ) ) . toBe ( "clip-300" ) ;
252+ expect ( host . querySelector ( '[data-el-id="clip-490"]' ) ) . not . toBeNull ( ) ;
253+
254+ act ( ( ) => root . unmount ( ) ) ;
255+ usePlayerStore . getState ( ) . reset ( ) ;
256+ } ) ;
107257} ) ;
0 commit comments