From a93b27cc9e6599e114e31f3d97a635c56d94cbf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Thu, 23 Oct 2025 11:12:39 +0200 Subject: [PATCH 01/29] docs: start architecture decision log and add ADR for fetch library decision --- ...markdown-architectural-decision-records.md | 26 ++++++++ .../0001-use-a-fetch-library-for-caching.md | 66 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 docs/adr/0000-use-markdown-architectural-decision-records.md create mode 100644 docs/adr/0001-use-a-fetch-library-for-caching.md diff --git a/docs/adr/0000-use-markdown-architectural-decision-records.md b/docs/adr/0000-use-markdown-architectural-decision-records.md new file mode 100644 index 0000000..7fe61b3 --- /dev/null +++ b/docs/adr/0000-use-markdown-architectural-decision-records.md @@ -0,0 +1,26 @@ +# Use Markdown Architectural Decision Records + +## Context and Problem Statement + +We want to record architectural decisions made in this project independent whether decisions concern the architecture ("architectural decision record"), the code, or other fields. +Which format and structure should these records follow? + +## Considered Options + +- [MADR](https://adr.github.io/madr/) 4.0.0 – The Markdown Architectural Decision Records +- [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) – The first incarnation of the term "ADR" +- [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) – The Y-Statements +- Other templates listed at +- Formless – No conventions for file format and structure + +## Decision Outcome + +Chosen option: "MADR 4.0.0", because + +- Implicit assumptions should be made explicit. + Design documentation is important to enable people understanding the decisions later on. + See also ["A rational design process: How and why to fake it"](https://doi.org/10.1109/TSE.1986.6312940). +- MADR allows for structured capturing of any decision. +- The MADR format is lean and fits our development style. +- The MADR structure is comprehensible and facilitates usage & maintenance. +- The MADR project is vivid. diff --git a/docs/adr/0001-use-a-fetch-library-for-caching.md b/docs/adr/0001-use-a-fetch-library-for-caching.md new file mode 100644 index 0000000..73c03d0 --- /dev/null +++ b/docs/adr/0001-use-a-fetch-library-for-caching.md @@ -0,0 +1,66 @@ +--- +# These are optional metadata elements. Feel free to remove any of them. +status: "accepted" +date: 2025-09-18 +decision-makers: @gadomski @AliceR +--- + +# Use a fetch library for caching + +## Context and Problem Statement + +Currently, `stac-react` uses the native `fetch` API for all STAC requests, with no built-in caching or request deduplication. As the library is intended for use in applications that may navigate between many STAC resources, efficient caching and request management are important for performance and developer experience. + +## Decision Drivers + +- Improve performance by caching repeated requests. +- Reduce network usage and latency. +- Provide a more robust API for request state, error handling, and background updates. +- Align with common React ecosystem practices. + +## Considered Options + +- Continue using native `fetch` with custom caching logic. +- Use TanStack Query (`@tanstack/react-query`) for fetching and caching. +- Use another fetch/caching library (e.g., SWR, Axios with custom cache). + +## Decision Outcome + +**Chosen option:** Use TanStack Query (`@tanstack/react-query`). + +**Justification:** +TanStack Query is widely adopted, well-documented, and provides robust caching, request deduplication, background refetching, and React integration. It will make `stac-react` more attractive to downstream applications and reduce the need for custom caching logic. + +### Consequences + +- **Good:** Improved performance and developer experience; less custom code for caching and request state. +- **Bad:** Adds a new dependency and requires refactoring existing hooks to use TanStack Query. + +### Confirmation + +- Implementation will be confirmed by refactoring hooks to use TanStack Query and verifying caching behavior in tests and example app. +- Code review will ensure correct usage and integration. + +## Pros and Cons of the Options + +### TanStack Query + +- **Good:** Robust caching, request deduplication, background updates, React integration. +- **Good:** Well-supported and documented. +- **Neutral:** Adds a dependency, but it is widely used. +- **Bad:** Requires refactoring and learning curve for maintainers. + +### Native Fetch + +- **Good:** No new dependencies. +- **Bad:** No built-in caching, more custom code required, less robust for complex scenarios. + +### Other Libraries (SWR, Axios) + +- **Good:** Some provide caching, but less feature-rich or less adopted for React. +- **Bad:** May require more custom integration. + +## More Information + +- [TanStack Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview) +- This ADR will be revisited if TanStack Query no longer meets project needs or if a better alternative emerges. From 9bccaaa27e693f96acc5bdb480d32a7f8b2b563c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Thu, 23 Oct 2025 16:32:23 +0200 Subject: [PATCH 02/29] feat: migrate useCollections hook to TanStack Query - Refactor useCollections and the closely related useCollection hooks to use TanStack Query for caching and fetching - Improve error propagation, keep original loading states - Update StacApiProvider to manage QueryClient and support custom clients - Clarify peer dependency requirements for @tanstack/react-query in README and docs/react-query-setup.md - Update ESLint config and package.json for new dependencies - Minor fixes to context and tests for compatibility --- README.md | 70 ++++++++++++++++++++++----------- docs/react-query-setup.md | 34 ++++++++++++++++ eslint.config.js | 10 ++--- package.json | 2 + src/context/index.tsx | 13 +++++- src/hooks/useCollection.ts | 28 ++++++------- src/hooks/useCollections.ts | 76 +++++++++++++++++++++++++----------- src/hooks/useStacApi.test.ts | 9 +++-- yarn.lock | 20 ++++++++++ 9 files changed, 192 insertions(+), 70 deletions(-) create mode 100644 docs/react-query-setup.md diff --git a/README.md b/README.md index 1f187da..e92cdc3 100644 --- a/README.md +++ b/README.md @@ -19,47 +19,75 @@ With Yarn: yarn add @developmentseed/stac-react ``` +### Peer Dependency: @tanstack/react-query + +stac-react relies on [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) for data fetching and caching. To avoid duplicate React Query clients and potential version conflicts, stac-react lists `@tanstack/react-query` as a **peer dependency**. This means you must install it in your project: + +```sh +npm install @tanstack/react-query +# or +yarn add @tanstack/react-query +``` + +If you do not install it, your package manager will warn you, and stac-react will not work correctly. + ## Getting started -Stac-react's hooks must be used inside children of a React context that provides access to the stac-react's core functionality. +stac-react's hooks must be used inside children of a React context that provides access to the stac-react's core functionality. -To get started, initialize `StacApiProvider` with the base URL of the STAC catalog. +To get started, initialize `StacApiProvider` with the base URL of the STAC catalog. `StacApiProvider` automatically sets up a [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) QueryClientProvider for you, so you do not need to wrap your app with QueryClientProvider yourself. ```jsx -import { StacApiProvider } from "stac-react"; +import { StacApiProvider } from 'stac-react'; function StacApp() { return ( - - // Other components - + {/* Other components */} + ); +} +``` + +If you want to provide your own custom QueryClient (for advanced caching or devtools), you can pass it as a prop: + +```jsx +import { StacApiProvider } from 'stac-react'; +import { QueryClient } from '@tanstack/react-query'; + +const queryClient = new QueryClient(); + +function StacApp() { + return ( + + {/* Other components */} + ); } ``` +For additional information, see the React Query setup guide: [docs/react-query-setup.md](docs/react-query-setup.md). + Now you can start using stac-react hooks in child components of `StacApiProvider` ```jsx -import { StacApiProvider, useCollections } from "stac-react"; +import { StacApiProvider, useCollections } from 'stac-react'; function Collections() { const { collections } = useCollections(); return ( -
    - {collections.collections.map(({ id, title }) => ( -
  • { title }
  • - ))} -
- - ) +
    + {collections.collections.map(({ id, title }) => ( +
  • {title}
  • + ))} +
