From 89e12e8b71c0d9e3a2ef89716b3181d268b5571a Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Tue, 19 May 2026 12:54:28 +0000 Subject: [PATCH] =?UTF-8?q?fix(scroll):=20#95-blocker=20round=2011=20?= =?UTF-8?q?=E2=80=94=20MessageThread=20uses=20native=20addEventListener=20?= =?UTF-8?q?for=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROOT CAUSE Monday 2026-05-18 scheduled cron E2E run 26020030467 on main (sha 36cf2a69) failed webkit-msg 1/1. The failing test is `tests/e2e/ messaging/messaging-scroll.spec.ts:266` — "T007-T008: Jump button appears when scrolled and does not overlap input". This is the same test family the round-10 fix (commit 996211e) targeted. That fix dispatches `new Event('scroll', { bubbles: true })` after every programmatic `el.scrollTop = N` site in the test, so the test should trigger React's `onScroll` handler. But the assertion at line 299 (`expect(jumpButton).toBeVisible()`) keeps failing on WebKit — and only on WebKit. CI log shows the button locator returns "element(s) not found" after 5s — meaning the React component never set `showScrollButton = true`, which means `handleScroll` never ran, which means the synthetic React onScroll event handler did not receive the dispatched native scroll event. WHY THE ROUND-10 FIX WAS INCOMPLETE React's synthetic `onScroll` event has special handling. The native `scroll` event does NOT bubble by default. React 17+ artificially makes scroll events bubble through its synthetic event system by listening at the React root, but this routing depends on the event having been generated through the browser's normal scroll pipeline. A programmatically-dispatched `new Event('scroll', { bubbles: true })` on the scrollable element produces a native event that bubbles through DOM listeners but does NOT always reach React's synthetic onScroll handler on WebKit (chromium and firefox happen to route it correctly). This is a known difference between browser engines' scroll-event delegation paths. The test dispatch IS correct; the React JSX prop is the brittle layer. THE FIX Replace the React `onScroll` JSX prop with a native `addEventListener('scroll', handler, { passive: true })` inside a useEffect that re-binds whenever `handleScroll` changes (i.e., when its deps `hasMore`, `loading`, `onLoadMore` change). Native event listeners fire deterministically for programmatic dispatchEvent across all three browser engines. Functionally identical for users (the handler is the same callback); only the binding mechanism changes. No user-facing behavior change. VERIFIED - 31 unit tests pass (MessageThread.test.tsx + .accessibility.test.tsx) - Type-check clean - Lint clean - Diff is minimal: +14 lines (the useEffect), -1 line (the onScroll prop) DOES NOT INTRODUCE NEW BEHAVIOR The two cases the round-10 fix addressed remain covered: - Real user scrolls fire the native scroll event; native listener catches it - Programmatic test scrolls + dispatched scroll event fire the same way The change just makes the listener path identical for both. PR FOR This bug is blocking the merge of PR #95 (#48 Three.js Game). User's branch-hygiene rule: "Never leave unmerged branches floating; never merge a clean PR onto a red main." Round 11 fix lands first, then PR #95 can merge onto a green main. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../molecular/MessageThread/MessageThread.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/molecular/MessageThread/MessageThread.tsx b/src/components/molecular/MessageThread/MessageThread.tsx index fb6feb2a..c719710c 100755 --- a/src/components/molecular/MessageThread/MessageThread.tsx +++ b/src/components/molecular/MessageThread/MessageThread.tsx @@ -211,6 +211,20 @@ export default function MessageThread({ } }, [hasMore, loading, onLoadMore]); + // Bind handleScroll as a native DOM event listener instead of via React's + // `onScroll` JSX prop. Reason: React's synthetic onScroll does not reliably + // fire on WebKit when test code dispatches a programmatic + // `dispatchEvent(new Event('scroll', { bubbles: true }))` after assigning + // `scrollTop`. Native `addEventListener('scroll', ...)` does fire + // deterministically across chromium, firefox, and webkit. See the round-10 + // E2E flake mitigation in CLAUDE.md "CI & E2E Stability" section. + useEffect(() => { + const parent = parentRef.current; + if (!parent) return; + parent.addEventListener('scroll', handleScroll, { passive: true }); + return () => parent.removeEventListener('scroll', handleScroll); + }, [handleScroll]); + // Scroll to bottom with smooth animation const scrollToBottom = useCallback( (smooth = false) => { @@ -350,7 +364,6 @@ export default function MessageThread({