fix: intermittent scroll bug (#2052)

This commit is contained in:
ananaBMaster
2026-08-07 16:41:11 -07:00
committed by GitHub
parent c9d98221bd
commit 6ce4f1331e
8 changed files with 217 additions and 3 deletions
@@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix(selection): remove blank gap under short source text and restore wheel scrolling
@@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix(selection): stop streamed custom-action output from yanking the popover scroll back to the bottom while the user is reading earlier content
+14 -2
View File
@@ -4,7 +4,16 @@ import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import * as React from "react"
import { cn } from "@/utils/styles/utils"
function ScrollArea({ className, children, ...props }: ScrollAreaPrimitive.Root.Props) {
interface ScrollAreaProps extends ScrollAreaPrimitive.Root.Props {
/**
* Classes for the scrollable viewport. Put height constraints here (e.g.
* `max-h-*`) so the area can shrink to its content instead of the root
* reserving a fixed box.
*/
viewportClassName?: string
}
function ScrollArea({ className, viewportClassName, children, ...props }: ScrollAreaProps) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
@@ -13,7 +22,10 @@ function ScrollArea({ className, children, ...props }: ScrollAreaPrimitive.Root.
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
className={cn(
"size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1",
viewportClassName,
)}
>
{children}
</ScrollAreaPrimitive.Viewport>
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { render } from "@testing-library/react"
import { beforeEach, describe, expect, it } from "vitest"
import { usePreventScrollThrough } from "../use-prevent-scroll-through"
interface Metrics {
scrollTop: number
scrollHeight: number
clientHeight: number
}
/** jsdom has no layout, so scroll metrics have to be declared explicitly. */
function setMetrics(element: HTMLElement, { scrollTop, scrollHeight, clientHeight }: Metrics) {
Object.defineProperties(element, {
scrollTop: { configurable: true, value: scrollTop, writable: true },
scrollHeight: { configurable: true, value: scrollHeight },
clientHeight: { configurable: true, value: clientHeight },
})
}
function Harness({ element }: { element: HTMLElement | null }) {
usePreventScrollThrough({ isEnabled: true, element })
return null
}
let body: HTMLDivElement
let nested: HTMLDivElement
let leaf: HTMLParagraphElement
beforeEach(() => {
body = document.createElement("div")
nested = document.createElement("div")
leaf = document.createElement("p")
nested.style.overflowY = "scroll"
nested.append(leaf)
body.append(nested)
document.body.append(body)
})
function wheel(target: HTMLElement, deltaY: number) {
const event = new WheelEvent("wheel", { deltaY, bubbles: true, cancelable: true })
target.dispatchEvent(event)
return event
}
describe("usePreventScrollThrough", () => {
it("lets a nested scroller consume the wheel when it still has room", () => {
// The popover body itself cannot scroll — the case that used to swallow
// every wheel event and leave the nested area draggable-scrollbar only.
setMetrics(body, { scrollTop: 0, scrollHeight: 200, clientHeight: 200 })
setMetrics(nested, { scrollTop: 0, scrollHeight: 180, clientHeight: 72 })
render(<Harness element={body} />)
expect(wheel(leaf, 120).defaultPrevented).toBe(false)
})
it("still blocks scroll-through once the nested scroller is exhausted", () => {
setMetrics(body, { scrollTop: 0, scrollHeight: 200, clientHeight: 200 })
setMetrics(nested, { scrollTop: 108, scrollHeight: 180, clientHeight: 72 })
render(<Harness element={body} />)
expect(wheel(leaf, 120).defaultPrevented).toBe(true)
})
it("ignores overflowing children that are not scrollers", () => {
setMetrics(body, { scrollTop: 0, scrollHeight: 200, clientHeight: 200 })
nested.style.overflowY = "hidden"
setMetrics(nested, { scrollTop: 0, scrollHeight: 180, clientHeight: 72 })
render(<Harness element={body} />)
expect(wheel(leaf, 120).defaultPrevented).toBe(true)
})
it("keeps blocking at the body's own boundaries", () => {
setMetrics(body, { scrollTop: 0, scrollHeight: 400, clientHeight: 200 })
setMetrics(nested, { scrollTop: 0, scrollHeight: 72, clientHeight: 72 })
render(<Harness element={body} />)
expect(wheel(leaf, -120).defaultPrevented).toBe(true)
expect(wheel(leaf, 120).defaultPrevented).toBe(false)
})
})
@@ -5,12 +5,51 @@ interface UsePreventScrollThroughOptions {
element: HTMLElement | null
}
const SCROLLABLE_OVERFLOW_Y = new Set(["auto", "scroll", "overlay"])
function isElement(node: EventTarget): node is HTMLElement {
return "nodeType" in node && (node as Node).nodeType === Node.ELEMENT_NODE
}
/**
* Whether this element is a scroller with room left in the wheel's direction,
* i.e. the browser would scroll it if we let the event through.
*/
function canAbsorbWheel(node: HTMLElement, deltaY: number) {
const { scrollTop, scrollHeight, clientHeight } = node
// Cheapest discriminator first: most nodes have nothing to scroll, so this
// keeps the per-wheel-event walk from hitting `getComputedStyle` at all.
if (scrollHeight <= clientHeight) {
return false
}
if (!SCROLLABLE_OVERFLOW_Y.has(getComputedStyle(node).overflowY)) {
return false
}
return deltaY < 0 ? scrollTop > 0 : scrollTop + clientHeight < scrollHeight - 1
}
export function usePreventScrollThrough({ isEnabled, element }: UsePreventScrollThroughOptions) {
const handleWheel = useEffectEvent((event: WheelEvent) => {
if (!element) {
return
}
// The listener sits on the popover body, so it also sees wheels aimed at
// nested scrollers (e.g. the collapsible source text). Preventing those
// would leave them scrollable only by dragging the scrollbar.
for (const node of event.composedPath()) {
if (node === element) {
break
}
if (isElement(node) && canAbsorbWheel(node, event.deltaY)) {
return
}
}
const { scrollTop, scrollHeight, clientHeight } = element
const isAtTop = event.deltaY < 0 && scrollTop === 0
const isAtBottom = event.deltaY > 0 && scrollTop + clientHeight >= scrollHeight - 1
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { SelectionSourceContent } from "../selection-source-content"
vi.mock("../copy-button", () => ({
CopyButton: () => <button type="button">Copy</button>,
}))
vi.mock("../speak-button", () => ({
SpeakButton: () => <button type="button">Speak</button>,
}))
function getViewport(container: HTMLElement) {
const viewport = container.querySelector<HTMLElement>("[data-slot=scroll-area-viewport]")
expect(viewport).not.toBeNull()
return viewport as HTMLElement
}
describe("selectionSourceContent", () => {
it("caps the expanded source text with a max height so short text keeps no blank space", () => {
const { container } = render(<SelectionSourceContent text="use" />)
const paragraph = screen.getByText("use")
expect(paragraph).toHaveClass("line-clamp-3")
// The chevron toggle is the first button rendered inside the source row.
const toggle = container.querySelector("button")
expect(toggle).not.toBeNull()
fireEvent.click(toggle as HTMLButtonElement)
expect(paragraph).not.toHaveClass("line-clamp-3")
const viewport = getViewport(container)
// `max-h-*` lets the area shrink to a one-word selection; a fixed `h-*`
// would always reserve the full box and leave a blank gap under the text.
expect(viewport).toHaveClass("max-h-18")
expect(viewport.className).not.toMatch(/(?:^|\s)h-\d/)
expect(getViewport(container).parentElement?.className).not.toMatch(/(?:^|\s)h-\d/)
})
it("does not constrain the height while collapsed", () => {
const { container } = render(<SelectionSourceContent text="use" />)
expect(getViewport(container).className).not.toMatch(/max-h-/)
})
})
@@ -27,7 +27,10 @@ export function SelectionSourceContent({
<>
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<ScrollArea className={cn("min-w-0 flex-1", actionsExpanded && "h-18 overflow-hidden")}>
<ScrollArea
className="min-w-0 flex-1"
viewportClassName={cn(actionsExpanded && "max-h-18")}
>
<p
className={cn(
"text-sm [overflow-wrap:anywhere] break-words whitespace-pre-wrap text-zinc-600 dark:text-zinc-400",
@@ -75,7 +75,23 @@ interface CustomActionExecutionRequest {
}
}
const FOLLOW_STREAM_BOTTOM_THRESHOLD = 8
function scrollSelectionPopoverBodyToBottom(ref: RefObject<HTMLDivElement | null>) {
const node = ref.current
if (!node) {
return
}
// Measured before the chunk renders: a reader who scrolled up to reread
// earlier output must not be yanked back down, and measuring after the
// append would misread "was at the bottom" as "far from it" whenever a
// chunk adds more height than the threshold.
const distanceToBottom = node.scrollHeight - node.scrollTop - node.clientHeight
if (distanceToBottom > FOLLOW_STREAM_BOTTOM_THRESHOLD) {
return
}
requestAnimationFrame(() => {
if (ref.current) {
ref.current.scrollTop = ref.current.scrollHeight