Repository navigation
perf(web): fetch the scope tree once on /lore instead of twice #693
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mthines
merged 5 commits into
main
from
weekly-improve/2026-10-17-dedupe-lore-scopes-fetch
Oct 17, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
a7af786
test(web): pin one scope-tree request when /lore mounts both scope hooks
8ebe08d
perf(web): fetch the scope tree once on /lore instead of twice
9f0c34d
fix(web): type the shared scope-tree query options for useQuery
5661439
address review comment: make the reverse-order scope-dedupe test actu…
fad5359
address review comment: drop the retry: false override so a joining u…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
123 changes: 123 additions & 0 deletions
123
packages/web/src/lib/queries/lore-scopes-dedupe.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| /** | ||
| * 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<typeof import('@tanstack/react-query')>(); | ||
| 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<unknown> { | ||
| harness.recorded.length = 0; | ||
| hook(); | ||
| const [options] = harness.recorded; | ||
| return harness.client.fetchQuery(options as Parameters<QueryClient['fetchQuery']>[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 () => { | ||
| // 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(((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 }[] }; | ||
|
|
||
| expect(api.listScopesRequest).toHaveBeenCalledTimes(1); | ||
| expect(lore.scopes.map((node) => node.scope)).toEqual(['repo::mthines/lorekit', 'global']); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.