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 ( - - - ) + + ); } 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/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. diff --git a/docs/react-query-setup.md b/docs/react-query-setup.md new file mode 100644 index 0000000..21a5c7f --- /dev/null +++ b/docs/react-query-setup.md @@ -0,0 +1,52 @@ +# 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. + +## 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/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/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 ? ( + + ) : ( + + )} =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..cc8eeaf 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,23 @@ export function StacApiProvider({ children, apiUrl, options }: StacApiProviderTy [addItem, collections, deleteItem, getItem, stacApi] ); - return {children}; + 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; + } } 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..4011e0a 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 + 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(); + if (!stacApi) { + setState('IDLE'); + } else if (isLoading || isFetching) { + setState('LOADING'); + } else { + setState('IDLE'); } - }, [getCollections, stacApi, collections, error]); + }, [stacApi, isLoading, isFetching]); return { collections, - reload: getCollections, + reload, state, - error, + error: error as ApiError, }; } 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, }; } 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/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; 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"