import { createClient, xhrExtra } from "@hyper-fetch/core"; import { act, waitFor } from "@testing-library/react"; import { createHttpMockingServer, sleep } from "@hyper-fetch/testing"; import { initialState } from "helpers"; import { renderUseTrackedState } from "../../../utils"; const { resetMocks, startServer, stopServer, mockRequest } = createHttpMockingServer(); describe("useTrackingState [ Events ]", () => { let client = createClient({ url: "http://localhost:3000" }); let request = client.createRequest<{ response: any }>()({ endpoint: "/test", }); beforeAll(() => { startServer(); }); afterEach(() => { resetMocks(); }); afterAll(() => { stopServer(); }); beforeEach(() => { vi.resetModules(); client = createClient({ url: "http://localhost:3000" }); request = client.createRequest<{ response: any }>()({ endpoint: "/test", }); }); describe("given state is initialized", () => { describe("when cache is empty", () => { it("should init with default data", async () => { const { result } = renderUseTrackedState(request); expect(result.current[0].data).toBe(initialState.data); expect(result.current[0].error).toBe(initialState.error); expect(result.current[0].status).toBe(initialState.status); expect(result.current[0].success).toBe(initialState.success); expect(result.current[0].extra).toStrictEqual(request.client.adapter.defaultExtra); expect(result.current[0].retries).toBe(0); expect(result.current[0].loading).toBe(initialState.loading); }); }); }); describe("given dependency tracking is active", () => { describe("when updating the state values and dependency tracking is on", () => { it("should not rerender hook when deep equal values are the same", async () => { const { result } = renderUseTrackedState(request, { dependencyTracking: true }); const renderCount = { value: 0 }; // Track data changes act(() => { result.current[2].setRenderKey("data"); }); // Set same data twice act(() => { result.current[2].setCacheData({ data: { test: "value" }, error: null, status: 200, success: true, extra: xhrExtra, retries: 0, requestTimestamp: +new Date(), responseTimestamp: +new Date(), addedTimestamp: +new Date(), triggerTimestamp: +new Date(), cacheKey: request.cacheKey, scope: request.scope, isCanceled: false, isOffline: false, willRetry: false, staleTime: request.staleTime, version: request.client.cache.version, cacheTime: Infinity, cached: true, }); }); const firstRenderData = result.current[0].data; renderCount.value += 1; act(() => { result.current[2].setCacheData({ data: { test: "value" }, error: null, status: 200, success: true, extra: xhrExtra, retries: 0, requestTimestamp: +new Date(), responseTimestamp: +new Date(), addedTimestamp: +new Date(), triggerTimestamp: +new Date(), cacheKey: request.cacheKey, scope: request.scope, isCanceled: false, isOffline: false, willRetry: false, staleTime: request.staleTime, version: request.client.cache.version, cacheTime: Infinity, cached: true, }); }); expect(result.current[0].data).toEqual(firstRenderData); }); it("should rerender component when attribute is used", async () => { const { result } = renderUseTrackedState(request, { dependencyTracking: true }); // Track data changes act(() => { result.current[2].setRenderKey("data"); }); const initialData = result.current[0].data; act(() => { result.current[2].setCacheData({ data: "new-data", error: null, status: 200, success: true, extra: xhrExtra, retries: 0, requestTimestamp: +new Date(), responseTimestamp: +new Date(), addedTimestamp: +new Date(), triggerTimestamp: +new Date(), cacheKey: request.cacheKey, scope: request.scope, isCanceled: false, isOffline: false, willRetry: false, staleTime: request.staleTime, version: request.client.cache.version, cacheTime: Infinity, cached: true, }); }); expect(result.current[0].data).not.toEqual(initialData); expect(result.current[0].data).toBe("new-data"); }); it("should not rerender component when attribute is not used", async () => { const { result } = renderUseTrackedState(request, { dependencyTracking: true }); // Only track error changes act(() => { result.current[2].setRenderKey("error"); }); const initialData = result.current[0].data; act(() => { result.current[2].setCacheData({ data: "new-data", error: null, status: 200, success: true, extra: xhrExtra, retries: 0, requestTimestamp: +new Date(), responseTimestamp: +new Date(), addedTimestamp: +new Date(), triggerTimestamp: +new Date(), cacheKey: request.cacheKey, scope: request.scope, isCanceled: false, isOffline: false, willRetry: false, staleTime: request.staleTime, version: request.client.cache.version, cacheTime: Infinity, cached: true, }); }); // Data should not change since we're only tracking error expect(result.current[0].data).toEqual(initialData); }); }); }); describe("given dependency tracking is off", () => { describe("when updating the state values", () => { it("should rerender on any attribute change", async () => { const { result } = renderUseTrackedState(request, { dependencyTracking: false }); const initialData = result.current[0].data; act(() => { result.current[2].setCacheData({ data: "new-data", error: null, status: 200, success: true, extra: xhrExtra, retries: 0, requestTimestamp: +new Date(), responseTimestamp: +new Date(), addedTimestamp: +new Date(), triggerTimestamp: +new Date(), cacheKey: request.cacheKey, scope: request.scope, isCanceled: false, isOffline: false, willRetry: false, staleTime: request.staleTime, version: request.client.cache.version, cacheTime: Infinity, cached: true, }); }); expect(result.current[0].data).not.toEqual(initialData); expect(result.current[0].data).toBe("new-data"); }); }); }); describe("given request is mapping response", () => { describe("when receiving data", () => { it("should map the data", async () => { const { result } = renderUseTrackedState( request.setResponseMapper((response) => { return { ...response, data: "new-data" } as typeof response; }), ); act(() => { result.current[2].setRenderKey("data"); result.current[2].setCacheData({ data: true as any, error: null, status: 200, success: true, extra: xhrExtra, retries: 0, requestTimestamp: +new Date(), responseTimestamp: +new Date(), addedTimestamp: +new Date(), triggerTimestamp: +new Date(), cacheKey: request.cacheKey, scope: request.scope, isCanceled: false, isOffline: false, willRetry: false, staleTime: request.staleTime, version: request.client.cache.version, cacheTime: Infinity, cached: true, }); }); expect(result.current[0].data).toBe("new-data"); expect(result.current[0].error).toBe(null); }); it("should map the cache data", async () => { mockRequest(request); await act(async () => { await request.send({}); }); const { result, rerender } = renderUseTrackedState( request.setResponseMapper((response) => { return { ...response, data: "new-data" } as typeof response; }), ); act(() => { rerender(); }); expect(result.current[0].data).toBe("new-data"); expect(result.current[0].error).toBe(null); }); it("should map the async data", async () => { const { result } = renderUseTrackedState( request.setResponseMapper(async (response) => { return Promise.resolve({ ...response, data: "new-data" } as typeof response); }), ); act(() => { result.current[2].setRenderKey("data"); result.current[2].setCacheData({ data: true as any, error: null, status: 200, success: true, extra: xhrExtra, retries: 0, requestTimestamp: +new Date(), responseTimestamp: +new Date(), addedTimestamp: +new Date(), triggerTimestamp: +new Date(), cacheKey: request.cacheKey, scope: request.scope, isCanceled: false, isOffline: false, willRetry: false, staleTime: request.staleTime, version: request.client.cache.version, cacheTime: Infinity, cached: true, }); }); expect(result.current[0]).toStrictEqual(expect.objectContaining({ ...initialState, extra: xhrExtra })); await waitFor(() => { expect(result.current[0].data).toBe("new-data"); expect(result.current[0].error).toBe(null); }); }); }); describe("when deepCompare is function", () => { it("should trigger with two values", async () => { const customDeepCompare = vi.fn().mockImplementation(() => true); const { result } = renderUseTrackedState(request, { deepCompare: customDeepCompare }); act(() => { result.current[2].setRenderKey("data"); result.current[2].setCacheData({ data: "test-data", error: null, status: 200, success: true, extra: xhrExtra, retries: 0, requestTimestamp: +new Date(), responseTimestamp: +new Date(), addedTimestamp: +new Date(), triggerTimestamp: +new Date(), cacheKey: request.cacheKey, scope: request.scope, isCanceled: false, isOffline: false, willRetry: false, staleTime: request.staleTime, version: request.client.cache.version, cacheTime: Infinity, cached: true, }); }); expect(customDeepCompare).toHaveBeenCalledWith(null, "test-data"); }); }); describe("when handling async response mapping", () => { it("should handle async response mapper rejection", async () => { const { result } = renderUseTrackedState( request.setResponseMapper(async (response) => { await sleep(1000); return response; }), ); act(() => { result.current[2].setRenderKey("data"); result.current[2].setCacheData({ data: true as any, error: null, status: 200, success: true, extra: xhrExtra, retries: 0, requestTimestamp: +new Date(), responseTimestamp: +new Date(), addedTimestamp: +new Date(), triggerTimestamp: +new Date(), cacheKey: request.cacheKey, scope: request.scope, isCanceled: false, isOffline: false, willRetry: false, staleTime: request.staleTime, version: request.client.cache.version, cacheTime: Infinity, cached: true, }); }); // Initial state should be maintained while processing expect(result.current[0]).toStrictEqual(expect.objectContaining({ ...initialState, extra: xhrExtra })); // Wait for the error to be processed await expect(async () => { await waitFor(() => { expect(result.current[0].data).toBe(true); }); }).not.toThrow(); }); it("should handle concurrent async response mapping", async () => { let resolveFirst: (value: any) => void; let resolveSecond: (value: any) => void; const firstPromise = new Promise((resolve) => { resolveFirst = resolve; }); const secondPromise = new Promise((resolve) => { resolveSecond = resolve; }); let mapperCallCount = 0; const { result } = renderUseTrackedState( request.setResponseMapper(async (response) => { mapperCallCount += 1; if (mapperCallCount === 1) { return firstPromise.then(() => ({ ...response, data: "first-data" }) as typeof response); } return secondPromise.then(() => ({ ...response, data: "second-data" }) as typeof response); }), ); // Set first data act(() => { result.current[2].setRenderKey("data"); result.current[2].setCacheData({ data: "initial" as any, error: null, status: 200, success: true, extra: xhrExtra, retries: 0, requestTimestamp: +new Date(), responseTimestamp: +new Date(), addedTimestamp: +new Date(), triggerTimestamp: +new Date(), cacheKey: request.cacheKey, scope: request.scope, isCanceled: false, isOffline: false, willRetry: false, staleTime: request.staleTime, version: request.client.cache.version, cacheTime: Infinity, cached: true, }); }); // Set second data before first resolves act(() => { result.current[2].setCacheData({ data: "newer" as any, error: null, status: 200, success: true, extra: xhrExtra, retries: 0, requestTimestamp: +new Date(), responseTimestamp: +new Date(), addedTimestamp: +new Date(), triggerTimestamp: +new Date(), cacheKey: request.cacheKey, scope: request.scope, isCanceled: false, isOffline: false, willRetry: false, staleTime: request.staleTime, version: request.client.cache.version, cacheTime: Infinity, cached: true, }); }); // Resolve promises in reverse order act(() => { resolveSecond({}); }); await waitFor(() => { expect(result.current[0].data).toBe("second-data"); }); act(() => { resolveFirst({}); }); // Should still show second data since it was the last update await waitFor(() => { expect(result.current[0].data).toBe("second-data"); }); }); }); }); describe("given custom deepCompare option is passed", () => { describe("when deepCompare is function", () => { it("should allow to use custom deepCompare", async () => { const deepCompare = vi.fn().mockImplementation(() => false); const { result } = renderUseTrackedState(request, { deepCompare, dependencyTracking: true }); act(() => { result.current[2].setRenderKey("data"); result.current[2].setCacheData({ data: true, error: null, status: 200, success: true, extra: xhrExtra, retries: 0, requestTimestamp: +new Date(), responseTimestamp: +new Date(), addedTimestamp: +new Date(), triggerTimestamp: +new Date(), cacheKey: request.cacheKey, scope: request.scope, isCanceled: false, isOffline: false, willRetry: false, staleTime: request.staleTime, version: request.client.cache.version, cacheTime: Infinity, cached: true, }); }); expect(deepCompare).toHaveBeenCalled(); expect(result.current[0].data).toBeTrue(); }); }); describe("when deepCompare is false", () => { it("should not compare values", async () => { const { result } = renderUseTrackedState(request, { deepCompare: false, dependencyTracking: true }); act(() => { result.current[2].setRenderKey("data"); result.current[2].setCacheData({ data: true, error: null, status: 200, success: true, extra: xhrExtra, retries: 0, requestTimestamp: +new Date(), responseTimestamp: +new Date(), addedTimestamp: +new Date(), triggerTimestamp: +new Date(), cacheKey: request.cacheKey, scope: request.scope, isCanceled: false, isOffline: false, willRetry: false, staleTime: request.staleTime, version: request.client.cache.version, cacheTime: Infinity, cached: true, }); }); expect(result.current[0].data).toBeTrue(); }); }); }); });