@@ -427,3 +427,172 @@ test("thread panel late row reflow keeps the reading reply stable", async ({
427427 expect ( drift . missingSamples ) . toBe ( 0 ) ;
428428 expect ( drift . maxDrift ) . toBeLessThanOrEqual ( 2 ) ;
429429} ) ;
430+
431+ test ( "thread panel stays put while replies stream in mid-scroll" , async ( {
432+ page,
433+ } , testInfo ) => {
434+ testInfo . setTimeout ( 45_000 ) ;
435+
436+ await installMockBridge ( page ) ;
437+ await page . goto ( "/" ) ;
438+ await waitForMockTimelineBridge ( page ) ;
439+
440+ page . on ( "console" , ( msg ) => {
441+ if ( msg . text ( ) . includes ( "ANCHOR_DEBUG" ) ) console . info ( msg . text ( ) ) ;
442+ } ) ;
443+
444+ const rootId = await page . evaluate ( ( ) => {
445+ const root = window . __BUZZ_E2E_EMIT_MOCK_MESSAGE__ ?.( {
446+ channelName : "general" ,
447+ content : "thread stream root" ,
448+ createdAt : 1_700_400_000 ,
449+ } ) ;
450+ if ( ! root ) throw new Error ( "Failed to seed thread stream root" ) ;
451+
452+ for ( let index = 0 ; index < 48 ; index += 1 ) {
453+ window . __BUZZ_E2E_EMIT_MOCK_MESSAGE__ ?.( {
454+ channelName : "general" ,
455+ content : `thread stream reply ${ index } \nsecond line ${ index } \nthird line ${ index } ` ,
456+ parentEventId : root . id ,
457+ createdAt : 1_700_400_001 + index ,
458+ } ) ;
459+ }
460+
461+ return root . id ;
462+ } ) ;
463+
464+ await page . getByTestId ( "channel-general" ) . click ( ) ;
465+ await expect ( page . getByTestId ( "chat-title" ) ) . toHaveText ( "general" ) ;
466+
467+ const timeline = page . getByTestId ( "message-timeline" ) ;
468+ const summary = timeline . locator (
469+ `[data-testid="message-thread-summary"][data-thread-head-id="${ rootId } "]` ,
470+ ) ;
471+ await expect ( summary ) . toBeVisible ( { timeout : 5_000 } ) ;
472+ await summary . click ( ) ;
473+
474+ const threadPanel = page . getByTestId ( "message-thread-panel" ) ;
475+ await expect ( threadPanel ) . toBeVisible ( ) ;
476+ const threadBody = threadPanel . getByTestId ( "message-thread-body" ) ;
477+ await expect ( threadBody . locator ( "[data-message-id]" ) . first ( ) ) . toBeVisible ( ) ;
478+ await page . waitForFunction ( ( ) => {
479+ const element = document . querySelector (
480+ '[data-testid="message-thread-body"]' ,
481+ ) as HTMLDivElement | null ;
482+ return element && element . scrollHeight > element . clientHeight + 800 ;
483+ } ) ;
484+
485+ // Park the reader mid-history (a real "reading older replies" position), not
486+ // at the bottom — the at-bottom stick path corrects differently.
487+ await threadBody . evaluate ( ( element ) => {
488+ const scroller = element as HTMLDivElement ;
489+ scroller . scrollTop = Math . floor ( scroller . scrollHeight * 0.4 ) ;
490+ scroller . dispatchEvent ( new Event ( "scroll" , { bubbles : true } ) ) ;
491+ } ) ;
492+ await page . waitForTimeout ( 100 ) ;
493+
494+ const before = await snapshotAnchor ( threadBody ) ;
495+ expect ( before . anchorId ) . not . toBe ( "" ) ;
496+
497+ // Scroll-compensated drift probe: a row's position in *content* coordinates
498+ // (viewport-top offset + scrollTop) is invariant unless content ABOVE it
499+ // changes height. This isolates involuntary jumps (the bug) from the
500+ // reader's own deliberate scroll, which the shared viewport-top sampler
501+ // cannot distinguish.
502+ await threadBody . evaluate ( ( element , anchorId ) => {
503+ const scroller = element as HTMLDivElement ;
504+ const win = window as typeof window & {
505+ __THREAD_STREAM_PROBE__ ?: {
506+ stop : boolean ;
507+ maxDrift : number ;
508+ samples : number ;
509+ baseline : number | null ;
510+ } ;
511+ } ;
512+ const probe = { stop : false , maxDrift : 0 , samples : 0 , baseline : null } ;
513+ win . __THREAD_STREAM_PROBE__ = probe ;
514+ const contentTop = ( ) => {
515+ const row = scroller . querySelector < HTMLElement > (
516+ `[data-message-id="${ CSS . escape ( anchorId ) } "]` ,
517+ ) ;
518+ if ( ! row ) return null ;
519+ return (
520+ row . getBoundingClientRect ( ) . top -
521+ scroller . getBoundingClientRect ( ) . top +
522+ scroller . scrollTop
523+ ) ;
524+ } ;
525+ const sample = ( ) => {
526+ if ( probe . stop ) return ;
527+ const top = contentTop ( ) ;
528+ if ( top !== null ) {
529+ if ( probe . baseline === null ) probe . baseline = top ;
530+ probe . maxDrift = Math . max (
531+ probe . maxDrift ,
532+ Math . abs ( top - probe . baseline ) ,
533+ ) ;
534+ }
535+ probe . samples += 1 ;
536+ requestAnimationFrame ( sample ) ;
537+ } ;
538+ requestAnimationFrame ( sample ) ;
539+ } , before . anchorId ) ;
540+
541+ // Drive a brisk upward wheel scroll while new replies stream into the open
542+ // thread on the live-event path — Wes's exact symptom: scrolling a live
543+ // thread while replies arrive. The reading row's CONTENT position must hold;
544+ // the reader's scroll is compensated out, so any drift here is the bug.
545+ for ( let batch = 0 ; batch < 6 ; batch += 1 ) {
546+ await threadBody . evaluate ( ( element ) => {
547+ const scroller = element as HTMLDivElement ;
548+ const PX = 40 ;
549+ scroller . dispatchEvent (
550+ new WheelEvent ( "wheel" , {
551+ deltaY : - PX ,
552+ bubbles : true ,
553+ cancelable : true ,
554+ } ) ,
555+ ) ;
556+ scroller . scrollTop = Math . max ( 0 , scroller . scrollTop - PX ) ;
557+ scroller . dispatchEvent ( new Event ( "scroll" , { bubbles : true } ) ) ;
558+ } ) ;
559+ await page . evaluate (
560+ ( { rootEventId, index } ) => {
561+ window . __BUZZ_E2E_EMIT_MOCK_MESSAGE__ ?.( {
562+ channelName : "general" ,
563+ content : `thread stream live reply ${ index } \nsecond line ${ index } ` ,
564+ parentEventId : rootEventId ,
565+ createdAt : 1_700_400_100 + index ,
566+ } ) ;
567+ } ,
568+ { rootEventId : rootId , index : batch } ,
569+ ) ;
570+ await page . waitForTimeout ( 80 ) ;
571+ }
572+
573+ await page . waitForTimeout ( 300 ) ;
574+
575+ const drift = await threadBody . evaluate ( ( element ) => {
576+ const win = window as typeof window & {
577+ __THREAD_STREAM_PROBE__ ?: {
578+ stop : boolean ;
579+ maxDrift : number ;
580+ samples : number ;
581+ } ;
582+ } ;
583+ const probe = win . __THREAD_STREAM_PROBE__ ;
584+ if ( ! probe ) throw new Error ( "no thread stream probe installed" ) ;
585+ probe . stop = true ;
586+ return {
587+ maxDrift : probe . maxDrift ,
588+ samples : probe . samples ,
589+ scrollTop : ( element as HTMLDivElement ) . scrollTop ,
590+ } ;
591+ } ) ;
592+ console . info ( "thread-panel-stream-scroll result" , JSON . stringify ( drift ) ) ;
593+ expect ( drift . samples ) . toBeGreaterThan ( 0 ) ;
594+ // The reader is driving the scroll; streamed replies append below the reading
595+ // row. In content coordinates the row must not move. Any material drift here
596+ // is the "content jumps around like crazy" bug.
597+ expect ( drift . maxDrift ) . toBeLessThanOrEqual ( 2 ) ;
598+ } ) ;
0 commit comments