diff options
Diffstat (limited to 'packages')
| -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): |