diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index c03b9243fc3..6cea279b906 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -73,6 +73,8 @@ export type CreateDataInput = { const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") const messagePageLimit = 20 +// Trailing window for event bursts that each ask for the same refetch. +export const settleMs = 150 // Global MCP elicitations temporarily use "global" instead of a real session ID, so the // server cannot recover their Location when settling them. Preserve the event Location @@ -141,12 +143,18 @@ function formRequestOptions(sessionID: string, ref?: LocationRef) { } function createSync() { - type Pending = { promise: Promise; invalidated: boolean } + // `started` is false while a reload waits for the load it replaces. Invalidations that land in + // that window are already covered, since the reload has not read anything yet. + type Pending = { promise: Promise; invalidated: boolean; started: boolean } const state = new Map() const start = (key: string, load: () => Promise, wait?: Promise) => { - const entry: Pending = { promise: Promise.resolve(), invalidated: false } + const entry: Pending = { promise: Promise.resolve(), invalidated: false, started: !wait } state.set(key, entry) - entry.promise = (wait ? wait.catch(() => undefined).then(load) : load()) + const run = () => { + entry.started = true + return load() + } + entry.promise = (wait ? wait.catch(() => undefined).then(run) : run()) .then(() => { if (state.get(key) === entry && !entry.invalidated) state.set(key, true) }) @@ -178,12 +186,12 @@ function createSync() { if (key) { const active = state.get(key) if (active === true) state.delete(key) - if (active !== undefined && active !== true) active.invalidated = true + if (active !== undefined && active !== true && active.started) active.invalidated = true return } state.forEach((active, current) => { if (active === true) state.delete(current) - if (active !== true) active.invalidated = true + if (active !== true && active.started) active.invalidated = true }) }, } @@ -203,6 +211,20 @@ export function createData(config: CreateDataInput) { }) } + // Runs `load` once a burst of same-key events goes quiet, so N events cost one refetch. + const settling = new Map>() + onCleanup(() => settling.forEach((timer) => clearTimeout(timer))) + function settle(key: string, load: () => Promise) { + clearTimeout(settling.get(key)) + settling.set( + key, + setTimeout(() => { + settling.delete(key) + refresh(load) + }, settleMs), + ) + } + const [store, setStore] = createStore({ session: { info: {}, @@ -1226,10 +1248,11 @@ export function createData(config: CreateDataInput) { refresh(() => result.location.websearch.refresh(location)) break // Authenticating an MCP integration reconnects its server, which emits mcp.status.changed, - // so the mcp list syncs here rather than off integration.updated. + // so the mcp list syncs here rather than off integration.updated. The server emits one event + // per MCP server as each settles, so a location booting nine servers emitted nine refetches. case "mcp.status.changed": result.location.mcp.server.invalidate(location) - refresh(() => result.location.mcp.server.sync(location)) + settle(`mcp.status:${locationKey(location)}`, () => result.location.mcp.server.sync(location)) break case "mcp.resources.changed": result.location.mcp.resource.invalidate(location) diff --git a/packages/client/test/solid-refresh.test.ts b/packages/client/test/solid-refresh.test.ts index 266c594fdcc..4b12642a4ad 100644 --- a/packages/client/test/solid-refresh.test.ts +++ b/packages/client/test/solid-refresh.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import { createRoot } from "solid-js" -import { createData, type CreateDataInput } from "../src/solid" +import { createData, settleMs, type CreateDataInput } from "../src/solid" import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise" test("config reads and update refreshes are opt-in", async () => { @@ -165,3 +165,86 @@ test.each(["reconnecting", "disposed"] as const)("background reads respect %s ow expect(state.requests).toBe(1) expect(errors).toEqual([]) }) + +test("a burst of mcp.status.changed events refetches the server list once it settles", async () => { + const listeners = new Set[0]>() + const requests: string[] = [] + const location = { directory: "/project" } + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push(new URL(request.url).pathname) + return Response.json({ location, data: [] }) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: location.directory, + event: { + on: () => () => {}, + listen(handler) { + listeners.add(handler) + return () => listeners.delete(handler) + }, + }, + }), + dispose, + })) + try { + await setup.data.location.mcp.server.sync() + expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(1) + for (const server of ["a", "b", "c", "d", "e"]) { + const event: OpenCodeEvent = { id: `evt_${server}`, created: 1, type: "mcp.status.changed", location, data: { server } } + listeners.forEach((listener) => listener({ name: event.type, details: event })) + } + expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(1) + await new Promise((resolve) => setTimeout(resolve, settleMs * 2)) + expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(2) + } finally { + setup.dispose() + } +}) + +test("invalidations that land before a queued reload starts are absorbed by it", async () => { + const pending: Array> = [] + const location = { directory: "/project" } + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: () => { + const deferred = Promise.withResolvers() + pending.push(deferred) + return deferred.promise + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: location.directory, + event: { on: () => () => {}, listen: () => () => {} }, + }), + dispose, + })) + try { + const first = setup.data.location.agent.sync() + expect(pending).toHaveLength(1) + setup.data.location.agent.invalidate() + const second = setup.data.location.agent.sync() + setup.data.location.agent.invalidate() + const third = setup.data.location.agent.sync() + setup.data.location.agent.invalidate() + expect(pending).toHaveLength(1) + pending[0].resolve(Response.json({ location, data: [] })) + await first + await Promise.resolve() + expect(pending).toHaveLength(2) + pending[1].resolve(Response.json({ location, data: [{ name: "reviewer" }] })) + await Promise.all([second, third]) + expect(pending).toHaveLength(2) + await setup.data.location.agent.sync() + expect(pending).toHaveLength(2) + } finally { + setup.dispose() + } +})