Files
open-design/apps/web/tests/runtime/chat-scroll-takeover.test.ts
lefarcenandGitHub 94f7a48633 fix(web): kick the compositor on a chat scroll freeze, then take the wheel and keyboard over by default (#8251)
* fix(web): kick the compositor on a chat scroll freeze, then take the wheel and keyboard over by default

When the freeze probe calls the chat log frozen, apply one frame of
`will-change: transform` so Blink rebuilds the scroller's compositing state
from the current layout, let the next downward wheel through natively and
measure it against what it asked for (the detector's own normalisation and
8px yardstick), and only if it stuck, fell short or snapped back answer the
wheel and the scrolling keys from JavaScript. The takeover is on by default
now; `open-design:chat-scroll-takeover = '0'` is the escape hatch.

`ChatPane` releases the takeover — and asks the probe to start a fresh
surface — when `activeConversationId` changes, because the log node is
reused across conversations. A healed surface is released the same way so a
recurrence can be judged again.

One new event, `client_chat_scroll_heal`, carries the outcome, the kick
geometry and the notch arithmetic, joined to the freeze by `probe_id`;
`client_chat_scroll_frozen` is untouched.

* fix(web): keep Home/End finite and let the takeover go when the chat log is removed

Home and End were ±Infinity folded into the same pixel accumulator as the
arrows, so Home-then-End in one frame (or key repeat) summed to NaN and the
browser read the NaN scrollTop as 0 — End could not reach the bottom. Edge
jumps now live in their own `pendingEdge` slot: the last edge pressed wins,
pressing one discards the steps queued before it, and steps pressed after it
apply on top; applyPending refuses to write a non-finite value.

A tab or route unmount removes the chat log without a conversation change,
and the probe only notices on a scroll the missing node never sends, so the
takeover stayed engaged with its document-level keydown listener cancelling
arrows and paging with nothing to scroll. Every input path (wheel, keydown,
frame) now checks `isConnected` and disengages, and ChatPane's release effect
also releases in its cleanup.
2026-10-16 06:14:33 +00:00

462 lines
18 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearExceptionTrackingContext,
setExceptionTrackingContext,
} from '../../src/analytics/error-tracking';
import {
__resetChatScrollFreezeForTest,
chatScrollFreezeListenerCount,
installChatScrollFreezeObserver,
} from '../../src/observability/chat-scroll-freeze';
import { FREEZE_WHEEL_COUNT } from '../../src/observability/chat-scroll-freeze-detector';
import {
CHAT_SCROLL_TAKEOVER_STORAGE_KEY,
OBSERVATION_SETTLE_MS,
__resetChatScrollTakeoverForTest,
chatScrollTakeoverEngaged,
installChatScrollTakeover,
} from '../../src/runtime/chat-scroll-takeover';
import {
FROZEN,
LAYOUT_MAX,
buildChatLog,
decodeSafetyEvents,
scrolled,
stubGeometry,
wheelEvent,
type GeometryHandle,
} from '../helpers/chat-scroll-fixture';
/**
* What these specs encode
* ----------------------
* The chat log's compositor-side scroll extent goes stale (see
* `observability/chat-scroll-freeze.ts` for the measurement). The effect on a
* real machine, measured with real OS wheel events:
*
* scrollTop = 1700 assigned from JS → took effect
* scrollTop = 99999 assigned from JS → clamped to the real layout maximum
* 12 wheel notches asking for 1440px → stopped dead at 91
*
* So the JS write path is healthy and the wheel path is not. This module takes
* the wheel over — `preventDefault` plus a programmatic write — but ONLY after
* the probe has said the surface is frozen, ONLY after its one-frame kick has
* been given a notch to prove itself and failed, and NOT when the user has
* pulled the `'0'` escape hatch.
*
* The kick and the observation have their own spec file
* (`chat-scroll-takeover-selfheal.test.ts`). This one is about the takeover
* proper: what the wheel does once JavaScript is answering it.
*
* jsdom performs no layout — `scrollHeight` / `clientHeight` are 0 for every
* element it builds — so geometry is installed by hand, exactly as the freeze
* probe's own specs do it. What that CANNOT show is the thing only a browser
* has: how the takeover actually feels under a trackpad flick. That stays in
* the handoff notes rather than being faked here.
*/
const fetchMock = vi.fn();
const ORIGINAL_FETCH = globalThis.fetch;
const ORIGINAL_RAF = globalThis.requestAnimationFrame;
const ORIGINAL_CAF = globalThis.cancelAnimationFrame;
let rafSpy = vi.fn();
let cafSpy = vi.fn();
let rafHandle = 0;
let clock = 0;
function advanceClock(ms: number): void {
clock += ms;
}
function eventsNamed(name: string): Array<Record<string, unknown>> {
return decodeSafetyEvents(fetchMock)
.filter((e) => e.event === name)
.map((e) => e.properties);
}
function pullEscapeHatch(): void {
globalThis.localStorage.setItem(CHAT_SCROLL_TAKEOVER_STORAGE_KEY, '0');
}
/**
* Drive the probe to a reported freeze on `log`, and stop the instant it calls
* it.
*
* Exactly `FREEZE_WHEEL_COUNT` notches of 120px: four stalled notches asking
* for 480px clears both bars the detector holds a report behind (four notches,
* 240px). With the synchronous rAF used here the kick's restore frame has
* already run by the time this returns, so the module is waiting for its
* observation notch.
*/
function driveToFreeze(log: HTMLElement): void {
scrolled(log);
for (let i = 0; i < FREEZE_WHEEL_COUNT; i += 1) {
advanceClock(16);
wheelEvent(log, 120);
}
}
/** One natively-passed notch that moves nothing, then the settle window: the takeover engages. */
function failTheObservation(log: HTMLElement): void {
const notch = wheelEvent(log, 120);
expect(notch.defaultPrevented).toBe(false);
vi.advanceTimersByTime(OBSERVATION_SETTLE_MS);
}
beforeEach(() => {
clock = 0;
fetchMock.mockReset();
fetchMock.mockResolvedValue(new Response('', { status: 200 }));
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
setExceptionTrackingContext({
apiKey: 'phc_test',
host: 'https://us.i.posthog.com',
distinctId: 'chat-scroll-takeover-test',
clientType: 'web',
osName: 'Mac OS X',
});
// A synchronous rAF makes both the probe and the takeover deterministic:
// one wheel in, one applied frame out, nothing to await.
rafSpy = vi.fn((cb: FrameRequestCallback) => {
cb(clock);
return ++rafHandle;
});
cafSpy = vi.fn();
globalThis.requestAnimationFrame =
rafSpy as unknown as typeof globalThis.requestAnimationFrame;
globalThis.cancelAnimationFrame =
cafSpy as unknown as typeof globalThis.cancelAnimationFrame;
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
vi.spyOn(performance, 'now').mockImplementation(() => clock);
globalThis.localStorage.clear();
document.body.innerHTML = '';
__resetChatScrollTakeoverForTest();
__resetChatScrollFreezeForTest();
});
afterEach(() => {
__resetChatScrollTakeoverForTest();
__resetChatScrollFreezeForTest();
vi.useRealTimers();
vi.restoreAllMocks();
clearExceptionTrackingContext();
globalThis.localStorage.clear();
globalThis.fetch = ORIGINAL_FETCH;
globalThis.requestAnimationFrame = ORIGINAL_RAF;
globalThis.cancelAnimationFrame = ORIGINAL_CAF;
document.body.innerHTML = '';
});
// ---------------------------------------------------------------------------
// Off is off
// ---------------------------------------------------------------------------
describe('chat scroll takeover — the escape hatch', () => {
it("costs no listener, no timer and no frame while the hatch is pulled ('0')", () => {
// A user who opted out must not be able to tell this module exists. "Off"
// therefore has to mean *nothing runs*, not "runs and decides not to act".
pullEscapeHatch();
const log = buildChatLog();
stubGeometry(log, FROZEN);
const addOnLog = vi.spyOn(log, 'addEventListener');
const addOnDocument = vi.spyOn(document, 'addEventListener');
const addOnWindow = vi.spyOn(window, 'addEventListener');
const timeout = vi.spyOn(globalThis, 'setTimeout');
const interval = vi.spyOn(globalThis, 'setInterval');
const framesBefore = rafSpy.mock.calls.length;
for (let i = 0; i < 20; i += 1) installChatScrollTakeover();
expect(addOnLog).not.toHaveBeenCalled();
expect(addOnDocument).not.toHaveBeenCalled();
expect(addOnWindow).not.toHaveBeenCalled();
expect(timeout).not.toHaveBeenCalled();
expect(interval).not.toHaveBeenCalled();
expect(rafSpy.mock.calls.length).toBe(framesBefore);
// …and it did not even subscribe. A subscription costs no listener, no
// timer and no frame, so none of the spies above can see one — which would
// leave "off" pinned by its outcome and not by its cost.
expect(chatScrollFreezeListenerCount()).toBe(0);
});
it('does not take the wheel over when the probe calls a freeze and the hatch is pulled', () => {
pullEscapeHatch();
const log = buildChatLog();
const geometry = stubGeometry(log, FROZEN);
installChatScrollFreezeObserver();
installChatScrollTakeover();
driveToFreeze(log);
expect(eventsNamed('client_chat_scroll_frozen')).toHaveLength(1);
failTheObservation(log);
const after = wheelEvent(log, 120);
expect(after.defaultPrevented).toBe(false);
expect(geometry.writes).toEqual([]);
expect(chatScrollTakeoverEngaged()).toBe(false);
});
it('does not take the wheel over before a freeze, even though it is on by default', () => {
// The other half of the same risk: every user still scrolls natively until
// the probe has actually called it. If this ever goes red, the takeover is
// stealing healthy scrolling.
const log = buildChatLog();
const geometry = stubGeometry(log, { scrollTop: 0, scrollHeight: 2347, clientHeight: 583 });
installChatScrollFreezeObserver();
installChatScrollTakeover();
expect(chatScrollFreezeListenerCount()).toBe(1);
scrolled(log);
// A healthy scroller: every notch moves it, so no streak can accumulate.
for (let i = 0; i < 12; i += 1) {
advanceClock(16);
geometry.setTop(120 * (i + 1));
const event = wheelEvent(log, 120);
expect(event.defaultPrevented).toBe(false);
}
expect(eventsNamed('client_chat_scroll_frozen')).toHaveLength(0);
expect(geometry.writes).toEqual([]);
expect(chatScrollTakeoverEngaged()).toBe(false);
expect(log.style.willChange).toBe('');
});
});
// ---------------------------------------------------------------------------
// Engaged behaviour
// ---------------------------------------------------------------------------
describe('chat scroll takeover — once the kick has failed to heal it', () => {
function freezeAndTakeOver(): { log: HTMLElement; geometry: GeometryHandle } {
const log = buildChatLog();
const geometry = stubGeometry(log, FROZEN);
installChatScrollFreezeObserver();
installChatScrollTakeover();
driveToFreeze(log);
failTheObservation(log);
return { log, geometry };
}
it('consumes the wheel and moves the log by the delta the wheel asked for', () => {
// The red spec this change exists for: the compositor will not move the
// log, the JS write path will, so the wheel is answered from JavaScript.
const { log, geometry } = freezeAndTakeOver();
expect(eventsNamed('client_chat_scroll_frozen')).toHaveLength(1);
expect(eventsNamed('client_chat_scroll_heal')).toMatchObject([{ outcome: 'takeover' }]);
expect(chatScrollTakeoverEngaged()).toBe(true);
const event = wheelEvent(log, 120);
expect(event.defaultPrevented).toBe(true);
expect(geometry.writes).toEqual([FROZEN.scrollTop + 120]);
expect(geometry.top()).toBe(211);
});
it('scrolls back up too, rather than leaving two input paths fighting', () => {
// Upward wheels still work natively during a freeze, so leaving them alone
// was an option. It is the wrong one: a single trackpad flick carries both
// signs, and splitting one gesture across a native path and a programmatic
// one makes the two disagree about where the log is.
const { log, geometry } = freezeAndTakeOver();
geometry.setTop(800);
const event = wheelEvent(log, -120);
expect(event.defaultPrevented).toBe(true);
expect(geometry.writes).toEqual([680]);
});
it('applies a burst of notches once per frame, not once per event', () => {
// Writing `scrollTop` from the handler would put a forced layout on the
// input path and make a fast flick judder. One write per frame, carrying
// the whole burst.
const { log, geometry } = freezeAndTakeOver();
// No frame runs until we say so, so the notches have to accumulate.
const callbacks: FrameRequestCallback[] = [];
rafSpy.mockImplementation((cb: FrameRequestCallback) => {
callbacks.push(cb);
return ++rafHandle;
});
wheelEvent(log, 40);
wheelEvent(log, 40);
wheelEvent(log, 40);
expect(geometry.writes).toEqual([]);
const framesForThreeNotches = callbacks.length;
// One frame carries all three notches.
callbacks.forEach((cb) => cb(clock));
expect(geometry.writes).toEqual([FROZEN.scrollTop + 120]);
// Twice the notches, the same number of frames, and still one write.
//
// The count is compared against itself rather than pinned at the literal
// 1, because the takeover is not the only thing on this element asking
// for frames: the freeze probe keeps sampling a surface it has already
// reported, which is deliberate — it is what makes a second symptom on an
// already-reported surface catchable at all. Both coalesce per frame, so
// what this spec is about, and all it should be able to see, is that
// neither of them scales with the number of EVENTS.
callbacks.length = 0;
geometry.writes.length = 0;
geometry.setTop(FROZEN.scrollTop);
for (let i = 0; i < 6; i += 1) wheelEvent(log, 40);
expect(callbacks.length).toBe(framesForThreeNotches);
callbacks.forEach((cb) => cb(clock));
expect(geometry.writes).toEqual([FROZEN.scrollTop + 240]);
});
it('normalises line and page wheels the way the detector does', () => {
// `deltaMode` is 0 / 1 / 2 depending on device and OS. A line wheel that
// moved 3 raw units must not move the log 3px.
const { log, geometry } = freezeAndTakeOver();
wheelEvent(log, 3, { deltaMode: 1 });
expect(geometry.writes).toEqual([FROZEN.scrollTop + 48]);
geometry.setTop(0);
wheelEvent(log, 1, { deltaMode: 2 });
expect(geometry.writes[1]).toBe(FROZEN.clientHeight);
});
it('clamps to the real layout extent instead of running off either end', () => {
const { log, geometry } = freezeAndTakeOver();
wheelEvent(log, 99_999);
expect(geometry.writes).toEqual([LAYOUT_MAX]);
wheelEvent(log, -99_999);
expect(geometry.writes).toEqual([LAYOUT_MAX, 0]);
});
it('leaves a wheel alone when a scrollable box inside the log can still eat it', () => {
// Every code block and tool-output box in a transcript is a scrollport.
// `preventDefault` at the log would freeze all of them, which would be a
// worse bug than the one being worked around.
const { log, geometry } = freezeAndTakeOver();
const inner = document.createElement('pre');
inner.style.overflowY = 'auto';
stubGeometry(inner, { scrollTop: 0, scrollHeight: 900, clientHeight: 200 });
log.appendChild(inner);
const event = wheelEvent(inner, 120);
expect(event.defaultPrevented).toBe(false);
expect(geometry.writes).toEqual([]);
});
it('leaves a zoom gesture alone', () => {
// ctrl+wheel is pinch-to-zoom on a trackpad. Consuming it would take page
// zoom away from the user.
const { log, geometry } = freezeAndTakeOver();
const event = wheelEvent(log, 120, { ctrlKey: true });
expect(event.defaultPrevented).toBe(false);
expect(geometry.writes).toEqual([]);
});
it('lets the wheel chain onward once the log is genuinely at its end', () => {
// At a real edge the native behaviour is to hand the wheel to whatever is
// outside. Holding on to it there would trap the gesture inside a log that
// has nothing left to give.
const { log, geometry } = freezeAndTakeOver();
// Drive to the bottom through the takeover itself, so the edge it sees is
// one it put the log at rather than one the spec asserted behind its back.
wheelEvent(log, 99_999);
expect(geometry.top()).toBe(LAYOUT_MAX);
geometry.writes.length = 0;
const event = wheelEvent(log, 120);
expect(event.defaultPrevented).toBe(false);
expect(geometry.writes).toEqual([]);
});
it('does not strand the user at a bottom that has since moved', () => {
// The edge check runs against a frame-old cache, which is fine for a
// settled log and a deadlock for a streaming one: parked at what used to
// be the bottom, every wheel is declined, a declined wheel schedules
// nothing, and nothing ever re-reads the geometry that would let the next
// one through — on a surface whose native path is broken. So a declined
// wheel still asks for a frame, purely to take a fresh reading.
const { log, geometry } = freezeAndTakeOver();
wheelEvent(log, 99_999);
expect(geometry.top()).toBe(LAYOUT_MAX);
geometry.writes.length = 0;
// A turn streams in: the log is now 600px taller than the takeover thinks.
geometry.setContent(FROZEN.scrollHeight + 600);
// This one is declined against the stale bottom — and refreshes the cache.
const stale = wheelEvent(log, 120);
expect(stale.defaultPrevented).toBe(false);
expect(geometry.writes).toEqual([]);
// …so the next one reaches the content that arrived.
const fresh = wheelEvent(log, 120);
expect(fresh.defaultPrevented).toBe(true);
expect(geometry.writes).toEqual([LAYOUT_MAX + 120]);
});
});
// ---------------------------------------------------------------------------
// Coming off
// ---------------------------------------------------------------------------
describe('chat scroll takeover — teardown', () => {
it('releases the wheel when the chat log is replaced', () => {
// Leaving and re-entering the chat tab unmounts and rebuilds the log,
// which is the one thing known to clear the stale ceiling. The replacement
// must start out scrolling natively — otherwise one freeze would degrade
// the surface for the rest of the session.
const first = buildChatLog();
const firstGeometry = stubGeometry(first, FROZEN);
installChatScrollFreezeObserver();
installChatScrollTakeover();
driveToFreeze(first);
failTheObservation(first);
expect(chatScrollTakeoverEngaged()).toBe(true);
first.remove();
const second = buildChatLog();
const secondGeometry = stubGeometry(second, {
scrollTop: 0,
scrollHeight: 2347,
clientHeight: 583,
});
advanceClock(500);
// The probe notices the swap on the next scroll out of the new log.
scrolled(second);
expect(chatScrollTakeoverEngaged()).toBe(false);
const onOld = wheelEvent(first, 120);
expect(onOld.defaultPrevented).toBe(false);
expect(firstGeometry.writes).toEqual([]);
const onNew = wheelEvent(second, 120);
expect(onNew.defaultPrevented).toBe(false);
expect(secondGeometry.writes).toEqual([]);
});
it('releases the wheel when the installer is torn down', () => {
const log = buildChatLog();
const geometry = stubGeometry(log, FROZEN);
installChatScrollFreezeObserver();
const teardown = installChatScrollTakeover();
driveToFreeze(log);
failTheObservation(log);
expect(chatScrollTakeoverEngaged()).toBe(true);
teardown();
expect(chatScrollTakeoverEngaged()).toBe(false);
const event = wheelEvent(log, 120);
expect(event.defaultPrevented).toBe(false);
expect(geometry.writes).toEqual([]);
});
});