fix(app): correct virtual range clamping

This commit is contained in:
LukeParkerDev 2026-07-10 10:03:46 +10:00
parent 686a3cf26a
commit 036c12d921
7 changed files with 77 additions and 628 deletions

View file

@ -1066,7 +1066,6 @@
"patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
@ -1076,6 +1075,7 @@
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
},
"overrides": {
"@opentui/core": "catalog:",

View file

@ -1,79 +1,12 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { startTimelineDiagnostics, type TimelineDiagnostics } from "../utils/timeline-cdp-diagnostics"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/RequestDocks"
const projectID = "proj_request_docks"
const sessionID = "ses_request_docks"
const title = "Request dock regression"
const activeAssistantID = "msg_0079_b13_request_assistant"
const activeToolID = "prt_0079_request_question"
const activeCallID = "call_0079_request_question"
const questionID = "question-focus-return"
const questionPrompts = [
{
header: "Focus path",
question: "How was focus changing immediately before the timeline jumped?",
options: [
{ label: "Already in app", description: "The app stayed focused before answering." },
{ label: "Returned to app", description: "The app regained focus before answering." },
],
},
]
type RequestTimelineEvent = {
directory: string
payload: {
id: string
} & (
| {
type: "question.asked"
properties: {
id: string
sessionID: string
questions: typeof questionPrompts
tool: { messageID: string; callID: string }
}
}
| {
type: "question.replied"
properties: { sessionID: string; requestID: string; answers: string[][] }
}
| {
type: "message.part.updated"
properties: { sessionID: string; part: Record<string, unknown>; time: number }
}
| {
type: "message.updated"
properties: { sessionID: string; info: Record<string, unknown> }
}
)
}
type QuestionTimelineProbe = Window & {
__questionTimelineProbe?: {
blank: boolean
samples: number
stop: boolean
submitted: boolean
transitionSamples: number
}
}
const timelineDiagnostics = new WeakMap<Page, TimelineDiagnostics>()
test.beforeEach(async ({ page }, testInfo) => {
if (process.env.TIMELINE_CDP_TRACE !== "1") return
timelineDiagnostics.set(page, await startTimelineDiagnostics(page, testInfo))
})
test.afterEach(async ({ page }) => {
await timelineDiagnostics.get(page)?.stop()
timelineDiagnostics.delete(page)
})
test("shows a pending question dock", async ({ page }) => {
await mockServer(page, {
@ -167,197 +100,11 @@ test("shows a pending permission dock", async ({ page }) => {
expect(request.postDataJSON()).toEqual({ response: "once" })
})
test("keeps an active split timeline visible when a focused question closes", async ({ page }) => {
test.setTimeout(180_000)
// Match the observed long session, assistant chain, and large review history before the focused dock handoff.
const messages = timelineMessages(80)
const activeMessage = messages.at(-1)!
const activeTool = activeMessage.parts[0]!
const responses: { requestID: string; answers?: string[][] }[] = []
const transport = await installSseTransport<RequestTimelineEvent>(page, {
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
retry: 20,
})
await mockServer(page, {
messages,
questions: [],
sessionStatus: { [sessionID]: { type: "busy" } },
vcsDiff: reviewDiffs(2_754),
onQuestionReply: async (input) => {
responses.push(input)
await transport.send({
directory,
payload: {
id: "evt_question_replied",
type: "question.replied",
properties: { sessionID, requestID: questionID, answers: [["Already in app"]] },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
await transport.send({
directory,
payload: {
id: "evt_tool_completed",
type: "message.part.updated",
properties: {
sessionID,
part: {
...activeTool,
state: {
status: "completed",
input: { questions: questionPrompts },
output: "Questions answered",
title: "Questions answered",
metadata: { answers: [["Already in app"]] },
time: { start: 1700000791000, end: 1700000795000 },
},
},
time: 1700000795000,
},
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
await transport.burst([
{
directory,
payload: {
id: "evt_assistant_completed",
type: "message.updated",
properties: {
sessionID,
info: {
...activeMessage.info,
time: { ...activeMessage.info.time, completed: 1700000796000 },
},
},
},
},
{
directory,
payload: {
id: "evt_continued_assistant",
type: "message.updated",
properties: {
sessionID,
info: {
...activeMessage.info,
id: "msg_0079_b14_request_assistant",
time: { created: 1700000797000 },
},
},
},
},
])
return true
},
})
await page.setViewportSize({ width: 1700, height: 1220 })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
await expectSessionTitle(page, title)
const reviewToggle = page.getByRole("button", { name: "Toggle review" })
const review = page.locator('#review-panel [data-component="session-review-v2"]')
await reviewToggle.click()
await expect(review).toBeVisible()
await reviewToggle.click()
await expect(review).toHaveCount(0)
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const firstPart = page.locator(`[data-timeline-part-id="${messages[0]!.parts[0]!.id}"]`)
const lastTextPart = page.locator('[data-timeline-part-id="prt_0079_12_request_assistant"]')
const lastPart = page.locator(`[data-timeline-part-id="${activeToolID}"]`)
await expect(lastTextPart).toBeInViewport()
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeInViewport()
await expect(firstPart).toHaveCount(0)
await expect.poll(() => distanceFromBottom(scroller)).toBeGreaterThan(-3)
await expect.poll(() => distanceFromBottom(scroller)).toBeLessThan(3)
const composer = page.locator('[data-component="prompt-input"]')
await composer.click()
await expect(composer).toBeFocused()
await transport.send({
directory,
payload: {
id: "evt_question_asked",
type: "question.asked",
properties: {
id: questionID,
sessionID,
questions: questionPrompts,
tool: { messageID: activeAssistantID, callID: activeCallID },
},
},
})
await expect(question).toBeVisible()
await expect(question.getByRole("radio", { name: /Already in app/ })).toBeFocused()
await expect(lastTextPart).toBeInViewport()
await expect.poll(() => distanceFromBottom(scroller)).toBeGreaterThan(-3)
await expect.poll(() => distanceFromBottom(scroller)).toBeLessThan(3)
await page.evaluate(() => {
const state = { blank: false, samples: 0, stop: false, submitted: false, transitionSamples: 0 }
;(window as QuestionTimelineProbe).__questionTimelineProbe = state
const submit = [...document.querySelectorAll<HTMLButtonElement>('[data-component="dock-prompt"] button')].find(
(button) => button.textContent?.trim() === "Submit",
)
submit?.addEventListener(
"click",
() => {
state.submitted = true
state.transitionSamples = 0
},
{ capture: true, once: true },
)
const sample = () => {
const viewport = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-virtual-content]"),
)
const view = viewport?.getBoundingClientRect()
const visible = [...(viewport?.querySelectorAll<HTMLElement>("[data-timeline-part-id]") ?? [])].some((part) => {
const rect = part.getBoundingClientRect()
return !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
})
if (view && view.width > 0 && view.height > 0 && !visible) state.blank = true
state.samples++
if (state.submitted) state.transitionSamples++
if (!state.stop) requestAnimationFrame(sample)
}
requestAnimationFrame(sample)
})
await page.waitForFunction(() => ((window as QuestionTimelineProbe).__questionTimelineProbe?.samples ?? 0) >= 2)
await question.getByRole("radio", { name: /Already in app/ }).click()
await question.getByRole("button", { name: "Submit" }).click()
await expect.poll(() => responses).toEqual([{ requestID: questionID, answers: [["Already in app"]] }])
await expect(question).toHaveCount(0)
await expect(page.locator('[data-component="session-composer"]')).toBeVisible()
await expect(page.locator('[data-component="toast-v2"]')).toHaveCount(0)
await expect(lastPart).toBeInViewport()
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeInViewport()
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await expect.poll(() => distanceFromBottom(scroller)).toBeGreaterThan(-3)
await expect.poll(() => distanceFromBottom(scroller)).toBeLessThan(3)
expect(await visibleTimelineRows(scroller)).toBeGreaterThan(0)
const probe = await page.evaluate(() => {
const state = (window as QuestionTimelineProbe).__questionTimelineProbe!
state.stop = true
return state
})
expect(probe).toMatchObject({ blank: false })
expect(probe.submitted).toBe(true)
expect(probe.transitionSamples).toBeGreaterThan(0)
})
async function mockServer(
page: Page,
requests: {
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
messages?: { info: { id: string }; parts: { id: string }[] }[]
sessionStatus?: unknown
vcsDiff?: unknown[]
onQuestionReply?: (input: { requestID: string; answers?: string[][] }) => unknown | Promise<unknown>
},
) {
await mockOpenCodeServer(page, {
@ -398,131 +145,11 @@ async function mockServer(
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: (_, limit, before) => {
const messages = requests.messages ?? []
const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
const start = Math.max(0, end - limit)
return {
items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined,
}
},
message: (_, messageID) => requests.messages?.find((message) => message.info.id === messageID),
pageMessages: () => ({ items: [] }),
permissions: requests.permissions,
questions: requests.questions,
sessionStatus: requests.sessionStatus,
onQuestionReply: requests.onQuestionReply,
vcsDiff: requests.vcsDiff,
fileList: () => [],
})
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ general: { newLayoutDesigns: true, shellToolPartsExpanded: true } }),
)
})
}
function timelineMessages(turns: number) {
return Array.from({ length: turns }, (_, index) => {
const key = String(index).padStart(4, "0")
const userID = `msg_${key}_a_request_user`
const active = index === turns - 1
const user = {
info: {
id: userID,
sessionID,
role: "user",
time: { created: 1700000000000 + index * 10_000 },
summary: { diffs: [] },
agent: "build",
model: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
parts: [
{
id: `prt_${key}_request_user`,
sessionID,
messageID: userID,
type: "text",
text: `Request turn ${index}`,
},
],
}
const assistants = Array.from({ length: active ? 14 : 1 }, (_, assistantIndex) => {
const suffix = active ? `b${String(assistantIndex).padStart(2, "0")}` : "b"
const assistantID = `msg_${key}_${suffix}_request_assistant`
const current = active && assistantIndex === 13
const created = 1700000001000 + index * 10_000 + assistantIndex * 100
return {
info: {
id: assistantID,
sessionID,
role: "assistant",
time: { created, ...(current ? {} : { completed: created + 50 }) },
parentID: userID,
modelID: "claude-opus-4-6",
providerID: "opencode",
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0,
tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: current
? [
{
id: activeToolID,
sessionID,
messageID: assistantID,
type: "tool",
callID: activeCallID,
tool: "question",
state: {
status: "running",
input: { questions: questionPrompts },
metadata: {},
time: { start: 1700000791000 },
},
},
]
: [
{
id: `prt_${key}_${assistantIndex}_request_assistant`,
sessionID,
messageID: assistantID,
type: "text",
text: `Assistant response ${index}.${assistantIndex}. ${"Long timeline content. ".repeat(12)}`,
},
],
}
})
return [user, ...assistants]
}).flat()
}
function distanceFromBottom(scroller: ReturnType<Page["locator"]>) {
return scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)
}
function reviewDiffs(count: number) {
return Array.from({ length: count }, (_, index) => {
const file = `src/focus-${String(index).padStart(4, "0")}.ts`
return {
file,
additions: 1,
deletions: 1,
status: "modified",
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const focused = false\n+export const focused = true\n`,
}
})
}
function visibleTimelineRows(scroller: ReturnType<Page["locator"]>) {
return scroller.evaluate((element) => {
const view = element.getBoundingClientRect()
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].filter((row) => {
const rect = row.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
}).length
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
}

View file

@ -20,7 +20,6 @@ export interface MockServerConfig {
todos?: (sessionID: string) => unknown[]
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
onQuestionReply?: (input: { requestID: string; answers?: string[][] }) => unknown | Promise<unknown>
fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
@ -111,18 +110,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
const permissionRespondMatch = path.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
if (permissionRespondMatch && route.request().method() === "POST") return json(route, true)
const questionReplyMatch = path.match(/^\/question\/([^/]+)\/reply$/)
if (questionReplyMatch && route.request().method() === "POST") {
const body = route.request().postDataJSON() as { answers?: string[][] }
return json(
route,
(await config.onQuestionReply?.({ requestID: questionReplyMatch[1]!, answers: body.answers })) ?? true,
)
}
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const token = url.searchParams.get("before") ?? undefined

View file

@ -162,7 +162,7 @@ export async function installSseTransport<T>(
const request = new Request(input, init)
const url = new URL(request.url)
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
return originalFetch(request)
return originalFetch(input, init)
const id = ++nextConnectionID
const record = {

View file

@ -1,226 +0,0 @@
import type { CDPSession, Page, TestInfo } from "@playwright/test"
export async function startTimelineDiagnostics(page: Page, testInfo: TestInfo) {
const cdp = await page.context().newCDPSession(page)
const pauses: unknown[] = []
const scripts: { url: string; scriptID: string }[] = []
const breakpoints = new Map<string, string>()
const setup: Promise<void>[] = []
let handling = Promise.resolve()
await cdp.send("Debugger.enable")
cdp.on("Debugger.scriptParsed", (event) => {
if (!/tanstack|solid-virtual|virtual-core/i.test(event.url)) return
scripts.push({ url: event.url, scriptID: event.scriptId })
setup.push(
installSourceBreakpoints(cdp, event.scriptId, breakpoints).catch((error) => {
pauses.push({ type: "breakpoint-install-error", url: event.url, error: String(error) })
}),
)
})
cdp.on("Debugger.paused", (event) => {
handling = handling.then(async () => {
const frame = event.callFrames[0]
const state = frame
? await cdp
.send("Debugger.evaluateOnCallFrame", {
callFrameId: frame.callFrameId,
expression: `(() => {
const root = [...document.querySelectorAll('.scroll-view__viewport')].find((element) =>
element.querySelector('[data-timeline-virtual-content]')
)
const indexes = root ? [...root.querySelectorAll('[data-index]')].map((item) => Number(item.getAttribute('data-index'))) : []
const owner = this && typeof this === 'object' && 'scrollOffset' in this ? this : undefined
return {
now: performance.now(),
dom: root ? {
scrollTop: root.scrollTop,
scrollHeight: root.scrollHeight,
clientHeight: root.clientHeight,
indexes,
} : null,
virtualizer: owner ? {
scrollOffset: owner.scrollOffset,
scrollAdjustments: owner.scrollAdjustments,
intendedScrollOffset: owner._intendedScrollOffset,
isScrolling: owner.isScrolling,
range: owner.range,
totalSize: typeof owner.getTotalSize === 'function' ? owner.getTotalSize() : undefined,
} : null,
active: document.activeElement ? {
tag: document.activeElement.tagName,
component: document.activeElement.getAttribute('data-component'),
slot: document.activeElement.getAttribute('data-slot'),
text: document.activeElement.textContent?.trim().slice(0, 80),
} : null,
}
})()`,
returnByValue: true,
})
.then((result) => result.result.value)
.catch((error) => ({ error: String(error) }))
: undefined
pauses.push({
type: "pause",
reason: event.reason,
data: event.data,
hitBreakpoints: event.hitBreakpoints?.map((id) => breakpoints.get(id) ?? id),
state,
stack: event.callFrames.slice(0, 20).map((item) => ({
functionName: item.functionName,
url: item.url,
line: item.location.lineNumber + 1,
column: (item.location.columnNumber ?? 0) + 1,
})),
})
await cdp.send("Debugger.resume").catch(() => {})
})
})
await page.addInitScript(() => {
type TraceWindow = Window & { __timelineDomTrace?: unknown[] }
const output: unknown[] = []
;(window as TraceWindow).__timelineDomTrace = output
let last = ""
let observed: HTMLElement | undefined
const active = () => {
const element = document.activeElement
if (!(element instanceof HTMLElement)) return null
return {
tag: element.tagName,
component: element.dataset.component,
slot: element.dataset.slot,
text: element.textContent?.trim().slice(0, 80),
}
}
const root = () =>
[...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-virtual-content]"),
)
const record = (type: string, detail: Record<string, unknown> = {}) => {
const element = root()
const indexes = element
? [...element.querySelectorAll<HTMLElement>("[data-index]")].map((item) => Number(item.dataset.index))
: []
output.push({
type,
at: performance.now(),
scrollTop: element?.scrollTop,
scrollHeight: element?.scrollHeight,
clientHeight: element?.clientHeight,
indexes,
active: active(),
question: !!document.querySelector('[data-component="dock-prompt"][data-kind="question"]'),
review: !!document.querySelector('#review-panel [data-component="session-review-v2"]'),
...detail,
})
}
const watch = () => {
const element = root()
if (element && element !== observed) {
observed = element
new ResizeObserver(() => record("resize")).observe(element)
}
requestAnimationFrame(watch)
}
const sample = () => {
const element = root()
const indexes = element
? [...element.querySelectorAll<HTMLElement>("[data-index]")].map((item) => Number(item.dataset.index))
: []
const next = JSON.stringify([
element?.scrollTop,
element?.scrollHeight,
element?.clientHeight,
indexes[0],
indexes.at(-1),
document.activeElement?.getAttribute("data-slot"),
!!document.querySelector('[data-component="dock-prompt"][data-kind="question"]'),
])
if (next !== last) {
last = next
record("frame")
}
requestAnimationFrame(sample)
}
document.addEventListener("scroll", (event) => record("scroll", { trusted: event.isTrusted }), true)
document.addEventListener("focusin", (event) => record("focusin", { trusted: event.isTrusted }), true)
document.addEventListener("focusout", (event) => record("focusout", { trusted: event.isTrusted }), true)
requestAnimationFrame(watch)
requestAnimationFrame(sample)
})
await cdp.send("Tracing.start", {
categories: "devtools.timeline,blink.user_timing,v8.execute,disabled-by-default-devtools.timeline.stack",
options: "sampling-frequency=10000",
transferMode: "ReturnAsStream",
})
return {
async stop() {
await Promise.allSettled(setup)
await handling
const complete = new Promise<string>((resolve, reject) => {
cdp.once("Tracing.tracingComplete", (event) => {
if (!event.stream) {
reject(new Error("CDP tracing completed without a stream"))
return
}
resolve(event.stream)
})
})
await cdp.send("Tracing.end")
const stream = await complete
const trace = await readCdpStream(cdp, stream)
const dom = await page
.evaluate(() => (window as Window & { __timelineDomTrace?: unknown[] }).__timelineDomTrace ?? [])
.catch(() => [])
const { writeFile } = await import("node:fs/promises")
const tracePath = "C:\\tmp\\opencode\\timeline-devtools-trace.json"
const debuggerPath = "C:\\tmp\\opencode\\timeline-cdp-debugger.json"
const domPath = "C:\\tmp\\opencode\\timeline-dom-events.json"
await Promise.all([
writeFile(tracePath, trace),
writeFile(debuggerPath, JSON.stringify({ test: testInfo.title, scripts, pauses }, null, 2)),
writeFile(domPath, JSON.stringify(dom, null, 2)),
])
await cdp.send("Debugger.disable").catch(() => {})
await cdp.detach().catch(() => {})
console.log(`TIMELINE_TRACE ${JSON.stringify({ tracePath, debuggerPath, domPath, pauses: pauses.length })}`)
},
}
}
export type TimelineDiagnostics = Awaited<ReturnType<typeof startTimelineDiagnostics>>
async function installSourceBreakpoints(cdp: CDPSession, scriptID: string, labels: Map<string, string>) {
const targets = [
["offset-observer", "this.scrollOffset = offset"],
["resize-item", "this.resizeItem = (index, size)"],
["apply-scroll-adjustment", "applyScrollAdjustment(delta, behavior)"],
["scroll-to-offset", "this._scrollToOffset = (offset"],
["scroll-to-end", "this.scrollToEnd ="],
] as const
for (const [label, query] of targets) {
const matches = await cdp.send("Debugger.searchInContent", { scriptId: scriptID, query })
for (const match of matches.result) {
const result = await cdp.send("Debugger.setBreakpoint", {
location: { scriptId: scriptID, lineNumber: match.lineNumber },
})
labels.set(result.breakpointId, label)
}
}
}
async function readCdpStream(cdp: CDPSession, handle: string) {
const chunks: Buffer[] = []
while (true) {
const chunk = await cdp.send("IO.read", { handle })
chunks.push(Buffer.from(chunk.data, chunk.base64Encoded ? "base64" : "utf8"))
if (chunk.eof) break
}
await cdp.send("IO.close", { handle })
return Buffer.concat(chunks)
}

View file

@ -66,19 +66,26 @@ test("initial rect projects rows before a scroll element connects", () => {
})
})
test("clamps an oversized initial offset to a full final viewport range", () => {
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>({
test("clamps oversized offsets with scroll margin and padding changes", () => {
const options = (paddingEnd: number) => ({
count: 20,
estimateSize: () => 60,
initialOffset: Number.MAX_SAFE_INTEGER,
initialRect: { width: 800, height: 600 },
scrollMargin: 64,
paddingEnd,
overscan: 1,
getScrollElement: () => null,
scrollToFn: () => {},
observeElementRect: () => {},
observeElementOffset: () => {},
})
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options(64))
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
virtualizer.setOptions(options(600))
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([18, 19])
})
test("stale pinned indexes do not produce missing virtual items after count shrinks", () => {

View file

@ -1,12 +1,22 @@
diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs
index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..bcee09df7377c37ffb220606b741c9ff434b3470 100644
index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..830f646f5bd80f4e00f442291ad6e4c6c508630d 100644
--- a/dist/cjs/index.cjs
+++ b/dist/cjs/index.cjs
@@ -723,10 +723,12 @@ class Virtualizer {
@@ -716,17 +716,20 @@ class Virtualizer {
this.getMeasurements(),
this.getSize(),
this.getScrollOffset(),
- this.options.lanes
+ this.options.lanes,
+ this.options.paddingEnd
],
- (measurements, outerSize, scrollOffset, lanes) => {
+ (measurements, outerSize, scrollOffset, lanes, _paddingEnd) => {
if (measurements.length === 0 || outerSize === 0) {
this.range = null;
return null;
}
+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0);
+ const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0);
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
this.range = calculateRangeImpl(
measurements,
@ -16,15 +26,51 @@ index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..bcee09df7377c37ffb220606b741c9ff
lanes,
// Pass the typed array so binary search + forward-walk can read
// start/end directly from Float64Array, skipping the Proxy traps.
diff --git a/dist/cjs/index.d.cts b/dist/cjs/index.d.cts
index c61ee17752565253f795c7fc7d57e86237ecbb52..430c622a18c69407df9f1ca82ee9440e9be287b7 100644
--- a/dist/cjs/index.d.cts
+++ b/dist/cjs/index.d.cts
@@ -144,7 +144,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
startIndex: number;
endIndex: number;
} | null;
- updateDeps(newDeps: [VirtualItem[], number, number, number]): void;
+ updateDeps(newDeps: [VirtualItem[], number, number, number, number]): void;
};
getVirtualIndexes: {
(): number[];
diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts
index b03abab604eb6578f6f56ff92c489259cfaf8f19..940310f9215ba5bf666c5b71e92dddbbbc85635d 100644
--- a/dist/esm/index.d.ts
+++ b/dist/esm/index.d.ts
@@ -144,7 +144,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
startIndex: number;
endIndex: number;
} | null;
- updateDeps(newDeps: [VirtualItem[], number, number, number]): void;
+ updateDeps(newDeps: [VirtualItem[], number, number, number, number]): void;
};
getVirtualIndexes: {
(): number[];
diff --git a/dist/esm/index.js b/dist/esm/index.js
index 3032c0ca457582be3f47923cba1f7d92c848745c..90b574881a073aabac99c075f7eab0a8f363fff6 100644
index 3032c0ca457582be3f47923cba1f7d92c848745c..6e52124048fb3bc7b7ee37e763354b33593a6285 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -721,10 +721,12 @@ class Virtualizer {
@@ -714,17 +714,20 @@ class Virtualizer {
this.getMeasurements(),
this.getSize(),
this.getScrollOffset(),
- this.options.lanes
+ this.options.lanes,
+ this.options.paddingEnd
],
- (measurements, outerSize, scrollOffset, lanes) => {
+ (measurements, outerSize, scrollOffset, lanes, _paddingEnd) => {
if (measurements.length === 0 || outerSize === 0) {
this.range = null;
return null;
}
+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0);
+ const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0);
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
this.range = calculateRangeImpl(
measurements,
@ -35,14 +81,22 @@ index 3032c0ca457582be3f47923cba1f7d92c848745c..90b574881a073aabac99c075f7eab0a8
// Pass the typed array so binary search + forward-walk can read
// start/end directly from Float64Array, skipping the Proxy traps.
diff --git a/src/index.ts b/src/index.ts
index 7ad79aacd591c3a4f7855c91b2727a59a9579bca..faef81e8e567424146143242408eec35a634cf04 100644
index 7ad79aacd591c3a4f7855c91b2727a59a9579bca..201406779ee85d2f2e5b01a8ea284aa9a5f694e2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1373,10 +1373,12 @@ export class Virtualizer<
@@ -1367,16 +1367,19 @@ export class Virtualizer<
this.getSize(),
this.getScrollOffset(),
this.options.lanes,
+ this.options.paddingEnd,
],
- (measurements, outerSize, scrollOffset, lanes) => {
+ (measurements, outerSize, scrollOffset, lanes, _paddingEnd) => {
if (measurements.length === 0 || outerSize === 0) {
this.range = null
return null
}
+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0)
+ const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0)
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset)
this.range = calculateRangeImpl(
measurements,