Skip to content

Commit 9f30e60

Browse files
committed
feat: optimize React Query cache keys
- Add centralized query key generators in src/utils/queryKeys.ts - Extract minimal parameters instead of hashing full request objects - Update all hooks to use optimized query key generators Reduces hashing costs for React Query cache lookups by generating smaller, more stable keys with only search-relevant parameters.
1 parent 1dfbf58 commit 9f30e60

File tree

7 files changed

+394
-16
lines changed

7 files changed

+394
-16
lines changed

src/hooks/useCollections.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
33
import { type ApiError, type LoadingState } from '../types';
44
import type { CollectionsResponse } from '../types/stac';
55
import debounce from '../utils/debounce';
6+
import { generateCollectionsQueryKey } from '../utils/queryKeys';
67
import { useStacApiContext } from '../context/useStacApiContext';
78

89
type StacCollectionsHook = {
@@ -44,7 +45,7 @@ function useCollections(): StacCollectionsHook {
4445
isFetching,
4546
refetch,
4647
} = useQuery<CollectionsResponse, ApiError>({
47-
queryKey: ['collections'],
48+
queryKey: generateCollectionsQueryKey(),
4849
queryFn: fetchCollections,
4950
enabled: !!stacApi,
5051
retry: false,

src/hooks/useItem.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
33
import { Item } from '../types/stac';
44
import { ApiError, LoadingState } from '../types';
55
import { useStacApiContext } from '../context/useStacApiContext';
6+
import { generateItemQueryKey } from '../utils/queryKeys';
67

78
type ItemHook = {
89
item?: Item;
@@ -42,7 +43,7 @@ function useItem(url: string): ItemHook {
4243
isFetching,
4344
refetch,
4445
} = useQuery<Item, ApiError>({
45-
queryKey: ['item', url],
46+
queryKey: generateItemQueryKey(url),
4647
queryFn: fetchItem,
4748
enabled: !!stacApi,
4849
retry: false,

src/hooks/useStacApi.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
22
import StacApi, { SearchMode } from '../stac-api';
33
import { Link } from '../types/stac';
44
import { GenericObject } from '../types';
5+
import { generateStacApiQueryKey } from '../utils/queryKeys';
56

67
type StacApiHook = {
78
stacApi?: StacApi;
@@ -11,7 +12,7 @@ type StacApiHook = {
1112

1213
function useStacApi(url: string, options?: GenericObject): StacApiHook {
1314
const { data, isSuccess, isLoading, isError } = useQuery({
14-
queryKey: ['stacApi', url, options],
15+
queryKey: generateStacApiQueryKey(url, options),
1516
queryFn: async () => {
1617
let searchMode = SearchMode.GET;
1718
const response = await fetch(url, {

src/hooks/useStacSearch.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { useCallback, useState, useMemo, useEffect } from 'react';
22
import { useQuery, useQueryClient } from '@tanstack/react-query';
33
import debounce from '../utils/debounce';
4+
import { generateStacSearchQueryKey } from '../utils/queryKeys';
45
import type { ApiError, LoadingState } from '../types';
56
import type {
67
Link,
78
Bbox,
89
CollectionIdList,
9-
SearchPayload,
1010
SearchResponse,
1111
Sortby,
12+
FetchRequest,
1213
} from '../types/stac';
1314
import { useStacApiContext } from '../context/useStacApiContext';
1415

@@ -37,17 +38,6 @@ type StacSearchHook = {
3738
previousPage: PaginationHandler | undefined;
3839
};
3940

40-
type FetchRequest =
41-
| {
42-
type: 'search';
43-
payload: SearchPayload;
44-
headers?: Record<string, string>;
45-
}
46-
| {
47-
type: 'get';
48-
url: string;
49-
};
50-
5141
function useStacSearch(): StacSearchHook {
5242
const { stacApi } = useStacApiContext();
5343
const queryClient = useQueryClient();
@@ -151,7 +141,7 @@ function useStacSearch(): StacSearchHook {
151141
isLoading,
152142
isFetching,
153143
} = useQuery<SearchResponse, ApiError>({
154-
queryKey: ['stacSearch', currentRequest],
144+
queryKey: currentRequest ? generateStacSearchQueryKey(currentRequest) : ['stacSearch', 'idle'],
155145
queryFn: () => fetchRequest(currentRequest!),
156146
enabled: currentRequest !== null,
157147
retry: false,

src/types/stac.d.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,37 @@ export type SearchPayload = {
2121
sortby?: Sortby[];
2222
};
2323

24+
/**
25+
* Extended search payload that includes both the base SearchPayload structure
26+
* and additional properties used in API requests.
27+
*/
28+
export type SearchRequestPayload = SearchPayload & {
29+
/** Datetime string in ISO 8601 format (transformed from dateRange) */
30+
datetime?: string;
31+
/** Maximum number of results to return */
32+
limit?: number;
33+
/** Pagination token for cursor-based pagination */
34+
token?: string;
35+
/** Page number for offset-based pagination */
36+
page?: number;
37+
/** Flag indicating if this payload should be merged with current search params */
38+
merge?: boolean;
39+
};
40+
41+
/**
42+
* Type for fetch requests used in useStacSearch hook
43+
*/
44+
export type FetchRequest =
45+
| {
46+
type: 'search';
47+
payload: SearchRequestPayload;
48+
headers?: Record<string, string>;
49+
}
50+
| {
51+
type: 'get';
52+
url: string;
53+
};
54+
2455
export type LinkBody = SearchPayload & {
2556
merge?: boolean;
2657
};

src/utils/queryKeys.test.ts

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import { describe, it, expect } from '@jest/globals';
2+
import {
3+
generateStacSearchQueryKey,
4+
generateStacApiQueryKey,
5+
generateItemQueryKey,
6+
generateCollectionsQueryKey,
7+
} from './queryKeys';
8+
import type { SearchRequestPayload, Sortby } from '../types/stac';
9+
10+
describe('Query Key Generators', () => {
11+
describe('generateCollectionsQueryKey', () => {
12+
it('should generate a simple static key', () => {
13+
const key = generateCollectionsQueryKey();
14+
expect(key).toEqual(['collections']);
15+
});
16+
});
17+
18+
describe('generateItemQueryKey', () => {
19+
it('should generate key with item URL', () => {
20+
const url = 'https://example.com/collections/test/items/item1';
21+
const key = generateItemQueryKey(url);
22+
expect(key).toEqual(['item', url]);
23+
});
24+
25+
it('should handle different URLs', () => {
26+
const url1 = 'https://example.com/items/a';
27+
const url2 = 'https://example.com/items/b';
28+
const key1 = generateItemQueryKey(url1);
29+
const key2 = generateItemQueryKey(url2);
30+
expect(key1).not.toEqual(key2);
31+
expect(key1).toEqual(['item', url1]);
32+
expect(key2).toEqual(['item', url2]);
33+
});
34+
});
35+
36+
describe('generateStacApiQueryKey', () => {
37+
it('should generate key with URL only when no options', () => {
38+
const url = 'https://example.com/stac';
39+
const key = generateStacApiQueryKey(url);
40+
expect(key).toEqual(['stacApi', url, undefined]);
41+
});
42+
43+
it('should extract only headers from options', () => {
44+
const url = 'https://example.com/stac';
45+
const options = {
46+
headers: { Authorization: 'Bearer token123' },
47+
someOtherField: { deeply: { nested: { object: 'value' } } },
48+
anotherField: 'ignored',
49+
};
50+
const key = generateStacApiQueryKey(url, options);
51+
expect(key).toEqual(['stacApi', url, { headers: { Authorization: 'Bearer token123' } }]);
52+
});
53+
54+
it('should handle options without headers', () => {
55+
const url = 'https://example.com/stac';
56+
const options = {
57+
someField: 'value',
58+
anotherField: { nested: 'data' },
59+
};
60+
const key = generateStacApiQueryKey(url, options);
61+
expect(key).toEqual(['stacApi', url, undefined]);
62+
});
63+
64+
it('should handle empty options object', () => {
65+
const url = 'https://example.com/stac';
66+
const key = generateStacApiQueryKey(url, {});
67+
expect(key).toEqual(['stacApi', url, undefined]);
68+
});
69+
});
70+
71+
describe('generateStacSearchQueryKey', () => {
72+
describe('for search requests', () => {
73+
it('should generate key with minimal search params', () => {
74+
const payload: SearchRequestPayload = {
75+
collections: ['collection1'],
76+
limit: 25,
77+
};
78+
const key = generateStacSearchQueryKey({ type: 'search', payload });
79+
expect(key).toEqual([
80+
'stacSearch',
81+
'search',
82+
{
83+
collections: ['collection1'],
84+
limit: 25,
85+
},
86+
]);
87+
});
88+
89+
it('should include all search parameters when present', () => {
90+
const sortby: Sortby[] = [
91+
{ field: 'id', direction: 'asc' },
92+
{ field: 'properties.cloud', direction: 'desc' },
93+
];
94+
const payload: SearchRequestPayload = {
95+
ids: ['item1', 'item2'],
96+
bbox: [-180, -90, 180, 90],
97+
collections: ['collection1', 'collection2'],
98+
datetime: '2023-01-01/2023-12-31',
99+
sortby,
100+
limit: 50,
101+
};
102+
const key = generateStacSearchQueryKey({ type: 'search', payload });
103+
expect(key).toEqual([
104+
'stacSearch',
105+
'search',
106+
{
107+
ids: ['item1', 'item2'],
108+
bbox: [-180, -90, 180, 90],
109+
collections: ['collection1', 'collection2'],
110+
datetime: '2023-01-01/2023-12-31',
111+
sortby,
112+
limit: 50,
113+
},
114+
]);
115+
});
116+
117+
it('should omit undefined search parameters', () => {
118+
const payload: SearchRequestPayload = {
119+
collections: ['collection1'],
120+
limit: 25,
121+
};
122+
const key = generateStacSearchQueryKey({ type: 'search', payload });
123+
expect(key[2]).not.toHaveProperty('ids');
124+
expect(key[2]).not.toHaveProperty('bbox');
125+
expect(key[2]).not.toHaveProperty('datetime');
126+
expect(key[2]).not.toHaveProperty('sortby');
127+
});
128+
129+
it('should handle empty collections array', () => {
130+
const payload: SearchRequestPayload = {
131+
collections: [],
132+
limit: 25,
133+
};
134+
const key = generateStacSearchQueryKey({ type: 'search', payload });
135+
expect(key).toEqual([
136+
'stacSearch',
137+
'search',
138+
{
139+
collections: [],
140+
limit: 25,
141+
},
142+
]);
143+
});
144+
145+
it('should ignore headers in search requests for key generation', () => {
146+
const payload: SearchRequestPayload = {
147+
collections: ['collection1'],
148+
limit: 25,
149+
};
150+
const key = generateStacSearchQueryKey({
151+
type: 'search',
152+
payload,
153+
headers: { Authorization: 'Bearer token', 'X-Custom': 'value' },
154+
});
155+
expect(key).toEqual([
156+
'stacSearch',
157+
'search',
158+
{
159+
collections: ['collection1'],
160+
limit: 25,
161+
},
162+
]);
163+
expect(key[2]).not.toHaveProperty('headers');
164+
});
165+
});
166+
167+
describe('for pagination GET requests', () => {
168+
it('should generate key with URL for GET requests', () => {
169+
const url = 'https://example.com/search?page=2&limit=25';
170+
const key = generateStacSearchQueryKey({ type: 'get', url });
171+
expect(key).toEqual(['stacSearch', 'page', url]);
172+
});
173+
174+
it('should handle different pagination URLs', () => {
175+
const url1 = 'https://example.com/search?page=1';
176+
const url2 = 'https://example.com/search?page=2';
177+
const key1 = generateStacSearchQueryKey({ type: 'get', url: url1 });
178+
const key2 = generateStacSearchQueryKey({ type: 'get', url: url2 });
179+
expect(key1).not.toEqual(key2);
180+
expect(key1).toEqual(['stacSearch', 'page', url1]);
181+
expect(key2).toEqual(['stacSearch', 'page', url2]);
182+
});
183+
});
184+
185+
describe('key stability', () => {
186+
it('should generate identical keys for identical search payloads', () => {
187+
const payload: SearchRequestPayload = {
188+
collections: ['collection1', 'collection2'],
189+
bbox: [-10, -5, 10, 5],
190+
limit: 25,
191+
};
192+
const key1 = generateStacSearchQueryKey({ type: 'search', payload });
193+
const key2 = generateStacSearchQueryKey({ type: 'search', payload });
194+
expect(key1).toEqual(key2);
195+
});
196+
197+
it('should generate different keys for different search payloads', () => {
198+
const payload1: SearchRequestPayload = {
199+
collections: ['collection1'],
200+
limit: 25,
201+
};
202+
const payload2: SearchRequestPayload = {
203+
collections: ['collection2'],
204+
limit: 25,
205+
};
206+
const key1 = generateStacSearchQueryKey({ type: 'search', payload: payload1 });
207+
const key2 = generateStacSearchQueryKey({ type: 'search', payload: payload2 });
208+
expect(key1).not.toEqual(key2);
209+
});
210+
211+
it('should generate different keys when only limit changes', () => {
212+
const payload1: SearchRequestPayload = {
213+
collections: ['collection1'],
214+
limit: 25,
215+
};
216+
const payload2: SearchRequestPayload = {
217+
collections: ['collection1'],
218+
limit: 50,
219+
};
220+
const key1 = generateStacSearchQueryKey({ type: 'search', payload: payload1 });
221+
const key2 = generateStacSearchQueryKey({ type: 'search', payload: payload2 });
222+
expect(key1).not.toEqual(key2);
223+
});
224+
225+
it('should include extra properties for pagination tokens and custom params', () => {
226+
const payload1: SearchRequestPayload = {
227+
collections: ['collection1'],
228+
limit: 25,
229+
};
230+
231+
const payload2 = {
232+
collections: ['collection1'],
233+
limit: 25,
234+
token: 'next:abc123',
235+
} as SearchRequestPayload;
236+
const key1 = generateStacSearchQueryKey({ type: 'search', payload: payload1 });
237+
const key2 = generateStacSearchQueryKey({ type: 'search', payload: payload2 });
238+
239+
expect(key1).not.toEqual(key2);
240+
expect(key2[2]).toHaveProperty('token', 'next:abc123');
241+
});
242+
});
243+
});
244+
245+
describe('edge cases', () => {
246+
it('should handle empty search payload', () => {
247+
const payload: SearchRequestPayload = {};
248+
const key = generateStacSearchQueryKey({ type: 'search', payload });
249+
expect(key).toEqual(['stacSearch', 'search', {}]);
250+
});
251+
252+
it('should handle very long URLs', () => {
253+
const longUrl = 'https://example.com/items/' + 'a'.repeat(1000);
254+
const key = generateItemQueryKey(longUrl);
255+
expect(key).toEqual(['item', longUrl]);
256+
});
257+
258+
it('should handle special characters in URLs', () => {
259+
const url = 'https://example.com/items/test%20item?query=hello&world=1';
260+
const key = generateItemQueryKey(url);
261+
expect(key).toEqual(['item', url]);
262+
});
263+
});
264+
});

0 commit comments

Comments
 (0)