From a7af7865b1bfc171cdffc16ff715ebf4dc982cb3 Mon Sep 17 00:00:00 2001 From: Agent0 Date: Sat, 17 Oct 2026 08:50:18 +0000 Subject: [PATCH 1/5] test(web): pin one scope-tree request when /lore mounts both scope hooks Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- .../lib/queries/lore-scopes-dedupe.spec.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts diff --git a/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts b/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts new file mode 100644 index 000000000..ba382c22a --- /dev/null +++ b/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts @@ -0,0 +1,98 @@ +/** + * The Lore Explorer mounts `useScopeTree` AND `useLoreData` together, and both + * need the scope tree. Each used to call `GET /memories/scopes` on its own, so + * every `/lore` load ran the `lorekit_memory_scopes` RPC twice, concurrently. + * Pinned here: the two hooks share ONE request, and `useLoreData`'s `scopes` + * is the very tree `useScopeTree` resolves to. + * + * There is no DOM test environment in this package, so the hooks run as plain + * functions: `useQuery` is swapped for a recorder of the options each hook + * hands it, and those options are then fetched through one real QueryClient — + * the same cache, de-duplication and stale-time logic a mounted page gets. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, type UseQueryOptions } from '@tanstack/react-query'; + +const SCOPE_ROWS = [ + { scope: 'repo::mthines/lorekit', count: 3 }, + { scope: 'branch::mthines/lorekit::main', count: 2 }, + { scope: 'global', count: 1 }, +]; + +const api = vi.hoisted(() => ({ + listScopesRequest: vi.fn(), + listMemoriesRequest: vi.fn(), + activityRequest: vi.fn(), +})); + +const harness = vi.hoisted(() => ({ + client: undefined as unknown as QueryClient, + recorded: [] as UseQueryOptions[], +})); + +vi.mock('@/lib/api/session-browser', () => ({ + browserAccessToken: async () => 'test-token', +})); + +vi.mock('@/lib/api/memories', () => ({ + ...api, + getMemoryByIdRequest: vi.fn(), + getMemoryByRefRequest: vi.fn(), + listFacetsPostRequest: vi.fn(), + pivotPostRequest: vi.fn(), +})); + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useQuery: (options: UseQueryOptions) => { + harness.recorded.push(options); + return { data: undefined }; + }, + useQueryClient: () => harness.client, + }; +}); + +const { useLoreData, useScopeTree } = await import('./lore'); + +/** Run a hook the way a mount would: capture its options, then fetch them. */ +function mount(hook: () => unknown): Promise { + harness.recorded.length = 0; + hook(); + const [options] = harness.recorded; + return harness.client.fetchQuery(options as Parameters[0]); +} + +beforeEach(() => { + harness.client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + api.listScopesRequest.mockReset().mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ scopes: SCOPE_ROWS }), 10)), + ); + api.listMemoriesRequest.mockReset().mockResolvedValue({ entries: [] }); + api.activityRequest.mockReset().mockResolvedValue({ buckets: [] }); +}); + +describe('the /lore scope tree is fetched once', () => { + it('shares one GET /memories/scopes between useScopeTree and useLoreData (page mount order)', async () => { + const [tree, lore] = await Promise.all([mount(useScopeTree), mount(useLoreData)]); + + expect(api.listScopesRequest).toHaveBeenCalledTimes(1); + expect((lore as { scopes: unknown }).scopes).toEqual(tree); + }); + + it('shares the request whichever hook mounts first', async () => { + const [lore, tree] = await Promise.all([mount(useLoreData), mount(useScopeTree)]); + + expect(api.listScopesRequest).toHaveBeenCalledTimes(1); + expect((lore as { scopes: unknown }).scopes).toEqual(tree); + }); + + it('still fetches the scopes for useLoreData when no scope tree is cached', async () => { + const lore = (await mount(useLoreData)) as { scopes: { scope: string }[] }; + + expect(api.listScopesRequest).toHaveBeenCalledTimes(1); + expect(lore.scopes.map((node) => node.scope)).toEqual(['repo::mthines/lorekit', 'global']); + }); +}); From 8ebe08d7ab006f0ed00a9e3e48245d411a497dd2 Mon Sep 17 00:00:00 2001 From: Agent0 Date: Sat, 17 Oct 2026 08:52:46 +0000 Subject: [PATCH 2/5] perf(web): fetch the scope tree once on /lore instead of twice useLoreData fetched GET /memories/scopes itself while LorePageClient also mounted useScopeTree, so every /lore load ran the lorekit_memory_scopes RPC twice, concurrently. fetchLoreData now reads the scope tree through the shared ['lore-scopes'] query via queryClient.fetchQuery, which joins an in-flight fetch or serves a fresh cached tree. One shared options object keeps the key and stale time defined once. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/web/src/lib/queries/lore.ts | 38 ++++++++++++++++++---------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/web/src/lib/queries/lore.ts b/packages/web/src/lib/queries/lore.ts index 5e3b4c47b..5ac0603f1 100644 --- a/packages/web/src/lib/queries/lore.ts +++ b/packages/web/src/lib/queries/lore.ts @@ -2,6 +2,7 @@ import { keepPreviousData, + queryOptions, useQueries, useQuery, useInfiniteQuery, @@ -313,14 +314,21 @@ export function useFacetCatalog( }); } +/** + * The ONE definition of the scope-tree query, shared by `useScopeTree` and + * `fetchLoreData` so the two cannot drift onto different keys or stale times — + * a drift that would silently bring back the duplicate `GET /memories/scopes`. + */ +const scopeTreeQueryOptions = queryOptions({ + queryKey: ['lore-scopes'], + queryFn: ({ signal }) => fetchScopes(signal), + // Scope tree is read-heavy — keep data for 90 s before refetching. + staleTime: 90_000, + retry: retryUnlessSignedOut, +}); + export function useScopeTree() { - return useQuery({ - queryKey: ['lore-scopes'], - queryFn: ({ signal }) => fetchScopes(signal), - // Scope tree is read-heavy — keep data for 90 s before refetching. - staleTime: 90_000, - retry: retryUnlessSignedOut, - }); + return useQuery(scopeTreeQueryOptions); } // --------------------------------------------------------------------------- @@ -334,11 +342,16 @@ export function useScopeTree() { // from `GET /memories/activity`, which buckets per UTC day in Postgres. // --------------------------------------------------------------------------- -async function fetchLoreData(signal?: AbortSignal): Promise { +async function fetchLoreData(queryClient: QueryClient, signal?: AbortSignal): Promise { const token = await requireBrowserToken(); - const [scopesRes, page, activity] = await Promise.all([ - listScopesRequest(token, signal), + const [scopes, page, activity] = await Promise.all([ + // Through the shared scope-tree query, not a request of its own: `/lore` + // mounts `useScopeTree` beside this hook, and two independent requests ran + // the `lorekit_memory_scopes` RPC twice, concurrently, on every load. + // `fetchQuery` joins an in-flight fetch and serves a fresh cached tree. + // `retry: false` because this query's own retry already re-runs this half. + queryClient.fetchQuery({ ...scopeTreeQueryOptions, retry: false }), listMemoriesRequest(token, { limit: LEGACY_PAGE_SIZE, sort: 'created_at' }, signal), // `since` is EXPLICIT, not left to the endpoint's default. That default is // 200 days, sized when the heatmap was a fixed 26 weeks; the desktop grid @@ -349,8 +362,6 @@ async function fetchLoreData(signal?: AbortSignal): Promise { activityRequest(token, { bucket: 'day', since: heatmapSince(new Date().toISOString()) }, signal), ]); - const scopes: ScopeNode[] = buildScopeTree(scopesRes.scopes); - return { scopes, lessons: page.entries.map(lessonFromMemoryEntry), @@ -359,9 +370,10 @@ async function fetchLoreData(signal?: AbortSignal): Promise { } export function useLoreData() { + const queryClient = useQueryClient(); return useQuery({ queryKey: ['lore'], - queryFn: ({ signal }) => fetchLoreData(signal), + queryFn: ({ signal }) => fetchLoreData(queryClient, signal), // Lore explorer is read-heavy — keep data for 90 s before refetching. staleTime: 90_000, retry: retryUnlessSignedOut, From 9f0c34dc8677092adee207d4f2843ef1de4f8e12 Mon Sep 17 00:00:00 2001 From: Agent0 Date: Sat, 17 Oct 2026 08:53:37 +0000 Subject: [PATCH 3/5] fix(web): type the shared scope-tree query options for useQuery Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/web/src/lib/queries/lore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web/src/lib/queries/lore.ts b/packages/web/src/lib/queries/lore.ts index 5ac0603f1..be9173b7c 100644 --- a/packages/web/src/lib/queries/lore.ts +++ b/packages/web/src/lib/queries/lore.ts @@ -319,7 +319,7 @@ export function useFacetCatalog( * `fetchLoreData` so the two cannot drift onto different keys or stale times — * a drift that would silently bring back the duplicate `GET /memories/scopes`. */ -const scopeTreeQueryOptions = queryOptions({ +const scopeTreeQueryOptions = queryOptions({ queryKey: ['lore-scopes'], queryFn: ({ signal }) => fetchScopes(signal), // Scope tree is read-heavy — keep data for 90 s before refetching. @@ -328,7 +328,7 @@ const scopeTreeQueryOptions = queryOptions({ }); export function useScopeTree() { - return useQuery(scopeTreeQueryOptions); + return useQuery(scopeTreeQueryOptions); } // --------------------------------------------------------------------------- From 56614397eea71f54c9d6647b99779f7eecd31fc7 Mon Sep 17 00:00:00 2001 From: Agent0 Date: Sat, 17 Oct 2026 09:06:26 +0000 Subject: [PATCH 4/5] address review comment: make the reverse-order scope-dedupe test actually mount useLoreData first Addresses @dash0-dev[bot]'s comment: https://github.com/mthines/lorekit/pull/693#discussion_r4302137598 Refs: https://github.com/mthines/lorekit/pull/693 Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- .../web/src/lib/queries/lore-scopes-dedupe.spec.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts b/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts index ba382c22a..b9eb1f8bd 100644 --- a/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts +++ b/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts @@ -83,10 +83,17 @@ describe('the /lore scope tree is fetched once', () => { }); it('shares the request whichever hook mounts first', async () => { - const [lore, tree] = await Promise.all([mount(useLoreData), mount(useScopeTree)]); + // Mounting both in one `Promise.all` does not reverse the order: the lore + // fetch awaits the session token before it reaches the scopes query, so + // `useScopeTree` would still start the request. Wait until the lore fetch + // has actually issued it, then mount the scope tree into the in-flight + // request (the mock answers after 10 ms; polling every 1 ms lands inside it). + const lore = mount(useLoreData); + await vi.waitFor(() => expect(api.listScopesRequest).toHaveBeenCalledTimes(1), { interval: 1 }); + const tree = await mount(useScopeTree); expect(api.listScopesRequest).toHaveBeenCalledTimes(1); - expect((lore as { scopes: unknown }).scopes).toEqual(tree); + expect(((await lore) as { scopes: unknown }).scopes).toEqual(tree); }); it('still fetches the scopes for useLoreData when no scope tree is cached', async () => { From fad53595b6fe3392ee3f20cf46d16f181dc630e5 Mon Sep 17 00:00:00 2001 From: Agent0 Date: Sat, 17 Oct 2026 09:07:06 +0000 Subject: [PATCH 5/5] address review comment: drop the retry: false override so a joining useScopeTree keeps its retry policy Addresses @dash0-dev[bot]'s comment: https://github.com/mthines/lorekit/pull/693#issuecomment-6232061798 Refs: https://github.com/mthines/lorekit/pull/693 Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- .../src/lib/queries/lore-scopes-dedupe.spec.ts | 18 ++++++++++++++++++ packages/web/src/lib/queries/lore.ts | 8 ++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts b/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts index b9eb1f8bd..23071e01c 100644 --- a/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts +++ b/packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts @@ -96,6 +96,24 @@ describe('the /lore scope tree is fetched once', () => { expect(((await lore) as { scopes: unknown }).scopes).toEqual(tree); }); + it('hands a joining useScopeTree the shared retry policy when useLoreData starts the fetch', async () => { + // The retry policy belongs to whichever caller STARTS the shared fetch, so + // a no-retry override in the lore fetch would turn one transient scopes + // failure into an error for the scope tree that joined it. Real ~1 s + // backoff: TanStack's default retry delay, kept rather than faked. + api.listScopesRequest.mockImplementationOnce( + () => new Promise((_, reject) => setTimeout(() => reject(new Error('transient')), 10)), + ); + + const lore = mount(useLoreData); + await vi.waitFor(() => expect(api.listScopesRequest).toHaveBeenCalledTimes(1), { interval: 1 }); + const tree = (await mount(useScopeTree)) as { scope: string }[]; + + expect(api.listScopesRequest).toHaveBeenCalledTimes(2); + expect(tree.map((node) => node.scope)).toEqual(['repo::mthines/lorekit', 'global']); + expect(((await lore) as { scopes: unknown }).scopes).toEqual(tree); + }); + it('still fetches the scopes for useLoreData when no scope tree is cached', async () => { const lore = (await mount(useLoreData)) as { scopes: { scope: string }[] }; diff --git a/packages/web/src/lib/queries/lore.ts b/packages/web/src/lib/queries/lore.ts index be9173b7c..d141c17b8 100644 --- a/packages/web/src/lib/queries/lore.ts +++ b/packages/web/src/lib/queries/lore.ts @@ -350,8 +350,12 @@ async function fetchLoreData(queryClient: QueryClient, signal?: AbortSignal): Pr // mounts `useScopeTree` beside this hook, and two independent requests ran // the `lorekit_memory_scopes` RPC twice, concurrently, on every load. // `fetchQuery` joins an in-flight fetch and serves a fresh cached tree. - // `retry: false` because this query's own retry already re-runs this half. - queryClient.fetchQuery({ ...scopeTreeQueryOptions, retry: false }), + // No `retry` override: the retry policy is captured by whichever caller + // STARTS the shared fetch, so a no-retry override here would hand a + // `useScopeTree` that joins it a fetch that fails on one transient error. + // The cost is accepted: during a sustained scopes outage this query's own + // retry stacks on top, re-running an already-retried, backoff-spaced fetch. + queryClient.fetchQuery(scopeTreeQueryOptions), listMemoriesRequest(token, { limit: LEGACY_PAGE_SIZE, sort: 'created_at' }, signal), // `since` is EXPLICIT, not left to the endpoint's default. That default is // 200 days, sized when the heatmap was a fixed 26 weeks; the desktop grid