[Bug]: Trackpad swipes over inline HTML renders are dropped after a direction change (macOS desktop)#23864
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/web
Steps to reproduce
- On macOS, open the desktop app in a thread whose assistant replies include several inline
html_renderpages. Mine are diagram and table pages about 1,300 to 2,200 px tall, all fitted to their content (no inner scrollbar). - Put the pointer over one of the renders.
- Scroll with the built-in trackpad, flicking a few screens at a time and alternating direction (up, down, up, ...).
Expected behavior
Every swipe scrolls the thread, as it does over plain chat text.
Actual behavior
Swipes are dropped, often right after a direction change. The thread does not move, and the render under the pointer does not visibly move either. Repeating the swipe in the same direction usually works. In practice you have to swipe twice every time you change direction.
Cause
Some fitted render frames are a fraction of a pixel shorter than their page, so the page can scroll inside its frame by 0.5 px.
In the running desktop app (current main, 60b35a1ee0), I read the scroll state inside each inline render frame over the DevTools protocol. Three of the six frames in the thread had a vertical scroll range of 0.5 px. For example, one frame was 1,366 px tall and its page measured 1,366.5 px. At 2x scale, that is one device pixel.
Chromium latches a trackpad gesture to the innermost frame that can scroll in the gesture's direction, even by one device pixel:
- A swipe that the frame can absorb latches to it. The frame moves by its 0.5 px and the rest of the swipe is lost.
- That leaves the frame at one end of its range. The next swipe the same way can't scroll it, so it reaches the thread. That swipe works.
- A swipe in the opposite direction can scroll the frame again, so it latches and is lost.
That matches the "reversal lost, repeat works" pattern below.
A minimal page reproduces it. In headless Chrome with synthesized trackpad gestures, put a cross-site sandbox="allow-scripts" iframe, 1,500 px tall, in a scrollable parent, and load this page in it:
| 1 | <!doctype html><html style="scrollbar-width:none"><body style="margin:0"> |
| 2 | <div style="height:1500.5px;background:linear-gradient(#fcc,#ccf)">0.5px taller</div> |
| 3 | </body></html> |
7 of 10 alternating swipes over the frame moved neither the frame visibly nor the parent. With scrolling="no" on the iframe, 0 of 10 were lost.
I did not pin down why the frame ends up short. Forcing a window resize corrected that frame to 1,367 px, so the frame's height had fallen behind the page's last reported size. The bootstrap rounds the page height up and only posts a size message when that rounded value changes (if(v===h)return), so a size report the host misses is never sent again.
I missed this at first. Loaded on its own in a test page at the same column width, each of these renders fits its frame exactly.
Measurements
I recorded a session on 0.1.1 to measure the symptom. Scroll events came from a listen-only macOS event tap (deltas, finger and momentum phases, pointer position), and the screen was captured at 60 fps. Both use wall-clock timestamps, so each swipe lines up with the thread's on-screen motion. I measured motion by aligning consecutive frames of the chat column. A swipe counts as lost if the thread moved less than 35% of the finger's travel.
Results for 96 swipes over the chat column in 86 seconds:
| Swipe | Lost |
|---|---|
| All swipes | 37 / 96 (39%) |
| Same direction as the previous swipe | 8 / 45 (18%) |
| Opposite direction to the previous swipe | 28 / 50 (56%) |
| Previous swipe worked, this one reverses | 13 / 32 (41%) |
| Previous swipe was lost, this one reverses | 15 / 18 (83%) |
| Previous swipe was lost, this one repeats the direction | 4 / 19 (21%) |
The first swipe has no previous swipe, so it isn't in the direction rows.
- 26 of the 37 lost swipes moved the thread exactly 0 px.
- From 43.5 s to 46.6 s, 10 consecutive alternating swipes of up to 1,502 px each moved nothing. The frames before and after each one are pixel-identical around the pointer. A 0.5 px move inside the render is below the capture's 1 px = 1 pt resolution.
- The pause before a swipe and whether momentum from the previous swipe was still running made no clear difference (30 to 42% lost in every group). The direction change did.
Per-swipe data (96 rows)
| 1 | t_s,direction,reversal,input_px,chat_moved_px,result |
| 2 | 7.05,up,no,202,0,LOST |
| 3 | 7.94,up,no,216,216,ok |
| 4 | 9.08,down,yes,410,0,LOST |
| 5 | 9.87,down,no,360,0,LOST |
| 6 | 10.44,down,no,340,348,ok |
| 7 | 11.01,down,no,108,120,ok |
| 8 | 12.24,up,yes,295,240,ok |
| 9 | 12.66,up,no,245,252,ok |
| 10 | 13.31,down,yes,420,244,ok |
| 11 | 13.72,down,no,277,268,ok |
| 12 | 14.20,up,yes,234,0,LOST |
| 13 | 14.80,up,no,125,136,ok |
| 14 | 16.52,down,yes,312,28,LOST |
| 15 | 16.93,down,no,158,164,ok |
| 16 | 17.92,up,yes,250,0,LOST |
| 17 | 18.75,up,no,413,152,ok |
| 18 | 19.35,up,no,523,520,ok |
| 19 | 20.04,down,yes,363,120,LOST |
| 20 | 20.59,down,no,318,308,ok |
| 21 | 21.11,up,yes,579,64,LOST |
| 22 | 21.50,up,no,790,624,ok |
| 23 | 22.07,down,yes,332,172,ok |
| 24 | 22.52,down,no,534,536,ok |
| 25 | 23.07,up,yes,436,164,ok |
| 26 | 23.52,up,no,550,536,ok |
| 27 | 24.11,down,yes,183,0,LOST |
| 28 | 24.89,down,no,230,924,ok |
| 29 | 25.28,down,no,1724,1672,ok |
| 30 | 25.76,up,yes,288,148,LOST |
| 31 | 26.42,down,yes,985,872,ok |
| 32 | 26.81,down,no,595,472,ok |
| 33 | 27.69,up,yes,137,64,ok |
| 34 | 28.58,up,no,164,160,ok |
| 35 | 29.19,down,yes,438,608,ok |
| 36 | 29.78,down,no,407,432,ok |
| 37 | 30.16,up,yes,125,0,LOST |
| 38 | 30.90,up,no,321,308,ok |
| 39 | 31.54,down,yes,271,268,ok |
| 40 | 32.99,down,no,350,0,LOST |
| 41 | 33.57,down,no,258,252,ok |
| 42 | 39.53,up,yes,214,0,LOST |
| 43 | 40.05,down,yes,170,0,LOST |
| 44 | 41.37,up,yes,210,0,LOST |
| 45 | 41.88,down,yes,478,0,LOST |
| 46 | 42.24,up,yes,383,0,LOST |
| 47 | 42.96,up,no,189,180,ok |
| 48 | 43.51,down,yes,388,0,LOST |
| 49 | 43.95,up,yes,447,0,LOST |
| 50 | 44.33,down,yes,883,0,LOST |
| 51 | 44.65,up,yes,626,0,LOST |
| 52 | 45.01,down,yes,1254,0,LOST |
| 53 | 45.34,up,yes,1283,0,LOST |
| 54 | 45.67,down,yes,1414,0,LOST |
| 55 | 46.00,up,yes,1502,0,LOST |
| 56 | 46.33,down,yes,1019,0,LOST |
| 57 | 46.63,up,yes,379,0,LOST |
| 58 | 49.07,down,yes,2756,428,LOST |
| 59 | 49.58,down,no,1877,2284,ok |
| 60 | 50.16,down,no,2252,3108,ok |
| 61 | 50.65,down,no,1925,1996,ok |
| 62 | 51.13,up,yes,620,612,ok |
| 63 | 51.96,down,yes,401,392,ok |
| 64 | 52.71,up,yes,206,200,ok |
| 65 | 53.55,up,no,142,44,LOST |
| 66 | 53.98,down,yes,371,372,ok |
| 67 | 55.02,up,yes,220,212,ok |
| 68 | 55.79,down,yes,187,208,ok |
| 69 | 56.23,down,no,276,268,ok |
| 70 | 56.84,up,yes,140,136,ok |
| 71 | 57.52,down,yes,85,88,ok |
| 72 | 58.34,up,yes,143,140,ok |
| 73 | 58.85,down,yes,136,452,ok |
| 74 | 59.54,down,no,401,412,ok |
| 75 | 60.32,down,no,196,196,ok |
| 76 | 61.54,up,yes,87,80,ok |
| 77 | 62.64,down,yes,528,848,ok |
| 78 | 63.02,down,no,463,584,ok |
| 79 | 63.98,down,no,598,608,ok |
| 80 | 65.22,up,yes,119,12,LOST |
| 81 | 65.65,down,yes,976,968,ok |
| 82 | 66.23,down,no,870,1016,ok |
| 83 | 66.58,down,no,438,656,ok |
| 84 | 66.98,down,no,97,160,ok |
| 85 | 67.53,up,yes,136,0,LOST |
| 86 | 68.05,up,no,209,0,LOST |
| 87 | 68.59,down,yes,348,0,LOST |
| 88 | 69.11,down,no,158,148,ok |
| 89 | 69.65,up,yes,129,124,ok |
| 90 | 70.41,up,no,226,12,LOST |
| 91 | 70.95,up,no,292,344,ok |
| 92 | 71.43,up,no,2894,1436,ok |
| 93 | 71.75,up,no,4097,324,LOST |
| 94 | 72.14,up,no,2223,580,LOST |
| 95 | 72.70,up,no,2471,112,LOST |
| 96 | 73.06,up,no,404,1432,ok |
| 97 | 73.44,up,no,3567,1444,ok |
t_s is seconds from the start of the recording. input_px is the trackpad's total vertical delta for the swipe, including momentum. chat_moved_px is the thread's measured vertical motion during the swipe plus 250 ms.
Possible fix
Draft PR: #23865
It sets scrolling="no" on inline render frames while they are fitted to their page. That makes the page unable to scroll its own viewport by user input, so Chromium never latches a swipe to the frame. The page's own scrollable elements, wheel handlers, and script-driven scrolling keep working. Frames for pages past HTML_RENDER_MAX_FIT_HEIGHT keep scrolling. The fix is in the host because the bootstrap is baked into each render when it is published, so a bootstrap change would not reach existing renders.
On a dev build of main with the same thread and trackpad, counting swipes with the pointer over a render:
| Build | Reversed swipes lost | Same-direction swipes lost |
|---|---|---|
main | 19 / 44 (43%) | 5 / 38 (13%) |
| With the fix | 2 / 24 (8%) | 3 / 12 (25%) |
The swipes still counted as lost with the fix either came before the app window was in front or overlapped momentum from the previous swipe, where my frame-motion measurement is least reliable. In use, the double swipe is gone.
The short frame height is a separate bug. The fix above does not depend on it.
Related
- Renders past 2,000 px remount at the cap. Render frames unload when they leave the timeline's render window. When one scrolls back in, it remounts at
HTML_RENDER_MAX_HEIGHT(2,000 px) until the page reports its height (2,178 px for one of mine). While capped, the page has a real inner scroll range. In nine back-and-forth flicks, frames remounted 7 times and one was capped for about 1.85 s in total. The PR treats a frame with no reported height yet as fitted, so this case is covered too. - #16977 (wheel stuck on a tall render; fixed for fitted renders by #22131) and #17056.
Impact
Major degradation or frequent failure
Version or commit
Reported on 0.1.1 (fe27e8e3dc12). Reproduced on main at 60b35a1ee0.
Environment
macOS 26.5, T3 Code (Beta) desktop 0.1.1 (Electron 44.4.5), MacBook Pro built-in trackpad, 1312×848 pt Retina display
Workaround
After a swipe does nothing, swipe the same direction again. Scrolling with the pointer in the margin outside the chat column avoids the renders.