diff --git a/bun.lock b/bun.lock index 72f852daf6a..228d57cd0c7 100644 --- a/bun.lock +++ b/bun.lock @@ -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:", diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index f186444f226..036eaaef422 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -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; time: number } - } - | { - type: "message.updated" - properties: { sessionID: string; info: Record } - } - ) -} - -type QuestionTimelineProbe = Window & { - __questionTimelineProbe?: { - blank: boolean - samples: number - stop: boolean - submitted: boolean - transitionSamples: number - } -} - -const timelineDiagnostics = new WeakMap() - -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(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('[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(".scroll-view__viewport")].find((element) => - element.querySelector("[data-timeline-virtual-content]"), - ) - const view = viewport?.getBoundingClientRect() - const visible = [...(viewport?.querySelectorAll("[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 }, ) { 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) { - 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) { - return scroller.evaluate((element) => { - const view = element.getBoundingClientRect() - return [...element.querySelectorAll("[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 } })) }) } diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 503fe45a6a1..e1946b8fba0 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -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 fileList?: (path: string) => unknown | Promise fileContent?: (path: string) => unknown | Promise 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 diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index 55420485f39..186962998d1 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -162,7 +162,7 @@ export async function installSseTransport( 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 = { diff --git a/packages/app/e2e/utils/timeline-cdp-diagnostics.ts b/packages/app/e2e/utils/timeline-cdp-diagnostics.ts deleted file mode 100644 index 60003cb3f8b..00000000000 --- a/packages/app/e2e/utils/timeline-cdp-diagnostics.ts +++ /dev/null @@ -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() - const setup: Promise[] = [] - 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(".scroll-view__viewport")].find((element) => - element.querySelector("[data-timeline-virtual-content]"), - ) - const record = (type: string, detail: Record = {}) => { - const element = root() - const indexes = element - ? [...element.querySelectorAll("[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("[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((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> - -async function installSourceBreakpoints(cdp: CDPSession, scriptID: string, labels: Map) { - 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) -} diff --git a/packages/app/test-browser/solid-virtual.test.ts b/packages/app/test-browser/solid-virtual.test.ts index 2a9d98ca683..9327eacf426 100644 --- a/packages/app/test-browser/solid-virtual.test.ts +++ b/packages/app/test-browser/solid-virtual.test.ts @@ -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({ +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(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", () => { diff --git a/patches/@tanstack%2Fvirtual-core@3.17.3.patch b/patches/@tanstack%2Fvirtual-core@3.17.3.patch index e5201888b9e..2450d4234b4 100644 --- a/patches/@tanstack%2Fvirtual-core@3.17.3.patch +++ b/patches/@tanstack%2Fvirtual-core@3.17.3.patch @@ -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 { ++ (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,