Compare commits

...
1 Commits
Author SHA1 Message Date
LukeParkerDev 1f914b5d81 fix(client): refresh only loaded catalogs on location events
Every Location booted on the server emits catalog, agent, command, skill,
reference, integration, websearch and MCP events, including Locations this
client never opened. Each event eagerly re-fetched that location's catalog,
producing request waves across hundreds of directories.

Events still invalidate so the next explicit read is fresh, but only catalogs
a consumer already read are re-fetched, mirroring the existing config.updated
guard. credential.* events apply the same rule per stored location key.

Websearch providers have no explicit reader; the TUI's integration dialog
relies on events to fill them, so that refresh is gated on the location having
been opened (syncInfo) instead of on a prior read.
2026-09-05 14:58:52 +10:00
2 changed files with 151 additions and 32 deletions
+29 -32
View File
@@ -1126,7 +1126,8 @@ export function createData(config: CreateDataInput) {
const location = { directory: ref[0], workspaceID: ref[1] ?? undefined }
if (event.type === "credential.updated") {
result.location.integration.invalidate(location)
refresh(() => result.location.integration.sync(location))
// Branch and shell events create location keys for unopened directories too.
if (result.location.integration.loaded(location)) refresh(() => result.location.integration.sync(location))
return
}
setStore("location", key, (data) => ({
@@ -1144,30 +1145,34 @@ export function createData(config: CreateDataInput) {
}))
result.location.model.invalidate(location)
result.location.provider.invalidate(location)
refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]))
if (result.location.model.loaded(location)) refresh(() => result.location.model.sync(location))
if (result.location.provider.loaded(location)) refresh(() => result.location.provider.sync(location))
})
return
}
if (!event.location) return
const location = event.location
// Every Location booted on the server emits these, including ones this client never opened.
// Invalidate always so the next explicit read is fresh, but only re-fetch catalogs a consumer
// already read; eagerly reading them all floods the browser's connection pool.
const resync = (...resources: Array<Pick<ReturnType<typeof locationResource>, "invalidate" | "loaded" | "sync">>) =>
resources.forEach((resource) => {
resource.invalidate(location)
if (resource.loaded(location)) refresh(() => resource.sync(location))
})
switch (event.type) {
case "catalog.updated":
result.location.model.invalidate(location)
result.location.provider.invalidate(location)
refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]))
resync(result.location.model, result.location.provider)
break
case "agent.updated":
result.location.agent.invalidate(location)
refresh(() => result.location.agent.sync(location))
resync(result.location.agent)
break
case "command.updated":
result.location.command.invalidate(location)
refresh(() => result.location.command.sync(location))
resync(result.location.command)
break
case "skill.updated":
result.location.skill.invalidate(location)
refresh(() => result.location.skill.sync(location))
resync(result.location.skill)
break
case "vcs.branch.updated":
setStore("location", locationKey(location), (data) => ({
@@ -1201,39 +1206,26 @@ export function createData(config: CreateDataInput) {
}))
break
case "reference.updated":
result.location.reference.invalidate(location)
refresh(() => result.location.reference.sync(location))
resync(result.location.reference)
break
case "integration.updated":
result.location.integration.invalidate(location)
result.location.model.invalidate(location)
result.location.provider.invalidate(location)
refresh(() =>
Promise.all([
result.location.integration.sync(location),
result.location.model.sync(location),
result.location.provider.sync(location),
]),
)
resync(result.location.integration, result.location.model, result.location.provider)
break
// Nothing reads websearch providers explicitly; events populate them for opened locations.
case "config.updated":
result.location.config.invalidate(location)
if (result.location.config.list(location) !== undefined || sync.has(`location.config:${locationKey(location)}`))
refresh(() => result.location.config.sync(location))
refresh(() => result.location.websearch.refresh(location))
resync(result.location.config)
if (result.location.info(location)) refresh(() => result.location.websearch.refresh(location))
break
case "websearch.updated":
refresh(() => result.location.websearch.refresh(location))
if (result.location.info(location)) 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.
case "mcp.status.changed":
result.location.mcp.server.invalidate(location)
refresh(() => result.location.mcp.server.sync(location))
resync(result.location.mcp.server)
break
case "mcp.resources.changed":
result.location.mcp.resource.invalidate(location)
refresh(() => result.location.mcp.resource.sync(location))
resync(result.location.mcp.resource)
break
}
}
@@ -1249,6 +1241,11 @@ export function createData(config: CreateDataInput) {
const publish = (key: string, value: LocationData[Field]) => setStore("location", key, { [field]: value })
return {
list: (ref?: LocationRef) => store.location[locationKey(ref ?? defaultLocation())]?.[field],
// True once a consumer has read or is reading this catalog; event refreshes are opt-in to it.
loaded: (ref?: LocationRef) => {
const id = locationKey(ref ?? defaultLocation())
return store.location[id]?.[field] !== undefined || sync.has(`location.${field}:${id}`)
},
sync: (ref?: LocationRef) => {
const location = ref ?? defaultLocation()
const id = locationKey(location)
+122
View File
@@ -53,6 +53,128 @@ test("config reads and update refreshes are opt-in", async () => {
}
})
test("mcp catalog refreshes are opt-in to locations a consumer already read", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: string[] = []
const opened = { directory: "/opened" }
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
const location = { directory: url.searchParams.get("location[directory]") ?? opened.directory }
requests.push(`${url.pathname}?${location.directory}`)
if (url.pathname === "/api/mcp/resource")
return Response.json({ location, data: { resources: [], templates: [] } })
return Response.json({ location, data: [] })
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: opened.directory,
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
const emit = (directory: string) => {
const location = { directory }
const events: OpenCodeEvent[] = [
{ id: "evt_status", created: 1, type: "mcp.status.changed", location, data: { server: "test" } },
{ id: "evt_resources", created: 2, type: "mcp.resources.changed", location, data: { server: "test" } },
]
events.forEach((event) => listeners.forEach((listener) => listener({ name: event.type, details: event })))
}
try {
Array.from({ length: 300 }, (_, index) => emit(`/history/${index}`))
await new Promise((resolve) => setTimeout(resolve, 0))
expect(requests).toEqual([])
await Promise.all([setup.data.location.mcp.server.sync(opened), setup.data.location.mcp.resource.sync(opened)])
expect(requests).toEqual([`/api/mcp?${opened.directory}`, `/api/mcp/resource?${opened.directory}`])
emit(opened.directory)
emit("/history/0")
await new Promise((resolve) => setTimeout(resolve, 0))
expect(requests).toEqual([
`/api/mcp?${opened.directory}`,
`/api/mcp/resource?${opened.directory}`,
`/api/mcp?${opened.directory}`,
`/api/mcp/resource?${opened.directory}`,
])
// An unread location stays invalidated, so the first explicit read still fetches.
await setup.data.location.mcp.server.sync({ directory: "/history/0" })
expect(requests.at(-1)).toBe("/api/mcp?/history/0")
} finally {
setup.dispose()
}
})
test("websearch providers populate from events for opened locations only", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: string[] = []
const opened = { directory: "/opened" }
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
const location = { directory: url.searchParams.get("location[directory]") ?? opened.directory }
requests.push(`${url.pathname}?${location.directory}`)
if (url.pathname === "/api/location") return Response.json(location)
if (url.pathname === "/api/websearch/provider")
return Response.json({ location, data: [{ id: "exa", name: "Exa", status: "connected" }] })
return Response.json({ location, data: [] })
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: opened.directory,
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
const emit = (directory: string) => {
const event: OpenCodeEvent = {
id: "evt_ws",
created: 1,
type: "websearch.updated",
location: { directory },
data: {},
}
listeners.forEach((listener) => listener({ name: event.type, details: event }))
}
try {
emit("/history")
await new Promise((resolve) => setTimeout(resolve, 0))
expect(requests).toEqual([])
// The TUI's integration dialog reads websearch without ever calling refresh; syncInfo is the demand.
await setup.data.location.syncInfo()
expect(setup.data.location.websearch.list()).toBeUndefined()
emit(opened.directory)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(requests.filter((path) => path.startsWith("/api/websearch/provider"))).toEqual([
`/api/websearch/provider?${opened.directory}`,
])
expect(setup.data.location.websearch.list()).toMatchObject([{ id: "exa" }])
} finally {
setup.dispose()
}
})
test("event refreshes report failures, remain retryable, and preserve explicit read errors", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const reported = Promise.withResolvers<unknown>()