+ ); } function StacApp() { return ( - + ); } ``` @@ -73,14 +101,10 @@ Provides the React context required for stac-react hooks. #### Initialization ```jsx -import { StacApiProvider } from "stac-react"; +import { StacApiProvider } from 'stac-react'; function StacApp() { - return ( - - // Other components - - ); + return // Other components; } ``` @@ -471,9 +495,9 @@ function StacComponent() { ``` | Option | Type | Description | -| ------------ | -------- | --------------------------------- | ------------------------------------------------------------------------------------------- | +| ------------ | -------- | --------------------------------- | ------------------------------------------------------------------------------------------ | | `detail` | `string` | `object | The error return from the API. Either a`string` or and `object` depending on the response. | -| `status` | `number` | HTTP status code of the response. | +| `status` | `number` | HTTP status code of the response. | | `statusText` | `string` | Status text for the response. | ## Development diff --git a/docs/react-query-setup.md b/docs/react-query-setup.md new file mode 100644 index 0000000..780c3a1 --- /dev/null +++ b/docs/react-query-setup.md @@ -0,0 +1,34 @@ +# QueryClient Best Practice + +stac-react relies on [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) for data fetching and caching. To avoid duplicate React Query clients and potential version conflicts, stac-react lists `@tanstack/react-query` as a **peer dependency**. + +## Why peer dependency? + +- Prevents multiple versions of React Query in your app. +- Ensures your app and stac-react share the same QueryClient instance. +- Follows best practices for React libraries that integrate with popular frameworks. + +stac-react manages the QueryClient for you by default, but you can provide your own for advanced use cases. + +**Important:** If your app uses multiple providers that require a TanStack QueryClient (such as `QueryClientProvider` and `StacApiProvider`), always use the same single QueryClient instance for all providers. This ensures that queries, mutations, and cache are shared across your app and prevents cache fragmentation or duplicate network requests. + +**Example:** + +```jsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { StacApiProvider } from 'stac-react'; + +const queryClient = new QueryClient(); + +function App() { + return ( + + + {/* ...your app... */} + + + ); +} +``` + +If you do not pass the same QueryClient instance, each provider will maintain its own cache, which can lead to unexpected behavior. diff --git a/eslint.config.js b/eslint.config.js index c626fb0..77c2775 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -91,11 +91,11 @@ export default defineConfig([ ], // TODO: Consider making these errors in the future (use recommendedTypeChecked rules!). '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unsafe-assignment': 'warn', - '@typescript-eslint/no-unsafe-call': 'warn', - '@typescript-eslint/no-unsafe-member-access': 'warn', - '@typescript-eslint/no-unsafe-return': 'warn', - '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unsafe-enum-comparison': 'warn', }, }, diff --git a/package.json b/package.json index f21f849..5632e25 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,12 @@ "types": "./dist/index.d.ts", "source": "./src/index.ts", "peerDependencies": { + "@tanstack/react-query": ">=4.0.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, "devDependencies": { + "@tanstack/react-query": "^5.90.5", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", diff --git a/src/context/index.tsx b/src/context/index.tsx index f4e3bbc..e10fb85 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useState, useCallback } from 'react'; import { StacApiContext } from './context'; import type { CollectionsResponse, Item } from '../types/stac'; import { GenericObject } from '../types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import useStacApi from '../hooks/useStacApi'; @@ -9,9 +10,10 @@ type StacApiProviderType = { apiUrl: string; children: React.ReactNode; options?: GenericObject; + queryClient?: QueryClient; }; -export function StacApiProvider({ children, apiUrl, options }: StacApiProviderType) { +export function StacApiProvider({ children, apiUrl, options, queryClient }: StacApiProviderType) { const { stacApi } = useStacApi(apiUrl, options); const [collections, setCollections] = useState(); const [items, setItems] = useState(new Map()); @@ -46,5 +48,12 @@ export function StacApiProvider({ children, apiUrl, options }: StacApiProviderTy [addItem, collections, deleteItem, getItem, stacApi] ); - return {children}; + const defaultClient = useMemo(() => new QueryClient(), []); + const client: QueryClient = queryClient ?? defaultClient; + + return ( + + {children} + + ); } diff --git a/src/hooks/useCollection.ts b/src/hooks/useCollection.ts index b9a665d..8c0acb9 100644 --- a/src/hooks/useCollection.ts +++ b/src/hooks/useCollection.ts @@ -1,4 +1,4 @@ -import { useMemo, useState, useEffect } from 'react'; +import { useMemo } from 'react'; import type { ApiError, LoadingState } from '../types'; import type { Collection } from '../types/stac'; @@ -13,24 +13,22 @@ type StacCollectionHook = { function useCollection(collectionId: string): StacCollectionHook { const { collections, state, error: requestError, reload } = useCollections(); - const [error, setError] = useState(); - - useEffect(() => { - setError(requestError); - }, [requestError]); const collection = useMemo(() => { - const coll = collections?.collections.find(({ id }) => id === collectionId); - if (!coll) { - setError({ - status: 404, - statusText: 'Not found', - detail: 'Collection does not exist', - }); - } - return coll; + return collections?.collections.find(({ id }) => id === collectionId); }, [collectionId, collections]); + // Determine error: prefer requestError, else local 404 if collection not found + const error: ApiError | undefined = requestError + ? requestError + : !collection && collections + ? { + status: 404, + statusText: 'Not found', + detail: 'Collection does not exist', + } + : undefined; + return { collection, state, diff --git a/src/hooks/useCollections.ts b/src/hooks/useCollections.ts index 531971b..522a9dd 100644 --- a/src/hooks/useCollections.ts +++ b/src/hooks/useCollections.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState, useMemo } from 'react'; +import { useEffect, useState, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { type ApiError, type LoadingState } from '../types'; import type { CollectionsResponse } from '../types/stac'; import debounce from '../utils/debounce'; @@ -12,38 +13,69 @@ type StacCollectionsHook = { }; function useCollections(): StacCollectionsHook { - const { stacApi, collections, setCollections } = useStacApiContext(); + const { stacApi, setCollections } = useStacApiContext(); const [state, setState] = useState('IDLE'); - const [error, setError] = useState(); - const _getCollections = useCallback(() => { - if (stacApi) { - setState('LOADING'); + const fetchCollections = async (): Promise => { + if (!stacApi) throw new Error('No STAC API configured'); + const response: Response = await stacApi.getCollections(); + if (!response.ok) { + let detail; + try { + detail = await response.json(); + } catch { + detail = await response.text(); + } + + const err = Object.assign(new Error(response.statusText), { + status: response.status, + statusText: response.statusText, + detail, + }); + throw err; + } + return await response.json(); + }; + + const { + data: collections, + error, + isLoading, + isFetching, + refetch, + } = useQuery({ + queryKey: ['collections'], + queryFn: fetchCollections, + enabled: !!stacApi, + retry: false, + }); - stacApi - .getCollections() - .then((response: Response) => response.json()) - .then(setCollections) - .catch((err: unknown) => { - setError(err as ApiError); - setCollections(undefined); - }) - .finally(() => setState('IDLE')); + // Sync collections with context + // This preserves the previous logic for consumers and tests + useEffect(() => { + if (collections) { + setCollections(collections); + } else if (error) { + setCollections(undefined); } - }, [setCollections, stacApi]); - const getCollections = useMemo(() => debounce(_getCollections), [_getCollections]); + }, [collections, error, setCollections]); + + const reload = useMemo(() => debounce(refetch), [refetch]); useEffect(() => { - if (stacApi && !error && !collections) { - getCollections(); + // Map TanStack Query loading states to previous LoadingState type + if (isLoading || isFetching) { + setState('LOADING'); + } else { + setState('IDLE'); } - }, [getCollections, stacApi, collections, error]); + }, [isLoading, isFetching]); return { collections, - reload: getCollections, + reload, state, - error, + error: error as ApiError, }; } diff --git a/src/hooks/useStacApi.test.ts b/src/hooks/useStacApi.test.ts index aca7546..3633f21 100644 --- a/src/hooks/useStacApi.test.ts +++ b/src/hooks/useStacApi.test.ts @@ -4,8 +4,11 @@ import useCollections from './useCollections'; import wrapper from './wrapper'; describe('useStacApi', () => { - beforeEach(() => fetch.resetMocks()); - it('initilises StacAPI', async () => { + beforeEach(() => { + fetch.resetMocks(); + }); + + it('initializes StacAPI', async () => { fetch .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) .mockResponseOnce(JSON.stringify({ data: '12345' })); @@ -16,7 +19,7 @@ describe('useStacApi', () => { ); }); - it('initilises StacAPI with redirect URL', async () => { + it('initializes StacAPI with redirect URL', async () => { fetch .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net/redirect/', diff --git a/yarn.lock b/yarn.lock index 126b45b..12848af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -535,6 +535,7 @@ __metadata: version: 0.0.0-use.local resolution: "@developmentseed/stac-react@workspace:." dependencies: + "@tanstack/react-query": "npm:^5.90.5" "@testing-library/dom": "npm:^10.4.1" "@testing-library/jest-dom": "npm:^6.9.1" "@testing-library/react": "npm:^16.3.0" @@ -563,6 +564,7 @@ __metadata: vite: "npm:^7.1.11" vite-plugin-dts: "npm:^4.5.4" peerDependencies: + "@tanstack/react-query": ">=4.0.0" react: ^19.2.0 react-dom: ^19.2.0 languageName: unknown @@ -1687,6 +1689,24 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:5.90.5": + version: 5.90.5 + resolution: "@tanstack/query-core@npm:5.90.5" + checksum: 10c0/3b9460cc10d494357a30ddd3138f2a831611d14b5b8ce3587daa17a078d63945fcdf419864d9dc8e1249aa89b512003d2f134977c64ceccdbdfdd79f1f7e0a34 + languageName: node + linkType: hard + +"@tanstack/react-query@npm:^5.90.5": + version: 5.90.5 + resolution: "@tanstack/react-query@npm:5.90.5" + dependencies: + "@tanstack/query-core": "npm:5.90.5" + peerDependencies: + react: ^18 || ^19 + checksum: 10c0/b2450259e40afc2aec5e455414f204c511ec98ebbbd25963316ab72b25758722ee424ed51210bd6863f78f03ae414e18571879f9d70a022e11049f3f04ef5ce2 + languageName: node + linkType: hard + "@testing-library/dom@npm:^10.4.1": version: 10.4.1 resolution: "@testing-library/dom@npm:10.4.1" From e40fccecdef9c757c199acdee53d28b1ca1379a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Mon, 27 Oct 2025 10:44:34 +0100 Subject: [PATCH 03/29] feat: enable TanStack Query DevTools browser extension - Expose QueryClient on window for integration with TanStack Query DevTools (browser extension) - Documented alternative: TanStack Query Devtools floating/embedded component (see https://tanstack.com/query/latest/docs/framework/react/devtools) - Chose browser extension to keep project dependencies clean and offload devtools responsibility to the developer --- docs/react-query-setup.md | 18 ++++++++++++++++++ src/context/index.tsx | 11 +++++++++++ 2 files changed, 29 insertions(+) diff --git a/docs/react-query-setup.md b/docs/react-query-setup.md index 780c3a1..21a5c7f 100644 --- a/docs/react-query-setup.md +++ b/docs/react-query-setup.md @@ -32,3 +32,21 @@ function App() { ``` If you do not pass the same QueryClient instance, each provider will maintain its own cache, which can lead to unexpected behavior. + +## TanStack Query DevTools Integration + +stac-react automatically connects your QueryClient to the [TanStack Query DevTools browser extension](https://tanstack.com/query/latest/docs/framework/react/devtools) when running in development mode. This allows you to inspect queries, mutations, and cache directly in your browser without adding extra dependencies to your project. + +**How it works:** + +- In development (`process.env.NODE_ENV === 'development'`), stac-react exposes the QueryClient on `window.__TANSTACK_QUERY_CLIENT__`. +- The browser extension detects this and connects automatically. +- No code changes or additional dependencies are required. + +> By default, React Query Devtools are only included in bundles when process.env.NODE_ENV === 'development', so you don't need to worry about excluding them during a production build. + +**Alternative:** + +- If you prefer an embedded/floating devtools panel, you can install and use the [TanStack Query Devtools React component](https://tanstack.com/query/latest/docs/framework/react/devtools#floating-devtools) in your app. This adds a UI panel directly to your app, but increases bundle size and dependencies. + +For more details, see the [TanStack Query DevTools documentation](https://tanstack.com/query/latest/docs/framework/react/devtools). diff --git a/src/context/index.tsx b/src/context/index.tsx index e10fb85..cc8eeaf 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -51,9 +51,20 @@ export function StacApiProvider({ children, apiUrl, options, queryClient }: Stac const defaultClient = useMemo(() => new QueryClient(), []); const client: QueryClient = queryClient ?? defaultClient; + if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') { + // Connect TanStack Query DevTools (browser extension) + window.__TANSTACK_QUERY_CLIENT__ = client; + } + return ( {children} ); } + +declare global { + interface Window { + __TANSTACK_QUERY_CLIENT__: import('@tanstack/query-core').QueryClient; + } +} From a04443b6ce04970e7772f07c54414fda8c0a7e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Mon, 27 Oct 2025 13:07:53 +0100 Subject: [PATCH 04/29] feat: migrate useItem hook to TanStack Query - Refactor useItem to use TanStack Query for caching and fetching - Improve error propagation, keep original loading states --- src/hooks/useItem.test.ts | 5 +- src/hooks/useItem.ts | 99 +++++++++++++++++---------------------- 2 files changed, 46 insertions(+), 58 deletions(-) diff --git a/src/hooks/useItem.test.ts b/src/hooks/useItem.test.ts index 8eb07fe..28d7612 100644 --- a/src/hooks/useItem.test.ts +++ b/src/hooks/useItem.test.ts @@ -68,7 +68,10 @@ describe('useItem', () => { }); await waitFor(() => expect(result.current.item).toEqual({ id: 'abc' })); - act(() => result.current.reload()); + await act(async () => { + // eslint-disable-next-line @typescript-eslint/await-thenable + await result.current.reload(); + }); await waitFor(() => expect(result.current.item).toEqual({ id: 'abc', description: 'Updated' })); }); diff --git a/src/hooks/useItem.ts b/src/hooks/useItem.ts index 74063ba..cbdabee 100644 --- a/src/hooks/useItem.ts +++ b/src/hooks/useItem.ts @@ -1,4 +1,5 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Item } from '../types/stac'; import { ApiError, LoadingState } from '../types'; import { useStacApiContext } from '../context/useStacApiContext'; @@ -11,71 +12,55 @@ type ItemHook = { }; function useItem(url: string): ItemHook { - const { stacApi, getItem, addItem, deleteItem } = useStacApiContext(); + const { stacApi } = useStacApiContext(); const [state, setState] = useState('IDLE'); - const [item, setItem] = useState(); - const [error, setError] = useState(); - useEffect(() => { - if (!stacApi) return; - - setState('LOADING'); - new Promise((resolve, reject) => { - const i = getItem(url); - if (i) { - resolve(i); - } else { - stacApi - .fetch(url) - .then((r: Response) => r.json()) - .then((r: Item) => { - addItem(url, r); - resolve(r); - }) - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - .catch((err: unknown) => reject(err)); + const fetchItem = async (): Promise => { + if (!stacApi) throw new Error('No STAC API configured'); + const response: Response = await stacApi.get(url); + if (!response.ok) { + let detail; + try { + detail = await response.json(); + } catch { + detail = await response.text(); } - }) - .then(setItem) - .catch((err: unknown) => setError(err as ApiError)) - .finally(() => setState('IDLE')); - }, [stacApi, addItem, getItem, url]); - - const fetchItem = useCallback(() => { - if (!stacApi) return; + const err = Object.assign(new Error(response.statusText), { + status: response.status, + statusText: response.statusText, + detail, + }); + throw err; + } + return await response.json(); + }; - setState('LOADING'); - new Promise((resolve, reject) => { - const i = getItem(url); - if (i) { - resolve(i); - } else { - stacApi - .fetch(url) - .then((r: Response) => r.json()) - .then((r: Item) => { - addItem(url, r); - resolve(r); - }) - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - .catch((err: unknown) => reject(err)); - } - }) - .then(setItem) - .catch((err: unknown) => setError(err as ApiError)) - .finally(() => setState('IDLE')); - }, [addItem, getItem, stacApi, url]); + const { + data: item, + error, + isLoading, + isFetching, + refetch, + } = useQuery({ + queryKey: ['item', url], + queryFn: fetchItem, + enabled: !!stacApi, + retry: false, + }); - const reload = useCallback(() => { - deleteItem(url); - fetchItem(); - }, [deleteItem, fetchItem, url]); + useEffect(() => { + if (isLoading || isFetching) { + setState('LOADING'); + } else { + setState('IDLE'); + } + }, [isLoading, isFetching]); return { item, state, - error, - reload, + error: error as ApiError, + reload: refetch, }; } From 3013303348a78f2c1f043bdf458d939053b61889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Mon, 27 Oct 2025 13:10:40 +0100 Subject: [PATCH 05/29] docs(example): add item detail panel demonstrating useItem hook Adds an ItemDetails component to the example app, showing how to use the useItem hook to fetch and display STAC item details. Improves documentation for consumers of stac-react. --- example/src/pages/Main/ItemDetails.jsx | 57 ++++++++++++++++++++++++++ example/src/pages/Main/ItemList.jsx | 13 ++++-- example/src/pages/Main/index.jsx | 32 +++++++++++---- 3 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 example/src/pages/Main/ItemDetails.jsx diff --git a/example/src/pages/Main/ItemDetails.jsx b/example/src/pages/Main/ItemDetails.jsx new file mode 100644 index 0000000..a7404e4 --- /dev/null +++ b/example/src/pages/Main/ItemDetails.jsx @@ -0,0 +1,57 @@ +import { useItem } from 'stac-react'; + +import { H2 } from '../../components/headers'; +import Panel from '../../layout/Panel'; +import { Button } from '../../components/buttons'; + +function ItemDetails({ item, onClose }) { + const itemUrl = item.links.find((r) => r.rel === 'self')?.href; + const { item: newItem, state, error, reload } = useItem(itemUrl); + + const isLoading = state === 'LOADING'; + + return ( + +
+
+

Selected Item

+ +
+ {isLoading &&

Loading...

} + {error &&

{error}

} + {newItem && ( +
+            {JSON.stringify(newItem, null, 2)}
+          
+ )} +
+
+ +
+
+ ); +} +export default ItemDetails; diff --git a/example/src/pages/Main/ItemList.jsx b/example/src/pages/Main/ItemList.jsx index dccbfb7..19432b5 100644 --- a/example/src/pages/Main/ItemList.jsx +++ b/example/src/pages/Main/ItemList.jsx @@ -19,7 +19,7 @@ PaginationButton.propTypes = { children: T.node.isRequired, }; -function ItemList({ items, isLoading, error, nextPage, previousPage }) { +function ItemList({ items, isLoading, error, nextPage, previousPage, onSelect }) { return (
@@ -27,9 +27,13 @@ function ItemList({ items, isLoading, error, nextPage, previousPage }) { {isLoading &&

Loading...

} {error &&

{error}

} {items && ( -
    - {items.features.map(({ id }) => ( -
  • {id}
  • +
      + {items.features.map((item) => ( +
    • + +
    • ))}
    )} @@ -52,6 +56,7 @@ ItemList.propTypes = { error: T.string, previousPage: T.func, nextPage: T.func, + onSelect: T.func, }; export default ItemList; diff --git a/example/src/pages/Main/index.jsx b/example/src/pages/Main/index.jsx index 57aa049..3e9cebe 100644 --- a/example/src/pages/Main/index.jsx +++ b/example/src/pages/Main/index.jsx @@ -5,6 +5,7 @@ import { useStacSearch, useCollections, useStacApi, StacApiProvider } from 'stac import ItemList from './ItemList'; import Map from './Map'; import QueryBuilder from './QueryBuilder'; +import ItemDetails from './ItemDetails'; // eslint-disable-next-line no-unused-vars const options = { @@ -43,6 +44,18 @@ function Main() { [setBbox] ); + const [selectedItem, setSelectedItem] = useState(null); + + const onSelect = useCallback( + (item) => () => { + setSelectedItem(item); + }, + [] + ); + const onClose = useCallback(() => { + setSelectedItem(null); + }, []); + return (
    - + {selectedItem ? ( + + ) : ( + + )} Date: Thu, 30 Oct 2025 16:50:53 +0100 Subject: [PATCH 06/29] feat: migrate useStacApi hook to TanStack Query - Refactor useStacApi to use TanStack Query for fetching and caching - Add QueryClientProvider to wrapper to ensure a clean instance for every test - Refactor useStacSearch.test.ts to use setupStacSearch helper for all tests, adding a step to wait for IDLE stacApi --- src/hooks/useCollections.ts | 8 +- src/hooks/useStacApi.ts | 58 ++-- src/hooks/useStacSearch.test.ts | 491 ++++++++++++-------------------- src/hooks/useStacSearch.ts | 2 +- src/hooks/wrapper.tsx | 20 +- 5 files changed, 235 insertions(+), 344 deletions(-) diff --git a/src/hooks/useCollections.ts b/src/hooks/useCollections.ts index 522a9dd..4011e0a 100644 --- a/src/hooks/useCollections.ts +++ b/src/hooks/useCollections.ts @@ -51,7 +51,6 @@ function useCollections(): StacCollectionsHook { }); // Sync collections with context - // This preserves the previous logic for consumers and tests useEffect(() => { if (collections) { setCollections(collections); @@ -63,13 +62,14 @@ function useCollections(): StacCollectionsHook { const reload = useMemo(() => debounce(refetch), [refetch]); useEffect(() => { - // Map TanStack Query loading states to previous LoadingState type - if (isLoading || isFetching) { + if (!stacApi) { + setState('IDLE'); + } else if (isLoading || isFetching) { setState('LOADING'); } else { setState('IDLE'); } - }, [isLoading, isFetching]); + }, [stacApi, isLoading, isFetching]); return { collections, diff --git a/src/hooks/useStacApi.ts b/src/hooks/useStacApi.ts index 17314ff..334f8fb 100644 --- a/src/hooks/useStacApi.ts +++ b/src/hooks/useStacApi.ts @@ -1,46 +1,38 @@ -import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import StacApi, { SearchMode } from '../stac-api'; import { Link } from '../types/stac'; import { GenericObject } from '../types'; type StacApiHook = { stacApi?: StacApi; + isLoading: boolean; + isError: boolean; }; function useStacApi(url: string, options?: GenericObject): StacApiHook { - const [stacApi, setStacApi] = useState(); - - useEffect(() => { - let baseUrl: string; - let searchMode = SearchMode.GET; - - fetch(url, { - headers: { - 'Content-Type': 'application/json', - ...options?.headers, - }, - }) - .then((response) => { - baseUrl = response.url; - return response; - }) - .then((response) => response.json()) - .then((response) => { - const doesPost = response.links.find( - ({ rel, method }: Link) => rel === 'search' && method === 'POST' - ); - if (doesPost) { - searchMode = SearchMode.POST; - } - }) - .then(() => setStacApi(new StacApi(baseUrl, searchMode, options))) - .catch((e) => { - // eslint-disable-next-line no-console - console.error('Failed to initialize StacApi:', e); + const { data, isSuccess, isLoading, isError } = useQuery({ + queryKey: ['stacApi', url, options], + queryFn: async () => { + let searchMode = SearchMode.GET; + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, }); - }, [url, options]); - - return { stacApi }; + const baseUrl = response.url; + const json = await response.json(); + const doesPost = json.links?.find( + ({ rel, method }: Link) => rel === 'search' && method === 'POST' + ); + if (doesPost) { + searchMode = SearchMode.POST; + } + return new StacApi(baseUrl, searchMode, options); + }, + staleTime: Infinity, + }); + return { stacApi: isSuccess ? data : undefined, isLoading, isError }; } export default useStacApi; diff --git a/src/hooks/useStacSearch.test.ts b/src/hooks/useStacSearch.test.ts index 0bd441d..b35e219 100644 --- a/src/hooks/useStacSearch.test.ts +++ b/src/hooks/useStacSearch.test.ts @@ -10,6 +10,14 @@ function parseRequestPayload(mockApiCall?: RequestInit) { return JSON.parse(mockApiCall.body as string); } +async function setupStacSearch() { + const { result } = renderHook(() => useStacSearch(), { wrapper }); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + await act(async () => {}); + await waitFor(() => expect(result.current.state).toBe('IDLE')); + return result; +} + describe('useStacSearch — API supports POST', () => { beforeEach(() => { fetch.resetMocks(); @@ -27,28 +35,22 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setIds(['collection_1', 'collection_2'])); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual({ data: '12345' })); - // 8. Validate POST payload + // Validate POST payload const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); expect(postPayload).toEqual({ ids: ['collection_1', 'collection_2'], limit: 25 }); }); @@ -60,27 +62,22 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setBbox([-0.59, 51.24, 0.3, 51.74])); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual({ data: '12345' })); - // 8. Validate POST payload + // Validate POST payload const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); expect(postPayload).toEqual({ bbox: [-0.59, 51.24, 0.3, 51.74], limit: 25 }); }); @@ -92,27 +89,22 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setBbox([0.3, 51.74, -0.59, 51.24])); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual({ data: '12345' })); - // 8. Validate POST payload + // Validate POST payload const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); expect(postPayload).toEqual({ bbox: [-0.59, 51.24, 0.3, 51.74], limit: 25 }); }); @@ -124,27 +116,22 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setCollections(['wildfire', 'surface_temp'])); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual({ data: '12345' })); - // 8. Validate POST payload + // Validate POST payload const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); expect(postPayload).toEqual({ collections: ['wildfire', 'surface_temp'], limit: 25 }); }); @@ -156,27 +143,22 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setCollections([])); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual({ data: '12345' })); - // 8. Validate POST payload + // Validate POST payload const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); expect(postPayload).toEqual({ limit: 25 }); }); @@ -188,28 +170,23 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setDateRangeFrom('2022-01-17')); act(() => result.current.setDateRangeTo('2022-05-17')); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual({ data: '12345' })); - // 8. Validate POST payload + // Validate POST payload const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); expect(postPayload).toEqual({ datetime: '2022-01-17/2022-05-17', limit: 25 }); }); @@ -221,27 +198,22 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setDateRangeFrom('2022-01-17')); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual({ data: '12345' })); - // 8. Validate POST payload + // Validate POST payload const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); expect(postPayload).toEqual({ datetime: '2022-01-17/..', limit: 25 }); }); @@ -253,27 +225,22 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setDateRangeTo('2022-05-17')); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual({ data: '12345' })); - // 8. Validate POST payload + // Validate POST payload const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); expect(postPayload).toEqual({ datetime: '../2022-05-17', limit: 25 }); }); @@ -288,24 +255,19 @@ describe('useStacSearch — API supports POST', () => { statusText: 'Bad Request', }); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Submit (debounced) + // Submit (debounced) act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for error to be set in state + // Wait for error to be set in state await waitFor(() => expect(result.current.error).toEqual({ status: 400, @@ -322,24 +284,20 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce('Wrong query', { status: 400, statusText: 'Bad Request' }); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Submit (debounced) + // Submit (debounced) act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for error to be set in state + // Wait for error to be set in state await waitFor(() => expect(result.current.error).toEqual({ status: 400, @@ -370,29 +328,23 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify(response)); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setDateRangeTo('2022-05-17')); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual(response)); expect(result.current.nextPage).toBeDefined(); - - // 8. Trigger nextPage and validate + // Trigger nextPage and validate fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.nextPage && result.current.nextPage()); await waitFor(() => expect(result.current.state).toBe('LOADING')); @@ -423,29 +375,23 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify(response)); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setDateRangeTo('2022-05-17')); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual(response)); expect(result.current.previousPage).toBeDefined(); - - // 8. Trigger previousPage and validate + // Trigger previousPage and validate fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.previousPage && result.current.previousPage()); await waitFor(() => expect(result.current.state).toBe('LOADING')); @@ -476,29 +422,23 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify(response)); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setDateRangeTo('2022-05-17')); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual(response)); expect(result.current.previousPage).toBeDefined(); - - // 8. Trigger previousPage and validate + // Trigger previousPage and validate fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.previousPage && result.current.previousPage()); await waitFor(() => expect(result.current.state).toBe('LOADING')); @@ -530,29 +470,23 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify(response)); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setBbox([-0.59, 51.24, 0.3, 51.74])); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual(response)); expect(result.current.previousPage).toBeDefined(); - - // 8. Trigger previousPage and validate merged body + // Trigger previousPage and validate merged body fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.previousPage && result.current.previousPage()); await waitFor(() => expect(result.current.state).toBe('LOADING')); @@ -589,29 +523,23 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify(response)); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setBbox([-0.59, 51.24, 0.3, 51.74])); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual(response)); expect(result.current.previousPage).toBeDefined(); - - // 8. Trigger previousPage and validate header + // Trigger previousPage and validate header fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.previousPage && result.current.previousPage()); await waitFor(() => expect(result.current.state).toBe('LOADING')); @@ -636,29 +564,23 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify(response)); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setDateRangeTo('2022-05-17')); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual(response)); expect(result.current.nextPage).toBeDefined(); - - // 8. Trigger nextPage and validate GET request + // Trigger nextPage and validate GET request fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.nextPage && result.current.nextPage()); await waitFor(() => expect(result.current.state).toBe('LOADING')); @@ -683,29 +605,23 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify(response)); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setDateRangeTo('2022-05-17')); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual(response)); expect(result.current.previousPage).toBeDefined(); - - // 8. Trigger previousPage and validate GET request + // Trigger previousPage and validate GET request fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.previousPage && result.current.previousPage()); await waitFor(() => expect(result.current.state).toBe('LOADING')); @@ -722,27 +638,22 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setSortby([{ field: 'id', direction: 'asc' }])); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual({ data: '12345' })); - // 8. Validate POST payload + // Validate POST payload const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); expect(postPayload).toEqual({ sortby: [{ field: 'id', direction: 'asc' }], limit: 25 }); }); @@ -754,27 +665,22 @@ describe('useStacSearch — API supports POST', () => { }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial API capabilities fetch (stacApi initialization) - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); + const result = await setupStacSearch(); - // 3. Set search parameters and submit (debounced) + // Set search parameters and submit (debounced) act(() => result.current.setLimit(50)); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - - // 6. Wait for the search request to complete (second fetch call) + // Wait for the search request to complete (second fetch call) await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Wait for results to be set in state + // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual({ data: '12345' })); - // 8. Validate POST payload + // Validate POST payload const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); expect(postPayload).toEqual({ limit: 50 }); }); @@ -817,24 +723,21 @@ describe('useStacSearch — API supports GET', () => { .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial hook setup - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); - // 3. Set search parameters and submit (debounced) + const result = await setupStacSearch(); + + // Set search parameters and submit (debounced) act(() => result.current.setBbox([-0.59, 51.24, 0.3, 51.74])); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - // 6. Wait for state to be IDLE + // Wait for state to be IDLE await waitFor(() => expect(result.current.state).toBe('IDLE')); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Assert fetch URL and results + // Assert fetch URL and results expect(fetch.mock.calls[1][0]).toEqual( 'https://fake-stac-api.net/search?limit=25&bbox=-0.59%2C51.24%2C0.3%2C51.74' ); @@ -846,24 +749,21 @@ describe('useStacSearch — API supports GET', () => { .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial hook setup - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); - // 3. Set search parameters and submit (debounced) + const result = await setupStacSearch(); + + // Set search parameters and submit (debounced) act(() => result.current.setCollections(['wildfire', 'surface_temp'])); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - // 6. Wait for state to be IDLE + // Wait for state to be IDLE await waitFor(() => expect(result.current.state).toBe('IDLE')); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Assert fetch URL and results + // Assert fetch URL and results expect(fetch.mock.calls[1][0]).toEqual( 'https://fake-stac-api.net/search?limit=25&collections=wildfire%2Csurface_temp' ); @@ -875,25 +775,22 @@ describe('useStacSearch — API supports GET', () => { .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial hook setup - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); - // 3. Set search parameters and submit (debounced) + const result = await setupStacSearch(); + + // Set search parameters and submit (debounced) act(() => result.current.setDateRangeFrom('2022-01-17')); act(() => result.current.setDateRangeTo('2022-05-17')); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - // 6. Wait for state to be IDLE and fetch to be called twice + // Wait for state to be IDLE and fetch to be called twice await waitFor(() => expect(result.current.state).toBe('IDLE')); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Assert fetch URL and results + // Assert fetch URL and results expect(fetch.mock.calls[1][0]).toEqual( 'https://fake-stac-api.net/search?limit=25&datetime=2022-01-17%2F2022-05-17' ); @@ -905,24 +802,21 @@ describe('useStacSearch — API supports GET', () => { .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial hook setup - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); - // 3. Set search parameters and submit (debounced) + const result = await setupStacSearch(); + + // Set search parameters and submit (debounced) act(() => result.current.setDateRangeFrom('2022-01-17')); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - // 6. Wait for state to be IDLE and fetch to be called twice + // Wait for state to be IDLE and fetch to be called twice await waitFor(() => expect(result.current.state).toBe('IDLE')); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Assert fetch URL and results + // Assert fetch URL and results expect(fetch.mock.calls[1][0]).toEqual( 'https://fake-stac-api.net/search?limit=25&datetime=2022-01-17%2F..' ); @@ -934,24 +828,21 @@ describe('useStacSearch — API supports GET', () => { .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial hook setup - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); - // 3. Set search parameters and submit (debounced) + const result = await setupStacSearch(); + + // Set search parameters and submit (debounced) act(() => result.current.setDateRangeTo('2022-05-17')); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - // 6. Wait for state to be IDLE and fetch to be called twice + // Wait for state to be IDLE and fetch to be called twice await waitFor(() => expect(result.current.state).toBe('IDLE')); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Assert fetch URL and results + // Assert fetch URL and results expect(fetch.mock.calls[1][0]).toEqual( 'https://fake-stac-api.net/search?limit=25&datetime=..%2F2022-05-17' ); @@ -963,12 +854,9 @@ describe('useStacSearch — API supports GET', () => { .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial hook setup - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); - // 3. Set search parameters and submit (debounced) + const result = await setupStacSearch(); + + // Set search parameters and submit (debounced) act(() => result.current.setSortby([ { field: 'id', direction: 'asc' }, @@ -976,16 +864,16 @@ describe('useStacSearch — API supports GET', () => { ]) ); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - // 6. Wait for state to be IDLE and fetch to be called twice + // Wait for state to be IDLE and fetch to be called twice await waitFor(() => expect(result.current.state).toBe('IDLE')); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Assert fetch URL and results + // Assert fetch URL and results expect(fetch.mock.calls[1][0]).toEqual( 'https://fake-stac-api.net/search?limit=25&sortby=%2Bid%2C-properties.cloud' ); @@ -997,24 +885,21 @@ describe('useStacSearch — API supports GET', () => { .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) .mockResponseOnce(JSON.stringify({ data: '12345' })); - const { result } = renderHook(() => useStacSearch(), { wrapper }); - // 1. Wait for initial hook setup - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - // 2. Flush React state update so stacApi is available - await act(async () => {}); - // 3. Set search parameters and submit (debounced) + const result = await setupStacSearch(); + + // Set search parameters and submit (debounced) act(() => result.current.setLimit(50)); act(() => result.current.submit()); - // 4. Advance timers to trigger debounce + // Advance timers to trigger debounce act(() => { jest.advanceTimersByTime(300); }); - // 5. Flush microtasks to ensure debounced function and state updates complete + // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); - // 6. Wait for state to be IDLE and fetch to be called twice + // Wait for state to be IDLE and fetch to be called twice await waitFor(() => expect(result.current.state).toBe('IDLE')); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - // 7. Assert fetch URL and results + // Assert fetch URL and results expect(fetch.mock.calls[1][0]).toEqual('https://fake-stac-api.net/search?limit=50'); expect(result.current.results).toEqual({ data: '12345' }); }); diff --git a/src/hooks/useStacSearch.ts b/src/hooks/useStacSearch.ts index a205188..32bfd32 100644 --- a/src/hooks/useStacSearch.ts +++ b/src/hooks/useStacSearch.ts @@ -135,7 +135,7 @@ function useStacSearch(): StacSearchHook { ); /** - * Retreives a page from a paginatied item set using the provided link config. + * Retrieves a page from a paginated item set using the provided link config. * Executes a POST request against the `search` endpoint if pagination uses POST * or retrieves the page items using GET against the link href */ diff --git a/src/hooks/wrapper.tsx b/src/hooks/wrapper.tsx index e6dcac7..8380984 100644 --- a/src/hooks/wrapper.tsx +++ b/src/hooks/wrapper.tsx @@ -1,11 +1,25 @@ import React from 'react'; import { StacApiProvider } from '../context'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; type WrapperType = { children: React.ReactNode; }; -const Wrapper = ({ children }: WrapperType) => ( - {children} -); +const Wrapper = ({ children }: WrapperType) => { + const testQueryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 0, + staleTime: 0, + retry: false, + }, + }, + }); + return ( + + {children} + + ); +}; export default Wrapper; From 4d0f7019977951bc7574e911bd151bf7084007c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Mon, 17 Nov 2025 12:29:04 +0100 Subject: [PATCH 07/29] feat: migrate useStacSearch hook to TanStack Query - Refactor useStacSearch to use TanStack Query for fetching and caching - Replace manual state management with useQuery for search and pagination - Add FetchRequest type to handle both search POST and pagination GET requests - Improve pagination handling to properly extract and manage next/prev links - Clear pagination links only when API changes, not on every new search - Add queryClient.invalidateQueries on API instance change for cache cleanup - Fix type casting in useItem reload function for consistency - Update tests to wrap pagination link assertions in waitFor to prevent race conditions - Maintain backwards compatibility with existing loading states and API --- src/hooks/useItem.ts | 2 +- src/hooks/useStacSearch.test.ts | 21 ++-- src/hooks/useStacSearch.ts | 181 ++++++++++++++++++++------------ 3 files changed, 128 insertions(+), 76 deletions(-) diff --git a/src/hooks/useItem.ts b/src/hooks/useItem.ts index cbdabee..6a5695f 100644 --- a/src/hooks/useItem.ts +++ b/src/hooks/useItem.ts @@ -60,7 +60,7 @@ function useItem(url: string): ItemHook { item, state, error: error as ApiError, - reload: refetch, + reload: refetch as () => void, }; } diff --git a/src/hooks/useStacSearch.test.ts b/src/hooks/useStacSearch.test.ts index b35e219..0a87180 100644 --- a/src/hooks/useStacSearch.test.ts +++ b/src/hooks/useStacSearch.test.ts @@ -343,7 +343,8 @@ describe('useStacSearch — API supports POST', () => { await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual(response)); - expect(result.current.nextPage).toBeDefined(); + // Wait for pagination links to be extracted from results + await waitFor(() => expect(result.current.nextPage).toBeDefined()); // Trigger nextPage and validate fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.nextPage && result.current.nextPage()); @@ -390,7 +391,8 @@ describe('useStacSearch — API supports POST', () => { await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual(response)); - expect(result.current.previousPage).toBeDefined(); + // Wait for pagination links to be extracted from results + await waitFor(() => expect(result.current.previousPage).toBeDefined()); // Trigger previousPage and validate fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.previousPage && result.current.previousPage()); @@ -437,7 +439,8 @@ describe('useStacSearch — API supports POST', () => { await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual(response)); - expect(result.current.previousPage).toBeDefined(); + // Wait for pagination links to be extracted from results + await waitFor(() => expect(result.current.previousPage).toBeDefined()); // Trigger previousPage and validate fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.previousPage && result.current.previousPage()); @@ -485,7 +488,8 @@ describe('useStacSearch — API supports POST', () => { await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual(response)); - expect(result.current.previousPage).toBeDefined(); + // Wait for pagination links to be extracted from results + await waitFor(() => expect(result.current.previousPage).toBeDefined()); // Trigger previousPage and validate merged body fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.previousPage && result.current.previousPage()); @@ -538,7 +542,8 @@ describe('useStacSearch — API supports POST', () => { await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual(response)); - expect(result.current.previousPage).toBeDefined(); + // Wait for pagination links to be extracted from results + await waitFor(() => expect(result.current.previousPage).toBeDefined()); // Trigger previousPage and validate header fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.previousPage && result.current.previousPage()); @@ -579,7 +584,8 @@ describe('useStacSearch — API supports POST', () => { await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual(response)); - expect(result.current.nextPage).toBeDefined(); + // Wait for pagination links to be extracted from results + await waitFor(() => expect(result.current.nextPage).toBeDefined()); // Trigger nextPage and validate GET request fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.nextPage && result.current.nextPage()); @@ -620,7 +626,8 @@ describe('useStacSearch — API supports POST', () => { await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Wait for results to be set in state await waitFor(() => expect(result.current.results).toEqual(response)); - expect(result.current.previousPage).toBeDefined(); + // Wait for pagination links to be extracted from results + await waitFor(() => expect(result.current.previousPage).toBeDefined()); // Trigger previousPage and validate GET request fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.previousPage && result.current.previousPage()); diff --git a/src/hooks/useStacSearch.ts b/src/hooks/useStacSearch.ts index 32bfd32..d39e454 100644 --- a/src/hooks/useStacSearch.ts +++ b/src/hooks/useStacSearch.ts @@ -1,4 +1,5 @@ import { useCallback, useState, useMemo, useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import debounce from '../utils/debounce'; import type { ApiError, LoadingState } from '../types'; import type { @@ -7,7 +8,6 @@ import type { CollectionIdList, SearchPayload, SearchResponse, - LinkBody, Sortby, } from '../types/stac'; import { useStacApiContext } from '../context/useStacApiContext'; @@ -37,9 +37,22 @@ type StacSearchHook = { previousPage: PaginationHandler | undefined; }; +type FetchRequest = + | { + type: 'search'; + payload: SearchPayload; + headers?: Record; + } + | { + type: 'get'; + url: string; + }; + function useStacSearch(): StacSearchHook { const { stacApi } = useStacApiContext(); - const [results, setResults] = useState(); + const queryClient = useQueryClient(); + + // Search parameters state const [ids, setIds] = useState(); const [bbox, setBbox] = useState(); const [collections, setCollections] = useState(); @@ -47,14 +60,14 @@ function useStacSearch(): StacSearchHook { const [dateRangeTo, setDateRangeTo] = useState(''); const [limit, setLimit] = useState(25); const [sortby, setSortby] = useState(); - const [state, setState] = useState('IDLE'); - const [error, setError] = useState(); + + // Track the current request (search or pagination) for React Query + const [currentRequest, setCurrentRequest] = useState(null); const [nextPageConfig, setNextPageConfig] = useState(); const [previousPageConfig, setPreviousPageConfig] = useState(); const reset = () => { - setResults(undefined); setBbox(undefined); setCollections(undefined); setIds(undefined); @@ -62,15 +75,24 @@ function useStacSearch(): StacSearchHook { setDateRangeTo(''); setSortby(undefined); setLimit(25); + setCurrentRequest(null); + setNextPageConfig(undefined); + setPreviousPageConfig(undefined); }; /** * Reset state when stacApi changes */ - useEffect(reset, [stacApi]); + useEffect(() => { + if (stacApi) { + reset(); + // Invalidate all search queries when API changes + void queryClient.invalidateQueries({ queryKey: ['stacSearch'] }); + } + }, [stacApi, queryClient]); /** - * Extracts the pagination config from the the links array of the items response + * Extracts the pagination config from the links array of the items response */ const setPaginationConfig = useCallback((links: Link[]) => { setNextPageConfig(links.find(({ rel }) => rel === 'next')); @@ -93,85 +115,108 @@ function useStacSearch(): StacSearchHook { ); /** - * Resets the state and processes the results from the provided request + * Fetch function for searches using TanStack Query */ - const processRequest = useCallback( - (request: Promise) => { - setResults(undefined); - setState('LOADING'); - setError(undefined); - setNextPageConfig(undefined); - setPreviousPageConfig(undefined); - - request - .then((response) => response.json()) - .then((data) => { - setResults(data); - if (data.links) { - setPaginationConfig(data.links); - } - }) - .catch((err) => setError(err)) - .finally(() => setState('IDLE')); - }, - [setPaginationConfig] - ); + const fetchRequest = async (request: FetchRequest): Promise => { + if (!stacApi) throw new Error('No STAC API configured'); + + const response = + request.type === 'search' + ? await stacApi.search(request.payload, request.headers) + : await stacApi.get(request.url); + + if (!response.ok) { + let detail; + try { + detail = await response.json(); + } catch { + detail = await response.text(); + } + const err = Object.assign(new Error(response.statusText), { + status: response.status, + statusText: response.statusText, + detail, + }); + throw err; + } + return await response.json(); + }; /** - * Executes a POST request against the `search` endpoint using the provided payload and headers + * useQuery for search and pagination with caching */ - const executeSearch = useCallback( - (payload: SearchPayload, headers = {}) => - stacApi && processRequest(stacApi.search(payload, headers)), - [stacApi, processRequest] - ); + const { + data: results, + error, + isLoading, + isFetching, + } = useQuery({ + queryKey: ['stacSearch', currentRequest], + queryFn: () => fetchRequest(currentRequest!), + enabled: currentRequest !== null, + retry: false, + }); /** - * Execute a GET request against the provided URL + * Extract pagination links from results */ - const getItems = useCallback( - (url: string) => stacApi && processRequest(stacApi.get(url)), - [stacApi, processRequest] - ); + useEffect(() => { + // Only update pagination links when we have actual results with links + // Don't clear them when results becomes undefined (during new requests) + if (results?.links) { + setPaginationConfig(results.links); + } + }, [results, setPaginationConfig]); /** - * Retrieves a page from a paginated item set using the provided link config. - * Executes a POST request against the `search` endpoint if pagination uses POST - * or retrieves the page items using GET against the link href + * Convert a pagination Link to a FetchRequest */ - const flipPage = useCallback( - (link?: Link) => { - if (link) { - let payload = link.body as LinkBody; - if (payload) { - if (payload.merge) { - payload = { - ...payload, - ...getSearchPayload(), - }; - } - executeSearch(payload, link.headers); - } else { - getItems(link.href); - } + const linkToRequest = useCallback( + (link: Link): FetchRequest => { + if (link.body) { + const payload = link.body.merge ? { ...link.body, ...getSearchPayload() } : link.body; + return { + type: 'search', + payload, + headers: link.headers, + }; } + return { + type: 'get', + url: link.href, + }; }, - [executeSearch, getItems, getSearchPayload] + [getSearchPayload] ); - const nextPageFn = useCallback(() => flipPage(nextPageConfig), [flipPage, nextPageConfig]); - - const previousPageFn = useCallback( - () => flipPage(previousPageConfig), - [flipPage, previousPageConfig] - ); + /** + * Pagination handlers + */ + const nextPageFn = useCallback(() => { + if (nextPageConfig) { + setCurrentRequest(linkToRequest(nextPageConfig)); + } + }, [nextPageConfig, linkToRequest]); + + const previousPageFn = useCallback(() => { + if (previousPageConfig) { + setCurrentRequest(linkToRequest(previousPageConfig)); + } + }, [previousPageConfig, linkToRequest]); + /** + * Submit handler for new searches + */ const _submit = useCallback(() => { const payload = getSearchPayload(); - executeSearch(payload); - }, [executeSearch, getSearchPayload]); + setCurrentRequest({ type: 'search', payload }); + }, [getSearchPayload]); + const submit = useMemo(() => debounce(_submit), [_submit]); + // Sync loading state for backwards compatibility + const state: LoadingState = isLoading || isFetching ? 'LOADING' : 'IDLE'; + return { submit, ids, @@ -186,7 +231,7 @@ function useStacSearch(): StacSearchHook { setDateRangeTo, results, state, - error, + error: error ?? undefined, sortby, setSortby, limit, From e0819c43f24b92ebb60755fc8b8402e072bb9437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Mon, 17 Nov 2025 13:01:45 +0100 Subject: [PATCH 08/29] fix: externalize @tanstack/react-query in build and fix StacApiProvider initialization order - Add @tanstack/react-query to vite external dependencies to prevent bundling - Refactor StacApiProvider to split into inner/outer components, ensuring QueryClient is available before any React Query hooks execute - Add @tanstack/react-query peer dependency to example app package.json --- example/package.json | 1 + example/yarn.lock | 19 +++++++++++++++++++ src/context/index.tsx | 14 ++++++++++++-- vite.config.ts | 2 +- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/example/package.json b/example/package.json index e91636b..5cf3485 100644 --- a/example/package.json +++ b/example/package.json @@ -5,6 +5,7 @@ "dependencies": { "@mapbox/mapbox-gl-draw": "^1.3.0", "@mapbox/mapbox-gl-draw-static-mode": "^1.0.1", + "@tanstack/react-query": "^5.90.10", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^13.2.1", diff --git a/example/yarn.lock b/example/yarn.lock index 61dd036..e0ac922 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -2474,6 +2474,24 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:5.90.10": + version: 5.90.10 + resolution: "@tanstack/query-core@npm:5.90.10" + checksum: 10c0/c51762d4413f99886af24d0a4897e286233fae305131df2db8294c26b1375e74e87d0cd7dafbe1b7d23338221fca0304c05058dd9a909c0988a8829d8f7283e6 + languageName: node + linkType: hard + +"@tanstack/react-query@npm:^5.90.10": + version: 5.90.10 + resolution: "@tanstack/react-query@npm:5.90.10" + dependencies: + "@tanstack/query-core": "npm:5.90.10" + peerDependencies: + react: ^18 || ^19 + checksum: 10c0/a852c44fafb0331a47ba8b46f96bc6c3c66a1bfbec8a09de15fc0ba6fd1b867da42c4974f83489a03cc4d7fd9fa40ec23b8790d2f69a930ca63b85f2bf4a4885 + languageName: node + linkType: hard + "@testing-library/dom@npm:^8.5.0": version: 8.13.0 resolution: "@testing-library/dom@npm:8.13.0" @@ -5839,6 +5857,7 @@ __metadata: dependencies: "@mapbox/mapbox-gl-draw": "npm:^1.3.0" "@mapbox/mapbox-gl-draw-static-mode": "npm:^1.0.1" + "@tanstack/react-query": "npm:^5.90.10" "@testing-library/jest-dom": "npm:^5.14.1" "@testing-library/react": "npm:^13.0.0" "@testing-library/user-event": "npm:^13.2.1" diff --git a/src/context/index.tsx b/src/context/index.tsx index cc8eeaf..b9af9c4 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -13,7 +13,11 @@ type StacApiProviderType = { queryClient?: QueryClient; }; -export function StacApiProvider({ children, apiUrl, options, queryClient }: StacApiProviderType) { +function StacApiProviderInner({ + children, + apiUrl, + options, +}: Omit) { const { stacApi } = useStacApi(apiUrl, options); const [collections, setCollections] = useState(); const [items, setItems] = useState(new Map()); @@ -48,6 +52,10 @@ export function StacApiProvider({ children, apiUrl, options, queryClient }: Stac [addItem, collections, deleteItem, getItem, stacApi] ); + return {children}; +} + +export function StacApiProvider({ children, apiUrl, options, queryClient }: StacApiProviderType) { const defaultClient = useMemo(() => new QueryClient(), []); const client: QueryClient = queryClient ?? defaultClient; @@ -58,7 +66,9 @@ export function StacApiProvider({ children, apiUrl, options, queryClient }: Stac return ( - {children} + + {children} + ); } diff --git a/vite.config.ts b/vite.config.ts index 885df13..693f673 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ }, sourcemap: true, rollupOptions: { - external: ['react', 'react-dom'], + external: ['react', 'react-dom', '@tanstack/react-query'], }, }, plugins: [ From 1dfbf58f05ae6c0f466aa1f4fd4df3c1ec122a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Tue, 18 Nov 2025 11:29:36 +0100 Subject: [PATCH 09/29] fix: generate index.d.ts at dist root for proper TypeScript imports - Add insertTypesEntry: true to vite-plugin-dts config to generate index.d.ts - Ensures TypeScript can resolve module declarations when package is linked or installed --- vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vite.config.ts b/vite.config.ts index 693f673..6c00595 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ dts({ exclude: ['**/*.test.ts'], outDir: 'dist', + insertTypesEntry: true, }), ], }); From 9f30e60756214c9b84f580237b3aaaa6e705a2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Mon, 24 Nov 2025 09:54:23 +0100 Subject: [PATCH 10/29] 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. --- src/hooks/useCollections.ts | 3 +- src/hooks/useItem.ts | 3 +- src/hooks/useStacApi.ts | 3 +- src/hooks/useStacSearch.ts | 16 +-- src/types/stac.d.ts | 31 +++++ src/utils/queryKeys.test.ts | 264 ++++++++++++++++++++++++++++++++++++ src/utils/queryKeys.ts | 90 ++++++++++++ 7 files changed, 394 insertions(+), 16 deletions(-) create mode 100644 src/utils/queryKeys.test.ts create mode 100644 src/utils/queryKeys.ts diff --git a/src/hooks/useCollections.ts b/src/hooks/useCollections.ts index 4011e0a..a4f5e67 100644 --- a/src/hooks/useCollections.ts +++ b/src/hooks/useCollections.ts @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { type ApiError, type LoadingState } from '../types'; import type { CollectionsResponse } from '../types/stac'; import debounce from '../utils/debounce'; +import { generateCollectionsQueryKey } from '../utils/queryKeys'; import { useStacApiContext } from '../context/useStacApiContext'; type StacCollectionsHook = { @@ -44,7 +45,7 @@ function useCollections(): StacCollectionsHook { isFetching, refetch, } = useQuery({ - queryKey: ['collections'], + queryKey: generateCollectionsQueryKey(), queryFn: fetchCollections, enabled: !!stacApi, retry: false, diff --git a/src/hooks/useItem.ts b/src/hooks/useItem.ts index 6a5695f..2ce8e28 100644 --- a/src/hooks/useItem.ts +++ b/src/hooks/useItem.ts @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { Item } from '../types/stac'; import { ApiError, LoadingState } from '../types'; import { useStacApiContext } from '../context/useStacApiContext'; +import { generateItemQueryKey } from '../utils/queryKeys'; type ItemHook = { item?: Item; @@ -42,7 +43,7 @@ function useItem(url: string): ItemHook { isFetching, refetch, } = useQuery({ - queryKey: ['item', url], + queryKey: generateItemQueryKey(url), queryFn: fetchItem, enabled: !!stacApi, retry: false, diff --git a/src/hooks/useStacApi.ts b/src/hooks/useStacApi.ts index 334f8fb..19414af 100644 --- a/src/hooks/useStacApi.ts +++ b/src/hooks/useStacApi.ts @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import StacApi, { SearchMode } from '../stac-api'; import { Link } from '../types/stac'; import { GenericObject } from '../types'; +import { generateStacApiQueryKey } from '../utils/queryKeys'; type StacApiHook = { stacApi?: StacApi; @@ -11,7 +12,7 @@ type StacApiHook = { function useStacApi(url: string, options?: GenericObject): StacApiHook { const { data, isSuccess, isLoading, isError } = useQuery({ - queryKey: ['stacApi', url, options], + queryKey: generateStacApiQueryKey(url, options), queryFn: async () => { let searchMode = SearchMode.GET; const response = await fetch(url, { diff --git a/src/hooks/useStacSearch.ts b/src/hooks/useStacSearch.ts index d39e454..926d09e 100644 --- a/src/hooks/useStacSearch.ts +++ b/src/hooks/useStacSearch.ts @@ -1,14 +1,15 @@ import { useCallback, useState, useMemo, useEffect } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import debounce from '../utils/debounce'; +import { generateStacSearchQueryKey } from '../utils/queryKeys'; import type { ApiError, LoadingState } from '../types'; import type { Link, Bbox, CollectionIdList, - SearchPayload, SearchResponse, Sortby, + FetchRequest, } from '../types/stac'; import { useStacApiContext } from '../context/useStacApiContext'; @@ -37,17 +38,6 @@ type StacSearchHook = { previousPage: PaginationHandler | undefined; }; -type FetchRequest = - | { - type: 'search'; - payload: SearchPayload; - headers?: Record; - } - | { - type: 'get'; - url: string; - }; - function useStacSearch(): StacSearchHook { const { stacApi } = useStacApiContext(); const queryClient = useQueryClient(); @@ -151,7 +141,7 @@ function useStacSearch(): StacSearchHook { isLoading, isFetching, } = useQuery({ - queryKey: ['stacSearch', currentRequest], + queryKey: currentRequest ? generateStacSearchQueryKey(currentRequest) : ['stacSearch', 'idle'], queryFn: () => fetchRequest(currentRequest!), enabled: currentRequest !== null, retry: false, diff --git a/src/types/stac.d.ts b/src/types/stac.d.ts index 670ac92..13f7001 100644 --- a/src/types/stac.d.ts +++ b/src/types/stac.d.ts @@ -21,6 +21,37 @@ export type SearchPayload = { sortby?: Sortby[]; }; +/** + * Extended search payload that includes both the base SearchPayload structure + * and additional properties used in API requests. + */ +export type SearchRequestPayload = SearchPayload & { + /** Datetime string in ISO 8601 format (transformed from dateRange) */ + datetime?: string; + /** Maximum number of results to return */ + limit?: number; + /** Pagination token for cursor-based pagination */ + token?: string; + /** Page number for offset-based pagination */ + page?: number; + /** Flag indicating if this payload should be merged with current search params */ + merge?: boolean; +}; + +/** + * Type for fetch requests used in useStacSearch hook + */ +export type FetchRequest = + | { + type: 'search'; + payload: SearchRequestPayload; + headers?: Record; + } + | { + type: 'get'; + url: string; + }; + export type LinkBody = SearchPayload & { merge?: boolean; }; diff --git a/src/utils/queryKeys.test.ts b/src/utils/queryKeys.test.ts new file mode 100644 index 0000000..e7daede --- /dev/null +++ b/src/utils/queryKeys.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect } from '@jest/globals'; +import { + generateStacSearchQueryKey, + generateStacApiQueryKey, + generateItemQueryKey, + generateCollectionsQueryKey, +} from './queryKeys'; +import type { SearchRequestPayload, Sortby } from '../types/stac'; + +describe('Query Key Generators', () => { + describe('generateCollectionsQueryKey', () => { + it('should generate a simple static key', () => { + const key = generateCollectionsQueryKey(); + expect(key).toEqual(['collections']); + }); + }); + + describe('generateItemQueryKey', () => { + it('should generate key with item URL', () => { + const url = 'https://example.com/collections/test/items/item1'; + const key = generateItemQueryKey(url); + expect(key).toEqual(['item', url]); + }); + + it('should handle different URLs', () => { + const url1 = 'https://example.com/items/a'; + const url2 = 'https://example.com/items/b'; + const key1 = generateItemQueryKey(url1); + const key2 = generateItemQueryKey(url2); + expect(key1).not.toEqual(key2); + expect(key1).toEqual(['item', url1]); + expect(key2).toEqual(['item', url2]); + }); + }); + + describe('generateStacApiQueryKey', () => { + it('should generate key with URL only when no options', () => { + const url = 'https://example.com/stac'; + const key = generateStacApiQueryKey(url); + expect(key).toEqual(['stacApi', url, undefined]); + }); + + it('should extract only headers from options', () => { + const url = 'https://example.com/stac'; + const options = { + headers: { Authorization: 'Bearer token123' }, + someOtherField: { deeply: { nested: { object: 'value' } } }, + anotherField: 'ignored', + }; + const key = generateStacApiQueryKey(url, options); + expect(key).toEqual(['stacApi', url, { headers: { Authorization: 'Bearer token123' } }]); + }); + + it('should handle options without headers', () => { + const url = 'https://example.com/stac'; + const options = { + someField: 'value', + anotherField: { nested: 'data' }, + }; + const key = generateStacApiQueryKey(url, options); + expect(key).toEqual(['stacApi', url, undefined]); + }); + + it('should handle empty options object', () => { + const url = 'https://example.com/stac'; + const key = generateStacApiQueryKey(url, {}); + expect(key).toEqual(['stacApi', url, undefined]); + }); + }); + + describe('generateStacSearchQueryKey', () => { + describe('for search requests', () => { + it('should generate key with minimal search params', () => { + const payload: SearchRequestPayload = { + collections: ['collection1'], + limit: 25, + }; + const key = generateStacSearchQueryKey({ type: 'search', payload }); + expect(key).toEqual([ + 'stacSearch', + 'search', + { + collections: ['collection1'], + limit: 25, + }, + ]); + }); + + it('should include all search parameters when present', () => { + const sortby: Sortby[] = [ + { field: 'id', direction: 'asc' }, + { field: 'properties.cloud', direction: 'desc' }, + ]; + const payload: SearchRequestPayload = { + ids: ['item1', 'item2'], + bbox: [-180, -90, 180, 90], + collections: ['collection1', 'collection2'], + datetime: '2023-01-01/2023-12-31', + sortby, + limit: 50, + }; + const key = generateStacSearchQueryKey({ type: 'search', payload }); + expect(key).toEqual([ + 'stacSearch', + 'search', + { + ids: ['item1', 'item2'], + bbox: [-180, -90, 180, 90], + collections: ['collection1', 'collection2'], + datetime: '2023-01-01/2023-12-31', + sortby, + limit: 50, + }, + ]); + }); + + it('should omit undefined search parameters', () => { + const payload: SearchRequestPayload = { + collections: ['collection1'], + limit: 25, + }; + const key = generateStacSearchQueryKey({ type: 'search', payload }); + expect(key[2]).not.toHaveProperty('ids'); + expect(key[2]).not.toHaveProperty('bbox'); + expect(key[2]).not.toHaveProperty('datetime'); + expect(key[2]).not.toHaveProperty('sortby'); + }); + + it('should handle empty collections array', () => { + const payload: SearchRequestPayload = { + collections: [], + limit: 25, + }; + const key = generateStacSearchQueryKey({ type: 'search', payload }); + expect(key).toEqual([ + 'stacSearch', + 'search', + { + collections: [], + limit: 25, + }, + ]); + }); + + it('should ignore headers in search requests for key generation', () => { + const payload: SearchRequestPayload = { + collections: ['collection1'], + limit: 25, + }; + const key = generateStacSearchQueryKey({ + type: 'search', + payload, + headers: { Authorization: 'Bearer token', 'X-Custom': 'value' }, + }); + expect(key).toEqual([ + 'stacSearch', + 'search', + { + collections: ['collection1'], + limit: 25, + }, + ]); + expect(key[2]).not.toHaveProperty('headers'); + }); + }); + + describe('for pagination GET requests', () => { + it('should generate key with URL for GET requests', () => { + const url = 'https://example.com/search?page=2&limit=25'; + const key = generateStacSearchQueryKey({ type: 'get', url }); + expect(key).toEqual(['stacSearch', 'page', url]); + }); + + it('should handle different pagination URLs', () => { + const url1 = 'https://example.com/search?page=1'; + const url2 = 'https://example.com/search?page=2'; + const key1 = generateStacSearchQueryKey({ type: 'get', url: url1 }); + const key2 = generateStacSearchQueryKey({ type: 'get', url: url2 }); + expect(key1).not.toEqual(key2); + expect(key1).toEqual(['stacSearch', 'page', url1]); + expect(key2).toEqual(['stacSearch', 'page', url2]); + }); + }); + + describe('key stability', () => { + it('should generate identical keys for identical search payloads', () => { + const payload: SearchRequestPayload = { + collections: ['collection1', 'collection2'], + bbox: [-10, -5, 10, 5], + limit: 25, + }; + const key1 = generateStacSearchQueryKey({ type: 'search', payload }); + const key2 = generateStacSearchQueryKey({ type: 'search', payload }); + expect(key1).toEqual(key2); + }); + + it('should generate different keys for different search payloads', () => { + const payload1: SearchRequestPayload = { + collections: ['collection1'], + limit: 25, + }; + const payload2: SearchRequestPayload = { + collections: ['collection2'], + limit: 25, + }; + const key1 = generateStacSearchQueryKey({ type: 'search', payload: payload1 }); + const key2 = generateStacSearchQueryKey({ type: 'search', payload: payload2 }); + expect(key1).not.toEqual(key2); + }); + + it('should generate different keys when only limit changes', () => { + const payload1: SearchRequestPayload = { + collections: ['collection1'], + limit: 25, + }; + const payload2: SearchRequestPayload = { + collections: ['collection1'], + limit: 50, + }; + const key1 = generateStacSearchQueryKey({ type: 'search', payload: payload1 }); + const key2 = generateStacSearchQueryKey({ type: 'search', payload: payload2 }); + expect(key1).not.toEqual(key2); + }); + + it('should include extra properties for pagination tokens and custom params', () => { + const payload1: SearchRequestPayload = { + collections: ['collection1'], + limit: 25, + }; + + const payload2 = { + collections: ['collection1'], + limit: 25, + token: 'next:abc123', + } as SearchRequestPayload; + const key1 = generateStacSearchQueryKey({ type: 'search', payload: payload1 }); + const key2 = generateStacSearchQueryKey({ type: 'search', payload: payload2 }); + + expect(key1).not.toEqual(key2); + expect(key2[2]).toHaveProperty('token', 'next:abc123'); + }); + }); + }); + + describe('edge cases', () => { + it('should handle empty search payload', () => { + const payload: SearchRequestPayload = {}; + const key = generateStacSearchQueryKey({ type: 'search', payload }); + expect(key).toEqual(['stacSearch', 'search', {}]); + }); + + it('should handle very long URLs', () => { + const longUrl = 'https://example.com/items/' + 'a'.repeat(1000); + const key = generateItemQueryKey(longUrl); + expect(key).toEqual(['item', longUrl]); + }); + + it('should handle special characters in URLs', () => { + const url = 'https://example.com/items/test%20item?query=hello&world=1'; + const key = generateItemQueryKey(url); + expect(key).toEqual(['item', url]); + }); + }); +}); diff --git a/src/utils/queryKeys.ts b/src/utils/queryKeys.ts new file mode 100644 index 0000000..3613ec6 --- /dev/null +++ b/src/utils/queryKeys.ts @@ -0,0 +1,90 @@ +import type { SearchRequestPayload, FetchRequest } from '../types/stac'; +import type { GenericObject } from '../types'; + +/** + * Extracts only the essential search parameters from a payload for query key generation. + * This creates a minimal, stable key that's cheap to hash. + * Handles both dateRange (from useStacSearch state) and datetime (from API transformations). + * Also includes pagination-specific parameters (token, page, merge) that make requests unique. + */ +function extractSearchParams(payload: SearchRequestPayload): Record { + const params: Record = {}; + + // Only include defined search parameters + if (payload.ids !== undefined) params.ids = payload.ids; + if (payload.bbox !== undefined) params.bbox = payload.bbox; + if (payload.collections !== undefined) params.collections = payload.collections; + + // Handle both dateRange (from hook state) and datetime (from API transformation) + if (payload.datetime !== undefined) { + params.datetime = payload.datetime; + } else if (payload.dateRange !== undefined) { + // Convert dateRange to datetime format for consistent keys + const { from, to } = payload.dateRange; + if (from || to) { + params.datetime = `${from || '..'}/${to || '..'}`; + } + } + + if (payload.sortby !== undefined) params.sortby = payload.sortby; + if (payload.limit !== undefined) params.limit = payload.limit; + + // Include pagination-specific parameters + if (payload.token !== undefined) params.token = payload.token; + if (payload.page !== undefined) params.page = payload.page; + if (payload.merge !== undefined) params.merge = payload.merge; + + return params; +} + +/** + * Generates a query key for STAC collections requests. + * Collections are fetched from a single endpoint with no parameters. + */ +export function generateCollectionsQueryKey(): [string] { + return ['collections']; +} + +/** + * Generates a query key for STAC item requests. + * Items are fetched by URL. + */ +export function generateItemQueryKey(url: string): [string, string] { + return ['item', url]; +} + +/** + * Generates a query key for STAC API initialization. + * Extracts only the headers from options to avoid including large nested objects. + */ +export function generateStacApiQueryKey( + url: string, + options?: GenericObject +): [string, string, { headers: GenericObject } | undefined] { + // Only include headers in the query key, as other options don't affect the API initialization + const relevantOptions = options?.headers ? { headers: options.headers } : undefined; + return ['stacApi', url, relevantOptions]; +} + +/** + * Generates a query key for STAC search requests. + * For search requests: extracts minimal search parameters (ids, bbox, collections, datetime, sortby, limit) + * For pagination GET requests: uses the URL + * + * This ensures the query key is: + * - Small and cheap to hash + * - Stable across identical requests + * - Free from irrelevant data like headers + */ +export function generateStacSearchQueryKey( + request: FetchRequest +): [string, string, string] | [string, string, Record] { + if (request.type === 'get') { + // For pagination GET requests, use the URL + return ['stacSearch', 'page', request.url]; + } else { + // For search requests, extract only the essential search parameters + const searchParams = extractSearchParams(request.payload); + return ['stacSearch', 'search', searchParams]; + } +} From 6f5713d4546643612d1fc90214cef6c4ba9b3b96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Tue, 25 Nov 2025 11:14:03 +0100 Subject: [PATCH 11/29] fix: make DevTools opt-in to avoid conflicts with consuming apps - Add enableDevTools prop to StacApiProvider (defaults to false) - Remove automatic DevTools exposure in development mode - Make window.__TANSTACK_QUERY_CLIENT__ optional to prevent type conflicts - Update example app to enable DevTools in development This prevents the library from overwriting QueryClient instances in consuming applications while still allowing opt-in DevTools support. --- example/src/App.jsx | 4 +++- src/context/index.tsx | 13 ++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/example/src/App.jsx b/example/src/App.jsx index 033a890..26553de 100644 --- a/example/src/App.jsx +++ b/example/src/App.jsx @@ -4,8 +4,10 @@ import Main from './pages/Main'; function App() { const apiUrl = process.env.REACT_APP_STAC_API; + const isDevelopment = process.env.NODE_ENV === 'development'; + return ( - +
    diff --git a/src/context/index.tsx b/src/context/index.tsx index b9af9c4..65d80c8 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -11,6 +11,7 @@ type StacApiProviderType = { children: React.ReactNode; options?: GenericObject; queryClient?: QueryClient; + enableDevTools?: boolean; }; function StacApiProviderInner({ @@ -55,11 +56,17 @@ function StacApiProviderInner({ return {children}; } -export function StacApiProvider({ children, apiUrl, options, queryClient }: StacApiProviderType) { +export function StacApiProvider({ + children, + apiUrl, + options, + queryClient, + enableDevTools, +}: StacApiProviderType) { const defaultClient = useMemo(() => new QueryClient(), []); const client: QueryClient = queryClient ?? defaultClient; - if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') { + if (enableDevTools && typeof window !== 'undefined') { // Connect TanStack Query DevTools (browser extension) window.__TANSTACK_QUERY_CLIENT__ = client; } @@ -75,6 +82,6 @@ export function StacApiProvider({ children, apiUrl, options, queryClient }: Stac declare global { interface Window { - __TANSTACK_QUERY_CLIENT__: import('@tanstack/query-core').QueryClient; + __TANSTACK_QUERY_CLIENT__?: import('@tanstack/query-core').QueryClient; } } From bc3550db636cb655b6e0b06d16cd0e1662d98419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Tue, 25 Nov 2025 14:57:23 +0100 Subject: [PATCH 12/29] docs: add comment explaining gcTime in test wrapper Clarifies that gcTime (renamed from cacheTime in TanStack Query v5) controls inactive query memory retention and why it's set to 0 in tests. --- src/hooks/wrapper.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hooks/wrapper.tsx b/src/hooks/wrapper.tsx index 8380984..ddd502d 100644 --- a/src/hooks/wrapper.tsx +++ b/src/hooks/wrapper.tsx @@ -10,6 +10,8 @@ const Wrapper = ({ children }: WrapperType) => { const testQueryClient = new QueryClient({ defaultOptions: { queries: { + // gcTime (previously cacheTime in v4) controls how long unused/inactive queries + // remain in memory. Set to 0 in tests to prevent caching between test runs. gcTime: 0, staleTime: 0, retry: false, From 0b4bcdc84aa27f93434e388b8ff89baef79ffb3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Thu, 27 Nov 2025 11:42:08 +0100 Subject: [PATCH 13/29] refactor: replace Object.assign error pattern with ApiError class - Create ApiError class extending Error in src/utils/ApiError.ts - Rename ApiError type to ApiErrorType to avoid naming conflict - Update all hooks to throw new ApiError instances --- src/hooks/useCollection.ts | 6 +++--- src/hooks/useCollections.ts | 16 ++++++---------- src/hooks/useItem.ts | 17 +++++++---------- src/hooks/useStacSearch.ts | 15 ++++++--------- src/stac-api/index.ts | 4 ++-- src/types/index.d.ts | 2 +- src/utils/ApiError.ts | 25 +++++++++++++++++++++++++ 7 files changed, 50 insertions(+), 35 deletions(-) create mode 100644 src/utils/ApiError.ts diff --git a/src/hooks/useCollection.ts b/src/hooks/useCollection.ts index 8c0acb9..f7127e3 100644 --- a/src/hooks/useCollection.ts +++ b/src/hooks/useCollection.ts @@ -1,13 +1,13 @@ import { useMemo } from 'react'; -import type { ApiError, LoadingState } from '../types'; +import type { ApiErrorType, LoadingState } from '../types'; import type { Collection } from '../types/stac'; import useCollections from './useCollections'; type StacCollectionHook = { collection?: Collection; state: LoadingState; - error?: ApiError; + error?: ApiErrorType; reload: () => void; }; @@ -19,7 +19,7 @@ function useCollection(collectionId: string): StacCollectionHook { }, [collectionId, collections]); // Determine error: prefer requestError, else local 404 if collection not found - const error: ApiError | undefined = requestError + const error: ApiErrorType | undefined = requestError ? requestError : !collection && collections ? { diff --git a/src/hooks/useCollections.ts b/src/hooks/useCollections.ts index a4f5e67..2124303 100644 --- a/src/hooks/useCollections.ts +++ b/src/hooks/useCollections.ts @@ -1,8 +1,9 @@ import { useEffect, useState, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { type ApiError, type LoadingState } from '../types'; +import { type ApiErrorType, type LoadingState } from '../types'; import type { CollectionsResponse } from '../types/stac'; import debounce from '../utils/debounce'; +import { ApiError } from '../utils/ApiError'; import { generateCollectionsQueryKey } from '../utils/queryKeys'; import { useStacApiContext } from '../context/useStacApiContext'; @@ -10,7 +11,7 @@ type StacCollectionsHook = { collections?: CollectionsResponse; reload: () => void; state: LoadingState; - error?: ApiError; + error?: ApiErrorType; }; function useCollections(): StacCollectionsHook { @@ -28,12 +29,7 @@ function useCollections(): StacCollectionsHook { detail = await response.text(); } - const err = Object.assign(new Error(response.statusText), { - status: response.status, - statusText: response.statusText, - detail, - }); - throw err; + throw new ApiError(response.statusText, response.status, detail); } return await response.json(); }; @@ -44,7 +40,7 @@ function useCollections(): StacCollectionsHook { isLoading, isFetching, refetch, - } = useQuery({ + } = useQuery({ queryKey: generateCollectionsQueryKey(), queryFn: fetchCollections, enabled: !!stacApi, @@ -76,7 +72,7 @@ function useCollections(): StacCollectionsHook { collections, reload, state, - error: error as ApiError, + error: error as ApiErrorType, }; } diff --git a/src/hooks/useItem.ts b/src/hooks/useItem.ts index 2ce8e28..124375c 100644 --- a/src/hooks/useItem.ts +++ b/src/hooks/useItem.ts @@ -1,14 +1,15 @@ import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Item } from '../types/stac'; -import { ApiError, LoadingState } from '../types'; +import { type ApiErrorType, type LoadingState } from '../types'; import { useStacApiContext } from '../context/useStacApiContext'; +import { ApiError } from '../utils/ApiError'; import { generateItemQueryKey } from '../utils/queryKeys'; type ItemHook = { item?: Item; state: LoadingState; - error?: ApiError; + error?: ApiErrorType; reload: () => void; }; @@ -26,12 +27,8 @@ function useItem(url: string): ItemHook { } catch { detail = await response.text(); } - const err = Object.assign(new Error(response.statusText), { - status: response.status, - statusText: response.statusText, - detail, - }); - throw err; + + throw new ApiError(response.statusText, response.status, detail); } return await response.json(); }; @@ -42,7 +39,7 @@ function useItem(url: string): ItemHook { isLoading, isFetching, refetch, - } = useQuery({ + } = useQuery({ queryKey: generateItemQueryKey(url), queryFn: fetchItem, enabled: !!stacApi, @@ -60,7 +57,7 @@ function useItem(url: string): ItemHook { return { item, state, - error: error as ApiError, + error: error as ApiErrorType, reload: refetch as () => void, }; } diff --git a/src/hooks/useStacSearch.ts b/src/hooks/useStacSearch.ts index 926d09e..f4d0156 100644 --- a/src/hooks/useStacSearch.ts +++ b/src/hooks/useStacSearch.ts @@ -2,7 +2,8 @@ import { useCallback, useState, useMemo, useEffect } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import debounce from '../utils/debounce'; import { generateStacSearchQueryKey } from '../utils/queryKeys'; -import type { ApiError, LoadingState } from '../types'; +import { type ApiErrorType, type LoadingState } from '../types'; +import { ApiError } from '../utils/ApiError'; import type { Link, Bbox, @@ -33,7 +34,7 @@ type StacSearchHook = { submit: () => void; results?: SearchResponse; state: LoadingState; - error: ApiError | undefined; + error?: ApiErrorType; nextPage: PaginationHandler | undefined; previousPage: PaginationHandler | undefined; }; @@ -122,12 +123,8 @@ function useStacSearch(): StacSearchHook { } catch { detail = await response.text(); } - const err = Object.assign(new Error(response.statusText), { - status: response.status, - statusText: response.statusText, - detail, - }); - throw err; + + throw new ApiError(response.statusText, response.status, detail); } return await response.json(); }; @@ -140,7 +137,7 @@ function useStacSearch(): StacSearchHook { error, isLoading, isFetching, - } = useQuery({ + } = useQuery({ queryKey: currentRequest ? generateStacSearchQueryKey(currentRequest) : ['stacSearch', 'idle'], queryFn: () => fetchRequest(currentRequest!), enabled: currentRequest !== null, diff --git a/src/stac-api/index.ts b/src/stac-api/index.ts index e378689..8418fcb 100644 --- a/src/stac-api/index.ts +++ b/src/stac-api/index.ts @@ -1,4 +1,4 @@ -import type { ApiError, GenericObject } from '../types'; +import type { ApiErrorType, GenericObject } from '../types'; import type { Bbox, SearchPayload, DateRange } from '../types/stac'; type RequestPayload = SearchPayload; @@ -88,7 +88,7 @@ class StacApi { async handleError(response: Response) { const { status, statusText } = response; - const e: ApiError = { + const e: ApiErrorType = { status, statusText, }; diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 7e446bc..c41485b 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -2,7 +2,7 @@ export type GenericObject = { [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any }; -export type ApiError = { +export type ApiErrorType = { detail?: GenericObject | string; status: number; statusText: string; diff --git a/src/utils/ApiError.ts b/src/utils/ApiError.ts new file mode 100644 index 0000000..6bea0bf --- /dev/null +++ b/src/utils/ApiError.ts @@ -0,0 +1,25 @@ +import type { GenericObject } from '../types'; + +/** + * Custom error class for STAC API errors. + * Extends the native Error class with HTTP response details. + */ +export class ApiError extends Error { + status: number; + statusText: string; + detail?: GenericObject | string; + + constructor(statusText: string, status: number, detail?: GenericObject | string) { + super(statusText); + this.name = 'ApiError'; + this.status = status; + this.statusText = statusText; + this.detail = detail; + + // Maintains proper stack trace for where our error was thrown + // Note: Error.captureStackTrace is a V8-only feature (Node.js, Chrome) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ApiError); + } + } +} From 3ddb6f2cec24ca0401fa34d4b01d9e6ba74d6502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Thu, 27 Nov 2025 12:30:13 +0100 Subject: [PATCH 14/29] refactor: replace queryClient prop with auto-detecting parent QueryClient - Remove queryClient prop from StacApiProviderType - Detect parent via QueryClientContext instead of accepting prop - Update docs to show QueryClientProvider wrapping pattern - Document missing options and enableDevTools props in API reference This simplifies the API and prevents duplicate QueryClient instances. --- README.md | 29 +++++++++++++++++++---------- docs/react-query-setup.md | 31 +++++++++++++++++++++++-------- src/context/index.tsx | 30 ++++++++++++++++++++---------- 3 files changed, 62 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index e92cdc3..982d0f9 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ If you do not install it, your package manager will warn you, and stac-react wil stac-react's hooks must be used inside children of a React context that provides access to the stac-react's core functionality. -To get started, initialize `StacApiProvider` with the base URL of the STAC catalog. `StacApiProvider` automatically sets up a [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) QueryClientProvider for you, so you do not need to wrap your app with QueryClientProvider yourself. +To get started, initialize `StacApiProvider` with the base URL of the STAC catalog. `StacApiProvider` automatically sets up a [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) QueryClientProvider for you if one doesn't already exist in the component tree. ```jsx import { StacApiProvider } from 'stac-react'; @@ -47,19 +47,26 @@ function StacApp() { } ``` -If you want to provide your own custom QueryClient (for advanced caching or devtools), you can pass it as a prop: +If you want to customize the QueryClient configuration (e.g., for custom caching behavior, retry logic, or global settings), wrap `StacApiProvider` with your own `QueryClientProvider`: ```jsx import { StacApiProvider } from 'stac-react'; -import { QueryClient } from '@tanstack/react-query'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5 minutes + retry: 3, + }, + }, +}); function StacApp() { return ( - - {/* Other components */} - + + {/* Other components */} + ); } ``` @@ -110,9 +117,11 @@ function StacApp() { ##### Component Properties -| Option | Type | Description | -| --------- | -------- | --------------------------------- | -| `apiUrl`. | `string` | The base url of the STAC catalog. | +| Option | Type | Description | +| ---------------- | --------- | --------------------------------------------------------------------------------------------- | +| `apiUrl` | `string` | The base url of the STAC catalog. | +| `options` | `object` | Optional configuration object for customizing STAC API requests. | +| `enableDevTools` | `boolean` | Optional. Enables TanStack Query DevTools browser extension integration. Defaults to `false`. | ### useCollections diff --git a/docs/react-query-setup.md b/docs/react-query-setup.md index 21a5c7f..4190c5d 100644 --- a/docs/react-query-setup.md +++ b/docs/react-query-setup.md @@ -8,30 +8,45 @@ stac-react relies on [TanStack Query](https://tanstack.com/query/latest/docs/fra - Ensures your app and stac-react share the same QueryClient instance. - Follows best practices for React libraries that integrate with popular frameworks. -stac-react manages the QueryClient for you by default, but you can provide your own for advanced use cases. +## QueryClient Management -**Important:** If your app uses multiple providers that require a TanStack QueryClient (such as `QueryClientProvider` and `StacApiProvider`), always use the same single QueryClient instance for all providers. This ensures that queries, mutations, and cache are shared across your app and prevents cache fragmentation or duplicate network requests. +By default, `StacApiProvider` automatically creates and manages a QueryClient for you if one doesn't already exist in the component tree. This means you can use stac-react without any additional setup: -**Example:** +```jsx +import { StacApiProvider } from 'stac-react'; + +function App() { + return {/* ...your app... */}; +} +``` + +### Custom QueryClient Configuration + +If you need custom QueryClient configuration (e.g., custom caching behavior, retry logic, or global settings), wrap `StacApiProvider` with your own `QueryClientProvider`: ```jsx import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { StacApiProvider } from 'stac-react'; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5 minutes + retry: 3, + }, + }, +}); function App() { return ( - - {/* ...your app... */} - + {/* ...your app... */} ); } ``` -If you do not pass the same QueryClient instance, each provider will maintain its own cache, which can lead to unexpected behavior. +`StacApiProvider` will automatically detect the parent QueryClient and use it instead of creating a new one. ## TanStack Query DevTools Integration diff --git a/src/context/index.tsx b/src/context/index.tsx index 65d80c8..6977fdb 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -1,8 +1,8 @@ -import React, { useMemo, useState, useCallback } from 'react'; +import React, { useMemo, useState, useCallback, useContext } from 'react'; import { StacApiContext } from './context'; import type { CollectionsResponse, Item } from '../types/stac'; import { GenericObject } from '../types'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClient, QueryClientProvider, QueryClientContext } from '@tanstack/react-query'; import useStacApi from '../hooks/useStacApi'; @@ -10,7 +10,6 @@ type StacApiProviderType = { apiUrl: string; children: React.ReactNode; options?: GenericObject; - queryClient?: QueryClient; enableDevTools?: boolean; }; @@ -18,7 +17,7 @@ function StacApiProviderInner({ children, apiUrl, options, -}: Omit) { +}: Omit) { const { stacApi } = useStacApi(apiUrl, options); const [collections, setCollections] = useState(); const [items, setItems] = useState(new Map()); @@ -60,19 +59,30 @@ export function StacApiProvider({ children, apiUrl, options, - queryClient, enableDevTools, }: StacApiProviderType) { + const existingClient = useContext(QueryClientContext); const defaultClient = useMemo(() => new QueryClient(), []); - const client: QueryClient = queryClient ?? defaultClient; - if (enableDevTools && typeof window !== 'undefined') { - // Connect TanStack Query DevTools (browser extension) - window.__TANSTACK_QUERY_CLIENT__ = client; + const client = existingClient ?? defaultClient; + + // Setup DevTools once when component mounts or enableDevTools changes + useMemo(() => { + if (enableDevTools && typeof window !== 'undefined') { + window.__TANSTACK_QUERY_CLIENT__ = client; + } + }, [client, enableDevTools]); + + if (existingClient) { + return ( + + {children} + + ); } return ( - + {children} From 363d53ecfdc9c77207667880c0a574d1fdca55b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Thu, 27 Nov 2025 12:52:05 +0100 Subject: [PATCH 15/29] test: add StacApiProvider test coverage Add tests for StacApiProvider covering QueryClient management, DevTools integration, and context provisioning. - Creates QueryClient when no parent exists - Uses parent QueryClient without nesting providers - DevTools respects enableDevTools prop - Provides stacApi and context methods --- src/context/StacApiProvider.test.tsx | 205 +++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 src/context/StacApiProvider.test.tsx diff --git a/src/context/StacApiProvider.test.tsx b/src/context/StacApiProvider.test.tsx new file mode 100644 index 0000000..478484b --- /dev/null +++ b/src/context/StacApiProvider.test.tsx @@ -0,0 +1,205 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query'; +import { StacApiProvider } from './index'; +import { useStacApiContext } from './useStacApiContext'; + +// Mock fetch for testing - returns a successful response +beforeEach(() => { + (global.fetch as jest.Mock) = jest.fn((url: string) => + Promise.resolve({ + ok: true, + url, // Return the requested URL + json: () => + Promise.resolve({ + links: [], + }), + }) + ); +}); + +// Component to test that hooks work inside StacApiProvider +function TestComponent() { + const context = useStacApiContext(); + const queryClient = useQueryClient(); + + return ( +
    +
    {context.stacApi ? 'stacApi exists' : 'no stacApi'}
    +
    {queryClient ? 'queryClient exists' : 'no queryClient'}
    +
    + ); +} + +describe('StacApiProvider', () => { + beforeEach(() => { + // Clean up window.__TANSTACK_QUERY_CLIENT__ before each test + delete (window as Window & { __TANSTACK_QUERY_CLIENT__?: unknown }).__TANSTACK_QUERY_CLIENT__; + }); + + describe('QueryClient management', () => { + it('creates a QueryClient when no parent exists', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('stac-api')).toHaveTextContent('stacApi exists'); + }); + expect(screen.getByTestId('query-client')).toHaveTextContent('queryClient exists'); + }); + + it('uses existing QueryClient from parent context', async () => { + const parentClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('stac-api')).toHaveTextContent('stacApi exists'); + }); + expect(screen.getByTestId('query-client')).toHaveTextContent('queryClient exists'); + }); + + it('does not create nested QueryClientProvider when parent exists', () => { + const parentClient = new QueryClient(); + + // Component that checks if QueryClient instance is the parent + function ClientChecker() { + const client = useQueryClient(); + return
    {client === parentClient ? 'true' : 'false'}
    ; + } + + render( + + + + + + ); + + expect(screen.getByTestId('is-parent')).toHaveTextContent('true'); + }); + }); + + describe('DevTools integration', () => { + it('does not set up DevTools when enableDevTools is false', () => { + render( + + + + ); + + expect( + (window as Window & { __TANSTACK_QUERY_CLIENT__?: unknown }).__TANSTACK_QUERY_CLIENT__ + ).toBeUndefined(); + }); + + it('does not set up DevTools when enableDevTools is not provided', () => { + render( + + + + ); + + expect( + (window as Window & { __TANSTACK_QUERY_CLIENT__?: unknown }).__TANSTACK_QUERY_CLIENT__ + ).toBeUndefined(); + }); + + it('sets up DevTools when enableDevTools is true', () => { + render( + + + + ); + + expect( + (window as Window & { __TANSTACK_QUERY_CLIENT__?: unknown }).__TANSTACK_QUERY_CLIENT__ + ).toBeDefined(); + }); + + it('sets up DevTools with parent QueryClient when enabled', () => { + const parentClient = new QueryClient(); + + render( + + + + + + ); + + expect( + (window as Window & { __TANSTACK_QUERY_CLIENT__?: unknown }).__TANSTACK_QUERY_CLIENT__ + ).toBe(parentClient); + }); + }); + + describe('Context value', () => { + it('provides stacApi with correct apiUrl', async () => { + function ApiUrlChecker() { + const { stacApi } = useStacApiContext(); + return
    {stacApi?.baseUrl || 'loading'}
    ; + } + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('api-url')).toHaveTextContent('https://my-custom-api.com'); + }); + }); + + it('provides context methods', () => { + function ContextChecker() { + const context = useStacApiContext(); + const hasMethods = + typeof context.getItem === 'function' && + typeof context.addItem === 'function' && + typeof context.deleteItem === 'function'; + return
    {hasMethods ? 'true' : 'false'}
    ; + } + + render( + + + + ); + + expect(screen.getByTestId('has-methods')).toHaveTextContent('true'); + }); + + it('passes options to useStacApi hook', () => { + const customOptions = { headers: { 'X-Custom': 'value' } }; + + function OptionsChecker() { + // Options are passed to useStacApi which uses them in the query + return
    true
    ; + } + + render( + + + + ); + + // If provider doesn't error and renders, options were passed successfully + expect(screen.getByTestId('has-options')).toHaveTextContent('true'); + }); + }); +}); From fd431a309b52ec390a894d4f90c88103ec7dab16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Thu, 27 Nov 2025 15:24:57 +0100 Subject: [PATCH 16/29] refactor!: remove collections/items state from context BREAKING CHANGE: Remove collections, setCollections, getItem, addItem, deleteItem from context. Context now only provides stacApi. Users should use useCollections() hook directly instead of accessing collections from context. React Query cache is now the single source of truth for data. --- src/context/StacApiProvider.test.tsx | 19 ------------------ src/context/context.ts | 6 ------ src/context/index.tsx | 30 ++-------------------------- src/context/useStacApiContext.ts | 8 +------- src/hooks/useCollections.ts | 11 +--------- 5 files changed, 4 insertions(+), 70 deletions(-) diff --git a/src/context/StacApiProvider.test.tsx b/src/context/StacApiProvider.test.tsx index 478484b..6ed4444 100644 --- a/src/context/StacApiProvider.test.tsx +++ b/src/context/StacApiProvider.test.tsx @@ -165,25 +165,6 @@ describe('StacApiProvider', () => { }); }); - it('provides context methods', () => { - function ContextChecker() { - const context = useStacApiContext(); - const hasMethods = - typeof context.getItem === 'function' && - typeof context.addItem === 'function' && - typeof context.deleteItem === 'function'; - return
    {hasMethods ? 'true' : 'false'}
    ; - } - - render( - - - - ); - - expect(screen.getByTestId('has-methods')).toHaveTextContent('true'); - }); - it('passes options to useStacApi hook', () => { const customOptions = { headers: { 'X-Custom': 'value' } }; diff --git a/src/context/context.ts b/src/context/context.ts index b511221..1e644c0 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -1,14 +1,8 @@ import { createContext } from 'react'; -import type { CollectionsResponse, Item } from '../types/stac'; export type StacApiContextType = { // eslint-disable-next-line @typescript-eslint/no-explicit-any stacApi?: any; - collections?: CollectionsResponse; - setCollections: (collections?: CollectionsResponse) => void; - getItem: (id: string) => Item | undefined; - addItem: (id: string, item: Item) => void; - deleteItem: (id: string) => void; }; export const StacApiContext = createContext({} as StacApiContextType); diff --git a/src/context/index.tsx b/src/context/index.tsx index 6977fdb..addff7c 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -1,6 +1,5 @@ -import React, { useMemo, useState, useCallback, useContext } from 'react'; +import React, { useMemo, useContext } from 'react'; import { StacApiContext } from './context'; -import type { CollectionsResponse, Item } from '../types/stac'; import { GenericObject } from '../types'; import { QueryClient, QueryClientProvider, QueryClientContext } from '@tanstack/react-query'; @@ -19,37 +18,12 @@ function StacApiProviderInner({ options, }: Omit) { const { stacApi } = useStacApi(apiUrl, options); - const [collections, setCollections] = useState(); - const [items, setItems] = useState(new Map()); - - const getItem = useCallback((id: string) => items.get(id), [items]); - - const addItem = useCallback( - (itemPath: string, item: Item) => { - setItems(new Map(items.set(itemPath, item))); - }, - [items] - ); - - const deleteItem = useCallback( - (itemPath: string) => { - const tempItems = new Map(items); - items.delete(itemPath); - setItems(tempItems); - }, - [items] - ); const contextValue = useMemo( () => ({ stacApi, - collections, - setCollections, - getItem, - addItem, - deleteItem, }), - [addItem, collections, deleteItem, getItem, stacApi] + [stacApi] ); return {children}; diff --git a/src/context/useStacApiContext.ts b/src/context/useStacApiContext.ts index ff1ae64..04b344d 100644 --- a/src/context/useStacApiContext.ts +++ b/src/context/useStacApiContext.ts @@ -2,15 +2,9 @@ import { useContext } from 'react'; import { StacApiContext } from './context'; export function useStacApiContext() { - const { stacApi, collections, setCollections, getItem, addItem, deleteItem } = - useContext(StacApiContext); + const { stacApi } = useContext(StacApiContext); return { stacApi, - collections, - setCollections, - getItem, - addItem, - deleteItem, }; } diff --git a/src/hooks/useCollections.ts b/src/hooks/useCollections.ts index 2124303..5bfb0d0 100644 --- a/src/hooks/useCollections.ts +++ b/src/hooks/useCollections.ts @@ -15,7 +15,7 @@ type StacCollectionsHook = { }; function useCollections(): StacCollectionsHook { - const { stacApi, setCollections } = useStacApiContext(); + const { stacApi } = useStacApiContext(); const [state, setState] = useState('IDLE'); const fetchCollections = async (): Promise => { @@ -47,15 +47,6 @@ function useCollections(): StacCollectionsHook { retry: false, }); - // Sync collections with context - useEffect(() => { - if (collections) { - setCollections(collections); - } else if (error) { - setCollections(undefined); - } - }, [collections, error, setCollections]); - const reload = useMemo(() => debounce(refetch), [refetch]); useEffect(() => { From 802d1a2a4b7e5eed16c2f03ee24fc20552feec58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Thu, 27 Nov 2025 16:37:54 +0100 Subject: [PATCH 17/29] Revert "refactor: replace queryClient prop with auto-detecting parent QueryClient" This reverts commit 3ddb6f2cec24ca0401fa34d4b01d9e6ba74d6502. --- README.md | 29 ++++++++++------------------- docs/react-query-setup.md | 31 ++++++++----------------------- src/context/index.tsx | 30 ++++++++++-------------------- 3 files changed, 28 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 982d0f9..e92cdc3 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ If you do not install it, your package manager will warn you, and stac-react wil stac-react's hooks must be used inside children of a React context that provides access to the stac-react's core functionality. -To get started, initialize `StacApiProvider` with the base URL of the STAC catalog. `StacApiProvider` automatically sets up a [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) QueryClientProvider for you if one doesn't already exist in the component tree. +To get started, initialize `StacApiProvider` with the base URL of the STAC catalog. `StacApiProvider` automatically sets up a [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) QueryClientProvider for you, so you do not need to wrap your app with QueryClientProvider yourself. ```jsx import { StacApiProvider } from 'stac-react'; @@ -47,26 +47,19 @@ function StacApp() { } ``` -If you want to customize the QueryClient configuration (e.g., for custom caching behavior, retry logic, or global settings), wrap `StacApiProvider` with your own `QueryClientProvider`: +If you want to provide your own custom QueryClient (for advanced caching or devtools), you can pass it as a prop: ```jsx import { StacApiProvider } from 'stac-react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClient } from '@tanstack/react-query'; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, // 5 minutes - retry: 3, - }, - }, -}); +const queryClient = new QueryClient(); function StacApp() { return ( - - {/* Other components */} - + + {/* Other components */} + ); } ``` @@ -117,11 +110,9 @@ function StacApp() { ##### Component Properties -| Option | Type | Description | -| ---------------- | --------- | --------------------------------------------------------------------------------------------- | -| `apiUrl` | `string` | The base url of the STAC catalog. | -| `options` | `object` | Optional configuration object for customizing STAC API requests. | -| `enableDevTools` | `boolean` | Optional. Enables TanStack Query DevTools browser extension integration. Defaults to `false`. | +| Option | Type | Description | +| --------- | -------- | --------------------------------- | +| `apiUrl`. | `string` | The base url of the STAC catalog. | ### useCollections diff --git a/docs/react-query-setup.md b/docs/react-query-setup.md index 4190c5d..21a5c7f 100644 --- a/docs/react-query-setup.md +++ b/docs/react-query-setup.md @@ -8,45 +8,30 @@ stac-react relies on [TanStack Query](https://tanstack.com/query/latest/docs/fra - Ensures your app and stac-react share the same QueryClient instance. - Follows best practices for React libraries that integrate with popular frameworks. -## QueryClient Management +stac-react manages the QueryClient for you by default, but you can provide your own for advanced use cases. -By default, `StacApiProvider` automatically creates and manages a QueryClient for you if one doesn't already exist in the component tree. This means you can use stac-react without any additional setup: +**Important:** If your app uses multiple providers that require a TanStack QueryClient (such as `QueryClientProvider` and `StacApiProvider`), always use the same single QueryClient instance for all providers. This ensures that queries, mutations, and cache are shared across your app and prevents cache fragmentation or duplicate network requests. -```jsx -import { StacApiProvider } from 'stac-react'; - -function App() { - return {/* ...your app... */}; -} -``` - -### Custom QueryClient Configuration - -If you need custom QueryClient configuration (e.g., custom caching behavior, retry logic, or global settings), wrap `StacApiProvider` with your own `QueryClientProvider`: +**Example:** ```jsx import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { StacApiProvider } from 'stac-react'; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, // 5 minutes - retry: 3, - }, - }, -}); +const queryClient = new QueryClient(); function App() { return ( - {/* ...your app... */} + + {/* ...your app... */} + ); } ``` -`StacApiProvider` will automatically detect the parent QueryClient and use it instead of creating a new one. +If you do not pass the same QueryClient instance, each provider will maintain its own cache, which can lead to unexpected behavior. ## TanStack Query DevTools Integration diff --git a/src/context/index.tsx b/src/context/index.tsx index addff7c..6d948e0 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -1,7 +1,7 @@ -import React, { useMemo, useContext } from 'react'; +import React, { useMemo } from 'react'; import { StacApiContext } from './context'; import { GenericObject } from '../types'; -import { QueryClient, QueryClientProvider, QueryClientContext } from '@tanstack/react-query'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import useStacApi from '../hooks/useStacApi'; @@ -9,6 +9,7 @@ type StacApiProviderType = { apiUrl: string; children: React.ReactNode; options?: GenericObject; + queryClient?: QueryClient; enableDevTools?: boolean; }; @@ -16,7 +17,7 @@ function StacApiProviderInner({ children, apiUrl, options, -}: Omit) { +}: Omit) { const { stacApi } = useStacApi(apiUrl, options); const contextValue = useMemo( @@ -33,30 +34,19 @@ export function StacApiProvider({ children, apiUrl, options, + queryClient, enableDevTools, }: StacApiProviderType) { - const existingClient = useContext(QueryClientContext); const defaultClient = useMemo(() => new QueryClient(), []); + const client: QueryClient = queryClient ?? defaultClient; - const client = existingClient ?? defaultClient; - - // Setup DevTools once when component mounts or enableDevTools changes - useMemo(() => { - if (enableDevTools && typeof window !== 'undefined') { - window.__TANSTACK_QUERY_CLIENT__ = client; - } - }, [client, enableDevTools]); - - if (existingClient) { - return ( - - {children} - - ); + if (enableDevTools && typeof window !== 'undefined') { + // Connect TanStack Query DevTools (browser extension) + window.__TANSTACK_QUERY_CLIENT__ = client; } return ( - + {children} From dc9c226d5a46b4e2bec2a9ec891c065f584277d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Thu, 27 Nov 2025 16:53:54 +0100 Subject: [PATCH 18/29] test: update tests to use queryClient prop when providing custom client --- src/context/StacApiProvider.test.tsx | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/context/StacApiProvider.test.tsx b/src/context/StacApiProvider.test.tsx index 6ed4444..14c7517 100644 --- a/src/context/StacApiProvider.test.tsx +++ b/src/context/StacApiProvider.test.tsx @@ -60,7 +60,7 @@ describe('StacApiProvider', () => { render( - + @@ -83,7 +83,7 @@ describe('StacApiProvider', () => { render( - + @@ -130,20 +130,22 @@ describe('StacApiProvider', () => { ).toBeDefined(); }); - it('sets up DevTools with parent QueryClient when enabled', () => { - const parentClient = new QueryClient(); + it('sets up DevTools with custom queryClient when enabled', () => { + const customClient = new QueryClient(); render( - - - - - + + + ); expect( (window as Window & { __TANSTACK_QUERY_CLIENT__?: unknown }).__TANSTACK_QUERY_CLIENT__ - ).toBe(parentClient); + ).toBe(customClient); }); }); From 2752cebd08f503fd30f5762382e9252d1f313bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Thu, 27 Nov 2025 16:54:16 +0100 Subject: [PATCH 19/29] example: add custom QueryClient with optimized caching - Configure a custom QueryClient with specific caching strategies - Pass the queryClient instance to StacApiProvider via prop This change clarifies how to set up and use a custom QueryClient, and investigates the impact of multiple QueryClientProviders on app behavior. --- example/src/App.jsx | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/example/src/App.jsx b/example/src/App.jsx index 26553de..16e473a 100644 --- a/example/src/App.jsx +++ b/example/src/App.jsx @@ -1,20 +1,46 @@ import { StacApiProvider } from 'stac-react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import Header from './layout/Header'; import Main from './pages/Main'; +// Create a QueryClient with custom cache configuration +// IMPORTANT: Must be created outside the component to maintain cache across renders +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // STAC data doesn't change frequently, so we can cache it for 5 minutes + staleTime: 5 * 60 * 1000, // 5 minutes + // Keep unused data in cache for 10 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + retry: 1, + // Disable automatic refetching since STAC data is static + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + }, + }, +}); + function App() { const apiUrl = process.env.REACT_APP_STAC_API; const isDevelopment = process.env.NODE_ENV === 'development'; + // Debug: Verify QueryClient configuration + if (isDevelopment && typeof window !== 'undefined') { + console.log('[App] QueryClient defaults:', queryClient.getDefaultOptions()); + } + return ( - -
    -
    -
    -
    -
    -
    -
    + + +
    +
    +
    +
    +
    +
    +
    +
    ); } From f86970f2bb6408509486df315fd42b11bbf500c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Thu, 27 Nov 2025 17:08:48 +0100 Subject: [PATCH 20/29] docs: update QueryClient example and update API reference - Update example with staleTime and gcTime, and enableDevTools usage - Document queryClient, options, and enableDevTools props in Component Properties - Fix typo: items.description -> item.description in useItem example - Fix malformed Error type table markdown --- README.md | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e92cdc3..dcf5680 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,24 @@ If you want to provide your own custom QueryClient (for advanced caching or devt import { StacApiProvider } from 'stac-react'; import { QueryClient } from '@tanstack/react-query'; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + }, + }, +}); function StacApp() { + const isDevelopment = process.env.NODE_ENV === 'development'; + return ( - + {/* Other components */} ); @@ -110,9 +123,12 @@ function StacApp() { ##### Component Properties -| Option | Type | Description | -| --------- | -------- | --------------------------------- | -| `apiUrl`. | `string` | The base url of the STAC catalog. | +| Option | Type | Description | +| ---------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apiUrl` | `string` | The base URL of the STAC catalog. | +| `queryClient` | `QueryClient` | Optional. Custom TanStack Query QueryClient instance. If not provided, a default QueryClient will be created. | +| `options` | `object` | Optional. Configuration object for customizing STAC API requests (e.g., headers, authentication). | +| `enableDevTools` | `boolean` | Optional. Enables TanStack Query DevTools browser extension integration by exposing the QueryClient on `window.__TANSTACK_QUERY_CLIENT__`. Defaults to `false`. Recommended for development only. | ### useCollections @@ -259,7 +275,7 @@ function Item() { {item ? ( <>

    {item.id}

    -

    {items.description}

    +

    {item.description}

    ) : (

    Not found

    @@ -494,11 +510,11 @@ function StacComponent() { } ``` -| Option | Type | Description | -| ------------ | -------- | --------------------------------- | ------------------------------------------------------------------------------------------ | -| `detail` | `string` | `object | The error return from the API. Either a`string` or and `object` depending on the response. | -| `status` | `number` | HTTP status code of the response. | -| `statusText` | `string` | Status text for the response. | +| Option | Type | Description | +| ------------ | ------------------ | -------------------------------------------------------------------------------------------- | +| `detail` | `string \| object` | The error returned from the API. Either a `string` or an `object` depending on the response. | +| `status` | `number` | HTTP status code of the response. | +| `statusText` | `string` | Status text for the response. | ## Development From 32361668555d477ff4ec5ab75de6841a535fafb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Thu, 27 Nov 2025 20:53:16 +0100 Subject: [PATCH 21/29] refactor!: replace LoadingState with React Query's isLoading/isFetching BREAKING CHANGE: Hooks no longer return `state` property. Use `isLoading` and `isFetching` booleans instead. - Update hooks to return React Query's isLoading/isFetching directly - Update all tests to check isLoading instead of state - Update example app (Main/index.jsx, ItemDetails.jsx) to use isLoading - Update README with new API documentation Eliminates duplicate state management and provides more granular loading control. --- README.md | 54 ++++++++++++++------------ example/src/pages/Main/ItemDetails.jsx | 4 +- example/src/pages/Main/index.jsx | 4 +- src/hooks/useCollection.test.ts | 2 +- src/hooks/useCollection.ts | 10 +++-- src/hooks/useCollections.test.ts | 4 +- src/hooks/useCollections.ts | 21 +++------- src/hooks/useItem.test.ts | 2 +- src/hooks/useItem.ts | 18 +++------ src/hooks/useStacSearch.test.ts | 44 ++++++++++----------- src/hooks/useStacSearch.ts | 27 +++++++------ src/types/index.d.ts | 2 - 12 files changed, 88 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index dcf5680..064e9e1 100644 --- a/README.md +++ b/README.md @@ -143,12 +143,13 @@ const { collections } = useCollections(); #### Return values -| Option | Type | Description | -| ------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `collections` | `array` | A list of collections available from the STAC catalog. Is `null` if collections have not been retrieved. | -| `state` | `str` | The status of the request. `"IDLE"` before and after the request is sent or received. `"LOADING"` when the request is in progress. | -| `reload` | `function` | Callback function to trigger a reload of collections. | -| `error` | [`Error`](#error) | Error information if the last request was unsuccessful. `undefined` if the last request was successful. | +| Option | Type | Description | +| ------------- | ----------------- | -------------------------------------------------------------------------------------------------------- | +| `collections` | `array` | A list of collections available from the STAC catalog. Is `null` if collections have not been retrieved. | +| `isLoading` | `boolean` | `true` when the initial request is in progress. `false` once data is loaded or an error occurred. | +| `isFetching` | `boolean` | `true` when any request is in progress (including background refetches). `false` otherwise. | +| `reload` | `function` | Callback function to trigger a reload of collections. | +| `error` | [`Error`](#error) | Error information if the last request was unsuccessful. `undefined` if the last request was successful. | #### Example @@ -156,9 +157,9 @@ const { collections } = useCollections(); import { useCollections } from "stac-react"; function CollectionList() { - const { collections, state } = useCollections(); + const { collections, isLoading } = useCollections(); - if (state === "LOADING") { + if (isLoading) { return

    Loading collections...

    } @@ -198,12 +199,13 @@ const { collection } = useCollection(id); #### Return values -| Option | Type | Description | -| ------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `collection` | `object` | The collection matching the provided ID. Is `null` if collection has not been retrieved. | -| `state` | `str` | The status of the request. `"IDLE"` before and after the request is sent or received. `"LOADING"` when the request is in progress. | -| `reload` | `function` | Callback function to trigger a reload of the collection. | -| `error` | [`Error`](#error) | Error information if the last request was unsuccessful. `undefined` if the last request was successful. | +| Option | Type | Description | +| ------------ | ----------------- | ------------------------------------------------------------------------------------------------------- | +| `collection` | `object` | The collection matching the provided ID. Is `null` if collection has not been retrieved. | +| `isLoading` | `boolean` | `true` when the initial request is in progress. `false` once data is loaded or an error occurred. | +| `isFetching` | `boolean` | `true` when any request is in progress (including background refetches). `false` otherwise. | +| `reload` | `function` | Callback function to trigger a reload of the collection. | +| `error` | [`Error`](#error) | Error information if the last request was unsuccessful. `undefined` if the last request was successful. | #### Example @@ -211,9 +213,9 @@ const { collection } = useCollection(id); import { useCollection } from 'stac-react'; function Collection() { - const { collection, state } = useCollection('collection_id'); + const { collection, isLoading } = useCollection('collection_id'); - if (state === 'LOADING') { + if (isLoading) { return

    Loading collection...

    ; } @@ -251,12 +253,13 @@ const { item } = useItem(url); #### Return values -| Option | Type | Description | -| -------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `item` | `object` | The item matching the provided URL. | -| `state` | `str` | The status of the request. `"IDLE"` before and after the request is sent or received. `"LOADING"` when the request is in progress. | -| `reload` | `function` | Callback function to trigger a reload of the item. | -| `error` | [`Error`](#error) | Error information if the last request was unsuccessful. `undefined` if the last request was successful. | +| Option | Type | Description | +| ------------ | ----------------- | ------------------------------------------------------------------------------------------------------- | +| `item` | `object` | The item matching the provided URL. | +| `isLoading` | `boolean` | `true` when the initial request is in progress. `false` once data is loaded or an error occurred. | +| `isFetching` | `boolean` | `true` when any request is in progress (including background refetches). `false` otherwise. | +| `reload` | `function` | Callback function to trigger a reload of the item. | +| `error` | [`Error`](#error) | Error information if the last request was unsuccessful. `undefined` if the last request was successful. | #### Examples @@ -264,9 +267,9 @@ const { item } = useItem(url); import { useItem } from 'stac-react'; function Item() { - const { item, state } = useItem('https://stac-catalog.com/items/abc123'); + const { item, isLoading } = useItem('https://stac-catalog.com/items/abc123'); - if (state === 'LOADING') { + if (isLoading) { return

    Loading item...

    ; } @@ -316,7 +319,8 @@ const { results } = useStacSearch(); | `limit` | `number` | The number of results returned per result page. | | `setLimit(limit)` | `function` | Callback to set `limit`. `limit` must be a `number`, or `undefined` to reset. | | `results` | `object` | The result of the last search query; a [GeoJSON `FeatureCollection` with additional members](https://github.com/radiantearth/stac-api-spec/blob/v1.0.0-rc.2/fragments/itemcollection/README.md). `undefined` if the search request has not been submitted, or if there was an error. | -| `state` | `string` | The status of the request. `"IDLE"` before and after the request is sent or received. `"LOADING"` when the request is in progress. | +| `isLoading` | `boolean` | `true` when the initial request is in progress. `false` once data is loaded or an error occurred. | +| `isFetching` | `boolean` | `true` when any request is in progress (including background refetches and pagination). `false` otherwise. | | `error` | [`Error`](#error) | Error information if the last request was unsuccessful. `undefined` if the last request was successful. | | `nextPage` | `function` | Callback function to load the next page of results. Is `undefined` if the last page is the currently loaded. | | `previousPage` | `function` | Callback function to load the previous page of results. Is `undefined` if the first page is the currently loaded. | diff --git a/example/src/pages/Main/ItemDetails.jsx b/example/src/pages/Main/ItemDetails.jsx index a7404e4..1fa89d0 100644 --- a/example/src/pages/Main/ItemDetails.jsx +++ b/example/src/pages/Main/ItemDetails.jsx @@ -6,9 +6,7 @@ import { Button } from '../../components/buttons'; function ItemDetails({ item, onClose }) { const itemUrl = item.links.find((r) => r.rel === 'self')?.href; - const { item: newItem, state, error, reload } = useItem(itemUrl); - - const isLoading = state === 'LOADING'; + const { item: newItem, isLoading, error, reload } = useItem(itemUrl); return ( diff --git a/example/src/pages/Main/index.jsx b/example/src/pages/Main/index.jsx index 3e9cebe..648a0a8 100644 --- a/example/src/pages/Main/index.jsx +++ b/example/src/pages/Main/index.jsx @@ -27,7 +27,7 @@ function Main() { setDateRangeTo, submit, results, - state, + isLoading, error, nextPage, previousPage, @@ -74,7 +74,7 @@ function Main() { ) : ( { ); const { result } = renderHook(() => useCollection('abc'), { wrapper }); - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toEqual(false)); await waitFor(() => expect(result.current.collection).toEqual({ id: 'abc', title: 'Collection A' }) ); diff --git a/src/hooks/useCollection.ts b/src/hooks/useCollection.ts index f7127e3..8860fc7 100644 --- a/src/hooks/useCollection.ts +++ b/src/hooks/useCollection.ts @@ -1,18 +1,19 @@ import { useMemo } from 'react'; -import type { ApiErrorType, LoadingState } from '../types'; +import type { ApiErrorType } from '../types'; import type { Collection } from '../types/stac'; import useCollections from './useCollections'; type StacCollectionHook = { collection?: Collection; - state: LoadingState; + isLoading: boolean; + isFetching: boolean; error?: ApiErrorType; reload: () => void; }; function useCollection(collectionId: string): StacCollectionHook { - const { collections, state, error: requestError, reload } = useCollections(); + const { collections, isLoading, isFetching, error: requestError, reload } = useCollections(); const collection = useMemo(() => { return collections?.collections.find(({ id }) => id === collectionId); @@ -31,7 +32,8 @@ function useCollection(collectionId: string): StacCollectionHook { return { collection, - state, + isLoading, + isFetching, error, reload, }; diff --git a/src/hooks/useCollections.test.ts b/src/hooks/useCollections.test.ts index 7dcba51..7ba7ab2 100644 --- a/src/hooks/useCollections.test.ts +++ b/src/hooks/useCollections.test.ts @@ -19,7 +19,7 @@ describe('useCollections', () => { expect(fetch.mock.calls[1][0]).toEqual('https://fake-stac-api.net/collections') ); await waitFor(() => expect(result.current.collections).toEqual({ data: '12345' })); - await waitFor(() => expect(result.current.state).toEqual('IDLE')); + await waitFor(() => expect(result.current.isLoading).toEqual(false)); }); it('reloads collections', async () => { @@ -30,7 +30,7 @@ describe('useCollections', () => { const { result } = renderHook(() => useCollections(), { wrapper }); await waitFor(() => expect(result.current.collections).toEqual({ data: 'original' })); - await waitFor(() => expect(result.current.state).toEqual('IDLE')); + await waitFor(() => expect(result.current.isLoading).toEqual(false)); act(() => result.current.reload()); diff --git a/src/hooks/useCollections.ts b/src/hooks/useCollections.ts index 5bfb0d0..9ba9314 100644 --- a/src/hooks/useCollections.ts +++ b/src/hooks/useCollections.ts @@ -1,6 +1,6 @@ -import { useEffect, useState, useMemo } from 'react'; +import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { type ApiErrorType, type LoadingState } from '../types'; +import { type ApiErrorType } from '../types'; import type { CollectionsResponse } from '../types/stac'; import debounce from '../utils/debounce'; import { ApiError } from '../utils/ApiError'; @@ -10,13 +10,13 @@ import { useStacApiContext } from '../context/useStacApiContext'; type StacCollectionsHook = { collections?: CollectionsResponse; reload: () => void; - state: LoadingState; + isLoading: boolean; + isFetching: boolean; error?: ApiErrorType; }; function useCollections(): StacCollectionsHook { const { stacApi } = useStacApiContext(); - const [state, setState] = useState('IDLE'); const fetchCollections = async (): Promise => { if (!stacApi) throw new Error('No STAC API configured'); @@ -49,20 +49,11 @@ function useCollections(): StacCollectionsHook { const reload = useMemo(() => debounce(refetch), [refetch]); - useEffect(() => { - if (!stacApi) { - setState('IDLE'); - } else if (isLoading || isFetching) { - setState('LOADING'); - } else { - setState('IDLE'); - } - }, [stacApi, isLoading, isFetching]); - return { collections, reload, - state, + isLoading, + isFetching, error: error as ApiErrorType, }; } diff --git a/src/hooks/useItem.test.ts b/src/hooks/useItem.test.ts index 28d7612..0ee5960 100644 --- a/src/hooks/useItem.test.ts +++ b/src/hooks/useItem.test.ts @@ -17,7 +17,7 @@ describe('useItem', () => { wrapper, }); await waitFor(() => expect(result.current.item).toEqual({ id: 'abc' })); - await waitFor(() => expect(result.current.state).toEqual('IDLE')); + await waitFor(() => expect(result.current.isLoading).toEqual(false)); }); it('handles error with JSON response', async () => { diff --git a/src/hooks/useItem.ts b/src/hooks/useItem.ts index 124375c..09a6a61 100644 --- a/src/hooks/useItem.ts +++ b/src/hooks/useItem.ts @@ -1,21 +1,20 @@ -import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Item } from '../types/stac'; -import { type ApiErrorType, type LoadingState } from '../types'; +import { type ApiErrorType } from '../types'; import { useStacApiContext } from '../context/useStacApiContext'; import { ApiError } from '../utils/ApiError'; import { generateItemQueryKey } from '../utils/queryKeys'; type ItemHook = { item?: Item; - state: LoadingState; + isLoading: boolean; + isFetching: boolean; error?: ApiErrorType; reload: () => void; }; function useItem(url: string): ItemHook { const { stacApi } = useStacApiContext(); - const [state, setState] = useState('IDLE'); const fetchItem = async (): Promise => { if (!stacApi) throw new Error('No STAC API configured'); @@ -46,17 +45,10 @@ function useItem(url: string): ItemHook { retry: false, }); - useEffect(() => { - if (isLoading || isFetching) { - setState('LOADING'); - } else { - setState('IDLE'); - } - }, [isLoading, isFetching]); - return { item, - state, + isLoading, + isFetching, error: error as ApiErrorType, reload: refetch as () => void, }; diff --git a/src/hooks/useStacSearch.test.ts b/src/hooks/useStacSearch.test.ts index 0a87180..741df3a 100644 --- a/src/hooks/useStacSearch.test.ts +++ b/src/hooks/useStacSearch.test.ts @@ -14,7 +14,7 @@ async function setupStacSearch() { const { result } = renderHook(() => useStacSearch(), { wrapper }); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); await act(async () => {}); - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); return result; } @@ -348,8 +348,8 @@ describe('useStacSearch — API supports POST', () => { // Trigger nextPage and validate fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.nextPage && result.current.nextPage()); - await waitFor(() => expect(result.current.state).toBe('LOADING')); - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); const postPayload = parseRequestPayload(fetch.mock.calls[2][1]); expect(result.current.results).toEqual({ data: '12345' }); expect(postPayload).toEqual(response.links[0].body); @@ -396,8 +396,8 @@ describe('useStacSearch — API supports POST', () => { // Trigger previousPage and validate fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.previousPage && result.current.previousPage()); - await waitFor(() => expect(result.current.state).toBe('LOADING')); - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); const postPayload = parseRequestPayload(fetch.mock.calls[2][1]); expect(result.current.results).toEqual({ data: '12345' }); expect(postPayload).toEqual(response.links[0].body); @@ -444,8 +444,8 @@ describe('useStacSearch — API supports POST', () => { // Trigger previousPage and validate fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.previousPage && result.current.previousPage()); - await waitFor(() => expect(result.current.state).toBe('LOADING')); - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); const postPayload = parseRequestPayload(fetch.mock.calls[2][1]); expect(result.current.results).toEqual({ data: '12345' }); expect(postPayload).toEqual(response.links[0].body); @@ -493,8 +493,8 @@ describe('useStacSearch — API supports POST', () => { // Trigger previousPage and validate merged body fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.previousPage && result.current.previousPage()); - await waitFor(() => expect(result.current.state).toBe('LOADING')); - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); const postPayload = parseRequestPayload(fetch.mock.calls[2][1]); expect(result.current.results).toEqual({ data: '12345' }); expect(postPayload).toEqual({ @@ -547,8 +547,8 @@ describe('useStacSearch — API supports POST', () => { // Trigger previousPage and validate header fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.previousPage && result.current.previousPage()); - await waitFor(() => expect(result.current.state).toBe('LOADING')); - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.results).toEqual({ data: '12345' }); const postHeader = fetch.mock.calls[2][1]?.headers; expect(postHeader).toEqual({ 'Content-Type': 'application/json', next: '123abc' }); @@ -589,8 +589,8 @@ describe('useStacSearch — API supports POST', () => { // Trigger nextPage and validate GET request fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.nextPage && result.current.nextPage()); - await waitFor(() => expect(result.current.state).toBe('LOADING')); - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(fetch.mock.calls[2][0]).toEqual('https://fake-stac-api.net/?page=2'); expect(fetch.mock.calls[2][1]?.method).toEqual('GET'); expect(result.current.results).toEqual({ data: '12345' }); @@ -631,8 +631,8 @@ describe('useStacSearch — API supports POST', () => { // Trigger previousPage and validate GET request fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); act(() => result.current.previousPage && result.current.previousPage()); - await waitFor(() => expect(result.current.state).toBe('LOADING')); - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(fetch.mock.calls[2][0]).toEqual('https://fake-stac-api.net/?page=2'); expect(fetch.mock.calls[2][1]?.method).toEqual('GET'); expect(result.current.results).toEqual({ data: '12345' }); @@ -742,7 +742,7 @@ describe('useStacSearch — API supports GET', () => { // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); // Wait for state to be IDLE - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Assert fetch URL and results expect(fetch.mock.calls[1][0]).toEqual( @@ -768,7 +768,7 @@ describe('useStacSearch — API supports GET', () => { // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); // Wait for state to be IDLE - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Assert fetch URL and results expect(fetch.mock.calls[1][0]).toEqual( @@ -795,7 +795,7 @@ describe('useStacSearch — API supports GET', () => { // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); // Wait for state to be IDLE and fetch to be called twice - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Assert fetch URL and results expect(fetch.mock.calls[1][0]).toEqual( @@ -821,7 +821,7 @@ describe('useStacSearch — API supports GET', () => { // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); // Wait for state to be IDLE and fetch to be called twice - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Assert fetch URL and results expect(fetch.mock.calls[1][0]).toEqual( @@ -847,7 +847,7 @@ describe('useStacSearch — API supports GET', () => { // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); // Wait for state to be IDLE and fetch to be called twice - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Assert fetch URL and results expect(fetch.mock.calls[1][0]).toEqual( @@ -878,7 +878,7 @@ describe('useStacSearch — API supports GET', () => { // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); // Wait for state to be IDLE and fetch to be called twice - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Assert fetch URL and results expect(fetch.mock.calls[1][0]).toEqual( @@ -904,7 +904,7 @@ describe('useStacSearch — API supports GET', () => { // Flush microtasks to ensure debounced function and state updates complete await act(async () => {}); // Wait for state to be IDLE and fetch to be called twice - await waitFor(() => expect(result.current.state).toBe('IDLE')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); // Assert fetch URL and results expect(fetch.mock.calls[1][0]).toEqual('https://fake-stac-api.net/search?limit=50'); diff --git a/src/hooks/useStacSearch.ts b/src/hooks/useStacSearch.ts index f4d0156..0e805bc 100644 --- a/src/hooks/useStacSearch.ts +++ b/src/hooks/useStacSearch.ts @@ -2,7 +2,7 @@ import { useCallback, useState, useMemo, useEffect } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import debounce from '../utils/debounce'; import { generateStacSearchQueryKey } from '../utils/queryKeys'; -import { type ApiErrorType, type LoadingState } from '../types'; +import { type ApiErrorType } from '../types'; import { ApiError } from '../utils/ApiError'; import type { Link, @@ -18,22 +18,23 @@ type PaginationHandler = () => void; type StacSearchHook = { ids?: string[]; - setIds: (ids: string[]) => void; + setIds: (ids?: string[]) => void; bbox?: Bbox; - setBbox: (bbox: Bbox) => void; + setBbox: (bbox?: Bbox) => void; collections?: CollectionIdList; - setCollections: (collectionIds: CollectionIdList) => void; - dateRangeFrom?: string; + setCollections: (collections?: CollectionIdList) => void; + dateRangeFrom: string; setDateRangeFrom: (date: string) => void; - dateRangeTo?: string; + dateRangeTo: string; setDateRangeTo: (date: string) => void; - limit?: number; - setLimit: (limit: number) => void; sortby?: Sortby[]; - setSortby: (sort: Sortby[]) => void; + setSortby: (sortby?: Sortby[]) => void; + limit: number; + setLimit: (limit: number) => void; submit: () => void; results?: SearchResponse; - state: LoadingState; + isLoading: boolean; + isFetching: boolean; error?: ApiErrorType; nextPage: PaginationHandler | undefined; previousPage: PaginationHandler | undefined; @@ -201,9 +202,6 @@ function useStacSearch(): StacSearchHook { const submit = useMemo(() => debounce(_submit), [_submit]); - // Sync loading state for backwards compatibility - const state: LoadingState = isLoading || isFetching ? 'LOADING' : 'IDLE'; - return { submit, ids, @@ -217,7 +215,8 @@ function useStacSearch(): StacSearchHook { dateRangeTo, setDateRangeTo, results, - state, + isLoading, + isFetching, error: error ?? undefined, sortby, setSortby, diff --git a/src/types/index.d.ts b/src/types/index.d.ts index c41485b..a12b52f 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -7,5 +7,3 @@ export type ApiErrorType = { status: number; statusText: string; }; - -export type LoadingState = 'IDLE' | 'LOADING'; From c785dea250b5d9c1b1320ba3d5a6e452174886d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Thu, 27 Nov 2025 21:57:26 +0100 Subject: [PATCH 22/29] refactor: fetch single collection directly from /collections/{id} endpoint - Add getCollection(collectionId) method to StacApi class - Refactor useCollection to query /collections/{collectionId} directly - Add generateCollectionQueryKey for independent collection caching - Update tests to mock single collection endpoint instead of filtering Previously, useCollection fetched all collections and filtered to find one. Now it uses the STAC spec's dedicated endpoint, improving efficiency for large catalogs and enabling independent collection caching. --- src/hooks/useCollection.test.ts | 60 ++++++++++----------------------- src/hooks/useCollection.ts | 53 ++++++++++++++++++----------- src/stac-api/index.ts | 4 +++ src/utils/queryKeys.test.ts | 16 +++++++++ src/utils/queryKeys.ts | 8 +++++ 5 files changed, 80 insertions(+), 61 deletions(-) diff --git a/src/hooks/useCollection.test.ts b/src/hooks/useCollection.test.ts index ecf2081..0375743 100644 --- a/src/hooks/useCollection.test.ts +++ b/src/hooks/useCollection.test.ts @@ -11,40 +11,30 @@ describe('useCollection', () => { it('queries collection', async () => { fetch .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) - .mockResponseOnce( - JSON.stringify({ - collections: [ - { id: 'abc', title: 'Collection A' }, - { id: 'def', title: 'Collection B' }, - ], - }) - ); + .mockResponseOnce(JSON.stringify({ id: 'abc', title: 'Collection A' })); const { result } = renderHook(() => useCollection('abc'), { wrapper }); await waitFor(() => expect(result.current.isLoading).toEqual(false)); await waitFor(() => expect(result.current.collection).toEqual({ id: 'abc', title: 'Collection A' }) ); + expect(fetch.mock.calls[1][0]).toEqual('https://fake-stac-api.net/collections/abc'); }); it('returns error if collection does not exist', async () => { fetch .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) - .mockResponseOnce( - JSON.stringify({ - collections: [ - { id: 'abc', title: 'Collection A' }, - { id: 'def', title: 'Collection B' }, - ], - }) - ); + .mockResponseOnce(JSON.stringify({ error: 'Collection not found' }), { + status: 404, + statusText: 'Not Found', + }); - const { result } = renderHook(() => useCollection('ghi'), { wrapper }); + const { result } = renderHook(() => useCollection('nonexistent'), { wrapper }); await waitFor(() => expect(result.current.error).toEqual({ status: 404, - statusText: 'Not found', - detail: 'Collection does not exist', + statusText: 'Not Found', + detail: { error: 'Collection not found' }, }) ); }); @@ -73,34 +63,20 @@ describe('useCollection', () => { .mockResponseOnce('Wrong query', { status: 400, statusText: 'Bad Request' }); const { result } = renderHook(() => useCollection('abc'), { wrapper }); - await waitFor(() => expect(result.current.error).toBeDefined()); - - expect(result.current.error).toEqual({ - status: 400, - statusText: 'Bad Request', - detail: 'Wrong query', - }); + await waitFor(() => + expect(result.current.error).toEqual({ + status: 400, + statusText: 'Bad Request', + detail: 'Wrong query', + }) + ); }); it('reloads collection', async () => { fetch .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) - .mockResponseOnce( - JSON.stringify({ - collections: [ - { id: 'abc', title: 'Collection A' }, - { id: 'def', title: 'Collection B' }, - ], - }) - ) - .mockResponseOnce( - JSON.stringify({ - collections: [ - { id: 'abc', title: 'Collection A - Updated' }, - { id: 'def', title: 'Collection B' }, - ], - }) - ); + .mockResponseOnce(JSON.stringify({ id: 'abc', title: 'Collection A' })) + .mockResponseOnce(JSON.stringify({ id: 'abc', title: 'Collection A - Updated' })); const { result } = renderHook(() => useCollection('abc'), { wrapper }); await waitFor(() => diff --git a/src/hooks/useCollection.ts b/src/hooks/useCollection.ts index 8860fc7..aeb15ee 100644 --- a/src/hooks/useCollection.ts +++ b/src/hooks/useCollection.ts @@ -1,8 +1,9 @@ -import { useMemo } from 'react'; - +import { useQuery } from '@tanstack/react-query'; import type { ApiErrorType } from '../types'; import type { Collection } from '../types/stac'; -import useCollections from './useCollections'; +import { ApiError } from '../utils/ApiError'; +import { generateCollectionQueryKey } from '../utils/queryKeys'; +import { useStacApiContext } from '../context/useStacApiContext'; type StacCollectionHook = { collection?: Collection; @@ -13,29 +14,43 @@ type StacCollectionHook = { }; function useCollection(collectionId: string): StacCollectionHook { - const { collections, isLoading, isFetching, error: requestError, reload } = useCollections(); + const { stacApi } = useStacApiContext(); - const collection = useMemo(() => { - return collections?.collections.find(({ id }) => id === collectionId); - }, [collectionId, collections]); + const fetchCollection = async (): Promise => { + if (!stacApi) throw new Error('No STAC API configured'); + const response: Response = await stacApi.getCollection(collectionId); + if (!response.ok) { + let detail; + try { + detail = await response.json(); + } catch { + detail = await response.text(); + } - // Determine error: prefer requestError, else local 404 if collection not found - const error: ApiErrorType | undefined = requestError - ? requestError - : !collection && collections - ? { - status: 404, - statusText: 'Not found', - detail: 'Collection does not exist', - } - : undefined; + throw new ApiError(response.statusText, response.status, detail); + } + return await response.json(); + }; + + const { + data: collection, + error, + isLoading, + isFetching, + refetch, + } = useQuery({ + queryKey: generateCollectionQueryKey(collectionId), + queryFn: fetchCollection, + enabled: !!stacApi, + retry: false, + }); return { collection, isLoading, isFetching, - error, - reload, + error: error as ApiErrorType, + reload: refetch as () => void, }; } diff --git a/src/stac-api/index.ts b/src/stac-api/index.ts index 8418fcb..4abfa54 100644 --- a/src/stac-api/index.ts +++ b/src/stac-api/index.ts @@ -152,6 +152,10 @@ class StacApi { return this.fetch(`${this.baseUrl}/collections`); } + getCollection(collectionId: string): Promise { + return this.fetch(`${this.baseUrl}/collections/${collectionId}`); + } + get(href: string, headers = {}): Promise { return this.fetch(href, { headers }); } diff --git a/src/utils/queryKeys.test.ts b/src/utils/queryKeys.test.ts index e7daede..b07a10c 100644 --- a/src/utils/queryKeys.test.ts +++ b/src/utils/queryKeys.test.ts @@ -4,6 +4,7 @@ import { generateStacApiQueryKey, generateItemQueryKey, generateCollectionsQueryKey, + generateCollectionQueryKey, } from './queryKeys'; import type { SearchRequestPayload, Sortby } from '../types/stac'; @@ -15,6 +16,21 @@ describe('Query Key Generators', () => { }); }); + describe('generateCollectionQueryKey', () => { + it('should generate key with collection ID', () => { + const key = generateCollectionQueryKey('my-collection'); + expect(key).toEqual(['collection', 'my-collection']); + }); + + it('should handle different collection IDs', () => { + const key1 = generateCollectionQueryKey('collection-a'); + const key2 = generateCollectionQueryKey('collection-b'); + expect(key1).not.toEqual(key2); + expect(key1).toEqual(['collection', 'collection-a']); + expect(key2).toEqual(['collection', 'collection-b']); + }); + }); + describe('generateItemQueryKey', () => { it('should generate key with item URL', () => { const url = 'https://example.com/collections/test/items/item1'; diff --git a/src/utils/queryKeys.ts b/src/utils/queryKeys.ts index 3613ec6..8685f63 100644 --- a/src/utils/queryKeys.ts +++ b/src/utils/queryKeys.ts @@ -45,6 +45,14 @@ export function generateCollectionsQueryKey(): [string] { return ['collections']; } +/** + * Generates a query key for a single STAC collection request. + * Collections are fetched by ID from /collections/{collectionId}. + */ +export function generateCollectionQueryKey(collectionId: string): [string, string] { + return ['collection', collectionId]; +} + /** * Generates a query key for STAC item requests. * Items are fetched by URL. From 0109e59b8dd11245628cbca8d446d997ae16018c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Thu, 27 Nov 2025 22:27:03 +0100 Subject: [PATCH 23/29] fix: properly type reload as async across all hooks - Change reload return type from () => void to () => Promise - Remove incorrect type casts from refetch in useCollection, useCollections, useItem - Update tests to await reload() calls with proper act() wrapping - Remove eslint-disable comment from useItem test - Remove debounce wrapper from useCollections reload (was returning void) The refetch function from React Query returns a Promise, not void. This fix properly reflects the async nature of reload operations and resolves React act() warnings in tests. Debouncing was removed from useCollections.reload as it's uncommon to debounce explicit reload actions. --- src/hooks/useCollection.test.ts | 4 +++- src/hooks/useCollection.ts | 6 +++--- src/hooks/useCollections.test.ts | 4 +++- src/hooks/useCollections.ts | 10 +++------- src/hooks/useItem.test.ts | 1 - src/hooks/useItem.ts | 6 +++--- 6 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/hooks/useCollection.test.ts b/src/hooks/useCollection.test.ts index 0375743..2e9e402 100644 --- a/src/hooks/useCollection.test.ts +++ b/src/hooks/useCollection.test.ts @@ -83,7 +83,9 @@ describe('useCollection', () => { expect(result.current.collection).toEqual({ id: 'abc', title: 'Collection A' }) ); - act(() => result.current.reload()); + await act(async () => { + await result.current.reload(); + }); await waitFor(() => expect(result.current.collection).toEqual({ id: 'abc', title: 'Collection A - Updated' }) diff --git a/src/hooks/useCollection.ts b/src/hooks/useCollection.ts index aeb15ee..afcff1a 100644 --- a/src/hooks/useCollection.ts +++ b/src/hooks/useCollection.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, type QueryObserverResult } from '@tanstack/react-query'; import type { ApiErrorType } from '../types'; import type { Collection } from '../types/stac'; import { ApiError } from '../utils/ApiError'; @@ -10,7 +10,7 @@ type StacCollectionHook = { isLoading: boolean; isFetching: boolean; error?: ApiErrorType; - reload: () => void; + reload: () => Promise>; }; function useCollection(collectionId: string): StacCollectionHook { @@ -50,7 +50,7 @@ function useCollection(collectionId: string): StacCollectionHook { isLoading, isFetching, error: error as ApiErrorType, - reload: refetch as () => void, + reload: refetch, }; } diff --git a/src/hooks/useCollections.test.ts b/src/hooks/useCollections.test.ts index 7ba7ab2..2fc826b 100644 --- a/src/hooks/useCollections.test.ts +++ b/src/hooks/useCollections.test.ts @@ -32,7 +32,9 @@ describe('useCollections', () => { await waitFor(() => expect(result.current.collections).toEqual({ data: 'original' })); await waitFor(() => expect(result.current.isLoading).toEqual(false)); - act(() => result.current.reload()); + await act(async () => { + await result.current.reload(); + }); await waitFor(() => expect(result.current.collections).toEqual({ data: 'reloaded' })); }); diff --git a/src/hooks/useCollections.ts b/src/hooks/useCollections.ts index 9ba9314..f876eec 100644 --- a/src/hooks/useCollections.ts +++ b/src/hooks/useCollections.ts @@ -1,15 +1,13 @@ -import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, type QueryObserverResult } from '@tanstack/react-query'; import { type ApiErrorType } from '../types'; import type { CollectionsResponse } from '../types/stac'; -import debounce from '../utils/debounce'; import { ApiError } from '../utils/ApiError'; import { generateCollectionsQueryKey } from '../utils/queryKeys'; import { useStacApiContext } from '../context/useStacApiContext'; type StacCollectionsHook = { collections?: CollectionsResponse; - reload: () => void; + reload: () => Promise>; isLoading: boolean; isFetching: boolean; error?: ApiErrorType; @@ -47,11 +45,9 @@ function useCollections(): StacCollectionsHook { retry: false, }); - const reload = useMemo(() => debounce(refetch), [refetch]); - return { collections, - reload, + reload: refetch, isLoading, isFetching, error: error as ApiErrorType, diff --git a/src/hooks/useItem.test.ts b/src/hooks/useItem.test.ts index 0ee5960..0ebbbc3 100644 --- a/src/hooks/useItem.test.ts +++ b/src/hooks/useItem.test.ts @@ -69,7 +69,6 @@ describe('useItem', () => { await waitFor(() => expect(result.current.item).toEqual({ id: 'abc' })); await act(async () => { - // eslint-disable-next-line @typescript-eslint/await-thenable await result.current.reload(); }); diff --git a/src/hooks/useItem.ts b/src/hooks/useItem.ts index 09a6a61..a996ab4 100644 --- a/src/hooks/useItem.ts +++ b/src/hooks/useItem.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, type QueryObserverResult } from '@tanstack/react-query'; import { Item } from '../types/stac'; import { type ApiErrorType } from '../types'; import { useStacApiContext } from '../context/useStacApiContext'; @@ -10,7 +10,7 @@ type ItemHook = { isLoading: boolean; isFetching: boolean; error?: ApiErrorType; - reload: () => void; + reload: () => Promise>; }; function useItem(url: string): ItemHook { @@ -50,7 +50,7 @@ function useItem(url: string): ItemHook { isLoading, isFetching, error: error as ApiErrorType, - reload: refetch as () => void, + reload: refetch, }; } From 44882cb4de29c6c0f759087732a8d078804062ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Thu, 27 Nov 2025 22:33:53 +0100 Subject: [PATCH 24/29] refactor!: rename reload to refetch in hooks API BREAKING CHANGE: reload() method renamed to refetch() in useCollection, useCollections, and useItem hooks. - Rename reload() to refetch() in useCollection, useCollections, and useItem - Update all tests to use refetch() instead of reload() - Maintain proper async typing with Promise Using refetch aligns with React Query's naming convention and makes the API more consistent with the underlying library. The function returns the Promise from React Query's refetch directly. Migration: Replace .reload() with .refetch() in your code. --- src/hooks/useCollection.test.ts | 2 +- src/hooks/useCollection.ts | 4 ++-- src/hooks/useCollections.test.ts | 2 +- src/hooks/useCollections.ts | 4 ++-- src/hooks/useItem.test.ts | 2 +- src/hooks/useItem.ts | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/hooks/useCollection.test.ts b/src/hooks/useCollection.test.ts index 2e9e402..bd97a8e 100644 --- a/src/hooks/useCollection.test.ts +++ b/src/hooks/useCollection.test.ts @@ -84,7 +84,7 @@ describe('useCollection', () => { ); await act(async () => { - await result.current.reload(); + await result.current.refetch(); }); await waitFor(() => diff --git a/src/hooks/useCollection.ts b/src/hooks/useCollection.ts index afcff1a..3457c7c 100644 --- a/src/hooks/useCollection.ts +++ b/src/hooks/useCollection.ts @@ -9,8 +9,8 @@ type StacCollectionHook = { collection?: Collection; isLoading: boolean; isFetching: boolean; + refetch: () => Promise>; error?: ApiErrorType; - reload: () => Promise>; }; function useCollection(collectionId: string): StacCollectionHook { @@ -49,8 +49,8 @@ function useCollection(collectionId: string): StacCollectionHook { collection, isLoading, isFetching, + refetch, error: error as ApiErrorType, - reload: refetch, }; } diff --git a/src/hooks/useCollections.test.ts b/src/hooks/useCollections.test.ts index 2fc826b..2d2c96b 100644 --- a/src/hooks/useCollections.test.ts +++ b/src/hooks/useCollections.test.ts @@ -33,7 +33,7 @@ describe('useCollections', () => { await waitFor(() => expect(result.current.isLoading).toEqual(false)); await act(async () => { - await result.current.reload(); + await result.current.refetch(); }); await waitFor(() => expect(result.current.collections).toEqual({ data: 'reloaded' })); diff --git a/src/hooks/useCollections.ts b/src/hooks/useCollections.ts index f876eec..af8647a 100644 --- a/src/hooks/useCollections.ts +++ b/src/hooks/useCollections.ts @@ -7,7 +7,7 @@ import { useStacApiContext } from '../context/useStacApiContext'; type StacCollectionsHook = { collections?: CollectionsResponse; - reload: () => Promise>; + refetch: () => Promise>; isLoading: boolean; isFetching: boolean; error?: ApiErrorType; @@ -47,7 +47,7 @@ function useCollections(): StacCollectionsHook { return { collections, - reload: refetch, + refetch, isLoading, isFetching, error: error as ApiErrorType, diff --git a/src/hooks/useItem.test.ts b/src/hooks/useItem.test.ts index 0ebbbc3..318d861 100644 --- a/src/hooks/useItem.test.ts +++ b/src/hooks/useItem.test.ts @@ -69,7 +69,7 @@ describe('useItem', () => { await waitFor(() => expect(result.current.item).toEqual({ id: 'abc' })); await act(async () => { - await result.current.reload(); + await result.current.refetch(); }); await waitFor(() => expect(result.current.item).toEqual({ id: 'abc', description: 'Updated' })); diff --git a/src/hooks/useItem.ts b/src/hooks/useItem.ts index a996ab4..608d8e9 100644 --- a/src/hooks/useItem.ts +++ b/src/hooks/useItem.ts @@ -10,7 +10,7 @@ type ItemHook = { isLoading: boolean; isFetching: boolean; error?: ApiErrorType; - reload: () => Promise>; + refetch: () => Promise>; }; function useItem(url: string): ItemHook { @@ -50,7 +50,7 @@ function useItem(url: string): ItemHook { isLoading, isFetching, error: error as ApiErrorType, - reload: refetch, + refetch, }; } From 9ecb267fe4d448ba9d37c5c217140fd004928503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Fri, 28 Nov 2025 08:55:25 +0100 Subject: [PATCH 25/29] docs: add migration guide for v1.0.0 (TanStack Query integration) --- docs/MIGRATION.md | 685 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 685 insertions(+) create mode 100644 docs/MIGRATION.md diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..b3aaf53 --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,685 @@ +# Migration Guide: stac-react v1.0.0 (TanStack Query) + +This guide helps you migrate your code from the previous version of stac-react (using custom fetch logic) to the new version that uses **TanStack Query** for data fetching and caching. + +## Overview of Changes + +The migration introduces three major improvements: + +1. **TanStack Query Integration**: Automatic caching, request deduplication, and background updates +2. **Simplified API**: Cleaner return types using `isLoading`/`isFetching` instead of custom state management +3. **Better Error Handling**: Centralized error objects with proper TypeScript types + +--- + +## Breaking Changes + +### 1. Hook Return Type: `state` → `isLoading` + `isFetching` + +**Before:** + +```typescript +const { state, error, results } = useStacSearch(); + +if (state === 'LOADING') { + // Show loading UI +} + +type LoadingState = 'IDLE' | 'LOADING'; +``` + +**After:** + +```typescript +const { isLoading, isFetching, error, results } = useStacSearch(); + +if (isLoading) { + // Initial load +} + +if (isFetching) { + // Any fetch (background updates, pagination) +} + +// isLoading: boolean (true during initial fetch) +// isFetching: boolean (true during any fetch, including background updates) +``` + +**Why the change?** + +TanStack Query distinguishes between: + +- **`isLoading`**: Initial data fetch in progress (no cached data) +- **`isFetching`**: Any fetch in progress (including cache updates) + +This gives you more fine-grained control over UX—you can show a skeleton loader for `isLoading` and a subtle refresh indicator for `isFetching`. + +**Migration checklist:** + +- [ ] Replace `state === 'LOADING'` with `isLoading` +- [ ] Replace `state === 'IDLE'` with `!isLoading` +- [ ] Use `isFetching` for background update indicators +- [ ] Update any error handling that checked `state` + +--- + +### 2. Method Rename: `reload` → `refetch` + +All hooks now use `refetch` to align with TanStack Query terminology. + +**Before:** + +```typescript +const { collection, reload } = useCollection('my-collection'); + +const handleRefresh = () => { + reload(); // Custom method +}; +``` + +**After:** + +```typescript +const { collection, refetch } = useCollection('my-collection'); + +const handleRefresh = async () => { + await refetch(); // TanStack Query standard +}; +``` + +**What's different?** + +The new `refetch` function: + +- Returns a Promise with the query result +- Is async (awaitable) +- Can be cancelled +- Respects retry configuration + +**Migration checklist:** + +- [ ] Rename all `reload()` calls to `refetch()` +- [ ] Make callers async if needed +- [ ] Consider handling the returned promise + +--- + +### 3. Removed: `collections` and `items` from Context + +**Before:** + +```typescript +const { collections, items } = useStacApiContext(); + +// You could access cached data directly +const allItems = items; +``` + +**After:** + +```typescript +// Use individual hooks instead +const { collections } = useCollections(); +const { item } = useItem(itemUrl); + +// Or useStacSearch for items +const { results: itemsResponse } = useStacSearch(); +``` + +**Why?** + +- Storing all data in context creates memory bloat +- TanStack Query manages caching automatically +- Individual hooks are more composable and efficient + +**Migration checklist:** + +- [ ] Replace `useStacApiContext()` for data access with individual hooks +- [ ] Use `useCollections()` instead of context for collections +- [ ] Use `useItem()` or `useStacSearch()` for items +- [ ] Update any components that relied on global data + +--- + +### 4. Error Handling: Unified `ApiError` Object + +**Before:** + +```typescript +const { error } = useCollection('my-collection'); + +// Errors were generic Error objects with Object.assign pattern +if (error && error.status === 404) { + // Collection not found +} +``` + +**After:** + +```typescript +import type { ApiErrorType } from 'stac-react'; + +const { error } = useCollection('my-collection'); + +if (error?.status === 404) { + // Collection not found +} + +// Error has proper TypeScript types +// error: { +// status: number; +// statusText: string; +// detail?: GenericObject | string; +// } +``` + +**What's different?** + +- Errors are now proper class instances with `ApiError` +- Better TypeScript support with `ApiErrorType` +- Includes HTTP status codes and response details +- Consistent across all hooks + +**Migration checklist:** + +- [ ] Import `ApiErrorType` from 'stac-react' +- [ ] Update error checks to use typed properties +- [ ] Test error scenarios (404, 500, network failures) + +--- + +## API Changes by Hook + +### `useStacSearch()` + +The most significant changes are in `useStacSearch`: + +#### Before: Manual State Management + +```typescript +function MySearch() { + const { + state, + error, + results, + setCollections, + setBbox, + submit, + } = useStacSearch(); + + const handleSearch = () => { + setCollections(['landsat-8']); + setBbox([-180, -90, 180, 90]); + submit(); // Must call submit manually + }; + + if (state === 'LOADING') return
    Loading...
    ; + + return ( +
    + + {results &&

    {results.features.length} items found

    } +
    + ); +} +``` + +#### After: TanStack Query Integration + +```typescript +function MySearch() { + const { + isLoading, + isFetching, + error, + results, + setCollections, + setBbox, + submit, + limit, + setLimit, + } = useStacSearch(); + + const handleSearch = () => { + setCollections(['landsat-8']); + setBbox([-180, -90, 180, 90]); + submit(); // Still must call submit + }; + + if (isLoading) return
    Loading...
    ; + if (isFetching && !results) return
    Fetching...
    ; + if (error) return
    Error: {error.statusText}
    ; + + return ( +
    + + {results && ( +
    +

    {results.features.length} items found

    + +
    + )} +
    + ); +} +``` + +**Key differences:** + +- `state` split into `isLoading` and `isFetching` +- `limit` is now part of return object (not just parameter) +- Error handling is more explicit +- No functional changes to `submit()` flow + +**Migration checklist:** + +- [ ] Update loading state logic +- [ ] Add error boundary +- [ ] Test pagination (if used) +- [ ] Update TypeScript types for new return object + +--- + +### `useCollections()`, `useCollection()`, `useItem()` + +These hooks have minimal API changes: + +#### Before + +```typescript +const { collections, isLoading, error, reload } = useCollections(); +``` + +#### After + +```typescript +const { collections, isLoading, isFetching, error, refetch } = useCollections(); +``` + +**Changes:** + +- `reload` → `refetch` +- Added `isFetching` +- New ability to pass `refetch` directly to buttons/callbacks + +**Migration checklist:** + +- [ ] Rename `reload` to `refetch` +- [ ] Use `isFetching` for background update indicators +- [ ] Everything else stays the same + +--- + +### `useStacApi()` + +This hook is mostly internal but had a refactor: + +**Before:** + +```typescript +const { stacApi, isLoading, error, reload } = useStacApi(apiUrl); +``` + +**After:** + +```typescript +const { stacApi, isLoading, isFetching, error, refetch } = useStacApi(apiUrl); +``` + +Same pattern as other hooks. Usually you don't use this directly—it's used internally by `StacApiProvider`. + +--- + +## Setup Changes + +### QueryClient Configuration + +You now must configure TanStack Query. The library doesn't do this for you (to avoid forcing opinions). + +#### Before + +```jsx +import { StacApiProvider } from 'stac-react'; + +function App() { + return {/* Your app */}; +} +``` + +#### After + +```jsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { StacApiProvider } from 'stac-react'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes (garbage collection time) + }, + }, +}); + +function App() { + return ( + + + {/* Your app */} + + + ); +} +``` + +**New configuration options:** + +| Option | Default | Description | +| ---------------------- | ------- | ------------------------------------------------ | +| `staleTime` | 0 | How long data is considered fresh (milliseconds) | +| `gcTime` | 5 min | How long unused queries stay in memory | +| `retry` | 3 | Number of retries on failure | +| `refetchOnWindowFocus` | true | Refetch when window regains focus | +| `refetchOnMount` | true | Refetch when hook mounts | + +See [TanStack Query documentation](https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults) for all options. + +**Migration checklist:** + +- [ ] Import `QueryClient` and `QueryClientProvider` +- [ ] Create `QueryClient` instance with desired config +- [ ] Wrap `StacApiProvider` with `QueryClientProvider` +- [ ] Pass `queryClient` to `StacApiProvider` +- [ ] Review caching strategy for your use case +- [ ] See [react-query-setup.md](./react-query-setup.md) for more details + +--- + +## Performance Improvements + +With TanStack Query, you get automatic caching and request deduplication. Here's what happens: + +### Automatic Caching + +```typescript +// First call - makes network request +const { collection: col1 } = useCollection('landsat-8'); + +// Later, another component +const { collection: col2 } = useCollection('landsat-8'); +// ^ Uses cached data! No network request! + +// After 5 minutes of no use, data is considered "stale" +// Next call will refetch in background while returning cached data first + +// After 10 minutes, data is garbage collected from memory +``` + +### Request Deduplication + +```typescript +// Multiple components request same collection +// Only ONE network request is made, even if 5 components use the hook + + + + +// Only 1 fetch! Shared among all three +``` + +### Invalidation on API Change + +```typescript +// If API URL changes, all queries are automatically invalidated + +// Switch to new API: + +// All cached data is cleared, fresh requests made +``` + +--- + +## Testing Updates + +Your tests need to be updated for TanStack Query: + +### Before + +```typescript +import { renderHook, waitFor } from '@testing-library/react'; +import { useCollections } from 'stac-react'; + +test('loads collections', async () => { + const { result } = renderHook(() => useCollections()); + + await waitFor(() => { + expect(result.current.collections).toBeDefined(); + }); +}); +``` + +### After + +```typescript +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useCollections } from 'stac-react'; + +test('loads collections', async () => { + // IMPORTANT: Disable caching in tests to avoid pollution + const testClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 0, // Don't cache between tests + staleTime: 0, // Always consider data stale + retry: false, // Don't retry in tests + }, + }, + }); + + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useCollections(), { wrapper }); + + await waitFor(() => { + expect(result.current.collections).toBeDefined(); + }); +}); +``` + +**Testing best practices:** + +1. **Disable caching in tests**: Use `gcTime: 0` to prevent state leaking between tests +2. **Mock fetch**: Use `jest-fetch-mock` or `msw` to intercept requests +3. **Test error scenarios**: Now that error types are structured, test them! +4. **Test refetch**: Verify `refetch()` works and returns data +5. **Avoid testing implementation details**: Don't test `isLoading` state directly + +--- + +--- + +## TypeScript Updates + +If you're using TypeScript, new types are available: + +```typescript +import type { + ApiErrorType, // Error response + FetchRequest, // Request types for useStacSearch + SearchRequestPayload, // Search parameters +} from 'stac-react'; + +// Your hook usage +const { error }: { error?: ApiErrorType } = useCollection('id'); + +if (error) { + console.log(error.status); // number + console.log(error.statusText); // string + console.log(error.detail); // unknown +} +``` + +--- + +## Common Migration Patterns + +### Pattern 1: Conditional Rendering + +**Before:** + +```typescript +{state === 'LOADING' && } +{state === 'IDLE' && results && } +{error && } +``` + +**After:** + +```typescript +{isLoading && } +{!isLoading && results && } +{error && } +``` + +### Pattern 2: Disable UI During Fetch + +**Before:** + +```typescript + +``` + +**After:** + +```typescript + +``` + +### Pattern 3: Refetch on Mount + +**Before:** + +```typescript +useEffect(() => { + reload(); +}, []); +``` + +**After:** + +```typescript +// Automatic! But if you need manual control: +useEffect(() => { + refetch(); +}, [refetch]); // refetch is stable reference +``` + +--- + +--- + +## Troubleshooting + +### Q: "No STAC API configured" error + +**Cause:** `StacApiProvider` not wrapping your component + +**Fix:** + +```tsx +// ❌ This won't work + // Tries to use useStacApiContext() outside provider + +// ✅ This works + + + +``` + +### Q: Data not updating after API URL changes + +**Cause:** Not sharing same QueryClient instance + +**Fix:** + +```tsx +const queryClient = new QueryClient(); + + + + {/* ... */} + +; +``` + +### Q: Cache is too aggressive - old data showing + +**Cause:** `staleTime` is too long + +**Fix:** + +```tsx +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 1000, // 30 seconds instead of 5 minutes + }, + }, +}); +``` + +### Q: Tests are flaky/interdependent + +**Cause:** TanStack Query caching between tests + +**Fix:** Use test QueryClient with `gcTime: 0`: + +```typescript +const testClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: 0, staleTime: 0, retry: false }, + }, +}); +``` + +--- + +## Need Help? + +- **TanStack Query docs**: [TanStack Query Documentation](https://tanstack.com/query/latest) +- **Issue/Discussion**: Open a GitHub issue for migration questions +- **Examples**: Check `/example` directory for working code + +--- + +## Summary Checklist + +- [ ] Update dependencies (install `@tanstack/react-query`) +- [ ] Wrap app with `StacApiProvider` +- [ ] Provide queryClient prop if already using React Query +- [ ] Replace `state` with `isLoading`/`isFetching` in all components +- [ ] Rename `reload` to `refetch` in all components +- [ ] Replace context data access with individual hooks +- [ ] Update error handling to use typed `ApiErrorType` +- [ ] Update tests to use test QueryClient +- [ ] Remove context data subscriptions +- [ ] Review caching strategy for your app +- [ ] Test in development and production From cc60b1d25773b4b05c63b1f3264d546101045b7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Fri, 28 Nov 2025 08:55:46 +0100 Subject: [PATCH 26/29] chore: bump version to 1.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5632e25..4d83817 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@developmentseed/stac-react", - "version": "0.1.0", + "version": "1.0.0", "description": "React components and hooks for building STAC-API front-ends", "repository": "git@github.com:developmentseed/stac-react.git", "authors": [ From 6b75fcc1c72f5ddfd9ca77b46347190556433716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Fri, 28 Nov 2025 09:04:27 +0100 Subject: [PATCH 27/29] fix: make date range parameters optional in useStacSearch hook - Changed dateRangeFrom and dateRangeTo from required to optional in return type - Updated state initialization to use undefined instead of empty strings - Updated setters to accept optional string parameters - Updated reset function to set undefined instead of empty strings This resolves type inconsistencies where the return type claimed required strings but the implementation initialized with empty strings. Conceptually, users should be able to clear/unset date filters by passing undefined. --- src/hooks/useStacSearch.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/hooks/useStacSearch.ts b/src/hooks/useStacSearch.ts index 0e805bc..5caa1d2 100644 --- a/src/hooks/useStacSearch.ts +++ b/src/hooks/useStacSearch.ts @@ -23,10 +23,10 @@ type StacSearchHook = { setBbox: (bbox?: Bbox) => void; collections?: CollectionIdList; setCollections: (collections?: CollectionIdList) => void; - dateRangeFrom: string; - setDateRangeFrom: (date: string) => void; - dateRangeTo: string; - setDateRangeTo: (date: string) => void; + dateRangeFrom?: string; + setDateRangeFrom: (date?: string) => void; + dateRangeTo?: string; + setDateRangeTo: (date?: string) => void; sortby?: Sortby[]; setSortby: (sortby?: Sortby[]) => void; limit: number; @@ -48,8 +48,8 @@ function useStacSearch(): StacSearchHook { const [ids, setIds] = useState(); const [bbox, setBbox] = useState(); const [collections, setCollections] = useState(); - const [dateRangeFrom, setDateRangeFrom] = useState(''); - const [dateRangeTo, setDateRangeTo] = useState(''); + const [dateRangeFrom, setDateRangeFrom] = useState(); + const [dateRangeTo, setDateRangeTo] = useState(); const [limit, setLimit] = useState(25); const [sortby, setSortby] = useState(); @@ -63,8 +63,8 @@ function useStacSearch(): StacSearchHook { setBbox(undefined); setCollections(undefined); setIds(undefined); - setDateRangeFrom(''); - setDateRangeTo(''); + setDateRangeFrom(undefined); + setDateRangeTo(undefined); setSortby(undefined); setLimit(25); setCurrentRequest(null); From ec62ab4251181591f3dfd8d3efdc2df301df8f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Fri, 28 Nov 2025 14:14:07 +0100 Subject: [PATCH 28/29] fix: add comprehensive error handling for JSON parsing failures - Add url field to ApiError class for better debugging context - Add try/catch blocks around all response.json() calls in success paths - Include original error messages in JSON parsing error details - Update ApiErrorType to include optional url field Previously, if a server returned a successful status (200) but invalid JSON (e.g., HTML error pages, malformed responses), the hooks would throw generic parse errors. Now they throw structured ApiError instances with context about what went wrong and which URL failed. --- src/hooks/useCollection.ts | 13 +++++++++++-- src/hooks/useCollections.ts | 13 +++++++++++-- src/hooks/useItem.ts | 13 +++++++++++-- src/hooks/useStacApi.ts | 9 ++++++++- src/hooks/useStacSearch.ts | 13 +++++++++++-- src/types/index.d.ts | 1 + src/utils/ApiError.ts | 4 +++- 7 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/hooks/useCollection.ts b/src/hooks/useCollection.ts index 3457c7c..44465c9 100644 --- a/src/hooks/useCollection.ts +++ b/src/hooks/useCollection.ts @@ -27,9 +27,18 @@ function useCollection(collectionId: string): StacCollectionHook { detail = await response.text(); } - throw new ApiError(response.statusText, response.status, detail); + throw new ApiError(response.statusText, response.status, detail, response.url); + } + try { + return await response.json(); + } catch (error) { + throw new ApiError( + 'Invalid JSON Response', + response.status, + `Response is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + response.url + ); } - return await response.json(); }; const { diff --git a/src/hooks/useCollections.ts b/src/hooks/useCollections.ts index af8647a..1219a40 100644 --- a/src/hooks/useCollections.ts +++ b/src/hooks/useCollections.ts @@ -27,9 +27,18 @@ function useCollections(): StacCollectionsHook { detail = await response.text(); } - throw new ApiError(response.statusText, response.status, detail); + throw new ApiError(response.statusText, response.status, detail, response.url); + } + try { + return await response.json(); + } catch (error) { + throw new ApiError( + 'Invalid JSON Response', + response.status, + `Response is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + response.url + ); } - return await response.json(); }; const { diff --git a/src/hooks/useItem.ts b/src/hooks/useItem.ts index 608d8e9..16f7f1e 100644 --- a/src/hooks/useItem.ts +++ b/src/hooks/useItem.ts @@ -27,9 +27,18 @@ function useItem(url: string): ItemHook { detail = await response.text(); } - throw new ApiError(response.statusText, response.status, detail); + throw new ApiError(response.statusText, response.status, detail, response.url); + } + try { + return await response.json(); + } catch (error) { + throw new ApiError( + 'Invalid JSON Response', + response.status, + `Response is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + response.url + ); } - return await response.json(); }; const { diff --git a/src/hooks/useStacApi.ts b/src/hooks/useStacApi.ts index 19414af..d357d7b 100644 --- a/src/hooks/useStacApi.ts +++ b/src/hooks/useStacApi.ts @@ -22,7 +22,14 @@ function useStacApi(url: string, options?: GenericObject): StacApiHook { }, }); const baseUrl = response.url; - const json = await response.json(); + let json; + try { + json = await response.json(); + } catch (error) { + throw new Error( + `Invalid JSON response from STAC API: ${error instanceof Error ? error.message : String(error)}` + ); + } const doesPost = json.links?.find( ({ rel, method }: Link) => rel === 'search' && method === 'POST' ); diff --git a/src/hooks/useStacSearch.ts b/src/hooks/useStacSearch.ts index 5caa1d2..cf6f6bf 100644 --- a/src/hooks/useStacSearch.ts +++ b/src/hooks/useStacSearch.ts @@ -125,9 +125,18 @@ function useStacSearch(): StacSearchHook { detail = await response.text(); } - throw new ApiError(response.statusText, response.status, detail); + throw new ApiError(response.statusText, response.status, detail, response.url); + } + try { + return await response.json(); + } catch (error) { + throw new ApiError( + 'Invalid JSON Response', + response.status, + `Response is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + response.url + ); } - return await response.json(); }; /** diff --git a/src/types/index.d.ts b/src/types/index.d.ts index a12b52f..b22b497 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -6,4 +6,5 @@ export type ApiErrorType = { detail?: GenericObject | string; status: number; statusText: string; + url?: string; }; diff --git a/src/utils/ApiError.ts b/src/utils/ApiError.ts index 6bea0bf..e5e5ef6 100644 --- a/src/utils/ApiError.ts +++ b/src/utils/ApiError.ts @@ -8,13 +8,15 @@ export class ApiError extends Error { status: number; statusText: string; detail?: GenericObject | string; + url?: string; - constructor(statusText: string, status: number, detail?: GenericObject | string) { + constructor(statusText: string, status: number, detail?: GenericObject | string, url?: string) { super(statusText); this.name = 'ApiError'; this.status = status; this.statusText = statusText; this.detail = detail; + this.url = url; // Maintains proper stack trace for where our error was thrown // Note: Error.captureStackTrace is a V8-only feature (Node.js, Chrome) From 9cb92fa90c9ece4ee1564f25bcb61e069ac3d311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alice=20R=C3=BChl?= Date: Fri, 28 Nov 2025 14:28:42 +0100 Subject: [PATCH 29/29] fix: prevent memory leak in debounced submit function - Add cancel method to debounce utility function with proper TypeScript typing - Add cleanup effect in useStacSearch to cancel pending debounced calls - Prevent orphaned timeouts when component unmounts or _submit function changes Previously, when _submit changed, a new debounced function was created but the old one's timeout wasn't cancelled, causing potential memory leaks and race conditions where stale searches could execute after newer ones. --- src/hooks/useStacSearch.ts | 9 ++++++++- src/utils/debounce.ts | 17 +++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/hooks/useStacSearch.ts b/src/hooks/useStacSearch.ts index cf6f6bf..05c1bbd 100644 --- a/src/hooks/useStacSearch.ts +++ b/src/hooks/useStacSearch.ts @@ -209,7 +209,14 @@ function useStacSearch(): StacSearchHook { setCurrentRequest({ type: 'search', payload }); }, [getSearchPayload]); - const submit = useMemo(() => debounce(_submit), [_submit]); + const submit = useMemo(() => debounce(_submit, 300), [_submit]); + + // Clean up debounced function on unmount or when _submit changes + useEffect(() => { + return () => { + submit.cancel(); + }; + }, [submit]); return { submit, diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts index e4cdbfc..b8c32bc 100644 --- a/src/utils/debounce.ts +++ b/src/utils/debounce.ts @@ -1,11 +1,24 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -const debounce = any>(fn: F, ms = 250) => { + +type DebouncedFunction any> = T & { + cancel: () => void; +}; + +const debounce = any>(fn: F, ms = 250): DebouncedFunction => { let timeoutId: ReturnType; - return function (this: any, ...args: any[]) { + const debouncedFn = function (this: any, ...args: any[]) { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn.apply(this, args), ms); + } as F; + + // Add cancel method to clear pending timeouts + (debouncedFn as DebouncedFunction).cancel = () => { + clearTimeout(timeoutId); }; + + return debouncedFn as DebouncedFunction; }; export default debounce; +export type { DebouncedFunction };