Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/documents-list-single-flight.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 6 additions & 7 deletions packages/app/src/components/EmptyEditorState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
10 changes: 5 additions & 5 deletions packages/app/src/components/PageListContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 } => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -321,6 +322,7 @@ describe('fetchPages', () => {

afterEach(() => {
globalThis.fetch = realFetch;
__resetDocumentListInflightForTests();
});

test('a folder row survives and maps to a kind:"folder" PageItem', async () => {
Expand Down
9 changes: 5 additions & 4 deletions packages/app/src/editor/extensions/wiki-link-suggestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -289,12 +290,12 @@ export async function fetchPages(): Promise<PageItem[]> {

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);
Expand Down
87 changes: 87 additions & 0 deletions packages/app/src/lib/documents-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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();
});
});
33 changes: 33 additions & 0 deletions packages/app/src/lib/documents-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export interface DocumentListFetchResult {
ok: boolean;
status: number;
body: unknown;
}

let inflight: Promise<DocumentListFetchResult> | null = null;

export function fetchDocumentListShared(): Promise<DocumentListFetchResult> {
if (inflight) return inflight;
const pending = (async (): Promise<DocumentListFetchResult> => {
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;
}