From 16e93319e602326b76618bf148a00be11d22a9f6 Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Sun, 6 Sep 2026 03:14:11 -0700 Subject: [PATCH 1/2] fix(ui): bookmark the anchor a wheel pages from A wheel at the top edge moves nothing, so no scroll event refreshes the reading anchor. The bookmark keeps naming a Turn the new range evicted, and the restore effect loads around it over the range paging just published. Generated-by: Claude Code (Opus 5) --- .../__tests__/paging-reading-anchor.test.tsx | 179 ++++++++++++++++++ packages/ui/src/use-chat-scroll.ts | 3 + 2 files changed, 182 insertions(+) create mode 100644 packages/ui/src/__tests__/paging-reading-anchor.test.tsx diff --git a/packages/ui/src/__tests__/paging-reading-anchor.test.tsx b/packages/ui/src/__tests__/paging-reading-anchor.test.tsx new file mode 100644 index 0000000000..5d047935f4 --- /dev/null +++ b/packages/ui/src/__tests__/paging-reading-anchor.test.tsx @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * A bookmark left on a Turn the last range evicted makes the restore effect + * load around it over the range paging just published, and the transcript + * stops advancing — the stall the E2E paging guard sees as a timeout. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import type { StoredMessage } from '@maka/core/session'; +import { TranscriptScrollAuthorityProvider } from '../transcript-scroll-authority.js'; +import { useChatScroll } from '../use-chat-scroll.js'; + +const originalGlobals = { + CSS: globalThis.CSS, + document: globalThis.document, + Element: globalThis.Element, + HTMLElement: globalThis.HTMLElement, + MutationObserver: globalThis.MutationObserver, + Node: globalThis.Node, + ResizeObserver: globalThis.ResizeObserver, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; + +let mountedRoot: ReturnType | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +test('a wheel at the top edge reports its anchor before it loads earlier history', async () => { + const { document, window } = parseHTML( + '
', + ); + const mount = document.querySelector('#mount'); + const scroller = document.querySelector('#scroller'); + assert.ok(mount); + assert.ok(scroller); + + let scrollHeight = 1_600; + let scrollTop = 0; + Object.defineProperties(scroller, { + clientHeight: { value: 600 }, + scrollHeight: { get: () => scrollHeight }, + scrollTop: { + get: () => scrollTop, + // No scroll event follows a write: that is the edge under test. + set: (value: number) => { + scrollTop = Math.max(0, Math.min(value, scrollHeight - 600)); + }, + }, + }); + scroller.getBoundingClientRect = () => ({ + bottom: 600, height: 600, left: 0, right: 800, top: 0, width: 800, x: 0, y: 0, + toJSON: () => undefined, + }); + + class Inert { + disconnect() {} + observe() {} + unobserve() {} + takeRecords(): MutationRecord[] { return []; } + } + Object.assign(window, { + cancelAnimationFrame: () => {}, + requestAnimationFrame: (callback: FrameRequestCallback) => { + callback(0); + return 0; + }, + }); + Object.assign(globalThis, { + CSS: { escape: (value: string) => value }, + document, + Element: window.Element, + HTMLElement: window.HTMLElement, + MutationObserver: Inert, + Node: window.Node, + ResizeObserver: Inert, + window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + const installTurns = (ids: readonly string[]): void => { + scrollHeight = ids.length * 800; + scroller.replaceChildren(); + ids.forEach((id, index) => { + const element = document.createElement('article'); + element.dataset.turnId = id; + const start = index * 800; + element.getBoundingClientRect = () => ({ + bottom: start + 800 - scrollTop, + height: 800, + left: 0, right: 800, width: 800, x: 0, + top: start - scrollTop, + y: start - scrollTop, + toJSON: () => undefined, + }); + element.scrollIntoView = () => { + scroller.scrollTop = start; + }; + scroller.append(element); + }); + }; + let anchor: string | undefined; + const loads: Array<{ anchorTurnId?: string; anchorWhenAsked?: string }> = []; + function Harness() { + const scrollRef = useRef(scroller); + useChatScroll({ + scrollRef, + sessionId: 'session-paging', + messages: [{ id: 'message-1' }] as StoredMessage[], + // A remembered position leaves the hook unpinned, as paging back does. + restoreTarget: { turnId: 'turn-0' }, + onReadingAnchorChange: (turnId) => { + anchor = turnId; + }, + behavior: 'auto', + hasOlderHistory: true, + onLoadEarlierHistory: (anchorTurnId) => { + loads.push({ anchorTurnId, anchorWhenAsked: anchor }); + }, + }); + return null; + } + + installTurns(['turn-0', 'turn-1']); + mountedRoot = createRoot(mount); + await act(() => mountedRoot?.render( + + + , + )); + // Settle the authority on "unpinned, away from the tail", where the wheel's + // own release publishes nothing and so refreshes no anchor. + scroller.dispatchEvent(new window.Event('scroll')); + assert.equal(anchor, 'turn-0', 'the restored position is the reading anchor'); + + installTurns(['turn-earlier', 'turn-0']); + scroller.scrollTop = 0; + + const wheel = new window.Event('wheel'); + Object.assign(wheel, { deltaY: -120, composedPath: () => [scroller] }); + scroller.dispatchEvent(wheel); + + assert.deepEqual( + loads.at(-1), + { anchorTurnId: 'turn-earlier', anchorWhenAsked: 'turn-earlier' }, + 'the wheel bookmarks the Turn it anchors the load to', + ); +}); diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index c95c03c10c..4f0fae2e7e 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -153,6 +153,9 @@ export function useChatScroll(input: { // already has is idempotent anyway. const requestHistory = (direction: 'up' | 'down'): void => { authority.releasePin(); + // A wheel at either edge moves nothing, so no scroll event refreshes the + // anchor and the restore effect would load around an evicted Turn. + reportReadingAnchor.current?.(); const anchorTurnId = direction === 'up' ? firstVisibleTurnId(root) : lastVisibleTurnId(root); From f501a2f7521c84c23b2652bc67234f90f0aa05b2 Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Mon, 7 Sep 2026 10:44:25 -0700 Subject: [PATCH 2/2] test(ui): fold the paging-anchor regression into the scroll tests The standalone paging-reading-anchor file repeated the global save/restore, React root lifecycle, LinkeDOM setup and environment wiring already present in use-chat-scroll.test.tsx. The case now runs there under a shared file-local environment installer, keeping the no-scroll-event edge, the real hook and authority composition, and the ordering assertion that the anchor is reported before onLoadEarlierHistory runs (removing the fix still fails it). Generated-by: Claude Code (Opus 5) --- .../__tests__/paging-reading-anchor.test.tsx | 179 --------------- .../ui/src/__tests__/use-chat-scroll.test.tsx | 205 +++++++++++++++--- 2 files changed, 170 insertions(+), 214 deletions(-) delete mode 100644 packages/ui/src/__tests__/paging-reading-anchor.test.tsx diff --git a/packages/ui/src/__tests__/paging-reading-anchor.test.tsx b/packages/ui/src/__tests__/paging-reading-anchor.test.tsx deleted file mode 100644 index 5d047935f4..0000000000 --- a/packages/ui/src/__tests__/paging-reading-anchor.test.tsx +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * A bookmark left on a Turn the last range evicted makes the restore effect - * load around it over the range paging just published, and the transcript - * stops advancing — the stall the E2E paging guard sees as a timeout. - */ - -import assert from 'node:assert/strict'; -import { afterEach, test } from 'node:test'; -import { act, useRef } from 'react'; -import { createRoot } from 'react-dom/client'; -import { parseHTML } from 'linkedom'; -import type { StoredMessage } from '@maka/core/session'; -import { TranscriptScrollAuthorityProvider } from '../transcript-scroll-authority.js'; -import { useChatScroll } from '../use-chat-scroll.js'; - -const originalGlobals = { - CSS: globalThis.CSS, - document: globalThis.document, - Element: globalThis.Element, - HTMLElement: globalThis.HTMLElement, - MutationObserver: globalThis.MutationObserver, - Node: globalThis.Node, - ResizeObserver: globalThis.ResizeObserver, - window: globalThis.window, -}; -const originalActEnvironment = (globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; -}).IS_REACT_ACT_ENVIRONMENT; - -let mountedRoot: ReturnType | undefined; - -afterEach(async () => { - if (mountedRoot) await act(() => mountedRoot?.unmount()); - mountedRoot = undefined; - Object.assign(globalThis, { - ...originalGlobals, - IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, - }); -}); - -test('a wheel at the top edge reports its anchor before it loads earlier history', async () => { - const { document, window } = parseHTML( - '
', - ); - const mount = document.querySelector('#mount'); - const scroller = document.querySelector('#scroller'); - assert.ok(mount); - assert.ok(scroller); - - let scrollHeight = 1_600; - let scrollTop = 0; - Object.defineProperties(scroller, { - clientHeight: { value: 600 }, - scrollHeight: { get: () => scrollHeight }, - scrollTop: { - get: () => scrollTop, - // No scroll event follows a write: that is the edge under test. - set: (value: number) => { - scrollTop = Math.max(0, Math.min(value, scrollHeight - 600)); - }, - }, - }); - scroller.getBoundingClientRect = () => ({ - bottom: 600, height: 600, left: 0, right: 800, top: 0, width: 800, x: 0, y: 0, - toJSON: () => undefined, - }); - - class Inert { - disconnect() {} - observe() {} - unobserve() {} - takeRecords(): MutationRecord[] { return []; } - } - Object.assign(window, { - cancelAnimationFrame: () => {}, - requestAnimationFrame: (callback: FrameRequestCallback) => { - callback(0); - return 0; - }, - }); - Object.assign(globalThis, { - CSS: { escape: (value: string) => value }, - document, - Element: window.Element, - HTMLElement: window.HTMLElement, - MutationObserver: Inert, - Node: window.Node, - ResizeObserver: Inert, - window, - IS_REACT_ACT_ENVIRONMENT: true, - }); - - const installTurns = (ids: readonly string[]): void => { - scrollHeight = ids.length * 800; - scroller.replaceChildren(); - ids.forEach((id, index) => { - const element = document.createElement('article'); - element.dataset.turnId = id; - const start = index * 800; - element.getBoundingClientRect = () => ({ - bottom: start + 800 - scrollTop, - height: 800, - left: 0, right: 800, width: 800, x: 0, - top: start - scrollTop, - y: start - scrollTop, - toJSON: () => undefined, - }); - element.scrollIntoView = () => { - scroller.scrollTop = start; - }; - scroller.append(element); - }); - }; - let anchor: string | undefined; - const loads: Array<{ anchorTurnId?: string; anchorWhenAsked?: string }> = []; - function Harness() { - const scrollRef = useRef(scroller); - useChatScroll({ - scrollRef, - sessionId: 'session-paging', - messages: [{ id: 'message-1' }] as StoredMessage[], - // A remembered position leaves the hook unpinned, as paging back does. - restoreTarget: { turnId: 'turn-0' }, - onReadingAnchorChange: (turnId) => { - anchor = turnId; - }, - behavior: 'auto', - hasOlderHistory: true, - onLoadEarlierHistory: (anchorTurnId) => { - loads.push({ anchorTurnId, anchorWhenAsked: anchor }); - }, - }); - return null; - } - - installTurns(['turn-0', 'turn-1']); - mountedRoot = createRoot(mount); - await act(() => mountedRoot?.render( - - - , - )); - // Settle the authority on "unpinned, away from the tail", where the wheel's - // own release publishes nothing and so refreshes no anchor. - scroller.dispatchEvent(new window.Event('scroll')); - assert.equal(anchor, 'turn-0', 'the restored position is the reading anchor'); - - installTurns(['turn-earlier', 'turn-0']); - scroller.scrollTop = 0; - - const wheel = new window.Event('wheel'); - Object.assign(wheel, { deltaY: -120, composedPath: () => [scroller] }); - scroller.dispatchEvent(wheel); - - assert.deepEqual( - loads.at(-1), - { anchorTurnId: 'turn-earlier', anchorWhenAsked: 'turn-earlier' }, - 'the wheel bookmarks the Turn it anchors the load to', - ); -}); diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index 49c970cad5..2687a1f7e6 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -56,6 +56,62 @@ afterEach(async () => { }); }); +/** + * Installs the globals the scroll hook reads onto the LinkeDOM window, either + * queueing rAF frames for explicit flushes or running them inline. + */ +const installScrollTestEnvironment = ( + document: Document, + window: ReturnType['window'], + { queueFrames = true }: { queueFrames?: boolean } = {}, +): { + frames: Map; + resizeCallbacks: ResizeObserverCallback[]; +} => { + let frameId = 0; + const frames = new Map(); + const resizeCallbacks: ResizeObserverCallback[] = []; + class TestResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallbacks.push(callback); + } + disconnect() {} + observe() {} + unobserve() {} + } + class TestMutationObserver { + disconnect() {} + observe() {} + unobserve() {} + takeRecords(): MutationRecord[] { return []; } + } + Object.assign(window, { + cancelAnimationFrame: (id: number) => frames.delete(id), + requestAnimationFrame: queueFrames + ? (callback: FrameRequestCallback) => { + const id = ++frameId; + frames.set(id, callback); + return id; + } + : (callback: FrameRequestCallback) => { + callback(0); + return 0; + }, + }); + Object.assign(globalThis, { + CSS: { escape: (value: string) => value }, + document, + Element: window.Element, + HTMLElement: window.HTMLElement, + MutationObserver: TestMutationObserver, + Node: window.Node, + ResizeObserver: TestResizeObserver, + window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + return { frames, resizeCallbacks }; +}; + test('pages only toward reader input, including wheels at a bounded edge', async () => { const { document, window } = parseHTML('
'); const scroller = document.querySelector('#scroller')!; @@ -160,9 +216,6 @@ test('a session switch restores a Turn anchor after async fill and preserves tai let scrollHeight = 600; let scrollTop = 0; let dispatchCommandScroll = true; - let frameId = 0; - const frames = new Map(); - const resizeCallbacks: ResizeObserverCallback[] = []; Object.defineProperties(scroller, { clientHeight: { value: 600 }, scrollHeight: { get: () => scrollHeight }, @@ -185,38 +238,7 @@ test('a session switch restores a Turn anchor after async fill and preserves tai toJSON: () => undefined, }); - class TestResizeObserver { - constructor(callback: ResizeObserverCallback) { - resizeCallbacks.push(callback); - } - disconnect() {} - observe() {} - unobserve() {} - } - class TestMutationObserver { - disconnect() {} - observe() {} - takeRecords(): MutationRecord[] { return []; } - } - Object.assign(window, { - cancelAnimationFrame: (id: number) => frames.delete(id), - requestAnimationFrame: (callback: FrameRequestCallback) => { - const id = ++frameId; - frames.set(id, callback); - return id; - }, - }); - Object.assign(globalThis, { - CSS: { escape: (value: string) => value }, - document, - Element: window.Element, - HTMLElement: window.HTMLElement, - MutationObserver: TestMutationObserver, - Node: window.Node, - ResizeObserver: TestResizeObserver, - window, - IS_REACT_ACT_ENVIRONMENT: true, - }); + const { frames, resizeCallbacks } = installScrollTestEnvironment(document, window); const installTranscript = ( height: number, @@ -401,3 +423,116 @@ test('a session switch restores a Turn anchor after async fill and preserves tai assert.equal(authority?.getSnapshot().pinned, true); assert.equal(anchors.has('session-b'), false); }); + +/** + * A bookmark left on a Turn the last range evicted makes the restore effect + * load around it over the range paging just published, and the transcript + * stops advancing — the stall the E2E paging guard sees as a timeout. + */ +test('a wheel at the top edge reports its anchor before it loads earlier history', async () => { + const { document, window } = parseHTML( + '
', + ); + const mount = document.querySelector('#mount'); + const scroller = document.querySelector('#scroller'); + assert.ok(mount); + assert.ok(scroller); + + let scrollHeight = 1_600; + let scrollTop = 0; + Object.defineProperties(scroller, { + clientHeight: { value: 600 }, + scrollHeight: { get: () => scrollHeight }, + scrollTop: { + get: () => scrollTop, + // No scroll event follows a write: that is the edge under test. + set: (value: number) => { + scrollTop = Math.max(0, Math.min(value, scrollHeight - 600)); + }, + }, + }); + scroller.getBoundingClientRect = () => ({ + bottom: 600, + height: 600, + left: 0, + right: 800, + top: 0, + width: 800, + x: 0, + y: 0, + toJSON: () => undefined, + }); + + installScrollTestEnvironment(document, window, { queueFrames: false }); + + const installTurns = (ids: readonly string[]): void => { + scrollHeight = ids.length * 800; + scroller.replaceChildren(); + ids.forEach((id, index) => { + const element = document.createElement('article'); + element.dataset.turnId = id; + const start = index * 800; + element.getBoundingClientRect = () => ({ + bottom: start + 800 - scrollTop, + height: 800, + left: 0, + right: 800, + width: 800, + x: 0, + top: start - scrollTop, + y: start - scrollTop, + toJSON: () => undefined, + }); + element.scrollIntoView = () => { + scroller.scrollTop = start; + }; + scroller.append(element); + }); + }; + let anchor: string | undefined; + const loads: Array<{ anchorTurnId?: string; anchorWhenAsked?: string }> = []; + function Harness() { + const scrollRef = useRef(scroller); + useChatScroll({ + scrollRef, + sessionId: 'session-paging', + messages: [{ id: 'message-1' }] as StoredMessage[], + // A remembered position leaves the hook unpinned, as paging back does. + restoreTarget: { turnId: 'turn-0' }, + onReadingAnchorChange: (turnId) => { + anchor = turnId; + }, + behavior: 'auto', + hasOlderHistory: true, + onLoadEarlierHistory: (anchorTurnId) => { + loads.push({ anchorTurnId, anchorWhenAsked: anchor }); + }, + }); + return null; + } + + installTurns(['turn-0', 'turn-1']); + mountedRoot = createRoot(mount); + await act(() => mountedRoot?.render( + + + , + )); + // Settle the authority on "unpinned, away from the tail", where the wheel's + // own release publishes nothing and so refreshes no anchor. + scroller.dispatchEvent(new window.Event('scroll')); + assert.equal(anchor, 'turn-0', 'the restored position is the reading anchor'); + + installTurns(['turn-earlier', 'turn-0']); + scroller.scrollTop = 0; + + const wheel = new window.Event('wheel'); + Object.assign(wheel, { deltaY: -120, composedPath: () => [scroller] }); + scroller.dispatchEvent(wheel); + + assert.deepEqual( + loads.at(-1), + { anchorTurnId: 'turn-earlier', anchorWhenAsked: 'turn-earlier' }, + 'the wheel bookmarks the Turn it anchors the load to', + ); +});