From 3b324757a3f0aec54904af7c03bd2eba453fe847 Mon Sep 17 00:00:00 2001 From: Andrew Mikofalvy <5668128+amikofalvy@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:18:12 -0700 Subject: [PATCH] perf(open-knowledge): single-flight the /api/documents listing fetch (#2280) PageListContext, EmptyEditorState, and the wiki-link suggestion source each independently fetch the full /api/documents listing; on boot and every CC1 files push they ran the same ready-gated disk walk two or three times concurrently. Route them through a shared single-flight coordinator so overlapping calls coalesce into one request plus one parse. Single-flight, not a cache: the slot releases on settle, so consumers that refetch on a files push still get current data. FileTree's depth=1 lazy fetch (a different URL) is unchanged. GitOrigin-RevId: e6ac46966acca08cf896e3263d9b7bffdd2043d5 --- .changeset/documents-list-single-flight.md | 5 ++ .../app/src/components/EmptyEditorState.tsx | 13 ++- .../app/src/components/PageListContext.tsx | 10 +-- .../extensions/wiki-link-suggestion.test.ts | 2 + .../editor/extensions/wiki-link-suggestion.ts | 9 +- packages/app/src/lib/documents-fetch.test.ts | 87 +++++++++++++++++++ packages/app/src/lib/documents-fetch.ts | 33 +++++++ 7 files changed, 143 insertions(+), 16 deletions(-) create mode 100644 .changeset/documents-list-single-flight.md create mode 100644 packages/app/src/lib/documents-fetch.test.ts create mode 100644 packages/app/src/lib/documents-fetch.ts diff --git a/.changeset/documents-list-single-flight.md b/.changeset/documents-list-single-flight.md new file mode 100644 index 000000000..89413a6b1 --- /dev/null +++ b/.changeset/documents-list-single-flight.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": patch +--- + +Coalesce the redundant `GET /api/documents` requests fired on load. Several consumers independently fetch the same full listing — the page-list context (asset/folder/file paths), the empty-editor state (entry count for the onboarding branch), and the wiki-link suggestion source (referenced assets) — so on boot and on every file-change push the server ran the same `ready`-gated disk walk two or three times concurrently. These now share a single in-flight request and one JSON parse. It is single-flight, not a result cache: the slot is released as soon as the request settles, so consumers that refetch on a file-change push still get current data. The sidebar file tree keeps its own `depth=1` lazy fetch (a different URL) unchanged. diff --git a/packages/app/src/components/EmptyEditorState.tsx b/packages/app/src/components/EmptyEditorState.tsx index c8fdb03ce..133be43c3 100644 --- a/packages/app/src/components/EmptyEditorState.tsx +++ b/packages/app/src/components/EmptyEditorState.tsx @@ -13,6 +13,7 @@ import { useIsEmbedded } from '@/hooks/use-is-embedded'; import { emitCreateTopLevelFile } from '@/lib/create-file-events'; import type { OkPackId } from '@/lib/desktop-bridge-types'; import { subscribeToDocumentsChanged } from '@/lib/documents-events'; +import { fetchDocumentListShared } from '@/lib/documents-fetch'; export function EmptyEditorState({ terminalVisible = false }: { terminalVisible?: boolean }) { const [seedDialogOpen, setSeedDialogOpen] = useState(false); @@ -29,10 +30,9 @@ export function EmptyEditorState({ terminalVisible = false }: { terminalVisible? async function refresh() { try { - const res = await fetch('/api/documents'); - const body = (await res.json().catch(() => null)) as unknown; + const { ok, body } = await fetchDocumentListShared(); if (cancelled) return; - const success = res.ok ? DocumentListSuccessSchema.safeParse(body) : null; + const success = ok ? DocumentListSuccessSchema.safeParse(body) : null; if (success?.success) { setDocumentCount(countEntries(success.data.documents)); documentCountResolvedRef.current = true; @@ -63,10 +63,9 @@ export function EmptyEditorState({ terminalVisible = false }: { terminalVisible? function handleSeedApplied() { clearTimeout(celebrateTimerRef.current); celebrateTimerRef.current = setTimeout(() => setCelebrateSignal((prev) => prev + 1), 500); - fetch('/api/documents') - .then(async (res) => { - const body = (await res.json().catch(() => null)) as unknown; - if (!res.ok) return; + fetchDocumentListShared() + .then(({ ok, body }) => { + if (!ok) return; const success = DocumentListSuccessSchema.safeParse(body); if (success.success) { setDocumentCount(countEntries(success.data.documents)); diff --git a/packages/app/src/components/PageListContext.tsx b/packages/app/src/components/PageListContext.tsx index eb525f427..7a1a116ab 100644 --- a/packages/app/src/components/PageListContext.tsx +++ b/packages/app/src/components/PageListContext.tsx @@ -7,6 +7,7 @@ import { setPageListCache, } from '@/editor/page-list-cache'; import { subscribeToDocumentsChanged } from '@/lib/documents-events'; +import { fetchDocumentListShared } from '@/lib/documents-fetch'; import { parseApiError } from '@/lib/parse-api-error'; import { deriveKnownFolderPaths } from './navigation-targets'; @@ -101,12 +102,11 @@ async function loadDocumentListSummary(): Promise<{ folderPaths: string[]; filePaths: string[]; }> { - const r = await fetch('/api/documents'); - if (!r.ok) { - const body = (await r.json().catch(() => null)) as unknown; - throw new Error(parseApiError(body) ?? `/api/documents responded with ${r.status}`); + const { ok, status, body } = await fetchDocumentListShared(); + if (!ok) { + throw new Error(parseApiError(body) ?? `/api/documents responded with ${status}`); } - const data: { documents?: DocumentListEntry[] } = await r.json(); + const data = (body ?? {}) as { documents?: DocumentListEntry[] }; if (!Array.isArray(data.documents)) return { assetPaths: [], folderPaths: [], filePaths: [] }; const assetPaths = data.documents .filter((entry): entry is DocumentListEntry & { kind: 'asset'; path: string } => { diff --git a/packages/app/src/editor/extensions/wiki-link-suggestion.test.ts b/packages/app/src/editor/extensions/wiki-link-suggestion.test.ts index cdba3b948..3e36972d4 100644 --- a/packages/app/src/editor/extensions/wiki-link-suggestion.test.ts +++ b/packages/app/src/editor/extensions/wiki-link-suggestion.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; import type { HeadingEntry } from '@inkeep/open-knowledge-core'; +import { __resetDocumentListInflightForTests } from '@/lib/documents-fetch'; import { autocompleteBoost, buildAnchorItems, @@ -321,6 +322,7 @@ describe('fetchPages', () => { afterEach(() => { globalThis.fetch = realFetch; + __resetDocumentListInflightForTests(); }); test('a folder row survives and maps to a kind:"folder" PageItem', async () => { diff --git a/packages/app/src/editor/extensions/wiki-link-suggestion.ts b/packages/app/src/editor/extensions/wiki-link-suggestion.ts index 629cebe89..2f88804f4 100644 --- a/packages/app/src/editor/extensions/wiki-link-suggestion.ts +++ b/packages/app/src/editor/extensions/wiki-link-suggestion.ts @@ -16,6 +16,7 @@ import type { ResolvedPos } from '@tiptap/pm/model'; import { PluginKey } from '@tiptap/pm/state'; import { ReactRenderer } from '@tiptap/react'; import Suggestion, { type SuggestionKeyDownProps, type SuggestionProps } from '@tiptap/suggestion'; +import { fetchDocumentListShared } from '@/lib/documents-fetch'; import { HttpResponseParseError } from '../http-client'; import { WikiLinkSuggestionMenu } from '../wiki-link-suggestion/WikiLinkSuggestionMenu'; import { getEditorDocName } from './doc-context'; @@ -289,12 +290,12 @@ export async function fetchPages(): Promise { let docData: { documents?: Array<{ kind?: string; path?: string }> }; try { - const docResponse = await fetch('/api/documents'); - if (!docResponse.ok) { - console.warn('[wiki-link-suggestion] /api/documents responded with', docResponse.status); + const { ok, status, body } = await fetchDocumentListShared(); + if (!ok) { + console.warn('[wiki-link-suggestion] /api/documents responded with', status); return pages; } - docData = await docResponse.json(); + docData = (body ?? {}) as { documents?: Array<{ kind?: string; path?: string }> }; if (!Array.isArray(docData.documents)) return pages; } catch (err) { console.warn('[wiki-link-suggestion] Failed to fetch referenced assets:', err); diff --git a/packages/app/src/lib/documents-fetch.test.ts b/packages/app/src/lib/documents-fetch.test.ts new file mode 100644 index 000000000..b690b07ab --- /dev/null +++ b/packages/app/src/lib/documents-fetch.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { __resetDocumentListInflightForTests, fetchDocumentListShared } from './documents-fetch'; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; + __resetDocumentListInflightForTests(); +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('fetchDocumentListShared', () => { + test('coalesces concurrent callers onto a single in-flight request', async () => { + let calls = 0; + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + globalThis.fetch = (async () => { + calls += 1; + await gate; + return jsonResponse({ documents: [{ kind: 'document' }] }); + }) as unknown as typeof fetch; + + const a = fetchDocumentListShared(); + const b = fetchDocumentListShared(); + release(); + const [ra, rb] = await Promise.all([a, b]); + + expect(calls).toBe(1); + expect(ra.ok).toBe(true); + expect(ra).toEqual(rb); + expect((ra.body as { documents: unknown[] }).documents).toHaveLength(1); + }); + + test('refetches after the prior request settles (single-flight, not a cache)', async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return jsonResponse({ documents: [] }); + }) as unknown as typeof fetch; + + await fetchDocumentListShared(); + await fetchDocumentListShared(); + expect(calls).toBe(2); + }); + + test('a rejected request clears the slot so the next call retries', async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + if (calls === 1) throw new Error('network down'); + return jsonResponse({ documents: [] }); + }) as unknown as typeof fetch; + + await expect(fetchDocumentListShared()).rejects.toThrow('network down'); + const result = await fetchDocumentListShared(); + expect(calls).toBe(2); + expect(result.ok).toBe(true); + }); + + test('surfaces ok=false and the status for an error response', async () => { + globalThis.fetch = (async () => + jsonResponse({ error: 'nope' }, 503)) as unknown as typeof fetch; + const { ok, status, body } = await fetchDocumentListShared(); + expect(ok).toBe(false); + expect(status).toBe(503); + expect(body).toEqual({ error: 'nope' }); + }); + + test('body is null when the response is not valid JSON', async () => { + globalThis.fetch = (async () => + new Response('not json', { + status: 200, + headers: { 'content-type': 'text/plain' }, + })) as unknown as typeof fetch; + const { ok, body } = await fetchDocumentListShared(); + expect(ok).toBe(true); + expect(body).toBeNull(); + }); +}); diff --git a/packages/app/src/lib/documents-fetch.ts b/packages/app/src/lib/documents-fetch.ts new file mode 100644 index 000000000..f5e84bd84 --- /dev/null +++ b/packages/app/src/lib/documents-fetch.ts @@ -0,0 +1,33 @@ +export interface DocumentListFetchResult { + ok: boolean; + status: number; + body: unknown; +} + +let inflight: Promise | null = null; + +export function fetchDocumentListShared(): Promise { + if (inflight) return inflight; + const pending = (async (): Promise => { + const res = await fetch('/api/documents'); + const body = (await res.json().catch((err: unknown) => { + console.warn('[documents-fetch] /api/documents response was not valid JSON:', err); + return null; + })) as unknown; + return { ok: res.ok, status: res.status, body }; + })(); + inflight = pending; + void pending.then( + () => { + if (inflight === pending) inflight = null; + }, + () => { + if (inflight === pending) inflight = null; + }, + ); + return pending; +} + +export function __resetDocumentListInflightForTests(): void { + inflight = null; +}