diff --git a/documentation/docs/core/cache.mdx b/documentation/docs/core/cache.mdx index a5be52e9..f606e0f6 100644 --- a/documentation/docs/core/cache.mdx +++ b/documentation/docs/core/cache.mdx @@ -90,6 +90,8 @@ const client = createClient({ - **Event-driven**: Emits events on cache changes for real-time updates. - **Persistence**: Easily enable persistent cache across sessions. - **Fine-grained control**: Read, write, update, delete, and invalidate cache entries with simple methods. +- **Structural sharing**: New data is merged with the previous entry - deeply equal parts keep their object references, + so an identical response keeps the same `data` reference. Disable it with `request.setStructuralSharing(false)`. - **Integration**: Works seamlessly with the Client and Request systems. --- diff --git a/documentation/docs/core/dispatcher.mdx b/documentation/docs/core/dispatcher.mdx index 296ccab4..ce3a2b75 100644 --- a/documentation/docs/core/dispatcher.mdx +++ b/documentation/docs/core/dispatcher.mdx @@ -504,4 +504,18 @@ const queueMonitor = new Plugin({ name: "queue-monitor" }) client.addPlugin(queueMonitor); ``` +To observe the requests that are currently **in flight**, subscribe to the running requests events on the dispatcher. +They fire whenever a request starts or stops running - finished, canceled or cleared: + +```ts +const unmount = client.fetchDispatcher.events.onRunningRequestsChange(({ queryKey, runningRequests }) => { + console.log(`${queryKey} has ${runningRequests.length} running requests`); +}); + +// Or for a single queue +client.fetchDispatcher.events.onRunningRequestsChangeByKey(getUsers.queryKey, console.log); +``` + +In React, the [`useIsFetching` and `useIsSubmitting`](/docs/react/hooks/use-is-fetching) hooks build on these events. + --- diff --git a/documentation/docs/core/request.mdx b/documentation/docs/core/request.mdx index 5f6b99d4..d642fb08 100644 --- a/documentation/docs/core/request.mdx +++ b/documentation/docs/core/request.mdx @@ -394,6 +394,7 @@ When creating a request, the following defaults are applied unless overridden: | `offline` | `true` | Whether requests are stored when offline | | `deduplicate` | `false` | Whether identical in-flight requests are deduplicated | | `deduplicateTime` | `null` | Custom deduplication window (uses request duration when `null`) | +| `structuralSharing` | `true` | Keep references of the unchanged parts of the data when saving responses to cache | --- diff --git a/documentation/docs/react/04-hooks/use-cache.mdx b/documentation/docs/react/04-hooks/use-cache.mdx index 92956c60..50130c60 100644 --- a/documentation/docs/react/04-hooks/use-cache.mdx +++ b/documentation/docs/react/04-hooks/use-cache.mdx @@ -132,6 +132,25 @@ function UsersCache() { --- +## Selecting Data + +Use the `select` option to subscribe to a part of the cached data. The hook re-renders only when the selected value +changes, and the result keeps its reference while it stays deeply equal (structural sharing). See +[useFetch - Selecting Data](/docs/react/hooks/use-fetch#selecting-data) for details. + +```tsx title="Cache selector" +function TodosCounter() { + // Re-renders only when the number of done todos changes + const { data: doneCount } = useCache(getTodos, { + select: (todos) => todos.filter((todo) => todo.done).length, + }); + + return {doneCount}; +} +``` + +--- + ## Options Configuration options for this hook provided as a second parameter. diff --git a/documentation/docs/react/04-hooks/use-fetch.mdx b/documentation/docs/react/04-hooks/use-fetch.mdx index f930df93..d18b85e3 100644 --- a/documentation/docs/react/04-hooks/use-fetch.mdx +++ b/documentation/docs/react/04-hooks/use-fetch.mdx @@ -245,13 +245,54 @@ function App() { :::info React 18+ Required -Suspense mode requires React 18 or later. You can combine it with `` components to handle error states -declaratively. +Suspense mode requires React 18 or later. You can combine it with `` components and the +[`throwOnError`](#error-boundaries) option to handle error states declaratively. ::: --- +## Error Boundaries + +Set `throwOnError: true` to throw the request error to the closest React +[Error Boundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) instead of +returning it in the `error` field. The error is thrown only once the request settles - attempts that will be retried, +canceled requests and offline (re-queued) requests never reach the boundary. + +```tsx title="Throwing errors to the boundary" +import { ErrorBoundary } from "react-error-boundary"; +import { useFetch } from "@hyper-fetch/react"; +import { getUsers } from "./api/users"; + +function UsersList() { + // highlight-next-line + const { data } = useFetch(getUsers, { suspense: true, throwOnError: true }); + + return ; +} + +function App() { + return ( + }> + Loading users...

}> + +
+
+ ); +} +``` + +Errors are not stored in the cache, so resetting the boundary remounts the component and fetches the data again. Pass a +function to decide per error - for example handle validation errors in the component and throw all the others: + +```tsx +const { data, error } = useFetch(getUsers, { + throwOnError: (error) => error.code !== "VALIDATION_ERROR", +}); +``` + +--- + ## Keep Previous Data By default, when the cache key changes (e.g. switching from one resource to another), `useFetch` clears the previous @@ -313,6 +354,62 @@ are used. This can be useful for debugging or when you need to ensure the compon --- +## Selecting Data + +Many components often need only a small part of the same response. The `select` option picks (or transforms) the data +the component uses, while the request and its cache stay shared between all the hooks. The component re-renders only +when **the selected value** changes - updates of the other parts of the response are skipped. + +```tsx title="Subscribing to a part of the response" +const useSymbol = (id: string) => { + // highlight-next-line + return useFetch(getSymbols, { select: (symbols) => symbols[id] }); +}; + +const useSymbolPrice = (id: string) => { + // highlight-next-line + return useFetch(getSymbols, { select: (symbols) => symbols[id]?.price }); +}; + +function Price({ id }: { id: string }) { + // Re-renders only when this symbol's price changes, even though all symbols are fetched and cached together + const { data: price } = useSymbolPrice(id); + return {price}; +} +``` + +- The selector receives the response data and runs only when there is data - `data` stays `null` otherwise. +- The selector can close over props. When it changes between renders (e.g. a new `id`), it is applied to the current + data right away, without waiting for a new response. +- Inline selectors are fine - the result is [structurally shared](#structural-sharing), so a recreated but equal object + keeps its previous reference. +- `data` and `setData` use the selected type. Event handlers like `onSuccess` and the cache keep the full response. +- An error thrown by the selector is passed to the closest Error Boundary. + +--- + +## Structural Sharing + +Every response saved to the cache is merged with the previous one: all the parts that are deeply equal keep their +previous object references. When the same data comes back from a refetch, `data` keeps the exact same reference, and +when only a part changed, the untouched branches are reused. Thanks to this `useMemo`, `useEffect` and `React.memo` +dependencies stay stable without any extra work. The same is applied to the `select` results inside the hook. + +```tsx +const { data } = useFetch(getUsers); + +useEffect(() => { + // Runs only when the users data really changed - not on every refetch + syncUsers(data); +}, [data]); +``` + +Structural sharing works on plain objects and arrays - class instances, dates, maps etc. are compared by reference. You +can disable it per request with `request.setStructuralSharing(false)` (or the `structuralSharing` request option), and +for the selected data with the `structuralSharing: false` hook option. + +--- + ## Clear State The `clearState` method resets all hook state (`data`, `error`, `status`, `extra`, timestamps) back to initial values. @@ -378,6 +475,9 @@ const { ... } = useFetch(request, { | `bounceType` | `"debounce"` | Bounce strategy: `"debounce"` or `"throttle"` | | `bounceTime` | `400` | Bounce delay in ms | | `deepCompare` | `true` | Deep compare options for memoization | +| `select` | `undefined` | Pick or transform the part of the data used by the component | +| `structuralSharing` | `true` | Keep references of the unchanged parts of the selected data | +| `throwOnError` | `false` | Throw settled request errors to the closest Error Boundary | :::info Refetch restarts the refresh timer diff --git a/documentation/docs/react/04-hooks/use-is-fetching.mdx b/documentation/docs/react/04-hooks/use-is-fetching.mdx new file mode 100644 index 00000000..c6248030 --- /dev/null +++ b/documentation/docs/react/04-hooks/use-is-fetching.mdx @@ -0,0 +1,65 @@ +--- +sidebar_position: 8 +title: React hooks - useIsFetching +sidebar_label: useIsFetching +--- + +# useIsFetching + +[Read the API Reference ยป](/api/react/Hooks/useIsFetching.mdx) + +**`useIsFetching`** returns the number of requests currently running in the fetch dispatcher of a +[`Client`](/core/client.mdx) - the ones triggered by `useFetch`, `useCache` or `client.fetchDispatcher`. +**`useIsSubmitting`** does the same for the submit dispatcher (`useSubmit`). + +--- + +:::tip Purpose + +1. Global loading and saving indicators +2. Counting the in-flight requests of a part of the app + +::: + +--- + +## Usage + +```tsx title="Global loading indicator" +import { useIsFetching, useIsSubmitting } from "@hyper-fetch/react"; +import { client } from "./api/client"; + +function GlobalSpinner() { + const fetching = useIsFetching(client); + const submitting = useIsSubmitting(client); + + if (!fetching && !submitting) return null; + return ; +} +``` + +--- + +## Filtering + +Count only the requests you are interested in with the `queryKey` (exact string or RegExp) or a `predicate`. + +```tsx +// Requests of one query +const usersFetching = useIsFetching(client, { queryKey: getUsers.queryKey }); + +// Requests matching a pattern +const adminFetching = useIsFetching(client, { queryKey: /admin/ }); + +// Any custom condition +const uploading = useIsSubmitting(client, { predicate: (request) => request.endpoint.startsWith("/files") }); +``` + +The count includes only the requests that are being sent at the moment - queued requests waiting for their turn and +requests waiting for the network to come back are not counted. The hooks re-render only when the count changes. + +--- + +## Options + +(@import react UseIsFetchingOptionsType type=returns) diff --git a/documentation/docs/react/04-hooks/use-submit.mdx b/documentation/docs/react/04-hooks/use-submit.mdx index 0116bea6..16eef8b5 100644 --- a/documentation/docs/react/04-hooks/use-submit.mdx +++ b/documentation/docs/react/04-hooks/use-submit.mdx @@ -317,6 +317,19 @@ function CreateUserForm() { --- +## Selecting Data + +The `select` option picks the part of the response kept in the hook's `data`. The promise returned by `submit()` and +the event handlers like `onSubmitSuccess` still receive the full response. + +```tsx title="Submit selector" +const { submit, data: userName } = useSubmit(login, { + select: (response) => response.user.name, +}); +``` + +--- + ## Options Customize the behavior of `useSubmit` by passing an options object as the second argument. diff --git a/documentation/docs/react/05-sockets/use-listener.mdx b/documentation/docs/react/05-sockets/use-listener.mdx index 045b5300..40e3297d 100644 --- a/documentation/docs/react/05-sockets/use-listener.mdx +++ b/documentation/docs/react/05-sockets/use-listener.mdx @@ -200,6 +200,35 @@ define them at module level, or the hook will re-subscribe on every render. See --- +## Selecting Data + +When many components listen to the same topic but need only a part of each message, use the `select` option. The +`data` value updates only when the selected value changes - messages that don't affect it don't re-render the +component. The selected result is structurally shared, so an equal object recreated by the selector keeps its previous +reference. + +```tsx title="Subscribing to a single symbol" +const useSymbolPrice = (id: string) => { + // highlight-next-line + return useListener(onPrices, { select: (prices) => prices[id]?.price }); +}; + +function Price({ id }: { id: string }) { + // Re-renders only when this symbol's price changes + const { data: price } = useSymbolPrice(id); + return {price}; +} +``` + +- `onEvent` still receives the full message. +- Without `select`, every message updates `data`, even when it is equal to the previous one - messages are events. +- The selector can close over props - when it changes (e.g. a new `id`), it is applied to the last message right away. +- An error thrown by the selector is passed to the closest Error Boundary. +- Read only the fields you render - the hook re-renders only for the accessed values (`dependencyTracking`, enabled by + default), so reading `timestamp` or `extra` makes the component re-render on every message again. + +--- + ## Options Configuration options for `useListener` must be provided as the second parameter. diff --git a/packages/core/__tests__/features/cache/cache.base.spec.ts b/packages/core/__tests__/features/cache/cache.base.spec.ts index 933802ae..56604c4b 100644 --- a/packages/core/__tests__/features/cache/cache.base.spec.ts +++ b/packages/core/__tests__/features/cache/cache.base.spec.ts @@ -202,6 +202,54 @@ describe("Cache [ Base ]", () => { }); }); + describe("When structural sharing is used", () => { + it("should keep the data reference for deeply equal responses", async () => { + const spy = vi.fn(); + cache.events.onDataByKey(request.cacheKey, spy); + + cache.set(request, { ...response, ...details, data: { list: [{ id: 1 }], meta: { page: 1 } } }); + const first = cache.get(request.cacheKey)?.data; + cache.set(request, { ...response, ...details, data: { list: [{ id: 1 }], meta: { page: 1 } } }); + const second = cache.get(request.cacheKey)?.data; + + expect(second).toBe(first); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[1][0].data).toBe(first); + }); + + it("should reuse unchanged parts of the data", async () => { + cache.set(request, { ...response, ...details, data: { list: [{ id: 1 }], meta: { page: 1 } } }); + const first = cache.get(request.cacheKey)?.data; + cache.set(request, { ...response, ...details, data: { list: [{ id: 1 }], meta: { page: 2 } } }); + const second = cache.get(request.cacheKey)?.data; + + expect(second).not.toBe(first); + expect(second.list).toBe(first.list); + expect(second.meta).toStrictEqual({ page: 2 }); + }); + + it("should share references for cache updates", async () => { + cache.set(request, { ...response, ...details, data: { list: [{ id: 1 }], meta: { page: 1 } } }); + const first = cache.get(request.cacheKey)?.data; + cache.update(request, (prev) => ({ ...prev, data: { ...prev?.data, meta: { page: 3 } } })); + const second = cache.get(request.cacheKey)?.data; + + expect(second.list).toBe(first.list); + expect(second.meta).toStrictEqual({ page: 3 }); + }); + + it("should allow to disable structural sharing", async () => { + const disabledRequest = request.setStructuralSharing(false); + const data = { list: [{ id: 1 }] }; + const newData = { list: [{ id: 1 }] }; + + cache.set(disabledRequest, { ...response, ...details, data }); + cache.set(disabledRequest, { ...response, ...details, data: newData }); + + expect(cache.get(request.cacheKey)?.data).toBe(newData); + }); + }); + describe("When CacheStore gets cleared before triggering cache actions", () => { it("should return undefined when removed cache entity", async () => { const trigger = vi.fn(); diff --git a/packages/core/__tests__/features/cache/cache.replace-equal-deep.spec.ts b/packages/core/__tests__/features/cache/cache.replace-equal-deep.spec.ts new file mode 100644 index 00000000..07ffdbea --- /dev/null +++ b/packages/core/__tests__/features/cache/cache.replace-equal-deep.spec.ts @@ -0,0 +1,125 @@ +import { isPlainObject, replaceEqualDeep } from "cache"; + +class Model { + value = 1; +} + +describe("replaceEqualDeep [ Utils ]", () => { + describe("When values are equal", () => { + it("should return previous reference for deeply equal objects", () => { + const prev = { a: 1, b: { c: [1, 2, { d: "x" }] } }; + const next = { a: 1, b: { c: [1, 2, { d: "x" }] } }; + + expect(replaceEqualDeep(prev, next)).toBe(prev); + }); + + it("should return previous reference for deeply equal arrays", () => { + const prev = [{ id: 1 }, { id: 2 }]; + const next = [{ id: 1 }, { id: 2 }]; + + expect(replaceEqualDeep(prev, next)).toBe(prev); + }); + + it("should handle primitives and NaN", () => { + expect(replaceEqualDeep(1, 1)).toBe(1); + expect(replaceEqualDeep("a", "b")).toBe("b"); + expect(replaceEqualDeep(NaN, NaN)).toBeNaN(); + expect(replaceEqualDeep(null, { a: 1 })).toStrictEqual({ a: 1 }); + expect(replaceEqualDeep({ a: 1 }, null)).toBeNull(); + }); + + it("should treat empty containers as equal", () => { + const prevObject = {}; + const prevArray: unknown[] = []; + + expect(replaceEqualDeep(prevObject, {})).toBe(prevObject); + expect(replaceEqualDeep(prevArray, [])).toBe(prevArray); + }); + }); + + describe("When values differ", () => { + it("should reuse unchanged branches of objects", () => { + const prev = { unchanged: { deep: [1, 2] }, changed: { value: 1 } }; + const next = { unchanged: { deep: [1, 2] }, changed: { value: 2 } }; + + const result = replaceEqualDeep(prev, next); + + expect(result).not.toBe(prev); + expect(result).toStrictEqual(next); + expect(result.unchanged).toBe(prev.unchanged); + expect(result.changed).not.toBe(prev.changed); + }); + + it("should reuse unchanged array items", () => { + const prev = [{ id: 1 }, { id: 2 }]; + const next = [{ id: 1 }, { id: 3 }, { id: 4 }]; + + const result = replaceEqualDeep(prev, next); + + expect(result).toStrictEqual(next); + expect(result[0]).toBe(prev[0]); + expect(result[1]).not.toBe(prev[1]); + }); + + it("should detect removed array items and object keys", () => { + const prevArray = [1, 2, 3]; + const prevObject = { a: 1, b: 2 }; + + expect(replaceEqualDeep(prevArray, [1, 2])).toStrictEqual([1, 2]); + expect(replaceEqualDeep(prevArray, [1, 2])).not.toBe(prevArray); + expect(replaceEqualDeep(prevObject, { a: 1 })).toStrictEqual({ a: 1 }); + expect(replaceEqualDeep(prevObject, { a: 1 })).not.toBe(prevObject); + }); + + it("should not treat a missing key as equal to an undefined value", () => { + const prev: Record = { a: 1, b: 2 }; + const next: Record = { a: 1, c: undefined }; + + const result = replaceEqualDeep(prev, next); + + expect(result).not.toBe(prev); + expect(Object.keys(result)).toStrictEqual(["a", "c"]); + }); + + it("should not mix arrays with objects", () => { + const prev = { 0: "a" }; + const next = ["a"]; + + expect(replaceEqualDeep(prev, next)).toBe(next); + }); + + it("should replace non plain objects", () => { + const prevDate = new Date(1000); + const nextDate = new Date(1000); + const nextModel = new Model(); + const nextMap = new Map([["a", 1]]); + + expect(replaceEqualDeep(prevDate, nextDate)).toBe(nextDate); + expect(replaceEqualDeep(new Model(), nextModel)).toBe(nextModel); + expect(replaceEqualDeep(new Map([["a", 1]]), nextMap)).toBe(nextMap); + }); + + it("should keep shared references inside non plain parents", () => { + const shared = { a: 1 }; + const prev = { list: [shared], date: new Date(1) }; + const next = { list: [{ a: 1 }], date: new Date(1) }; + + const result = replaceEqualDeep(prev, next); + + expect(result).not.toBe(prev); + expect(result.list).toBe(prev.list); + expect(result.date).toBe(next.date); + }); + }); + + describe("When checking plain objects", () => { + it("should recognize plain objects only", () => { + expect(isPlainObject({})).toBeTrue(); + expect(isPlainObject(Object.create(null))).toBeTrue(); + expect(isPlainObject([])).toBeFalse(); + expect(isPlainObject(new Date())).toBeFalse(); + expect(isPlainObject(null)).toBeFalse(); + expect(isPlainObject(new Model())).toBeFalse(); + }); + }); +}); diff --git a/packages/core/__tests__/features/dispatcher/dispatcher.events.spec.ts b/packages/core/__tests__/features/dispatcher/dispatcher.events.spec.ts index afd1cf17..b0714a00 100644 --- a/packages/core/__tests__/features/dispatcher/dispatcher.events.spec.ts +++ b/packages/core/__tests__/features/dispatcher/dispatcher.events.spec.ts @@ -306,4 +306,56 @@ describe("Dispatcher [ Events ]", () => { expect(spy).toHaveBeenCalledTimes(1); }); }); + + describe("When running requests change", () => { + it("should emit running requests change when request starts and finishes", async () => { + const spy = vi.fn(); + const keySpy = vi.fn(); + const unmount = dispatcher.events.onRunningRequestsChange(spy); + const unmountKey = dispatcher.events.onRunningRequestsChangeByKey(request.queryKey, keySpy); + + dispatcher.add(request); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].queryKey).toBe(request.queryKey); + expect(spy.mock.calls[0][0].runningRequests).toHaveLength(1); + + await waitFor(() => { + expect(spy).toHaveBeenCalledTimes(2); + }); + expect(spy.mock.calls[1][0].runningRequests).toHaveLength(0); + expect(keySpy).toHaveBeenCalledTimes(2); + + unmount(); + unmountKey(); + }); + + it("should emit running requests change on cancel and clear", async () => { + const spy = vi.fn(); + const unmount = dispatcher.events.onRunningRequestsChange(spy); + const otherRequest = request.setQueryKey("other"); + mockRequest(request, { delay: 100 }); + mockRequest(otherRequest, { delay: 100 }); + + const requestId = dispatcher.add(request); + dispatcher.add(otherRequest); + expect(dispatcher.getAllRunningRequests()).toHaveLength(2); + expect(spy).toHaveBeenCalledTimes(2); + + dispatcher.cancelRunningRequest(request.queryKey, requestId); + expect(spy).toHaveBeenCalledTimes(3); + expect(spy.mock.calls[2][0].runningRequests).toHaveLength(0); + + dispatcher.clear(); + expect(dispatcher.getAllRunningRequests()).toHaveLength(0); + expect(spy.mock.calls.at(-1)?.[0]).toStrictEqual({ queryKey: otherRequest.queryKey, runningRequests: [] }); + + // No change - no event + const calls = spy.mock.calls.length; + dispatcher.deleteRunningRequests(request.queryKey); + expect(spy).toHaveBeenCalledTimes(calls); + + unmount(); + }); + }); }); diff --git a/packages/core/__tests__/features/request/request.setters.spec.ts b/packages/core/__tests__/features/request/request.setters.spec.ts index bed6eced..55ac5847 100644 --- a/packages/core/__tests__/features/request/request.setters.spec.ts +++ b/packages/core/__tests__/features/request/request.setters.spec.ts @@ -115,6 +115,13 @@ describe("Request [ Setters ]", () => { const updatedRequest = request.setDeduplicateTime(1000); expect(updatedRequest.deduplicateTime).toBe(1000); }); + it("should allow for setting structural sharing", async () => { + expect(request.structuralSharing).toBeTrue(); + const updatedRequest = request.setStructuralSharing(false); + expect(updatedRequest.structuralSharing).toBeFalse(); + expect(updatedRequest.clone().structuralSharing).toBeFalse(); + expect(updatedRequest.toJSON().structuralSharing).toBeFalse(); + }); it("should allow for setting used", async () => { expect(request.used).toBeFalse(); const updatedRequest = request.setUsed(true); diff --git a/packages/core/src/cache/cache.ts b/packages/core/src/cache/cache.ts index f70aa390..351eaede 100644 --- a/packages/core/src/cache/cache.ts +++ b/packages/core/src/cache/cache.ts @@ -7,7 +7,7 @@ import type { CacheSetState, RequestCacheType, } from "cache"; -import { getCacheData, getCacheEvents } from "cache"; +import { getCacheData, getCacheEvents, replaceEqualDeep } from "cache"; import type { ClientInstance } from "client"; import type { ResponseDetailsType, LoggerMethods } from "managers"; import type { RequestInstance } from "request"; @@ -86,6 +86,11 @@ export class Cache { const processedResponse = typeof response === "function" ? response(previousCacheData || null) : response; const data = getCacheData(previousCacheData, processedResponse); + // Keep references of the unchanged data parts, so deeply equal responses don't produce new objects + if (request.structuralSharing !== false && previousCacheData) { + data.data = replaceEqualDeep(previousCacheData.data, data.data); + } + const newCacheData: CacheValueType> = { ...data, scope, diff --git a/packages/core/src/cache/cache.types.ts b/packages/core/src/cache/cache.types.ts index c2903955..3803dfc6 100644 --- a/packages/core/src/cache/cache.types.ts +++ b/packages/core/src/cache/cache.types.ts @@ -68,4 +68,4 @@ export type CacheSetState = CacheData | ((previousData: CacheData | n export type RequestCacheType = Pick< R, "cacheKey" | "cache" | "staleTime" | "cacheTime" | "scope" ->; +> & { structuralSharing?: boolean }; diff --git a/packages/core/src/cache/cache.utils.ts b/packages/core/src/cache/cache.utils.ts index eaa59d0d..de357135 100644 --- a/packages/core/src/cache/cache.utils.ts +++ b/packages/core/src/cache/cache.utils.ts @@ -32,3 +32,59 @@ export const getCacheByKey = (key: string): string => { export const getCacheKey = (): string => { return `cache`; }; + +/** + * Check if value is a plain object (object literal or `Object.create(null)`). + * Class instances, Dates, Maps, Blobs etc. are not plain objects. + */ +export const isPlainObject = (value: unknown): value is Record => { + if (Object.prototype.toString.call(value) !== "[object Object]") { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === null || prototype === Object.prototype; +}; + +const isPlainArray = (value: unknown): value is unknown[] => { + return Array.isArray(value) && Object.keys(value).length === value.length; +}; + +/** + * Structural sharing - returns `next` with every part that is deeply equal to `prev` replaced by the `prev` reference. + * When both values are deeply equal the `prev` reference is returned, so consumers can rely on `===` checks. + * Only plain objects and arrays are traversed, other values are compared with `Object.is`. + * @param prev previous value + * @param next incoming value + * @returns `next` sharing unchanged references with `prev` + */ +export const replaceEqualDeep = (prev: unknown, next: T): T => { + if (Object.is(prev, next)) { + return prev as T; + } + + const isArray = isPlainArray(prev) && isPlainArray(next); + if (!isArray && !(isPlainObject(prev) && isPlainObject(next))) { + return next; + } + + const prevItems = prev as Record; + const nextItems = next as Record; + const prevKeys = Object.keys(prevItems); + const nextKeys = Object.keys(nextItems); + const copy: Record = isArray ? ([] as unknown as Record) : {}; + + let equalItems = 0; + nextKeys.forEach((key) => { + const prevValue = prevItems[key]; + const nextValue = nextItems[key]; + const value = replaceEqualDeep(prevValue, nextValue); + copy[key] = value; + // undefined values are equal only when the key existed before + // eslint-disable-next-line prefer-object-has-own -- Object.hasOwn is not available in older runtimes + if (value === prevValue && (value !== undefined || Object.prototype.hasOwnProperty.call(prevItems, key))) { + equalItems += 1; + } + }); + + return (prevKeys.length === nextKeys.length && equalItems === prevKeys.length ? prev : copy) as T; +}; diff --git a/packages/core/src/dispatcher/dispatcher.events.ts b/packages/core/src/dispatcher/dispatcher.events.ts index 1722ad07..0a637d80 100644 --- a/packages/core/src/dispatcher/dispatcher.events.ts +++ b/packages/core/src/dispatcher/dispatcher.events.ts @@ -1,4 +1,4 @@ -import type { QueueDataType } from "dispatcher"; +import type { QueueDataType, RunningRequestsChangeType } from "dispatcher"; import { getDispatcherChangeKey, getDispatcherChangeByKey, @@ -6,6 +6,8 @@ import { getDispatcherStatusByKey, getDispatcherDrainedKey, getDispatcherDrainedByKey, + getDispatcherRunningKey, + getDispatcherRunningByKey, } from "dispatcher"; import type EventEmitter from "events"; import type { RequestInstance } from "request"; @@ -32,6 +34,10 @@ export const getDispatcherEvents = (emitter: EventEmitter) => ({ emitter.emit(getDispatcherChangeKey(), values, isTriggeredExternally); emitter.emit(getDispatcherChangeByKey(values.queryKey), values, isTriggeredExternally); }, + emitRunningRequestsChanged: (values: RunningRequestsChangeType, isTriggeredExternally?: boolean): void => { + emitter.emit(getDispatcherRunningKey(), values, isTriggeredExternally); + emitter.emit(getDispatcherRunningByKey(values.queryKey), values, isTriggeredExternally); + }, /** * When queue becomes empty * @param callback @@ -103,4 +109,26 @@ export const getDispatcherEvents = (emitter: EventEmitter) => ({ emitter.on(getDispatcherChangeByKey(queryKey), callback); return () => emitter.removeListener(getDispatcherChangeByKey(queryKey), callback); }, + /** + * When requests start or stop running (sent, finished, canceled or cleared) + * @param callback + * @returns + */ + onRunningRequestsChange: (callback: (values: RunningRequestsChangeType) => void): VoidFunction => { + emitter.on(getDispatcherRunningKey(), callback); + return () => emitter.removeListener(getDispatcherRunningKey(), callback); + }, + /** + * When requests start or stop running (sent, finished, canceled or cleared) for given queryKey + * @param queryKey + * @param callback + * @returns + */ + onRunningRequestsChangeByKey: ( + queryKey: string, + callback: (values: RunningRequestsChangeType) => void, + ): VoidFunction => { + emitter.on(getDispatcherRunningByKey(queryKey), callback); + return () => emitter.removeListener(getDispatcherRunningByKey(queryKey), callback); + }, }); diff --git a/packages/core/src/dispatcher/dispatcher.ts b/packages/core/src/dispatcher/dispatcher.ts index 0dab0dc9..251ae43e 100644 --- a/packages/core/src/dispatcher/dispatcher.ts +++ b/packages/core/src/dispatcher/dispatcher.ts @@ -259,6 +259,7 @@ export class Dispatcher { const keys = this.getQueuesKeys(); keys.forEach((queryKey) => this.cancelRunningRequests(queryKey)); + [...this.runningRequests.keys()].forEach((queryKey) => this.setRunningRequests(queryKey, [])); this.runningRequests.clear(); this.storage.clear(); this.client.triggerPlugins("onDispatcherCleared", { dispatcher: this }); @@ -327,14 +328,26 @@ export class Dispatcher { return runningRequests.find((req) => req.requestId === requestId); }; + /** + * Single place for changing the running requests - notifies about the change + */ + private setRunningRequests = (queryKey: string, runningRequests: RunningRequestValueType[]) => { + const previousRequests = this.getRunningRequests(queryKey); + this.runningRequests.set(queryKey, runningRequests); + if ( + previousRequests.length !== runningRequests.length || + previousRequests.some((req, i) => req !== runningRequests[i]) + ) { + this.events.emitRunningRequestsChanged({ queryKey, runningRequests }); + } + }; + /** * Add request to the running requests list */ addRunningRequest = (queryKey: string, requestId: string, request: RequestInstance): RunningRequestValueType => { const newRunningRequest = { requestId, request, timestamp: Date.now() }; - const runningRequests = this.getRunningRequests(queryKey); - runningRequests.push(newRunningRequest); - this.runningRequests.set(queryKey, runningRequests); + this.setRunningRequests(queryKey, [...this.getRunningRequests(queryKey), newRunningRequest]); return newRunningRequest; }; @@ -376,14 +389,14 @@ export class Dispatcher { return true; }); - this.runningRequests.set(queryKey, requests); + this.setRunningRequests(queryKey, requests); }; /** * Delete all started requests, but do NOT clear it from queue and do NOT cancel them */ deleteRunningRequests = (queryKey: string) => { - this.runningRequests.set(queryKey, []); + this.setRunningRequests(queryKey, []); }; /** @@ -391,7 +404,7 @@ export class Dispatcher { */ deleteRunningRequest = (queryKey: string, requestId: string) => { const runningRequests = this.getRunningRequests(queryKey); - this.runningRequests.set( + this.setRunningRequests( queryKey, runningRequests.filter((req) => req.requestId !== requestId), ); diff --git a/packages/core/src/dispatcher/dispatcher.types.ts b/packages/core/src/dispatcher/dispatcher.types.ts index 51e8644e..f5db2078 100644 --- a/packages/core/src/dispatcher/dispatcher.types.ts +++ b/packages/core/src/dispatcher/dispatcher.types.ts @@ -54,3 +54,8 @@ export type RunningRequestValueType = { request: RequestInstance; timestamp: number; }; + +export type RunningRequestsChangeType = { + queryKey: string; + runningRequests: RunningRequestValueType[]; +}; diff --git a/packages/core/src/dispatcher/dispatcher.utils.ts b/packages/core/src/dispatcher/dispatcher.utils.ts index ea55f143..320a48ae 100644 --- a/packages/core/src/dispatcher/dispatcher.utils.ts +++ b/packages/core/src/dispatcher/dispatcher.utils.ts @@ -22,6 +22,12 @@ export const getDispatcherChangeKey = (): string => { export const getDispatcherChangeByKey = (key: string): string => { return `${key}-change-event`; }; +export const getDispatcherRunningKey = (): string => { + return `running-event`; +}; +export const getDispatcherRunningByKey = (key: string): string => { + return `${key}-running-event`; +}; // Requesting diff --git a/packages/core/src/request/request.ts b/packages/core/src/request/request.ts index d8ea4f37..2305b076 100644 --- a/packages/core/src/request/request.ts +++ b/packages/core/src/request/request.ts @@ -98,6 +98,7 @@ export class Request< used: boolean; deduplicate: boolean; deduplicateTime: number | null; + structuralSharing: boolean; scope: string | null; /** @@ -190,6 +191,7 @@ export class Request< queryKey, deduplicate = false, deduplicateTime = null, + structuralSharing = true, } = configuration; this.endpoint = initialRequestConfiguration?.endpoint ?? endpoint; this.headers = initialRequestConfiguration?.headers ?? headers; @@ -213,6 +215,7 @@ export class Request< this.used = initialRequestConfiguration?.used ?? false; this.deduplicate = initialRequestConfiguration?.deduplicate ?? deduplicate; this.deduplicateTime = initialRequestConfiguration?.deduplicateTime ?? deduplicateTime; + this.structuralSharing = initialRequestConfiguration?.structuralSharing ?? structuralSharing; this.scope = initialRequestConfiguration?.scope ?? null; this.updatedAbortKey = initialRequestConfiguration?.updatedAbortKey ?? false; this.updatedCacheKey = initialRequestConfiguration?.updatedCacheKey ?? false; @@ -365,6 +368,11 @@ export class Request< return this.clone({ deduplicateTime }); }; + /** Enable or disable structural sharing - keeping references of unchanged parts of the response data in cache. */ + public setStructuralSharing = (structuralSharing: boolean) => { + return this.clone({ structuralSharing }); + }; + /** Mark the request as used (prevents automatic refetching in hooks). */ public setUsed = (used: boolean) => { return this.clone({ used }); @@ -506,6 +514,7 @@ export class Request< updatedQueryKey: this.updatedQueryKey, deduplicate: this.deduplicate, deduplicateTime: this.deduplicateTime, + structuralSharing: this.structuralSharing, scope: this.scope, isMockerEnabled: this.isMockerEnabled, hasMock: !!this.unstable_mock, diff --git a/packages/core/src/request/request.types.ts b/packages/core/src/request/request.types.ts index 1752cd11..55ac78ba 100644 --- a/packages/core/src/request/request.types.ts +++ b/packages/core/src/request/request.types.ts @@ -198,6 +198,7 @@ export type RequestJSON = { updatedQueryKey: boolean; deduplicate: boolean; deduplicateTime: number | null; + structuralSharing: boolean; scope: string | null; isMockerEnabled: boolean; hasMock: boolean; @@ -290,6 +291,11 @@ export type RequestOptionsType = (payload: Payload) => NewDataType; diff --git a/packages/core/src/sdk/sdk.ts b/packages/core/src/sdk/sdk.ts index 8e5acf5a..3a0ae0f5 100644 --- a/packages/core/src/sdk/sdk.ts +++ b/packages/core/src/sdk/sdk.ts @@ -64,6 +64,7 @@ export type SdkRequestDefaults = { offline?: boolean; deduplicate?: boolean; deduplicateTime?: number | null; + structuralSharing?: boolean; }; /** @@ -175,6 +176,9 @@ const applyObjectDefaults = (request: RequestInstance, config: SdkRequestDefault if (config.deduplicateTime !== undefined && config.deduplicateTime !== null) { result = result.setDeduplicateTime(config.deduplicateTime); } + if (config.structuralSharing !== undefined) { + result = result.setStructuralSharing(config.structuralSharing); + } return result; }; diff --git a/packages/react/__tests__/features/helpers/use-request-events/use-request-events.base.spec.ts b/packages/react/__tests__/features/helpers/use-request-events/use-request-events.base.spec.ts index 3f01a60d..9663130b 100644 --- a/packages/react/__tests__/features/helpers/use-request-events/use-request-events.base.spec.ts +++ b/packages/react/__tests__/features/helpers/use-request-events/use-request-events.base.spec.ts @@ -45,12 +45,12 @@ describe("useRequestEvents [ Base ]", () => { }); it("should not change loading state during cache processing", async () => { + const spy = vi.fn(); const { result, rerender } = renderUseRequestEvents(request, { + actions: { setLoading: spy }, getIsDataProcessing: () => false, }); - const spy = vi.spyOn(request.client.fetchDispatcher, "hasRunningRequests"); - act(() => { result.current[1].addCacheDataListener(request); request.client.requestManager.events.emitLoading({ diff --git a/packages/react/__tests__/features/strict-mode/strict-mode.requests.spec.ts b/packages/react/__tests__/features/strict-mode/strict-mode.requests.spec.ts index d5575c96..49ea1597 100644 --- a/packages/react/__tests__/features/strict-mode/strict-mode.requests.spec.ts +++ b/packages/react/__tests__/features/strict-mode/strict-mode.requests.spec.ts @@ -1,6 +1,9 @@ import { createHttpMockingServer } from "@hyper-fetch/testing"; import { act, renderHook, waitFor } from "@testing-library/react"; import { useAppManager } from "hooks/use-app-manager"; +import { useCache } from "hooks/use-cache"; +import { useFetch } from "hooks/use-fetch"; +import { useIsFetching } from "hooks/use-is-fetching"; import { useSubmit } from "hooks/use-submit"; import { StrictMode } from "react"; @@ -107,4 +110,46 @@ describe("Requests hooks [ StrictMode ]", () => { }); }); }); + + describe("given selectors are used in StrictMode", () => { + it("should select the fetched data and follow cache updates", async () => { + const getRequest = createRequest<{ a: number; b: number }>(); + mockRequest(getRequest, { data: { a: 1, b: 1 } }); + const { result } = renderHook( + () => ({ + fetched: useFetch(getRequest, { select: (value) => value.a }), + cached: useCache(getRequest, { select: (value) => value.b }), + }), + { wrapper: StrictMode }, + ); + + await waitFor(() => { + expect(result.current.fetched.data).toBe(1); + expect(result.current.cached.data).toBe(1); + }); + + act(() => { + client.cache.update(getRequest, (prev) => ({ ...prev, data: { a: 2, b: 3 } })); + }); + + expect(result.current.fetched.data).toBe(2); + expect(result.current.cached.data).toBe(3); + }); + it("should count the running requests", async () => { + const getRequest = createRequest(); + mockRequest(getRequest, { delay: 20 }); + const { result } = renderHook(() => useIsFetching(client), { wrapper: StrictMode }); + + act(() => { + client.fetchDispatcher.add(getRequest); + }); + + await waitFor(() => { + expect(result.current).toBe(1); + }); + await waitFor(() => { + expect(result.current).toBe(0); + }); + }); + }); }); diff --git a/packages/react/__tests__/features/strict-mode/strict-mode.sockets.spec.ts b/packages/react/__tests__/features/strict-mode/strict-mode.sockets.spec.ts index 01a9b504..f3cb91f5 100644 --- a/packages/react/__tests__/features/strict-mode/strict-mode.sockets.spec.ts +++ b/packages/react/__tests__/features/strict-mode/strict-mode.sockets.spec.ts @@ -242,6 +242,27 @@ describe("Sockets hooks [ StrictMode ]", () => { expect(spy).not.toHaveBeenCalled(); }); + it("should select the message data", async () => { + const listener = createListener(); + const view = renderHook(() => useListener(listener, { select: (message) => message.name }), { + wrapper: StrictMode, + }); + act(() => { + view.result.current.onEvent(spy); + }); + await waitFor(() => { + expect(view.result.current.connected).toBeTrue(); + }); + // Read data like a rendering component would - dependency tracking subscribes to the accessed values + expect(view.result.current.data).toBeNull(); + + emitListenerEvent(listener, { name: "Maciej", age: 99 }); + + await waitFor(() => { + expect(view.result.current.data).toBe("Maciej"); + }); + expect(spy).toHaveBeenCalledTimes(1); + }); }); describe("given useEmitter is rendered in StrictMode", () => { diff --git a/packages/react/__tests__/features/use-cache/use-cache.select.spec.tsx b/packages/react/__tests__/features/use-cache/use-cache.select.spec.tsx new file mode 100644 index 00000000..935292dc --- /dev/null +++ b/packages/react/__tests__/features/use-cache/use-cache.select.spec.tsx @@ -0,0 +1,117 @@ +import { act, render, screen } from "@testing-library/react"; +import { useCache } from "hooks/use-cache"; + +import { client, createRequest } from "../../utils"; + +type Todos = { items: { id: number; done: boolean }[]; total: number }; + +describe("useCache [ Select ]", () => { + let request = createRequest(); + + const setCacheData = (data: Todos) => { + act(() => { + client.cache.set(request, { + data, + error: null, + status: 200, + success: true, + extra: { headers: {} }, + retries: 0, + isCanceled: false, + isOffline: false, + willRetry: false, + addedTimestamp: Date.now(), + triggerTimestamp: Date.now(), + requestTimestamp: Date.now(), + responseTimestamp: Date.now(), + }); + }); + }; + + beforeEach(() => { + client.clear(); + request = createRequest(); + }); + + it("should return the selected cache data", () => { + setCacheData({ items: [{ id: 1, done: false }], total: 1 }); + + function Page() { + const { data } = useCache(request, { select: (value) => value.total }); + return
{JSON.stringify(data)}
; + } + + render(); + + expect(screen.getByTestId("result").textContent).toBe("1"); + }); + + it("should rerender only when the selected value changes", () => { + setCacheData({ items: [{ id: 1, done: false }], total: 1 }); + let renders = 0; + + function Page() { + const { data } = useCache(request, { select: (value) => value.items.filter((item) => item.done) }); + renders += 1; + return
{JSON.stringify(data)}
; + } + + render(); + expect(renders).toBe(1); + + // New undone item - the filtered result is still an empty list + setCacheData({ + items: [ + { id: 1, done: false }, + { id: 2, done: false }, + ], + total: 2, + }); + expect(renders).toBe(1); + + setCacheData({ + items: [ + { id: 1, done: true }, + { id: 2, done: false }, + ], + total: 2, + }); + expect(renders).toBe(2); + expect(screen.getByTestId("result").textContent).toBe(JSON.stringify([{ id: 1, done: true }])); + }); + + it("should return null without cache data", () => { + const selectSpy = vi.fn((value: Todos) => value.total); + + function Page() { + const { data } = useCache(request, { select: selectSpy }); + return
{JSON.stringify(data)}
; + } + + render(); + + expect(screen.getByTestId("result").textContent).toBe("null"); + expect(selectSpy).not.toHaveBeenCalled(); + }); + + it("should reset the selected data on clear", () => { + setCacheData({ items: [], total: 3 }); + let clearState: (() => void) | null = null; + + function Page() { + const view = useCache(request, { select: (value) => value.total }); + ({ clearState } = view); + return
{JSON.stringify(view.data)}
; + } + + const view = render(); + expect(screen.getByTestId("result").textContent).toBe("3"); + + act(() => { + clearState?.(); + }); + view.rerender(); + + expect(screen.getByTestId("result").textContent).toBe("null"); + }); +}); diff --git a/packages/react/__tests__/features/use-cache/use-cache.typescript.spec.ts b/packages/react/__tests__/features/use-cache/use-cache.typescript.spec.ts index 01b4fc57..0cabc8f8 100644 --- a/packages/react/__tests__/features/use-cache/use-cache.typescript.spec.ts +++ b/packages/react/__tests__/features/use-cache/use-cache.typescript.spec.ts @@ -1,6 +1,7 @@ import { Client } from "@hyper-fetch/core"; import { expectType, expectNotType, expectFunctionParametersType } from "@hyper-fetch/testing"; import type { UseCacheReturnType } from "hooks/use-cache"; +import { useCache } from "hooks/use-cache"; const client = new Client({ url: "http://localhost:3000", @@ -107,4 +108,13 @@ describe("useCache [Types]", () => { expectFunctionParametersType(invalidate).assert([/users/]); }); }); + + describe("select", () => { + it("should infer the selected data type", () => { + const useSelected = () => useCache(getUsers, { select: (users) => users.length }); + const data = null as unknown as ReturnType["data"]; + expectType().assert(data); + expectNotType<{ id: string; name: string }[] | null>().assert(data); + }); + }); }); diff --git a/packages/react/__tests__/features/use-fetch/use-fetch.refresh.spec.ts b/packages/react/__tests__/features/use-fetch/use-fetch.refresh.spec.ts index e1fd1bde..f29b0dd8 100644 --- a/packages/react/__tests__/features/use-fetch/use-fetch.refresh.spec.ts +++ b/packages/react/__tests__/features/use-fetch/use-fetch.refresh.spec.ts @@ -1,5 +1,5 @@ import { createHttpMockingServer } from "@hyper-fetch/testing"; -import { act } from "@testing-library/react"; +import { act, waitFor } from "@testing-library/react"; import { client, createRequest, renderUseFetch, waitForRender } from "../../utils"; @@ -44,11 +44,13 @@ describe("useFetch [ Refreshing ]", () => { result.current.onRequestStart(spy); }); - await waitForRender(); - expect(spy).toHaveBeenCalledTimes(1); - await waitForRender(hookOptions.refreshTime * 1.5); - - expect(spy).toHaveBeenCalledTimes(2); + // Wait for the events instead of fixed sleeps - the timings stretch on a busy machine + await waitFor(() => { + expect(spy).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(spy).toHaveBeenCalledTimes(2); + }); }); it("should refresh blurred tab", async () => { const spy = vi.fn(); @@ -60,9 +62,9 @@ describe("useFetch [ Refreshing ]", () => { client.appManager.setFocused(false); }); - await waitForRender(); - await waitForRender(hookOptions.refreshTime * 1.5); - expect(spy).toHaveBeenCalledTimes(2); + await waitFor(() => { + expect(spy).toHaveBeenCalledTimes(2); + }); }); it("should not refresh blurred tab", async () => { const spy = vi.fn(); @@ -93,11 +95,12 @@ describe("useFetch [ Refreshing ]", () => { result.current.onRequestStart(spy); }); - await waitForRender(); - expect(spy).toHaveBeenCalledTimes(1); - await waitForRender(hookOptions.refreshTime * 1.5); - - expect(spy).toHaveBeenCalledTimes(2); + await waitFor(() => { + expect(spy).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(spy).toHaveBeenCalledTimes(2); + }); act(() => { rerender({ refresh: false }); diff --git a/packages/react/__tests__/features/use-fetch/use-fetch.select.spec.tsx b/packages/react/__tests__/features/use-fetch/use-fetch.select.spec.tsx new file mode 100644 index 00000000..94cac79a --- /dev/null +++ b/packages/react/__tests__/features/use-fetch/use-fetch.select.spec.tsx @@ -0,0 +1,420 @@ +import type { ExtractResponseType, RequestInstance } from "@hyper-fetch/core"; +import { createHttpMockingServer } from "@hyper-fetch/testing"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { useFetch } from "hooks/use-fetch"; +import type { ReactNode } from "react"; +import { Component, Suspense, useEffect } from "react"; + +import { client, createRequest } from "../../utils"; + +const { resetMocks, startServer, stopServer, mockRequest } = createHttpMockingServer(); + +type Symbols = Record; + +class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + render() { + const { error } = this.state; + const { children } = this.props; + if (error) { + return
{error.message}
; + } + return children; + } +} + +describe("useFetch [ Select ]", () => { + const symbols: Symbols = { + btc: { price: 1, name: "Bitcoin" }, + eth: { price: 2, name: "Ethereum" }, + }; + + let request = createRequest(); + + const setCacheData = (req: RequestInstance, data: ExtractResponseType) => { + act(() => { + client.cache.set(req, { + data, + error: null, + status: 200, + success: true, + extra: {}, + retries: 0, + isCanceled: false, + isOffline: false, + willRetry: false, + addedTimestamp: Date.now(), + triggerTimestamp: Date.now(), + requestTimestamp: Date.now(), + responseTimestamp: Date.now(), + }); + }); + }; + + const waitForData = async (testId = "result") => { + await waitFor(() => { + expect(screen.getByTestId(testId).textContent).not.toBe("null"); + }); + }; + + beforeAll(() => { + startServer(); + }); + + afterEach(() => { + resetMocks(); + }); + + afterAll(() => { + stopServer(); + }); + + beforeEach(() => { + vi.resetModules(); + client.clear(); + request = createRequest(); + }); + + describe("when selecting part of the response", () => { + it("should return the selected data", async () => { + mockRequest(request, { data: symbols }); + + function Page() { + const { data } = useFetch(request, { select: (value) => value.btc }); + return
{JSON.stringify(data)}
; + } + + render(); + + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe(JSON.stringify(symbols.btc)); + }); + }); + + it("should share one request between hooks selecting different parts", async () => { + mockRequest(request, { data: symbols }); + const responseSpy = vi.fn(); + const unmount = client.requestManager.events.onResponse(responseSpy); + + function Price({ id }: { id: string }) { + const { data } = useFetch(request, { select: (value) => value[id].price }); + return
{JSON.stringify(data)}
; + } + + render( + <> + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("btc").textContent).toBe("1"); + expect(screen.getByTestId("eth").textContent).toBe("2"); + }); + expect(responseSpy).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("should not rerender when the unselected part of the data changes", async () => { + mockRequest(request, { data: symbols }); + let renders = 0; + + function Page() { + const { data } = useFetch(request, { select: (value) => value.btc }); + renders += 1; + return
{JSON.stringify(data)}
; + } + + render(); + await waitForData(); + const rendersAfterLoad = renders; + + setCacheData(request, { ...symbols, eth: { price: 999, name: "Ethereum" } }); + setCacheData(request, { ...symbols, eth: { price: 1000, name: "Ethereum" } }); + + expect(renders).toBe(rendersAfterLoad); + + setCacheData(request, { ...symbols, btc: { price: 5, name: "Bitcoin" } }); + + expect(renders).toBe(rendersAfterLoad + 1); + expect(screen.getByTestId("result").textContent).toBe(JSON.stringify({ price: 5, name: "Bitcoin" })); + }); + + it("should keep the reference of a recreated selected object when it is deeply equal", async () => { + mockRequest(request, { data: symbols }); + const references = new Set(); + const effectSpy = vi.fn(); + + function Page() { + // Inline selector creating a new object on every call + const { data, loading } = useFetch(request, { select: (value) => ({ price: value.btc.price }) }); + if (data) { + references.add(data); + } + useEffect(() => { + effectSpy(); + }, [data]); + return
{loading ? "loading" : JSON.stringify(data)}
; + } + + render(); + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe(JSON.stringify({ price: 1 })); + }); + const effectsAfterLoad = effectSpy.mock.calls.length; + + setCacheData(request, { ...symbols, eth: { price: 3, name: "Ethereum" } }); + setCacheData(request, { btc: { price: 1, name: "Renamed" }, eth: { price: 3, name: "Ethereum" } }); + + expect(references.size).toBe(1); + expect(effectSpy).toHaveBeenCalledTimes(effectsAfterLoad); + }); + + it("should re-run the selector when it changes between renders", async () => { + mockRequest(request, { data: symbols }); + + function Page({ id }: { id: string }) { + const { data } = useFetch(request, { select: (value) => value[id].name }); + return
{JSON.stringify(data)}
; + } + + const view = render(); + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe(JSON.stringify("Bitcoin")); + }); + + view.rerender(); + + // Same render - without waiting for any new data + expect(screen.getByTestId("result").textContent).toBe(JSON.stringify("Ethereum")); + }); + + it("should apply the selector to the cached data on mount", async () => { + setCacheData(request, symbols); + + function Page() { + const { data } = useFetch(request, { select: (value) => value.eth.price, revalidate: false }); + return
{JSON.stringify(data)}
; + } + + render(); + + expect(screen.getByTestId("result").textContent).toBe("2"); + }); + + it("should apply the selector to the initial response", async () => { + mockRequest(request, { data: symbols, delay: 50 }); + + function Page() { + const { data } = useFetch(request, { + select: (value) => value.btc.name, + initialResponse: { data: { btc: { price: 0, name: "Initial" } } }, + }); + return
{JSON.stringify(data)}
; + } + + render(); + + expect(screen.getByTestId("result").textContent).toBe(JSON.stringify("Initial")); + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe(JSON.stringify("Bitcoin")); + }); + }); + + it("should keep the data set locally with setData while selector is recreated on every render", async () => { + mockRequest(request, { data: symbols }); + let setData: ((value: number) => void) | null = null; + + function Page() { + const view = useFetch(request, { select: (value) => value.btc.price }); + setData = view.setData; + return
{JSON.stringify(view.data)}
; + } + + render(); + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe("1"); + }); + + act(() => { + setData?.(42); + }); + + expect(screen.getByTestId("result").textContent).toBe("42"); + + // Next response replaces the local data + setCacheData(request, { ...symbols, btc: { price: 7, name: "Bitcoin" } }); + expect(screen.getByTestId("result").textContent).toBe("7"); + }); + + it("should not call the selector for empty data", async () => { + mockRequest(request, { status: 400, delay: 10 }); + const selectSpy = vi.fn((value: Symbols) => value.btc); + + function Page() { + const { data, error } = useFetch(request, { select: selectSpy }); + return
{error ? "error" : JSON.stringify(data)}
; + } + + render(); + + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe("error"); + }); + expect(selectSpy).not.toHaveBeenCalled(); + }); + + it("should pass the selector error to the error boundary", async () => { + mockRequest(request, { data: symbols }); + vi.spyOn(console, "error").mockImplementation(() => null); + + function Page() { + const { data } = useFetch(request, { + select: (value) => { + if (value.btc.price > 1) { + throw new Error("Selector failed"); + } + return value.btc; + }, + }); + return
{JSON.stringify(data)}
; + } + + render( + + + , + ); + await waitForData(); + + setCacheData(request, { ...symbols, btc: { price: 2, name: "Bitcoin" } }); + + await waitFor(() => { + expect(screen.getByTestId("error-boundary").textContent).toBe("Selector failed"); + }); + }); + + it("should apply the selector in suspense mode", async () => { + mockRequest(request, { data: symbols }); + + function Page() { + const { data } = useFetch(request, { suspense: true, select: (value) => value.eth.name }); + return
{JSON.stringify(data)}
; + } + + render( + }> + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe(JSON.stringify("Ethereum")); + }); + }); + }); + + describe("when using structural sharing", () => { + it("should keep the data reference when the same data is received again", async () => { + mockRequest(request, { data: symbols }); + const effectSpy = vi.fn(); + let refetch: (() => void) | null = null; + + function Page() { + const view = useFetch(request); + refetch = view.refetch; + useEffect(() => { + effectSpy(view.data); + }, [view.data]); + return
{view.loading ? "loading" : JSON.stringify(view.data)}
; + } + + render(); + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe(JSON.stringify(symbols)); + }); + const firstData = effectSpy.mock.lastCall?.[0]; + + act(() => { + refetch?.(); + }); + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe(JSON.stringify(symbols)); + }); + + expect(effectSpy.mock.lastCall?.[0]).toBe(firstData); + // null -> data, never a second time for the equal response + expect(effectSpy.mock.calls.filter(([value]) => value !== null)).toHaveLength(1); + }); + + it("should reuse unchanged parts of the data", async () => { + setCacheData(request, symbols); + let current: Symbols | null = null; + + function Page() { + const { data } = useFetch(request, { revalidate: false }); + current = data; + return
{JSON.stringify(data)}
; + } + + render(); + const first = current as unknown as Symbols; + + setCacheData(request, { btc: { price: 1, name: "Bitcoin" }, eth: { price: 5, name: "Ethereum" } }); + const second = current as unknown as Symbols; + + expect(second).not.toBe(first); + expect(second.btc).toBe(first.btc); + expect(second.eth).toStrictEqual({ price: 5, name: "Ethereum" }); + }); + + it("should allow to disable structural sharing of the selected data", async () => { + setCacheData(request, symbols); + let current: unknown = null; + + function Page() { + const { data } = useFetch(request, { + revalidate: false, + structuralSharing: false, + deepCompare: false, + select: (value) => ({ price: value.btc.price }), + }); + current = data; + return
{JSON.stringify(data)}
; + } + + render(); + const first = current; + setCacheData(request, { ...symbols, eth: { price: 3, name: "Ethereum" } }); + + expect(current).not.toBe(first); + expect(current).toStrictEqual(first); + }); + + it("should give new references when sharing is disabled everywhere", async () => { + const unsharedRequest = request.setStructuralSharing(false); + setCacheData(unsharedRequest, symbols); + const references = new Set(); + + function Page() { + const { data } = useFetch(unsharedRequest, { revalidate: false, deepCompare: false }); + references.add(data); + return
{JSON.stringify(data)}
; + } + + render(); + setCacheData(unsharedRequest, { ...symbols }); + + expect(references.size).toBe(2); + }); + }); +}); diff --git a/packages/react/__tests__/features/use-fetch/use-fetch.throw-on-error.spec.tsx b/packages/react/__tests__/features/use-fetch/use-fetch.throw-on-error.spec.tsx new file mode 100644 index 00000000..65923844 --- /dev/null +++ b/packages/react/__tests__/features/use-fetch/use-fetch.throw-on-error.spec.tsx @@ -0,0 +1,213 @@ +import { createHttpMockingServer } from "@hyper-fetch/testing"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { useFetch } from "hooks/use-fetch"; +import type { ReactNode } from "react"; +import { Component, Suspense } from "react"; + +import { client, createRequest } from "../../utils"; + +const { resetMocks, startServer, stopServer, mockRequest } = createHttpMockingServer(); + +class ErrorBoundary extends Component<{ children: ReactNode }, { error: unknown }> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error: unknown) { + return { error }; + } + + handleReset = () => { + // eslint-disable-next-line react/no-set-state + this.setState({ error: null }); + }; + + render() { + const { error } = this.state; + const { children } = this.props; + if (error) { + return ( + + ); + } + return children; + } +} + +describe("useFetch [ Throw On Error ]", () => { + let request = createRequest(); + + beforeAll(() => { + startServer(); + }); + + afterEach(() => { + resetMocks(); + }); + + afterAll(() => { + stopServer(); + }); + + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + // React logs the errors caught by boundaries + vi.spyOn(console, "error").mockImplementation(() => null); + client.clear(); + request = createRequest(); + }); + + function Page({ throwOnError }: { throwOnError: boolean | ((error: any) => boolean) }) { + // Component does not read the error - the hook still has to throw it + const { data, loading } = useFetch(request, { throwOnError }); + return
{loading ? "loading" : JSON.stringify(data)}
; + } + + it("should throw the error to the error boundary", async () => { + const error = mockRequest(request, { status: 400 }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("error-boundary").textContent).toBe(JSON.stringify(error)); + }); + }); + + it("should not throw when the request succeeds", async () => { + const data = mockRequest(request, { data: { value: 1 } }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe(JSON.stringify(data)); + }); + expect(screen.queryByTestId("error-boundary")).toBeNull(); + }); + + it("should keep the error in the state when disabled", async () => { + mockRequest(request, { status: 400 }); + + function ErrorPage() { + const { error, loading } = useFetch(request); + const status = error ? "error" : "none"; + return
{loading ? "loading" : status}
; + } + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe("error"); + }); + expect(screen.queryByTestId("error-boundary")).toBeNull(); + }); + + it("should decide with the predicate", async () => { + mockRequest(request, { status: 400 }); + const predicate = vi.fn(() => false); + + render( + + + , + ); + + await waitFor(() => { + expect(predicate).toHaveBeenCalled(); + }); + expect(screen.queryByTestId("error-boundary")).toBeNull(); + expect(predicate.mock.calls[0]).toHaveLength(1); + }); + + it("should throw when the predicate allows it", async () => { + mockRequest(request, { status: 400 }); + + render( + + !!error} /> + , + ); + + await waitFor(() => { + expect(screen.getByTestId("error-boundary")).toBeInTheDocument(); + }); + }); + + it("should throw only after the retries are finished", async () => { + request = createRequest().setRetry(2).setRetryTime(10); + mockRequest(request, { status: 500 }); + const responseSpy = vi.fn(); + const unmount = client.requestManager.events.onResponse(responseSpy); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("error-boundary")).toBeInTheDocument(); + }); + expect(responseSpy).toHaveBeenCalledTimes(3); + unmount(); + }); + + it("should fetch again after the error boundary reset", async () => { + mockRequest(request, { status: 400 }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("error-boundary")).toBeInTheDocument(); + }); + + const data = mockRequest(request, { data: { recovered: true } }); + act(() => { + screen.getByTestId("error-boundary").click(); + }); + + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe(JSON.stringify(data)); + }); + }); + + it("should throw in suspense mode", async () => { + const error = mockRequest(request, { status: 400 }); + + function SuspensePage() { + const { data } = useFetch(request, { suspense: true, throwOnError: true }); + return
{JSON.stringify(data)}
; + } + + render( + + }> + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("error-boundary").textContent).toBe(JSON.stringify(error)); + }); + }); +}); diff --git a/packages/react/__tests__/features/use-fetch/use-fetch.typescript.spec.ts b/packages/react/__tests__/features/use-fetch/use-fetch.typescript.spec.ts index c7523ca4..2fa2ba46 100644 --- a/packages/react/__tests__/features/use-fetch/use-fetch.typescript.spec.ts +++ b/packages/react/__tests__/features/use-fetch/use-fetch.typescript.spec.ts @@ -1,6 +1,7 @@ import { Client } from "@hyper-fetch/core"; import { expectType, expectNotType, expectFunctionParametersType } from "@hyper-fetch/testing"; import type { UseFetchReturnType } from "hooks/use-fetch"; +import { useFetch } from "hooks/use-fetch"; const client = new Client({ url: "http://localhost:3000", @@ -178,4 +179,44 @@ describe("useFetch [Types]", () => { expectType<() => void>().assert(bounce.reset); }); }); + + describe("select", () => { + it("should infer the selected data type", () => { + const useSelected = () => useFetch(getUsers, { select: (users) => users.map((user) => user.name) }); + const data = null as unknown as ReturnType["data"]; + const setData = null as unknown as ReturnType["setData"]; + expectType().assert(data); + expectNotType<{ id: string; name: string }[] | null>().assert(data); + expectFunctionParametersType(setData).assert([["name"]]); + }); + + it("should type selector argument as the response", () => { + const useSelected = () => + useFetch(getUser, { + select: (user) => { + expectType<{ id: string; name: string; email: string }>().assert(user); + return user.email; + }, + }); + const data = null as unknown as ReturnType["data"]; + expectType().assert(data); + }); + + it("should keep the response type without selector", () => { + const useDefault = () => useFetch(getUsers, { structuralSharing: false }); + const data = null as unknown as ReturnType["data"]; + expectType<{ id: string; name: string }[] | null>().assert(data); + }); + + it("should type throwOnError predicate with the request error", () => { + const useThrowing = () => + useFetch(getUserWithError, { + throwOnError: (error) => { + expectType<{ code: number; message: string; details: string[] } | Error>().assert(error); + return true; + }, + }); + expectType<() => unknown>().assert(useThrowing); + }); + }); }); diff --git a/packages/react/__tests__/features/use-is-fetching/use-is-fetching.base.spec.tsx b/packages/react/__tests__/features/use-is-fetching/use-is-fetching.base.spec.tsx new file mode 100644 index 00000000..08e66967 --- /dev/null +++ b/packages/react/__tests__/features/use-is-fetching/use-is-fetching.base.spec.tsx @@ -0,0 +1,117 @@ +import { createHttpMockingServer } from "@hyper-fetch/testing"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useIsFetching, useIsSubmitting } from "hooks/use-is-fetching"; + +import { client, createRequest } from "../../utils"; + +const { resetMocks, startServer, stopServer, mockRequest } = createHttpMockingServer(); + +describe("useIsFetching [ Base ]", () => { + beforeAll(() => { + startServer(); + }); + + afterEach(() => { + resetMocks(); + }); + + afterAll(() => { + stopServer(); + }); + + beforeEach(() => { + vi.resetModules(); + client.clear(); + }); + + it("should count the running fetch requests", async () => { + const usersRequest = createRequest({ endpoint: "/users" }); + const postsRequest = createRequest({ endpoint: "/posts" }); + mockRequest(usersRequest, { delay: 30 }); + mockRequest(postsRequest, { delay: 60 }); + + const { result } = renderHook(() => useIsFetching(client)); + expect(result.current).toBe(0); + + act(() => { + client.fetchDispatcher.add(usersRequest); + client.fetchDispatcher.add(postsRequest); + }); + + await waitFor(() => { + expect(result.current).toBe(2); + }); + await waitFor(() => { + expect(result.current).toBe(1); + }); + await waitFor(() => { + expect(result.current).toBe(0); + }); + }); + + it("should filter the requests by query key and predicate", async () => { + const usersRequest = createRequest({ endpoint: "/users" }); + const postsRequest = createRequest({ endpoint: "/posts" }); + mockRequest(usersRequest, { delay: 30 }); + mockRequest(postsRequest, { delay: 30 }); + + const { result } = renderHook(() => ({ + exact: useIsFetching(client, { queryKey: usersRequest.queryKey }), + regex: useIsFetching(client, { queryKey: /posts/ }), + predicate: useIsFetching(client, { predicate: (request) => request.endpoint === "/missing" }), + })); + + act(() => { + client.fetchDispatcher.add(usersRequest); + client.fetchDispatcher.add(postsRequest); + }); + + await waitFor(() => { + expect(result.current).toStrictEqual({ exact: 1, regex: 1, predicate: 0 }); + }); + await waitFor(() => { + expect(result.current).toStrictEqual({ exact: 0, regex: 0, predicate: 0 }); + }); + }); + + it("should count canceled requests out", async () => { + const request = createRequest(); + mockRequest(request, { delay: 1000 }); + + const { result } = renderHook(() => useIsFetching(client)); + + act(() => { + client.fetchDispatcher.add(request); + }); + await waitFor(() => { + expect(result.current).toBe(1); + }); + + act(() => { + client.fetchDispatcher.cancelRunningRequests(request.queryKey); + }); + + expect(result.current).toBe(0); + }); + + it("should count the running submit requests separately", async () => { + const request = createRequest({ method: "POST" }); + mockRequest(request, { delay: 30 }); + + const { result } = renderHook(() => ({ + fetching: useIsFetching(client), + submitting: useIsSubmitting(client), + })); + + act(() => { + client.submitDispatcher.add(request); + }); + + await waitFor(() => { + expect(result.current).toStrictEqual({ fetching: 0, submitting: 1 }); + }); + await waitFor(() => { + expect(result.current).toStrictEqual({ fetching: 0, submitting: 0 }); + }); + }); +}); diff --git a/packages/react/__tests__/features/use-listener/use-listener.select.spec.tsx b/packages/react/__tests__/features/use-listener/use-listener.select.spec.tsx new file mode 100644 index 00000000..0f18af05 --- /dev/null +++ b/packages/react/__tests__/features/use-listener/use-listener.select.spec.tsx @@ -0,0 +1,183 @@ +import { createWebsocketMockingServer, waitForConnection } from "@hyper-fetch/testing"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { useListener } from "hooks/use-listener"; +import type { ReactNode } from "react"; +import { Component, useEffect } from "react"; + +import { createListener } from "../../utils/listener.utils"; + +type Prices = Record; + +class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + render() { + const { error } = this.state; + const { children } = this.props; + return error ?
{error.message}
: children; + } +} + +describe("useListener [ Select ]", () => { + const { startServer, stopServer, emitListenerEvent } = createWebsocketMockingServer(); + let listener = createListener(); + + beforeEach(async () => { + startServer(); + listener = createListener(); + await waitForConnection(listener.socket); + vi.resetModules(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + stopServer(); + }); + + const emit = async (message: Prices, onEvent: ReturnType, count: number) => { + act(() => { + emitListenerEvent(listener, message); + }); + // Wait for the message delivery instead of rendering, which may not happen on purpose + await waitFor(() => { + expect(onEvent).toHaveBeenCalledTimes(count); + }); + }; + + it("should select the message data and rerender only when it changes", async () => { + const onEvent = vi.fn(); + let renders = 0; + + function Page() { + const view = useListener(listener, { select: (value) => value.btc.price }); + view.onEvent(onEvent); + renders += 1; + return
{JSON.stringify(view.data)}
; + } + + render(); + const initialRenders = renders; + + await emit({ btc: { price: 1 }, eth: { price: 1 } }, onEvent, 1); + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe("1"); + }); + const rendersAfterFirst = renders; + expect(rendersAfterFirst).toBe(initialRenders + 1); + + await emit({ btc: { price: 1 }, eth: { price: 2 } }, onEvent, 2); + await emit({ btc: { price: 1 }, eth: { price: 3 } }, onEvent, 3); + expect(renders).toBe(rendersAfterFirst); + + await emit({ btc: { price: 4 }, eth: { price: 3 } }, onEvent, 4); + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe("4"); + }); + expect(onEvent.mock.lastCall?.[0].data).toStrictEqual({ btc: { price: 4 }, eth: { price: 3 } }); + }); + + it("should keep the reference of deeply equal selected objects", async () => { + const onEvent = vi.fn(); + const effectSpy = vi.fn(); + + function Page() { + const view = useListener(listener, { select: (value) => ({ btc: value.btc }) }); + view.onEvent(onEvent); + useEffect(() => { + effectSpy(view.data); + }, [view.data]); + return
{JSON.stringify(view.data)}
; + } + + render(); + + await emit({ btc: { price: 1 }, eth: { price: 1 } }, onEvent, 1); + await emit({ btc: { price: 1 }, eth: { price: 2 } }, onEvent, 2); + + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe(JSON.stringify({ btc: { price: 1 } })); + }); + // initial null and first message only + expect(effectSpy).toHaveBeenCalledTimes(2); + }); + + it("should update data on every message without a selector", async () => { + const onEvent = vi.fn(); + const effectSpy = vi.fn(); + + function Page() { + const view = useListener(listener); + view.onEvent(onEvent); + useEffect(() => { + effectSpy(view.data); + }, [view.data]); + return
{JSON.stringify(view.data)}
; + } + + render(); + + await emit({ btc: { price: 1 } }, onEvent, 1); + await emit({ btc: { price: 1 } }, onEvent, 2); + + await waitFor(() => { + expect(effectSpy).toHaveBeenCalledTimes(3); + }); + }); + + it("should re-run the selector when it changes between renders", async () => { + const onEvent = vi.fn(); + + function Page({ id }: { id: string }) { + const view = useListener(listener, { select: (value) => value[id].price }); + view.onEvent(onEvent); + return
{JSON.stringify(view.data)}
; + } + + const view = render(); + await emit({ btc: { price: 1 }, eth: { price: 2 } }, onEvent, 1); + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe("1"); + }); + + view.rerender(); + + expect(screen.getByTestId("result").textContent).toBe("2"); + }); + + it("should pass the selector error to the error boundary", async () => { + vi.spyOn(console, "error").mockImplementation(() => null); + const onEvent = vi.fn(); + + function Page() { + const view = useListener(listener, { + select: (value) => { + if (!value.btc) { + throw new Error("Missing btc"); + } + return value.btc.price; + }, + }); + view.onEvent(onEvent); + return
{JSON.stringify(view.data)}
; + } + + render( + + + , + ); + + await emit({ eth: { price: 1 } }, onEvent, 1); + + await waitFor(() => { + expect(screen.getByTestId("error-boundary").textContent).toBe("Missing btc"); + }); + }); +}); diff --git a/packages/react/__tests__/features/use-listener/use-listener.typescript.spec.ts b/packages/react/__tests__/features/use-listener/use-listener.typescript.spec.ts new file mode 100644 index 00000000..973bd036 --- /dev/null +++ b/packages/react/__tests__/features/use-listener/use-listener.typescript.spec.ts @@ -0,0 +1,23 @@ +import { expectType, expectNotType } from "@hyper-fetch/testing"; +import { useListener } from "hooks/use-listener"; + +import { createListener } from "../../utils/listener.utils"; + +const listener = createListener<{ prices: Record; updatedAt: number }>(); + +describe("useListener [ TypeScript ]", () => { + describe("select", () => { + it("should infer the selected data type", () => { + const useSelected = () => useListener(listener, { select: (message) => message.prices.btc }); + const data = null as unknown as ReturnType["data"]; + expectType().assert(data); + expectNotType<{ prices: Record; updatedAt: number } | null>().assert(data); + }); + + it("should keep the message type without selector", () => { + const useDefault = () => useListener(listener); + const data = null as unknown as ReturnType["data"]; + expectType<{ prices: Record; updatedAt: number } | null>().assert(data); + }); + }); +}); diff --git a/packages/react/__tests__/features/use-submit/use-submit.select.spec.tsx b/packages/react/__tests__/features/use-submit/use-submit.select.spec.tsx new file mode 100644 index 00000000..283b5070 --- /dev/null +++ b/packages/react/__tests__/features/use-submit/use-submit.select.spec.tsx @@ -0,0 +1,55 @@ +import { createHttpMockingServer } from "@hyper-fetch/testing"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { useSubmit } from "hooks/use-submit"; + +import { client, createRequest } from "../../utils"; + +const { resetMocks, startServer, stopServer, mockRequest } = createHttpMockingServer(); + +describe("useSubmit [ Select ]", () => { + let request = createRequest<{ user: { id: number; name: string }; token: string }>({ method: "POST" }); + + beforeAll(() => { + startServer(); + }); + + afterEach(() => { + resetMocks(); + }); + + afterAll(() => { + stopServer(); + }); + + beforeEach(() => { + vi.resetModules(); + client.clear(); + request = createRequest<{ user: { id: number; name: string }; token: string }>({ method: "POST" }); + }); + + it("should select the response data while callbacks and submit get the full response", async () => { + const mock = mockRequest(request, { data: { user: { id: 1, name: "Maciej" }, token: "secret" } }); + const onSuccess = vi.fn(); + let submit: (() => Promise) | null = null; + + function Page() { + const view = useSubmit(request, { select: (value) => value.user.name }); + ({ submit } = view); + view.onSubmitSuccess(({ response }) => onSuccess(response.data)); + return
{JSON.stringify(view.data)}
; + } + + render(); + + let submitResponse: any; + await act(async () => { + submitResponse = await submit?.(); + }); + + await waitFor(() => { + expect(screen.getByTestId("result").textContent).toBe(JSON.stringify("Maciej")); + }); + expect(onSuccess).toHaveBeenCalledWith(mock); + expect(submitResponse.data).toStrictEqual(mock); + }); +}); diff --git a/packages/react/__tests__/features/use-submit/use-submit.typescript.spec.ts b/packages/react/__tests__/features/use-submit/use-submit.typescript.spec.ts index f6b45161..04fc385c 100644 --- a/packages/react/__tests__/features/use-submit/use-submit.typescript.spec.ts +++ b/packages/react/__tests__/features/use-submit/use-submit.typescript.spec.ts @@ -7,6 +7,7 @@ import { expectNotFunctionParametersType, } from "@hyper-fetch/testing"; import type { UseSubmitReturnType } from "hooks/use-submit"; +import { useSubmit } from "hooks/use-submit"; const client = new Client({ url: "http://localhost:3000", @@ -321,4 +322,17 @@ describe("useSubmit [Types]", () => { expectType<{ saved: { name?: string | undefined } }>().assert({} as Ctx); }); }); + + describe("select", () => { + it("should infer the selected data type", () => { + const request = client.createRequest<{ response: { id: string; token: string }; payload: { name: string } }>()({ + method: "POST", + endpoint: "/users", + }); + const useSelected = () => useSubmit(request, { select: (value) => value.token }); + const data = null as unknown as ReturnType["data"]; + expectType().assert(data); + expectNotType<{ id: string; token: string } | null>().assert(data); + }); + }); }); diff --git a/packages/react/__tests__/utils/use-fetch.utils.ts b/packages/react/__tests__/utils/use-fetch.utils.ts index 3500f845..ad235004 100644 --- a/packages/react/__tests__/utils/use-fetch.utils.ts +++ b/packages/react/__tests__/utils/use-fetch.utils.ts @@ -6,6 +6,9 @@ import { useFetch } from "hooks/use-fetch"; export const renderUseFetch = (request: T, options?: UseFetchOptionsType) => { return renderHook((rerenderOptions: UseFetchOptionsType & { request?: RequestInstance }) => { const { request: req, ...rest } = rerenderOptions || {}; - return useFetch(req || request, { dependencyTracking: false, ...options, ...rest } as UseFetchOptionsType); + return useFetch( + (req || request) as T, + { dependencyTracking: false, ...options, ...rest } as UseFetchOptionsType, + ); }); }; diff --git a/packages/react/src/helpers/use-request-events/use-request-events.types.ts b/packages/react/src/helpers/use-request-events/use-request-events.types.ts index 6d787cfa..2319768b 100644 --- a/packages/react/src/helpers/use-request-events/use-request-events.types.ts +++ b/packages/react/src/helpers/use-request-events/use-request-events.types.ts @@ -26,7 +26,7 @@ export type UseRequestEventsPropsType = { request: T; dispatcher: Dispatcher>; logger: LoggerMethods; - actions: UseTrackedStateActions; + actions: Pick, "setLoading">; setCacheData: (cacheData: CacheValueType, ExtractErrorType>) => void; getIsDataProcessing: (cacheKey: string) => boolean; }; diff --git a/packages/react/src/helpers/use-socket-state/use-socket-state.hooks.ts b/packages/react/src/helpers/use-socket-state/use-socket-state.hooks.ts index 7bef22c0..fce43cdc 100644 --- a/packages/react/src/helpers/use-socket-state/use-socket-state.hooks.ts +++ b/packages/react/src/helpers/use-socket-state/use-socket-state.hooks.ts @@ -185,5 +185,7 @@ export const useSocketState = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [socket]); - return [state.current, actions, callbacks, { setRenderKey }] as const; + const getState = () => state.current; + + return [state.current, actions, callbacks, { setRenderKey, getState, rerender: emitChange }] as const; }; diff --git a/packages/react/src/helpers/use-tracked-state/use-tracked-state.hooks.ts b/packages/react/src/helpers/use-tracked-state/use-tracked-state.hooks.ts index 54cd4df6..7ec8877f 100644 --- a/packages/react/src/helpers/use-tracked-state/use-tracked-state.hooks.ts +++ b/packages/react/src/helpers/use-tracked-state/use-tracked-state.hooks.ts @@ -9,7 +9,7 @@ import type { ExtractAdapterExtraType, ResponseType, } from "@hyper-fetch/core"; -import { scopeKey } from "@hyper-fetch/core"; +import { replaceEqualDeep, scopeKey } from "@hyper-fetch/core"; import { useCallback, useRef, useSyncExternalStore } from "react"; import { isEqual } from "utils"; @@ -18,6 +18,7 @@ import type { UseTrackedStateType, UseTrackedStateProps, UseTrackedStateReturn, + UseTrackedStateResponseDetails, } from "./use-tracked-state.types"; import { getInitialState, @@ -35,7 +36,7 @@ import { * @param dependencies * @internal */ -export const useTrackedState = ({ +export const useTrackedState = >({ request, dispatcher, initialResponse, @@ -44,15 +45,64 @@ export const useTrackedState = ({ keepPreviousData = "auto", disabled, revalidate, -}: UseTrackedStateProps): UseTrackedStateReturn => { + select, + structuralSharing = true, +}: UseTrackedStateProps): UseTrackedStateReturn => { const { client, cacheKey, queryKey, staleTime, unstable_responseMapper } = request; const { cache } = client; - const state = useRef>(getInitialState({ initialResponse, dispatcher, request, disabled })); - const renderKeys = useRef<(keyof UseTrackedStateType)[]>([]); + const state = useRef>(null as unknown as UseTrackedStateType); + const renderKeys = useRef<(keyof UseTrackedStateType)[]>([]); const isProcessingData = useRef(""); const previousCacheKey = useRef(cacheKey); + // ****************** + // Data selection + // ****************** + + const selectRef = useRef(select); + selectRef.current = select; + const appliedSelect = useRef(select); + // Response data before applying the selector - allows to re-run it when the selector changes + const rawData = useRef | null>(null); + // Data set with the setData action has the selected shape and should not get re-selected + const isLocalData = useRef(false); + const selectError = useRef<{ error: unknown } | null>(null); + const responseDetails = useRef(null); + + const selectData = (data: ExtractResponseType | null, previousData: TData | null = null): TData | null => { + if (!selectRef.current || data === null || data === undefined) { + return data as TData | null; + } + const selected = selectRef.current(data); + return structuralSharing ? replaceEqualDeep(previousData, selected) : selected; + }; + + const getSelectedState = (newState: UseTrackedStateType): UseTrackedStateType => { + rawData.current = newState.data; + isLocalData.current = false; + responseDetails.current = null; + appliedSelect.current = selectRef.current; + return { ...newState, data: selectData(newState.data, state.current?.data) }; + }; + + if (!state.current) { + state.current = getSelectedState(getInitialState({ initialResponse, dispatcher, request, disabled })); + } + + // Selector failed while processing incoming data - surface it to the closest error boundary + if (selectError.current) { + const { error } = selectError.current; + selectError.current = null; + throw error; + } + + // Selector changed (e.g. it closes over a new prop) - re-run it against the latest response data + if (appliedSelect.current !== select && !isLocalData.current) { + appliedSelect.current = select; + state.current.data = selectData(rawData.current, state.current.data); + } + // ****************** // useSyncExternalStore // ****************** @@ -89,12 +139,12 @@ export const useTrackedState = ({ // Dependency Tracking // ****************** - const renderKeyTrigger = (keys: (keyof UseTrackedStateType)[]) => { + const renderKeyTrigger = (keys: (keyof UseTrackedStateType)[]) => { const shouldRerender = renderKeys.current.some((renderKey) => keys.includes(renderKey)); if (shouldRerender) {emitChange();} }; - const setRenderKey = (renderKey: keyof UseTrackedStateType) => { + const setRenderKey = (renderKey: keyof UseTrackedStateType) => { if (!renderKeys.current.includes(renderKey)) { renderKeys.current.push(renderKey); } @@ -119,8 +169,8 @@ export const useTrackedState = ({ if (shouldClear && !cacheState) { // Reset state to initial values when no cached data exists for the new key const resetState = getInitialState({ initialResponse, dispatcher, request, disabled, revalidate }); - state.current = resetState; - renderKeyTrigger(Object.keys(state.current) as (keyof UseTrackedStateType)[]); + state.current = getSelectedState(resetState); + renderKeyTrigger(Object.keys(state.current) as (keyof UseTrackedStateType)[]); } else if (cacheState) { state.current.loading = getIsInitiallyLoading({ queryKey: scopeKey(request.queryKey, request.scope), @@ -162,6 +212,9 @@ export const useTrackedState = ({ // ****************** const handleCompare = (firstValue: unknown, secondValue: unknown) => { + if (Object.is(firstValue, secondValue)) { + return true; + } if (typeof deepCompare === "function") { return deepCompare(firstValue, secondValue); } @@ -174,8 +227,27 @@ export const useTrackedState = ({ const handleCacheData = ( cacheData: CacheValueType, ExtractErrorType, ExtractAdapterType>, ) => { - const newStateValues: UseTrackedStateType = { - data: cacheData.data, + rawData.current = cacheData.data; + isLocalData.current = false; + appliedSelect.current = selectRef.current; + responseDetails.current = { + willRetry: !!cacheData.willRetry, + isCanceled: !!cacheData.isCanceled, + isOffline: !!cacheData.isOffline, + }; + + let data: TData | null; + try { + data = selectData(cacheData.data, state.current.data); + } catch (error) { + // Never throw inside of the cache events - rethrow it on render instead + selectError.current = { error }; + emitChange(); + return; + } + + const newStateValues: UseTrackedStateType = { + data, error: cacheData.error, status: cacheData.status, success: cacheData.success, @@ -186,18 +258,17 @@ export const useTrackedState = ({ loading: dispatcher.hasRunningRequests(queryKey), }; - const changedKeys = Object.keys(newStateValues).filter((key) => { - const keyValue = key as keyof UseTrackedStateType; - const firstValue = state.current[keyValue]; - const secondValue = newStateValues[keyValue]; + const nextState: UseTrackedStateType = { ...state.current }; + const changedKeys = (Object.keys(newStateValues) as (keyof UseTrackedStateType)[]).filter((key) => { + const isEqualValue = handleCompare(state.current[key], newStateValues[key]); + // Equal values keep their previous reference, so they stay stable between renders + if (!isEqualValue) { + (nextState as Record)[key] = newStateValues[key]; + } + return !isEqualValue; + }); - return !handleCompare(firstValue, secondValue); - }) as unknown as (keyof UseTrackedStateType)[]; - - state.current = { - ...state.current, - ...newStateValues, - }; + state.current = nextState; renderKeyTrigger(changedKeys); }; @@ -253,8 +324,9 @@ export const useTrackedState = ({ // Actions // ****************** - const actions: UseTrackedStateActions = { + const actions: UseTrackedStateActions = { setData: (data) => { + isLocalData.current = true; state.current.data = data instanceof Function ? data(state.current.data || null) : data; renderKeyTrigger(["data"]); }, @@ -313,6 +385,9 @@ export const useTrackedState = ({ renderKeyTrigger(["requestTimestamp"]); }, clearState: () => { + rawData.current = null; + isLocalData.current = false; + responseDetails.current = null; state.current = { data: null, error: null, @@ -323,10 +398,21 @@ export const useTrackedState = ({ retries: 0, responseTimestamp: null, requestTimestamp: null, - } as UseTrackedStateType; - renderKeyTrigger(Object.keys(state.current) as (keyof UseTrackedStateType)[]); + } as UseTrackedStateType; + renderKeyTrigger(Object.keys(state.current) as (keyof UseTrackedStateType)[]); }, }; - return [state.current, actions, { setRenderKey, setCacheData, getStaleStatus, getIsDataProcessing }]; + return [ + state.current, + actions, + { + setRenderKey, + setCacheData, + getStaleStatus, + getIsDataProcessing, + selectData: (data) => selectData(data, state.current.data), + getResponseDetails: () => responseDetails.current, + }, + ]; }; diff --git a/packages/react/src/helpers/use-tracked-state/use-tracked-state.types.ts b/packages/react/src/helpers/use-tracked-state/use-tracked-state.types.ts index 2498b358..1cfdbcab 100644 --- a/packages/react/src/helpers/use-tracked-state/use-tracked-state.types.ts +++ b/packages/react/src/helpers/use-tracked-state/use-tracked-state.types.ts @@ -9,12 +9,13 @@ import type { ExtractAdapterExtraType, ExtractAdapterResolvedType, NullableType, + ResponseDetailsType, CacheSetState, LoggerMethods, } from "@hyper-fetch/core"; import type { isEqual } from "utils"; -export type UseTrackedStateProps = { +export type UseTrackedStateProps> = { request: T; logger: LoggerMethods; initialResponse: NullableType>>; @@ -24,24 +25,40 @@ export type UseTrackedStateProps = { keepPreviousData?: "auto" | "preserve" | "clean"; disabled?: boolean; revalidate?: boolean; + select?: UseTrackedStateSelect; + structuralSharing?: boolean; }; -export type UseTrackedStateReturn = [ - UseTrackedStateType, - UseTrackedStateActions, +/** + * Selector picking (or transforming) the part of the response data used by the component + */ +export type UseTrackedStateSelect> = ( + data: ExtractResponseType, +) => TData; + +export type UseTrackedStateReturn> = [ + UseTrackedStateType, + UseTrackedStateActions, { - setRenderKey: (renderKey: keyof UseTrackedStateType) => void; + setRenderKey: (renderKey: keyof UseTrackedStateType) => void; setCacheData: (cacheData: CacheValueType, ExtractErrorType>) => void; getStaleStatus: () => boolean; getIsDataProcessing: (cacheKey: string) => boolean; + selectData: (data: ExtractResponseType | null) => TData | null; + /** + * Details of the last response received from the cache - null when the state did not come from a response + */ + getResponseDetails: () => UseTrackedStateResponseDetails | null; }, ]; -export type UseTrackedStateType = { +export type UseTrackedStateResponseDetails = Pick; + +export type UseTrackedStateType> = { /** * Request response data */ - data: null | ExtractResponseType; + data: null | TData; /** * Request response error */ @@ -76,12 +93,13 @@ export type UseTrackedStateType = { requestTimestamp: null | Date; }; -export type UseTrackedStateActions = { +export type UseTrackedStateActions> = { /** * Action to set custom data. We can do it locally(inside hook state). + * When `select` is used it receives the selected data shape. * If you need to update cache data use client.cache.update(). method. */ - setData: (data: CacheSetState | null>) => void; + setData: (data: CacheSetState) => void; /** * Action to set custom error. We can do it locally(inside hook state). * If you need to update cache data use client.cache.update() method. diff --git a/packages/react/src/hooks/use-cache/use-cache.constants.ts b/packages/react/src/hooks/use-cache/use-cache.constants.ts index 27f764f7..5716ed39 100644 --- a/packages/react/src/hooks/use-cache/use-cache.constants.ts +++ b/packages/react/src/hooks/use-cache/use-cache.constants.ts @@ -2,4 +2,5 @@ export const useCacheDefaultOptions = { dependencyTracking: true, initialResponse: null, deepCompare: true, + structuralSharing: true, }; diff --git a/packages/react/src/hooks/use-cache/use-cache.hooks.ts b/packages/react/src/hooks/use-cache/use-cache.hooks.ts index b026ce5f..8dac4e74 100644 --- a/packages/react/src/hooks/use-cache/use-cache.hooks.ts +++ b/packages/react/src/hooks/use-cache/use-cache.hooks.ts @@ -1,5 +1,5 @@ import { useDidUpdate } from "@better-hooks/lifecycle"; -import type { RequestInstance } from "@hyper-fetch/core"; +import type { ExtractResponseType, RequestInstance } from "@hyper-fetch/core"; import { getRequestDispatcher } from "@hyper-fetch/core"; import { useRequestEvents, useTrackedState } from "helpers"; import type { UseCacheOptionsType, UseCacheReturnType } from "hooks/use-cache"; @@ -9,10 +9,10 @@ import { useRef } from "react"; import { createTrackedProxy } from "utils"; /** Subscribe to cache updates for a given request without triggering network activity. */ -export const useCache = ( +export const useCache = >( request: T, - options?: UseCacheOptionsType, -): UseCacheReturnType => { + options?: UseCacheOptionsType, +): UseCacheReturnType => { const { cacheKey, client } = request; const { cache, loggerManager } = client; @@ -22,7 +22,7 @@ export const useCache = ( // Build the configuration options const { config: globalConfig } = useProvider(); - const { dependencyTracking, initialResponse, deepCompare } = { + const { dependencyTracking, initialResponse, deepCompare, structuralSharing } = { ...useCacheDefaultOptions, ...globalConfig.useCacheConfig, ...options, @@ -31,13 +31,15 @@ export const useCache = ( /** * State handler with optimization for rerendering, that hooks into the cache state and dispatchers queues */ - const [state, actions, { setRenderKey, setCacheData, getIsDataProcessing }] = useTrackedState({ + const [state, actions, { setRenderKey, setCacheData, getIsDataProcessing }] = useTrackedState({ logger, request, dispatcher, initialResponse, deepCompare, dependencyTracking, + select: options?.select, + structuralSharing, }); /** @@ -103,5 +105,5 @@ export const useCache = ( }, trackedKeys, setRenderKey, - ) as UseCacheReturnType; + ) as UseCacheReturnType; }; diff --git a/packages/react/src/hooks/use-cache/use-cache.types.ts b/packages/react/src/hooks/use-cache/use-cache.types.ts index 961ebbad..353c8c13 100644 --- a/packages/react/src/hooks/use-cache/use-cache.types.ts +++ b/packages/react/src/hooks/use-cache/use-cache.types.ts @@ -1,8 +1,8 @@ import type { RequestInstance, ExtractResponseType, ExtractErrorType, CacheValueType } from "@hyper-fetch/core"; -import type { UseTrackedStateActions, UseTrackedStateType } from "helpers"; +import type { UseTrackedStateActions, UseTrackedStateSelect, UseTrackedStateType } from "helpers"; import type { isEqual } from "utils"; -export type UseCacheOptionsType = { +export type UseCacheOptionsType> = { /** * If `true` it will rerender only when values used by our component gets changed. Otherwise it will rerender on any change. */ @@ -15,10 +15,22 @@ export type UseCacheOptionsType = { * Deep comparison function for hook to check for equality in incoming data, to limit rerenders. */ deepCompare?: boolean | typeof isEqual; + /** + * Pick or transform the part of the cached data used by the component. + * The hook rerenders only when the selected value changes, while the cache stays shared with other hooks. + */ + select?: UseTrackedStateSelect; + /** + * Keep references of the unchanged parts of the selected data between updates (default true). + */ + structuralSharing?: boolean; }; -export type UseCacheReturnType = UseTrackedStateType & - UseTrackedStateActions & { +export type UseCacheReturnType> = UseTrackedStateType< + T, + TSelected +> & + UseTrackedStateActions & { /** * Invalidate cache for the current request or pass custom key to trigger it by invalidationKey(Regex / cacheKey). */ diff --git a/packages/react/src/hooks/use-emitter/use-emitter.hooks.ts b/packages/react/src/hooks/use-emitter/use-emitter.hooks.ts index 52f0325c..cfc8561a 100644 --- a/packages/react/src/hooks/use-emitter/use-emitter.hooks.ts +++ b/packages/react/src/hooks/use-emitter/use-emitter.hooks.ts @@ -12,7 +12,7 @@ export const useEmitter = ( options?: UseEmitterOptionsType, ) => { const { config: globalConfig } = useProvider(); - const { dependencyTracking } = { ...globalConfig.useEmitter, ...options }; + const { dependencyTracking = true } = { ...globalConfig.useEmitter, ...options }; const [state, actions, callbacks, { setRenderKey }] = useSocketState(emitter.socket, { dependencyTracking }); diff --git a/packages/react/src/hooks/use-fetch/use-fetch.constants.ts b/packages/react/src/hooks/use-fetch/use-fetch.constants.ts index 7f73abfb..c4195af4 100644 --- a/packages/react/src/hooks/use-fetch/use-fetch.constants.ts +++ b/packages/react/src/hooks/use-fetch/use-fetch.constants.ts @@ -2,7 +2,7 @@ import type { RequestInstance, RequiredKeys } from "@hyper-fetch/core"; import { Time } from "@hyper-fetch/core"; import type { UseFetchOptionsType } from "hooks/use-fetch"; -type DefaultOptionsType = RequiredKeys, "initialResponse">> & { +type DefaultOptionsType = RequiredKeys, "initialResponse" | "select">> & { initialResponse: null; }; @@ -25,4 +25,6 @@ export const useFetchDefaultOptions: DefaultOptionsType = { bounceTime: 400, bounceTimeout: 400, deepCompare: true, + structuralSharing: true, + throwOnError: false, }; diff --git a/packages/react/src/hooks/use-fetch/use-fetch.hooks.ts b/packages/react/src/hooks/use-fetch/use-fetch.hooks.ts index a55e3eb5..09011abd 100644 --- a/packages/react/src/hooks/use-fetch/use-fetch.hooks.ts +++ b/packages/react/src/hooks/use-fetch/use-fetch.hooks.ts @@ -5,6 +5,8 @@ import type { ExtractAdapterStatusType, ExtractAdapterType, ExtractAdapterExtraType, + ExtractErrorType, + ExtractResponseType, } from "@hyper-fetch/core"; import { scopeKey } from "@hyper-fetch/core"; import { useRequestEvents, useTrackedState } from "helpers"; @@ -25,10 +27,10 @@ const suspenseResultMap = new Map( +export const useFetch = >( request: R, - options?: UseFetchOptionsType, -): UseFetchReturnType => { + options?: UseFetchOptionsType, +): UseFetchReturnType => { // Build the configuration options const { config: globalConfig } = useProvider(); const { @@ -50,6 +52,8 @@ export const useFetch = ( bounceTime, bounceTimeout, deepCompare, + structuralSharing, + throwOnError, } = { ...useFetchDefaultOptions, ...globalConfig.useFetchConfig, @@ -72,7 +76,11 @@ export const useFetch = ( /** * State handler with optimization for re-rendering, that hooks into the cache state and dispatchers queues */ - const [state, actions, { setRenderKey, setCacheData, getStaleStatus, getIsDataProcessing }] = useTrackedState({ + const [ + state, + actions, + { setRenderKey, setCacheData, getStaleStatus, getIsDataProcessing, selectData, getResponseDetails }, + ] = useTrackedState({ logger, request: request as unknown as R, dispatcher, @@ -82,6 +90,8 @@ export const useFetch = ( keepPreviousData, disabled, revalidate, + select: options?.select, + structuralSharing, }); /** @@ -273,7 +283,7 @@ export const useFetch = ( const suspenseResult = suspenseResultMap.get(cacheKey); if (suspenseResult) { suspenseResultMap.delete(cacheKey); - if (suspenseResult.data !== null) {state.data = suspenseResult.data;} + if (suspenseResult.data !== null) {state.data = selectData(suspenseResult.data);} if (suspenseResult.error !== null) {state.error = suspenseResult.error;} if (suspenseResult.status !== null) {state.status = suspenseResult.status;} if (suspenseResult.extra !== null) {state.extra = suspenseResult.extra;} @@ -324,6 +334,28 @@ export const useFetch = ( } } + // ****************** + // Error boundary + // ****************** + if (throwOnError) { + // The error has to be observed to rerender (and throw) even if the component does not read it + setRenderKey("error"); + setRenderKey("loading"); + setRenderKey("success"); + + const details = getResponseDetails(); + // Errors of retried, canceled or offline (re-queued) requests are not final + const isSettled = !state.loading && !details?.willRetry && !details?.isCanceled && !details?.isOffline; + const hasError = !state.success && state.error !== null && state.error !== undefined; + + if (isSettled && hasError) { + const error = state.error as ExtractErrorType; + if (typeof throwOnError === "function" ? throwOnError(error) : throwOnError) { + throw error; + } + } + } + const trackedKeys = [ "data", "error", @@ -354,5 +386,5 @@ export const useFetch = ( }, trackedKeys, setRenderKey, - ) as UseFetchReturnType; + ) as UseFetchReturnType; }; diff --git a/packages/react/src/hooks/use-fetch/use-fetch.types.ts b/packages/react/src/hooks/use-fetch/use-fetch.types.ts index e2a9dd62..7712fab9 100644 --- a/packages/react/src/hooks/use-fetch/use-fetch.types.ts +++ b/packages/react/src/hooks/use-fetch/use-fetch.types.ts @@ -1,5 +1,16 @@ -import type { RequestInstance, NullableType, ExtractAdapterResolvedType } from "@hyper-fetch/core"; -import type { UseRequestEventsActionsType, UseTrackedStateActions, UseTrackedStateType } from "helpers"; +import type { + RequestInstance, + NullableType, + ExtractAdapterResolvedType, + ExtractErrorType, + ExtractResponseType, +} from "@hyper-fetch/core"; +import type { + UseRequestEventsActionsType, + UseTrackedStateActions, + UseTrackedStateSelect, + UseTrackedStateType, +} from "helpers"; import type { isEqual } from "utils"; // export type UseFetchCastRequest = ExtendRequest< @@ -13,7 +24,7 @@ import type { isEqual } from "utils"; // export type UseFetchRequest = R extends UseFetchCastRequest ? R : UseFetchCastRequest; -export type UseFetchOptionsType = { +export type UseFetchOptionsType> = { /** * Enable React Suspense mode. When true, the hook throws a Promise while loading, * which is caught by the nearest Suspense boundary. The component re-renders @@ -81,6 +92,20 @@ export type UseFetchOptionsType = { * Deep comparison function for hook to check for equality in incoming data, to limit rerenders. */ deepCompare?: boolean | typeof isEqual; + /** + * Pick or transform the part of the response data used by the component. + * The hook rerenders only when the selected value changes, while the cache stays shared with other hooks. + */ + select?: UseTrackedStateSelect; + /** + * Keep references of the unchanged parts of the selected data between updates (default true). + */ + structuralSharing?: boolean; + /** + * Throw the request error to the closest React Error Boundary once the request settles with an error. + * Pass a function to decide per error. + */ + throwOnError?: boolean | ((error: ExtractErrorType) => boolean); } & ( | { /** @@ -112,8 +137,11 @@ export type UseFetchOptionsType = { } ); -export type UseFetchReturnType = UseTrackedStateType & - UseTrackedStateActions & +export type UseFetchReturnType> = UseTrackedStateType< + R, + TSelected +> & + UseTrackedStateActions & UseRequestEventsActionsType & { /** * Data related to current state of the bounce usage diff --git a/packages/react/src/hooks/use-is-fetching/index.ts b/packages/react/src/hooks/use-is-fetching/index.ts new file mode 100644 index 00000000..d24aa841 --- /dev/null +++ b/packages/react/src/hooks/use-is-fetching/index.ts @@ -0,0 +1,2 @@ +export * from "./use-is-fetching.hooks"; +export * from "./use-is-fetching.types"; diff --git a/packages/react/src/hooks/use-is-fetching/use-is-fetching.hooks.ts b/packages/react/src/hooks/use-is-fetching/use-is-fetching.hooks.ts new file mode 100644 index 00000000..8d98c41b --- /dev/null +++ b/packages/react/src/hooks/use-is-fetching/use-is-fetching.hooks.ts @@ -0,0 +1,51 @@ +import type { AdapterInstance, ClientInstance, Dispatcher } from "@hyper-fetch/core"; +import type { UseIsFetchingOptionsType } from "hooks/use-is-fetching"; +import { useCallback, useRef, useSyncExternalStore } from "react"; + +const useRunningRequestsCount = ( + dispatcher: Dispatcher, + options?: UseIsFetchingOptionsType, +): number => { + const optionsRef = useRef(options); + optionsRef.current = options; + + const subscribe = useCallback( + (listener: () => void) => dispatcher.events.onRunningRequestsChange(listener), + [dispatcher], + ); + + const getCount = () => { + const { queryKey, predicate } = optionsRef.current || {}; + return dispatcher.getAllRunningRequests().filter(({ request }) => { + if (typeof queryKey === "string" && request.queryKey !== queryKey) { + return false; + } + if (queryKey instanceof RegExp && !queryKey.test(request.queryKey)) { + return false; + } + return !predicate || predicate(request); + }).length; + }; + + return useSyncExternalStore(subscribe, getCount, getCount); +}; + +/** + * Count of the currently running fetch requests (useFetch, useCache and fetch dispatcher). Useful for global loading indicators. + * @param client Client instance + * @param options Filters for the counted requests + * @returns number of running requests + */ +export const useIsFetching = (client: ClientInstance, options?: UseIsFetchingOptionsType): number => { + return useRunningRequestsCount(client.fetchDispatcher, options); +}; + +/** + * Count of the currently running submit requests (useSubmit and submit dispatcher). Useful for global saving indicators. + * @param client Client instance + * @param options Filters for the counted requests + * @returns number of running requests + */ +export const useIsSubmitting = (client: ClientInstance, options?: UseIsFetchingOptionsType): number => { + return useRunningRequestsCount(client.submitDispatcher, options); +}; diff --git a/packages/react/src/hooks/use-is-fetching/use-is-fetching.types.ts b/packages/react/src/hooks/use-is-fetching/use-is-fetching.types.ts new file mode 100644 index 00000000..d29d2def --- /dev/null +++ b/packages/react/src/hooks/use-is-fetching/use-is-fetching.types.ts @@ -0,0 +1,12 @@ +import type { RequestInstance } from "@hyper-fetch/core"; + +export type UseIsFetchingOptionsType = { + /** + * Count only the requests with matching queryKey - exact string or RegExp + */ + queryKey?: string | RegExp; + /** + * Count only the requests for which the predicate returns true + */ + predicate?: (request: RequestInstance) => boolean; +}; diff --git a/packages/react/src/hooks/use-listener/use-listener.hooks.ts b/packages/react/src/hooks/use-listener/use-listener.hooks.ts index adf6c423..4f16f171 100644 --- a/packages/react/src/hooks/use-listener/use-listener.hooks.ts +++ b/packages/react/src/hooks/use-listener/use-listener.hooks.ts @@ -1,4 +1,5 @@ import { useDidUpdate, useWillUnmount } from "@better-hooks/lifecycle"; +import { replaceEqualDeep } from "@hyper-fetch/core"; import type { ListenerInstance, ExtractListenerDeliveredType } from "@hyper-fetch/sockets"; import { getDeliveryKey } from "@hyper-fetch/sockets"; import { useSocketState } from "helpers"; @@ -8,18 +9,56 @@ import { useRef } from "react"; import { createTrackedProxy } from "utils"; /** Subscribe to incoming socket messages on a given listener topic with tracked connection state and callbacks. */ -export const useListener = ( +export const useListener = < + ListenerType extends ListenerInstance, + TSelected = ExtractListenerDeliveredType, +>( listener: ListenerType, - options?: UseListenerOptionsType, + options?: UseListenerOptionsType, TSelected>, ) => { const { config: globalConfig } = useProvider(); - const { dependencyTracking } = { ...globalConfig.useListener, ...options }; - const [state, actions, callbacks, { setRenderKey }] = useSocketState< - ExtractListenerDeliveredType, + const { dependencyTracking = true, structuralSharing = true } = { ...globalConfig.useListener, ...options }; + const select = options?.select; + const [state, actions, callbacks, { setRenderKey, getState, rerender }] = useSocketState< + TSelected, ListenerType["socket"] >(listener.socket, { dependencyTracking }); const removeListenerRef = useRef | null>(null); + /** + * Selection + */ + + const selectRef = useRef(select); + selectRef.current = select; + const appliedSelect = useRef(select); + // Last message data before applying the selector - allows to re-run it when the selector changes + const rawData = useRef<{ data: ExtractListenerDeliveredType } | null>(null); + const selectError = useRef<{ error: unknown } | null>(null); + + const selectData = (data: ExtractListenerDeliveredType): TSelected => { + if (!selectRef.current) { + return data as unknown as TSelected; + } + const selected = selectRef.current(data); + return structuralSharing ? replaceEqualDeep(getState().data, selected) : selected; + }; + + // Selector failed while processing a message - surface it to the closest error boundary + if (selectError.current) { + const { error } = selectError.current; + selectError.current = null; + throw error; + } + + // Selector changed (e.g. it closes over a new prop) - re-run it against the last message + if (appliedSelect.current !== select) { + appliedSelect.current = select; + if (rawData.current) { + state.data = selectData(rawData.current.data); + } + } + /** * Callbacks */ @@ -40,7 +79,23 @@ export const useListener = ( stopListener(); removeListenerRef.current = listener.listen(({ data, extra }) => { onEventCallback.current?.({ data, extra }); - actions.setData(data); + rawData.current = { data }; + appliedSelect.current = selectRef.current; + + let selected: TSelected; + try { + selected = selectData(data); + } catch (error) { + // Never throw inside of the socket events - rethrow it on render instead + selectError.current = { error }; + rerender(); + return; + } + + // With a selector, data is updated only when the selected value changed + if (!selectRef.current || !Object.is(selected, getState().data)) { + actions.setData(selected); + } actions.setExtra(extra); actions.setTimestamp(Date.now()); }); diff --git a/packages/react/src/hooks/use-listener/use-listener.types.ts b/packages/react/src/hooks/use-listener/use-listener.types.ts index fda3e40b..b6915eda 100644 --- a/packages/react/src/hooks/use-listener/use-listener.types.ts +++ b/packages/react/src/hooks/use-listener/use-listener.types.ts @@ -1,4 +1,15 @@ -export type UseListenerOptionsType = { +export type UseListenerOptionsType = { /** When true (default), only re-renders when accessed state properties change */ dependencyTracking?: boolean; + /** + * Pick or transform the part of the message data used by the component. + * With a selector the `data` value updates only when the selected value changes, + * so components reading `data` rerender only for the relevant messages. + */ + select?: (data: DataType) => TSelected; + /** + * Keep references of the unchanged parts of the selected data between messages (default true). + * Applies to the `select` result - without a selector every message updates the data. + */ + structuralSharing?: boolean; }; diff --git a/packages/react/src/hooks/use-submit/use-submit.constants.ts b/packages/react/src/hooks/use-submit/use-submit.constants.ts index 7e553855..c5847588 100644 --- a/packages/react/src/hooks/use-submit/use-submit.constants.ts +++ b/packages/react/src/hooks/use-submit/use-submit.constants.ts @@ -2,7 +2,7 @@ import type { RequestInstance, RequiredKeys } from "@hyper-fetch/core"; import type { UseSubmitOptionsType } from "./use-submit.types"; -type DefaultOptionsType = RequiredKeys, "initialResponse">> & { +type DefaultOptionsType = RequiredKeys, "initialResponse" | "select">> & { initialResponse: null; }; @@ -14,4 +14,5 @@ export const useSubmitDefaultOptions: DefaultOptionsType = { bounceType: "debounce", bounceTime: 400, deepCompare: true, + structuralSharing: true, }; diff --git a/packages/react/src/hooks/use-submit/use-submit.hooks.ts b/packages/react/src/hooks/use-submit/use-submit.hooks.ts index 777f086a..8dc62ed6 100644 --- a/packages/react/src/hooks/use-submit/use-submit.hooks.ts +++ b/packages/react/src/hooks/use-submit/use-submit.hooks.ts @@ -25,13 +25,13 @@ import { createTrackedProxy, getBounceData } from "utils"; * @param options * @returns */ -export const useSubmit = ( +export const useSubmit = >( request: RequestType, - options?: UseSubmitOptionsType, -): UseSubmitReturnType => { + options?: UseSubmitOptionsType, +): UseSubmitReturnType => { // Build the configuration options const { config: globalConfig } = useProvider(); - const mergedOptions: UseSubmitOptionsType = useMemo( + const mergedOptions: UseSubmitOptionsType = useMemo( () => ({ ...useSubmitDefaultOptions, ...globalConfig.useSubmitConfig, @@ -40,7 +40,16 @@ export const useSubmit = ( // eslint-disable-next-line react-hooks/exhaustive-deps [globalConfig.useSubmitConfig, JSON.stringify(options), options?.deepCompare], ); - const { disabled, dependencyTracking, initialResponse, bounce, bounceType, bounceTime, deepCompare } = mergedOptions; + const { + disabled, + dependencyTracking, + initialResponse, + bounce, + bounceType, + bounceTime, + deepCompare, + structuralSharing, + } = mergedOptions; /** * Because of the dynamic cacheKey / queryKey signing within the request we need to store it's latest instance @@ -71,13 +80,19 @@ export const useSubmit = ( /** * State handler with optimization for rerendering, that hooks into the cache state and dispatchers queues */ - const [state, actions, { setRenderKey, setCacheData, getIsDataProcessing }] = useTrackedState({ + const [state, actions, { setRenderKey, setCacheData, getIsDataProcessing }] = useTrackedState< + RequestType, + TSelected + >({ logger, request, dispatcher, initialResponse, deepCompare: deepCompare as boolean, dependencyTracking: dependencyTracking as boolean, + // Read directly from options - functions are not part of the memoized options key + select: options?.select, + structuralSharing, }); /** @@ -239,5 +254,5 @@ export const useSubmit = ( }, trackedKeys, setSubmitRenderKey, - ) as UseSubmitReturnType; + ) as UseSubmitReturnType; }; diff --git a/packages/react/src/hooks/use-submit/use-submit.types.ts b/packages/react/src/hooks/use-submit/use-submit.types.ts index 42358f09..5e208d3b 100644 --- a/packages/react/src/hooks/use-submit/use-submit.types.ts +++ b/packages/react/src/hooks/use-submit/use-submit.types.ts @@ -1,4 +1,10 @@ -import type { ExtractAdapterResolvedType, NullableType, RequestInstance, RequestSendType } from "@hyper-fetch/core"; +import type { + ExtractAdapterResolvedType, + ExtractResponseType, + NullableType, + RequestInstance, + RequestSendType, +} from "@hyper-fetch/core"; import type { OnProgressCallbackType, OnStartCallbackType, @@ -7,10 +13,11 @@ import type { OnFinishedCallbackType, UseTrackedStateType, UseTrackedStateActions, + UseTrackedStateSelect, } from "helpers"; import type { isEqual } from "utils"; -export type UseSubmitOptionsType = { +export type UseSubmitOptionsType> = { /** * Disable submitting */ @@ -31,6 +38,15 @@ export type UseSubmitOptionsType = { * Deep comparison function for hook to check for equality in incoming data, to limit rerenders. */ deepCompare?: boolean | typeof isEqual; + /** + * Pick or transform the part of the response data used by the component. + * Callbacks like `onSubmitSuccess` still receive the full response. + */ + select?: UseTrackedStateSelect; + /** + * Keep references of the unchanged parts of the selected data between updates (default true). + */ + structuralSharing?: boolean; } & ( | { /** @@ -58,11 +74,11 @@ export type UseSubmitOptionsType = { } ); -export type UseSubmitReturnType = Omit< - UseTrackedStateType, - "loading" -> & - UseTrackedStateActions & { +export type UseSubmitReturnType< + RequestType extends RequestInstance, + TSelected = ExtractResponseType, +> = Omit, "loading"> & + UseTrackedStateActions & { /** * Callback which allows to cancel ongoing requests from given queryKey. */ diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 5feb9cfd..eb2f6c90 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -6,6 +6,7 @@ export * from "./hooks/use-stream"; export * from "./hooks/use-listener"; export * from "./hooks/use-emitter"; export * from "./hooks/use-app-manager"; +export * from "./hooks/use-is-fetching"; export * from "./helpers"; export * from "./provider"; export * from "./utils"; diff --git a/packages/react/src/provider/provider.types.ts b/packages/react/src/provider/provider.types.ts index 768b8358..9fff1757 100644 --- a/packages/react/src/provider/provider.types.ts +++ b/packages/react/src/provider/provider.types.ts @@ -7,12 +7,16 @@ import type { UseQueueOptionsType } from "hooks/use-queue"; import type { UseSubmitOptionsType } from "hooks/use-submit"; import type React from "react"; +// Omit applied to every union member - keeps the bounce options discriminated union intact +type DistributiveOmit = T extends unknown ? Omit : never; + export type ProviderOptionsType = { - useFetchConfig?: Partial>; - useSubmitConfig?: Partial>; - useCacheConfig?: Partial>; + // Selectors are component specific, they cannot be configured globally + useFetchConfig?: Partial, "select">>; + useSubmitConfig?: Partial, "select">>; + useCacheConfig?: Partial, "select">>; useQueueConfig?: Partial; - useListener?: Partial; + useListener?: Partial>; useEmitter?: Partial; };