diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 23:34:49 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 23:34:49 +0200 |
| commit | 32855a95e11032302f8d24036f6f2dd44b829368 (patch) | |
| tree | f0c00979232c1fd4c5bad0ddd726fbbde15eff1f /packages/meshbay-hub | |
| parent | 799d87999c8324564dce5159191532e008dd93d2 (diff) | |
| download | meshbay-32855a95e11032302f8d24036f6f2dd44b829368.tar.gz | |
fix(hub): let the reader scroll up in the chat again
The chat could not be read back: any wheel gesture was undone in the frame
it happened in, and the "jump to latest" button never appeared.
None of the pins in ChatPanel are at fault -- every one of them is guarded
by "only if the reader is at the bottom". The reader never got to stop
being at the bottom.
fit() set the panel's height, read documentElement.scrollHeight back and
subtracted the overflow, so the document alternately did and did not
overflow the window. The page scrollbar appeared and vanished with it and
visualViewport fired resize at every pass -- the event fit() is bound to.
It therefore re-entered itself for the life of the panel: measured at 240
firings in two seconds on a page nobody was touching, against 2 for a bare
document. Each pass ran fitAndPin, which re-pinned the list to the bottom
before the scroll event that would have recorded the gesture was delivered
a frame later, so atBottomRef never went false.
- fit() learns the space below the panel once and remembers it on the
element instead of re-deriving it by writing and measuring back. At the
steady state it writes nothing, so it produces no resize. A real window
resize or an orientation change forgets the learnt value and measures
again (the page under the panel may have reflowed); visualViewport
deliberately does not, since a phone fires it constantly.
- The scroll-to-bottom is now scoped to *arrival*, which is all it was ever
for: opening the group, or coming back to the Chat tab, including the
thumbnails and link-preview cards that keep growing the list for a second
afterwards. It ends when the reader takes hold of the scroll, and the
ResizeObserver disconnects there.
- That release is recorded from the gesture (wheel/touchmove/pointerdown/
keydown), not from the scroll event, which arrives too late to protect
anything.
Unchanged: landing on the newest message, following new messages while
already at the bottom, the "load older" anchor and the unread marker.
tests/harness/chat_scroll_probe.py mounts the real ChatPanel in a browser
and reads a conversation back; test_chat_scroll_up.py asserts against it.
With the fix reverted, five of its six tests fail and the sixth -- landing
on the newest message -- still passes, which is the property that must not
have been traded away. A structural test cannot see any of this, which is
why it is measured.
test_layout_responsive.py pinned the listener's name and follows the
rename.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
Diffstat (limited to 'packages/meshbay-hub')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/chat-app.js | 99 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/harness/chat_scroll_probe.py | 222 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_chat_scroll_up.py | 102 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_layout_responsive.py | 15 |
4 files changed, 416 insertions, 22 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js index 1850c74..3bc110f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js @@ -216,6 +216,12 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, // them but before the browser paints. const anchorRef = useRef(null); const atBottomRef = useRef(true); + // Landing on the newest message is an *arrival* behaviour: it belongs to + // opening the group and to coming back to the Chat tab, and it has to survive + // the thumbnails and link-preview cards that keep growing the list for a + // second or two afterwards. It ends the moment the reader asks to move, and + // after that nothing may move the view for them. + const arrivingRef = useRef(true); useEffect(() => { if (status !== 'connected') return; @@ -230,7 +236,13 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, setMessages(msgs); requestAnimationFrame(() => { const l = listRef.current; - if (l) { l.scrollTop = l.scrollHeight; atBottomRef.current = true; } + // Not if the reader already moved: a slow node means this page + // lands seconds after the panel opened, and by then they may be + // reading somewhere else entirely. + if (l && arrivingRef.current) { + l.scrollTop = l.scrollHeight; + atBottomRef.current = true; + } }); }) .catch(() => { loadedRef.current = false; }); @@ -284,27 +296,52 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, if (atBottomRef.current) list.scrollTop = list.scrollHeight; }, [messages, hasMore]); - // Keep the view pinned to the newest message while the reader is at the - // bottom, through everything that grows the content *after* the initial - // paint: attachment thumbnails and link-preview cards fetched over the - // network, late layout, and the panel resizing itself with `fit()` below. - // Without this, opening the Chat tab reliably lands a screen or two above - // the last message — the layout effect above ran when the list was still - // short. Does nothing once the reader scrolls up (`atBottomRef` is false), - // so it never fights reading back or the "load older" anchor. + // Hold the arrival at the newest message through everything that grows the + // content *after* the initial paint: attachment thumbnails and link-preview + // cards fetched over the network, late layout, and the panel resizing itself + // with `fit()` below. Without this, opening the Chat tab reliably lands a + // screen or two above the last message — the layout effect above ran when + // the list was still short. + // + // Bounded by `arrivingRef`, not by `atBottomRef` alone. The at-bottom test + // has a 40px tolerance and is fed by an event delivered a frame late, so on + // its own it let a resize storm hold the reader against the end of the + // conversation with no way back up it. Once the reader has asked to move, + // this stops observing entirely. useEffect(() => { const list = listRef.current; - if (!list || typeof ResizeObserver === 'undefined') return; + if (!list || typeof ResizeObserver === 'undefined' || !arrivingRef.current) return; + // Declared before `stick` closes over it: a `const` further down would be + // in its temporal dead zone here, which is the hook-ordering trap this + // codebase has already paid for once. + let ro = null; const stick = () => { + if (!arrivingRef.current) { if (ro) ro.disconnect(); return; } if (atBottomRef.current) list.scrollTop = list.scrollHeight; }; - const ro = new ResizeObserver(stick); + ro = new ResizeObserver(stick); ro.observe(list); for (const child of list.children) ro.observe(child); stick(); return () => ro.disconnect(); }, [messages]); + // The reader taking hold of the scroll ends the arrival, and it has to be + // recorded here rather than in `onScroll`: a `scroll` event is delivered at + // the next rendering step, so anything that pins in between undoes the + // movement before the handler that would have stopped it ever runs. These + // fire with the gesture itself. + useEffect(() => { + const list = listRef.current; + if (!list) return; + const release = () => { arrivingRef.current = false; }; + const events = ['wheel', 'touchmove', 'pointerdown', 'keydown']; + for (const name of events) list.addEventListener(name, release, { passive: true }); + return () => { + for (const name of events) list.removeEventListener(name, release, { passive: true }); + }; + }, []); + // Entering the Chat tab should leave the cursor in the composer, ready to // type — the panel mounts fresh on every tab switch, so a mount effect is // the tab-entry hook. `preventScroll` because the layout effects above are @@ -332,7 +369,6 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, // Document-relative, so a page that happens to be scrolled does not skew // the result — the answer must be the same either way. const top = el.getBoundingClientRect().top + window.scrollY; - el.style.height = `${Math.max(CHAT_MIN_HEIGHT, vh - top - CHAT_BOTTOM_GAP)}px`; // What sits *below* the panel is not knowable from up here — today it is // `.main`'s 24px bottom padding against this 16px gap, which left the // document 8px taller than the window and a scrollbar on the chat tab at @@ -340,10 +376,28 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, // change to the page break it again, the leftover is measured and taken // off. Self-correcting: anything added under the panel is absorbed the // same way. + // + // Remembered on the element rather than re-derived on every call, because + // this runs on `resize` and so can *cause* the event it listens for. + // Setting the naive height, reading the overflow back and subtracting it + // means the document alternately does and does not overflow the window: a + // page scrollbar appears and vanishes with it, `visualViewport` fires + // `resize` at every pass, and fit() re-enters itself for the life of the + // panel. Measured 2026-09-01 on a page nobody was touching: 240 firings + // in two seconds, against 2 for a bare document. Each one re-pinned the + // list to the bottom, so every attempt to scroll up was undone inside the + // same frame — before the `scroll` event that would have recorded it was + // even delivered, which is why the reader could not move and the "jump to + // latest" button never appeared. Converged, this writes nothing. + const below = el._chatFitBelow || CHAT_BOTTOM_GAP; + const target = Math.max(CHAT_MIN_HEIGHT, vh - top - below); + if (Math.abs(target - el.getBoundingClientRect().height) > 0.5) { + el.style.height = `${target}px`; + } const over = document.documentElement.scrollHeight - vh; if (over > 0) { - el.style.height = - `${Math.max(CHAT_MIN_HEIGHT, el.getBoundingClientRect().height - over)}px`; + el._chatFitBelow = below + over; + el.style.height = `${Math.max(CHAT_MIN_HEIGHT, target - over)}px`; } }; const fitAndPin = () => { @@ -351,13 +405,19 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, const l = listRef.current; if (l && atBottomRef.current) l.scrollTop = l.scrollHeight; }; + // A real viewport change can also mean the page below the panel reflowed, + // so the learnt leftover is dropped and measured again. Not on + // `visualViewport`: an Android keyboard changes the viewport, not the + // layout under the panel, and re-learning there would put the oscillation + // back on the one platform that fires that event constantly. + const refit = () => { el._chatFitBelow = 0; fitAndPin(); }; fitAndPin(); - window.addEventListener('resize', fitAndPin); - window.addEventListener('orientationchange', fitAndPin); + window.addEventListener('resize', refit); + window.addEventListener('orientationchange', refit); window.visualViewport?.addEventListener('resize', fitAndPin); return () => { - window.removeEventListener('resize', fitAndPin); - window.removeEventListener('orientationchange', fitAndPin); + window.removeEventListener('resize', refit); + window.removeEventListener('orientationchange', refit); window.visualViewport?.removeEventListener('resize', fitAndPin); }; }, []); @@ -366,6 +426,9 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, const el = e.target; const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; atBottomRef.current = bottom; + // Belt and braces for a scroll no gesture listener saw — a scrollbar + // dragged from outside the list, a "find in page" jump, assistive tech. + if (!bottom) arrivingRef.current = false; setAtBottom(bottom); if (bottom) setUnreadFrom(null); }, []); diff --git a/packages/meshbay-hub/tests/harness/chat_scroll_probe.py b/packages/meshbay-hub/tests/harness/chat_scroll_probe.py new file mode 100644 index 0000000..7373976 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/chat_scroll_probe.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +""" +Can the reader scroll up in the chat, and does the panel sit still when nobody +is touching it? + +`scroll_probe.py` answers "is the document taller than the window". This one +mounts **the real `ChatPanel`** — the shipped module, not a model of it — in a +browser, drives it the way a person does, and reports where the list ends up. + +It exists because the defect it was written for is invisible to every other kind +of test here. `fit()` set the panel's height, read the document's overflow back +and subtracted it, so the document alternately did and did not overflow the +window; the page scrollbar appeared and vanished with it, `visualViewport` fired +`resize` at each pass, and `fit()` is bound to that event. It therefore re-ran +about 120 times a second for the life of the panel, re-pinning the list to the +bottom every time — which undid each attempt to scroll up *inside the same +frame*, before the `scroll` event that would have recorded it was delivered. The +source reads as correct: every pin is guarded by "only if the reader is at the +bottom", and the reader never got to stop being at the bottom. + + chat_scroll_probe.py + +Prints JSON: an `idle` block (viewport-resize firings on a page nobody touches) +and a `steps` list (scroll position after each stage of a reading session). +""" +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8747 +RECORDS = [] +socketserver.TCPServer.allow_reuse_address = True + +# A bare document fires a couple of these while it settles. Anything above this +# is the panel driving itself. +IDLE_RESIZE_CEILING = 20 + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8> +<link rel="stylesheet" href="/style.css"></head> +<body> +<div class="layout"><div class="main"> + <div class="group-header"><div><h2>a group</h2><p class="group-desc">a description</p></div></div> + <div class="group-tabs"><button class="group-tab active">Chat</button></div> + <div id="root"></div> +</div></div> +<script type="module"> +import { html, render, useRef } from '/vendor/htm-preact.js'; +import { ChatPanel } from '/chat-app.js'; + +const N = 120; +const now = Date.now() / 1000; +const messages = []; +for (let i = 0; i < N; i++) { + messages.push({ + id: 'm' + i, sender_id: i % 3 ? 'someone' : 'me', + sender_name: i % 3 ? 'someone' : 'me', + // Every fifth message carries a link, so a preview card lands late and + // grows the list the way the real thing does. + payload: (i % 5 === 0 ? 'see https://example.invalid/p/' + i + ' ' : '') + + 'message number ' + i + ', long enough to give the bubble a height', + timestamp: now - (N - i) * 60, thread_id: null, + }); +} + +// Held back until the reader has scrolled up: the cards must not be able to +// drag them back down. +let previewsAnswer = false; +const transport = { + connected: true, + onChat: null, + async fetchChatHistory({ before }) { + return before ? { messages: [], hasMore: false } : { messages, hasMore: true }; + }, + async sendChat() {}, + async uploadFile() { return {}; }, + fetchLinkPreview(url) { + return new Promise(res => { + const tick = () => previewsAnswer + ? res({ ok: true, site_name: 'Example', title: 'A title for ' + url, + description: 'A description long enough to add a couple of lines ' + + 'to the card and grow the list content.' }) + : setTimeout(tick, 100); + tick(); + }); + }, +}; + +function Host() { + const transportRef = useRef(transport); + const gekRef = useRef(null); + return html`<${ChatPanel} transportRef=${transportRef} gekRef=${gekRef} + username="me" entries=${[]} status="connected" />`; +} +render(html`<${Host} />`, document.getElementById('root')); + +const out = { steps: [] }; +const list = () => document.querySelector('.chat-messages'); + +function snap(label) { + const l = list(); + out.steps.push({ + label, + scrollTop: Math.round(l.scrollTop), + fromBottom: Math.round(l.scrollHeight - l.scrollTop - l.clientHeight), + panelHeight: Math.round( + document.querySelector('.chat-panel').getBoundingClientRect().height), + // The jump button is the only outward sign that the panel noticed the + // reader leave the bottom. + jumpButton: !!document.querySelector('.chat-jump'), + }); +} + +const wait = ms => new Promise(r => setTimeout(r, ms)); + +(async () => { + await wait(1000); + snap('arrived'); + + // Nobody touches the page. A panel that resizes itself says so here. + let resizes = 0; + window.visualViewport?.addEventListener('resize', () => { resizes++; }); + await wait(1500); + out.idle = { viewportResizes: resizes, + documentOverflow: document.documentElement.scrollHeight - window.innerHeight }; + snap('after idle'); + + // Scrolling up, over several frames, with no synthetic `scroll` event: the + // browser fires the real one, a frame later, which is the whole point. + const l = list(); + for (let i = 0; i < 6; i++) { + l.scrollTop -= 120; + await new Promise(r => requestAnimationFrame(r)); + } + await wait(100); + snap('scrolled up'); + + // Now let the preview cards land: content grows above and below the reader. + previewsAnswer = true; + await wait(1200); + snap('previews landed'); + + transport.onChat({ id: 'live', sender_id: 'someone', sender_name: 'someone', + payload: 'a new message', timestamp: Date.now() / 1000 }); + await wait(400); + snap('message arrived'); + + window.dispatchEvent(new Event('resize')); + await wait(400); + snap('window resized'); + + fetch('/log', { method: 'POST', body: JSON.stringify(out) }); +})(); +</script></body></html>""" + + +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + RECORDS.append(json.loads( + self.rfile.read(int(self.headers["Content-Length"])).decode())) + self.send_response(204) + self.end_headers() + + def do_GET(self): + if self.path == "/": + body, ctype = PAGE.encode(), "text/html; charset=utf-8" + else: + path = (STATIC / self.path.lstrip("/")).resolve() + if not str(path).startswith(str(STATIC)) or not path.is_file(): + self.send_response(404) + self.end_headers() + return + body = path.read_bytes() + ctype = ("text/css" if path.suffix == ".css" + else "text/javascript" if path.suffix == ".js" + else "application/octet-stream") + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def main() -> int: + with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + with tempfile.TemporaryDirectory() as profile: + # Real time, not `--virtual-time-budget`: the defect is a feedback + # loop between layout and an event, and a virtual clock does not + # run it. + proc = subprocess.Popen( + ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", "--window-size=1100,800", + f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if RECORDS: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + if not RECORDS: + print(json.dumps({"error": "no measurement"}), file=sys.stderr) + return 1 + print(json.dumps(RECORDS[0], indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/test_chat_scroll_up.py b/packages/meshbay-hub/tests/test_chat_scroll_up.py new file mode 100644 index 0000000..6cafcdc --- /dev/null +++ b/packages/meshbay-hub/tests/test_chat_scroll_up.py @@ -0,0 +1,102 @@ +""" +Reading back through the conversation must work. + +The scroll-to-bottom on arrival has been patched five times, and the sixth +patch took the other side away: the panel re-pinned the list about 120 times a +second, so a wheel gesture was undone in the frame it happened in and the older +messages became unreachable. Every pin in the source is guarded by "only if the +reader is at the bottom" — the reader simply never got to stop being at the +bottom, because the `scroll` event that records it is delivered a frame after +the pin that erased it. + +None of that is visible in the source, which is why this measures the real +`ChatPanel` in a browser instead of reading `chat-app.js`. `test_chat_scroll_ +bottom.py` keeps the structural guards; this one keeps the behaviour. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "chat_scroll_probe.py" +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" + +pytestmark = pytest.mark.skipif( + shutil.which("google-chrome") is None or not (STATIC / "chat-app.js").exists(), + reason="Chrome or the SPA sources are not available") + +# A bare document fires a couple of these as it settles. Anything above this is +# the panel driving itself, which is what the loop looked like. +IDLE_RESIZE_CEILING = 20 + +# The harness scrolls up by six frames of 120px. Chrome's scroll anchoring +# moves the reader with the content when preview cards land above them, so the +# distance from the bottom is not expected to be exactly 720 afterwards — only +# to stay well clear of it. +SCROLLED_UP_FLOOR = 400 + + +@pytest.fixture(scope="module") +def probe(): + run = subprocess.run(["python3", str(HARNESS)], capture_output=True, timeout=180) + assert run.returncode == 0, run.stderr.decode()[-2000:] + data = json.loads(run.stdout.decode()) + return data, {s["label"]: s for s in data["steps"]} + + +def test_arrives_on_the_newest_message(probe): + """Opening the tab lands at the end of the conversation, after the late + layout and the panel sizing itself.""" + _, steps = probe + assert steps["arrived"]["fromBottom"] < 40, ( + "the chat must open on the newest message; it opened " + f"{steps['arrived']['fromBottom']}px above it") + + +def test_the_panel_does_not_resize_itself(probe): + """fit() runs on `resize` and must not produce one. When it did, it re-ran + every frame and re-pinned the scroll with it.""" + data, _ = probe + assert data["idle"]["viewportResizes"] <= IDLE_RESIZE_CEILING, ( + f"{data['idle']['viewportResizes']} viewport resizes on a page nobody " + "touched -- fit() is feeding the event it listens for, and every pass " + "re-pins the chat to the bottom") + assert data["idle"]["documentOverflow"] <= 0, ( + "the chat tab must not leave the document taller than the window") + + +def test_scrolling_up_holds(probe): + """The gesture must survive the frame it happened in.""" + _, steps = probe + assert steps["scrolled up"]["fromBottom"] >= SCROLLED_UP_FLOOR, ( + "scrolling up was undone: the list came back to " + f"{steps['scrolled up']['fromBottom']}px from the bottom") + assert steps["scrolled up"]["jumpButton"], ( + "the panel never noticed the reader leave the bottom -- the pin beat " + "the scroll event, so atBottom stayed true and no jump button appeared") + + +def test_late_content_does_not_drag_the_reader_down(probe): + """Link previews and thumbnails arrive over the following seconds and grow + the list. That is what the arrival pin is for, and it must be over by + now.""" + _, steps = probe + assert steps["previews landed"]["fromBottom"] >= SCROLLED_UP_FLOOR, ( + "preview cards landing pulled the reader back to the bottom") + + +def test_a_new_message_does_not_yank_the_reader_down(probe): + """Somebody writing while you read back marks the spot; it does not move + you.""" + _, steps = probe + assert steps["message arrived"]["fromBottom"] >= SCROLLED_UP_FLOOR, ( + "an incoming message pulled the reader away from what they were reading") + + +def test_a_resize_does_not_yank_the_reader_down(probe): + """A window resize, a rotation, or a phone's URL bar collapsing.""" + _, steps = probe + assert steps["window resized"]["fromBottom"] >= SCROLLED_UP_FLOOR, ( + "a resize pulled the reader back to the bottom") diff --git a/packages/meshbay-hub/tests/test_layout_responsive.py b/packages/meshbay-hub/tests/test_layout_responsive.py index d7a680d..4ee07d0 100644 --- a/packages/meshbay-hub/tests/test_layout_responsive.py +++ b/packages/meshbay-hub/tests/test_layout_responsive.py @@ -145,12 +145,19 @@ def test_the_measurement_survives_a_scrolled_page(app): def test_the_panel_refits_when_the_viewport_changes(app): - # `fit` is wrapped by `fitAndPin` (which also keeps the view pinned to - # the bottom on a resize) — that is what the listeners bind to. + # `fit` is wrapped by `fitAndPin` (which also keeps the view pinned to the + # bottom on a resize), and on the window by `refit`, which additionally + # forgets the learnt space below the panel — a real viewport change can + # mean the page under it reflowed. `visualViewport` deliberately does not + # forget: a phone fires that event constantly, and re-deriving the leftover + # there is what made fit() oscillate and weld the reader to the bottom of + # the conversation (see test_chat_scroll_up.py). for event in ("resize", "orientationchange"): - assert f"addEventListener('{event}', fitAndPin)" in app + assert f"addEventListener('{event}', refit)" in app assert "visualViewport?.addEventListener('resize', fitAndPin)" in app - assert "removeEventListener('resize', fitAndPin)" in app, "the listener must be released" + assert "removeEventListener('resize', refit)" in app, "the listener must be released" + assert "el._chatFitBelow = 0" in app, ( + "a window resize must forget the learnt leftover and measure it again") def test_the_css_floor_does_not_fight_the_measurement(css, app): |