@@ -57,7 +57,25 @@ afterAll(() => {
5757 document . body . innerHTML = "" ;
5858} ) ;
5959
60- describe ( "Timeline row virtualization" , ( ) => {
60+ /**
61+ * The virtualized list only mounts rows/clips after its ResizeObserver and the
62+ * follow-up layout effect have both flushed, which is more than one React tick.
63+ * A fixed number of flushes is a coin flip once the rest of the suite is
64+ * competing for workers, so wait for the DOM the assertions actually need.
65+ */
66+ async function settleUntil ( predicate : ( ) => boolean , tries = 60 ) : Promise < void > {
67+ for ( let attempt = 0 ; attempt < tries ; attempt ++ ) {
68+ if ( predicate ( ) ) return ;
69+ await act ( async ( ) => {
70+ await new Promise < void > ( ( resolve ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
71+ } ) ;
72+ }
73+ }
74+
75+ // These tests mount 500-10,000 timeline elements and settle the virtualizer,
76+ // which lands right on the 5s default once the rest of the suite is competing
77+ // for workers. The generous ceiling is a flake guard, not an expected runtime.
78+ describe ( "Timeline row virtualization" , { timeout : 30_000 } , ( ) => {
6179 it ( "keeps a zero-size first render bounded while the feature flag is enabled" , async ( ) => {
6280 clientWidth = 0 ;
6381 clientHeight = 0 ;
@@ -89,7 +107,60 @@ describe("Timeline row virtualization", () => {
89107
90108 act ( ( ) => root . unmount ( ) ) ;
91109 usePlayerStore . getState ( ) . reset ( ) ;
92- } , 10_000 ) ;
110+ } ) ;
111+
112+ it ( "defers rich clip content while scrolling without replacing the clip shell" , async ( ) => {
113+ const [ { Timeline } , { usePlayerStore } ] = await Promise . all ( [
114+ import ( "./Timeline" ) ,
115+ import ( "../store/playerStore" ) ,
116+ ] ) ;
117+ usePlayerStore . setState ( {
118+ duration : 60 ,
119+ timelineReady : true ,
120+ selectedElementId : "clip-0" ,
121+ elements : [ { id : "clip-0" , label : "Clip 0" , tag : "div" , start : 0 , duration : 10 , track : 0 } ] ,
122+ } ) ;
123+
124+ const host = document . createElement ( "div" ) ;
125+ document . body . append ( host ) ;
126+ const root = createRoot ( host ) ;
127+ try {
128+ await act ( async ( ) =>
129+ root . render (
130+ React . createElement ( Timeline , {
131+ renderClipContent : ( ) => React . createElement ( "span" , { "data-rich-content" : true } ) ,
132+ } ) ,
133+ ) ,
134+ ) ;
135+ await act ( async ( ) => { } ) ;
136+ await act ( async ( ) => {
137+ await new Promise ( ( resolve ) => setTimeout ( resolve , 110 ) ) ;
138+ } ) ;
139+
140+ const scroller = host . querySelector < HTMLElement > ( "[data-timeline-scroll-viewport]" ) ;
141+ const clip = host . querySelector < HTMLElement > ( '[data-el-id="clip-0"]' ) ;
142+ expect ( scroller ) . not . toBeNull ( ) ;
143+ expect ( clip ) . not . toBeNull ( ) ;
144+ expect ( clip ?. title ) . toBe ( "Clip 0 • 0.0s – 10.0s" ) ;
145+ expect ( host . querySelector ( "[data-rich-content]" ) ) . not . toBeNull ( ) ;
146+
147+ await act ( async ( ) => {
148+ scroller ?. dispatchEvent ( new Event ( "scroll" ) ) ;
149+ await new Promise < void > ( ( resolve ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
150+ } ) ;
151+ expect ( host . querySelector ( '[data-el-id="clip-0"]' ) ) . toBe ( clip ) ;
152+ expect ( host . querySelector ( "[data-rich-content]" ) ) . toBeNull ( ) ;
153+
154+ await act ( async ( ) => {
155+ await new Promise ( ( resolve ) => setTimeout ( resolve , 110 ) ) ;
156+ } ) ;
157+ expect ( host . querySelector ( '[data-el-id="clip-0"]' ) ) . toBe ( clip ) ;
158+ expect ( host . querySelector ( "[data-rich-content]" ) ) . not . toBeNull ( ) ;
159+ } finally {
160+ act ( ( ) => root . unmount ( ) ) ;
161+ usePlayerStore . getState ( ) . reset ( ) ;
162+ }
163+ } ) ;
93164
94165 it ( "mounts a bounded list range over the full geometry height" , async ( ) => {
95166 const [ { Timeline } , { usePlayerStore } , { getTimelineCanvasHeight, TRACK_H } ] =
@@ -101,6 +172,8 @@ describe("Timeline row virtualization", () => {
101172 usePlayerStore . setState ( {
102173 duration : 60 ,
103174 timelineReady : true ,
175+ // Repeated fixture shape intentionally contrasts row and clip windowing scales.
176+ // fallow-ignore-next-line code-duplication
104177 elements : Array . from ( { length : 1_000 } , ( _ , track ) => ( {
105178 id : `clip-${ track } ` ,
106179 tag : "div" ,
@@ -116,6 +189,11 @@ describe("Timeline row virtualization", () => {
116189 await act ( async ( ) => root . render ( React . createElement ( Timeline , { sessionEpoch : 3 } ) ) ) ;
117190 await act ( async ( ) => { } ) ;
118191
192+ await settleUntil (
193+ ( ) =>
194+ ( host . querySelector ( '[role="list"]' ) ?. querySelectorAll ( '[role="listitem"]' ) . length ?? 0 ) >
195+ 0 ,
196+ ) ;
119197 const list = host . querySelector < HTMLElement > ( '[role="list"]' ) ;
120198 const rows = list ?. querySelectorAll ( '[role="listitem"]' ) ?? [ ] ;
121199 expect ( rows . length ) . toBeGreaterThan ( 0 ) ;
@@ -143,5 +221,77 @@ describe("Timeline row virtualization", () => {
143221
144222 act ( ( ) => root . unmount ( ) ) ;
145223 usePlayerStore . getState ( ) . reset ( ) ;
146- } , 10_000 ) ;
224+ } ) ;
225+
226+ it ( "windows clips and ruler cells while retaining an off-window selected clip" , async ( ) => {
227+ const [ { Timeline } , { usePlayerStore } , { TIMELINE_VIEWPORT_BUDGETS } ] = await Promise . all ( [
228+ import ( "./Timeline" ) ,
229+ import ( "../store/playerStore" ) ,
230+ import ( "../lib/timelineViewportBudgets" ) ,
231+ ] ) ;
232+ usePlayerStore . setState ( {
233+ duration : 1_000 ,
234+ timelineReady : true ,
235+ zoomMode : "manual" ,
236+ manualZoomPercent : 2_000 ,
237+ selectedElementId : "clip-490" ,
238+ selectedElementIds : new Set ( [ "clip-490" ] ) ,
239+ // Repeated fixture shape intentionally contrasts row and clip windowing scales.
240+ // fallow-ignore-next-line code-duplication
241+ elements : Array . from ( { length : 500 } , ( _ , index ) => ( {
242+ id : `clip-${ index } ` ,
243+ tag : "div" ,
244+ start : index * 2 ,
245+ duration : 1 ,
246+ track : 0 ,
247+ } ) ) ,
248+ } ) ;
249+
250+ const host = document . createElement ( "div" ) ;
251+ document . body . append ( host ) ;
252+ const root = createRoot ( host ) ;
253+ await act ( async ( ) => root . render ( React . createElement ( Timeline , { sessionEpoch : 4 } ) ) ) ;
254+ await act ( async ( ) => { } ) ;
255+
256+ await settleUntil ( ( ) => host . querySelectorAll ( "[data-clip]" ) . length > 1 ) ;
257+ const initialClips = [ ...host . querySelectorAll < HTMLElement > ( "[data-clip]" ) ] ;
258+ const initialGridCells = host . querySelectorAll ( "[data-timeline-grid-cell]" ) ;
259+ expect ( initialClips . length ) . toBeGreaterThan ( 1 ) ;
260+ expect ( initialClips . length ) . toBeLessThanOrEqual (
261+ TIMELINE_VIEWPORT_BUDGETS . maxMountedClipRootsPerRow + 1 ,
262+ ) ;
263+ expect ( initialGridCells . length ) . toBeLessThan ( 100 ) ;
264+ expect ( host . querySelector ( '[data-el-id="clip-490"]' ) ) . not . toBeNull ( ) ;
265+ const initialWindowIds = initialClips . map ( ( clip ) => clip . dataset . elId ) ;
266+
267+ const scroller = host . querySelector < HTMLElement > ( "[data-timeline-scroll-viewport]" ) ;
268+ expect ( scroller ) . not . toBeNull ( ) ;
269+ if ( scroller ) {
270+ scroller . scrollLeft = 8_000 ;
271+ await act ( async ( ) => {
272+ scroller . dispatchEvent ( new Event ( "scroll" ) ) ;
273+ await new Promise < void > ( ( resolve ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
274+ } ) ;
275+ }
276+
277+ const scrolledClips = [ ...host . querySelectorAll < HTMLElement > ( "[data-clip]" ) ] ;
278+ expect ( scrolledClips . map ( ( clip ) => clip . dataset . elId ) ) . not . toEqual ( initialWindowIds ) ;
279+ expect ( scrolledClips . length ) . toBeLessThanOrEqual (
280+ TIMELINE_VIEWPORT_BUDGETS . maxMountedClipRootsPerRow + 1 ,
281+ ) ;
282+ expect ( host . querySelector ( '[data-el-id="clip-490"]' ) ) . not . toBeNull ( ) ;
283+ expect ( host . querySelectorAll ( "[data-timeline-grid-cell]" ) . length ) . toBeLessThan ( 100 ) ;
284+
285+ await act ( async ( ) => usePlayerStore . getState ( ) . requestClipReveal ( "clip-300" ) ) ;
286+ await act ( async ( ) => {
287+ await new Promise < void > ( ( resolve ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ;
288+ } ) ;
289+ await act ( async ( ) => { } ) ;
290+ expect ( usePlayerStore . getState ( ) . clipRevealRequest ) . toBeNull ( ) ;
291+ expect ( document . activeElement ?. getAttribute ( "data-el-id" ) ) . toBe ( "clip-300" ) ;
292+ expect ( host . querySelector ( '[data-el-id="clip-490"]' ) ) . not . toBeNull ( ) ;
293+
294+ act ( ( ) => root . unmount ( ) ) ;
295+ usePlayerStore . getState ( ) . reset ( ) ;
296+ } ) ;
147297} ) ;
0 commit comments