///
import type { ReactNode } from "react";
import { Suspense, StrictMode, Component, useState } from "react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createHttpMockingServer } from "@hyper-fetch/testing";
import { useFetch } from "hooks/use-fetch";
import { client, createRequest } from "../../utils";
const { resetMocks, startServer, stopServer, mockRequest } = createHttpMockingServer();
// ---------------------------------------------------------------------------
// Shared components
// ---------------------------------------------------------------------------
function DataView({ request, testId = "result" }: { request: any; testId?: string }) {
const { data, error, loading, status } = useFetch(request, {
suspense: true,
dependencyTracking: false,
});
return (
{error ? `error:${JSON.stringify(error)}` : `data:${JSON.stringify(data)}`}
);
}
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
error: Error | null;
}
class ErrorBoundary extends Component {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error) {
return { error };
}
render() {
const { error } = this.state;
const { fallback, children } = this.props;
if (error) {
return fallback ?? {error.message}
;
}
return children;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("useFetch [ Suspense ]", () => {
let request = createRequest();
beforeAll(() => {
startServer();
});
afterEach(() => {
resetMocks();
});
afterAll(() => {
stopServer();
});
beforeEach(() => {
vi.resetModules();
request = createRequest();
client.clear();
});
// -----------------------------------------------------------------------
// Fallback rendering
// -----------------------------------------------------------------------
describe("when rendering with a Suspense boundary", () => {
it("should show fallback while loading and render data once resolved", async () => {
const mock = mockRequest(request);
render(
Loading...}>
,
);
expect(screen.getByTestId("fallback")).toBeInTheDocument();
await waitFor(() => {
const el = screen.getByTestId("result");
expect(el.textContent).toContain(JSON.stringify(mock));
expect(el).toHaveAttribute("data-loading", "false");
});
});
it("should render error response without crashing", async () => {
mockRequest(request, { status: 400 });
render(
Loading...}>
,
);
expect(screen.getByTestId("fallback")).toBeInTheDocument();
await waitFor(() => {
const el = screen.getByTestId("result");
expect(el.textContent).toContain("error:");
});
});
it("should set correct status code on success", async () => {
mockRequest(request);
render(
Loading...}>
,
);
await waitFor(() => {
expect(screen.getByTestId("result")).toHaveAttribute("data-status", "200");
});
});
it("should set correct status code on error", async () => {
mockRequest(request, { status: 500 });
render(
Loading...}>
,
);
await waitFor(() => {
expect(screen.getByTestId("result")).toHaveAttribute("data-status", "500");
});
});
});
// -----------------------------------------------------------------------
// Cached data
// -----------------------------------------------------------------------
describe("when cache already has data", () => {
it("should render immediately without suspending", async () => {
const mock = mockRequest(request);
function CachedView({ testId }: { testId: string }) {
const { data, loading } = useFetch(request, {
suspense: true,
revalidate: false,
dependencyTracking: false,
});
return (
data:{JSON.stringify(data)},loading:{String(loading)}
);
}
// First render — suspends, then resolves
const first = render(
Loading...}>
,
);
await waitFor(() => {
expect(screen.getByTestId("first").textContent).toContain(JSON.stringify(mock));
});
first.unmount();
// Second render — should NOT suspend since data is cached
render(
Loading...}>
,
);
// Synchronous assertion — no fallback, data rendered immediately
expect(screen.getByTestId("second").textContent).toContain(JSON.stringify(mock));
});
});
// -----------------------------------------------------------------------
// Disabled
// -----------------------------------------------------------------------
describe("when disabled", () => {
it("should not suspend and render with null data", () => {
function DisabledView() {
const { data, loading } = useFetch(request, {
suspense: true,
disabled: true,
dependencyTracking: false,
});
return (
data:{String(data)},loading:{String(loading)}
);
}
render(
Loading...}>
,
);
expect(screen.getByTestId("disabled").textContent).toContain("data:null");
expect(screen.queryByTestId("fallback")).not.toBeInTheDocument();
});
});
// -----------------------------------------------------------------------
// Multiple components
// -----------------------------------------------------------------------
describe("when multiple components suspend under the same boundary", () => {
it("should show single fallback until all resolve", async () => {
const requestA = createRequest({ endpoint: "/suspense-a" });
const requestB = createRequest({ endpoint: "/suspense-b" });
const mockA = mockRequest(requestA);
const mockB = mockRequest(requestB, { delay: 50 });
render(
Loading all...}>
,
);
expect(screen.getByTestId("fallback")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("a").textContent).toContain(JSON.stringify(mockA));
expect(screen.getByTestId("b").textContent).toContain(JSON.stringify(mockB));
});
});
it("should support independent Suspense boundaries per component", async () => {
const requestA = createRequest({ endpoint: "/suspense-ind-a" });
const requestB = createRequest({ endpoint: "/suspense-ind-b" });
const mockA = mockRequest(requestA);
mockRequest(requestB, { delay: 100 });
render(
Loading A...
}>
Loading B...}>
,
);
// Both should initially show fallbacks
expect(screen.getByTestId("fallback-a")).toBeInTheDocument();
expect(screen.getByTestId("fallback-b")).toBeInTheDocument();
// A resolves first (no delay)
await waitFor(() => {
expect(screen.getByTestId("a").textContent).toContain(JSON.stringify(mockA));
});
});
});
// -----------------------------------------------------------------------
// Nested Suspense boundaries
// -----------------------------------------------------------------------
describe("when using nested Suspense boundaries", () => {
it("should allow outer fallback to wrap inner components", async () => {
const outerRequest = createRequest({ endpoint: "/nested-outer" });
const innerRequest = createRequest({ endpoint: "/nested-inner" });
const outerMock = mockRequest(outerRequest);
const innerMock = mockRequest(innerRequest, { delay: 50 });
function InnerView() {
const { data } = useFetch(innerRequest, { suspense: true, dependencyTracking: false });
return inner:{JSON.stringify(data)}
;
}
function OuterView() {
const { data } = useFetch(outerRequest, { suspense: true, dependencyTracking: false });
return (
outer:{JSON.stringify(data)}
Inner loading...
}>
);
}
render(
Outer loading...}>
,
);
expect(screen.getByTestId("outer-fallback")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("outer").textContent).toContain(JSON.stringify(outerMock));
});
await waitFor(() => {
expect(screen.getByTestId("inner").textContent).toContain(JSON.stringify(innerMock));
});
});
});
// -----------------------------------------------------------------------
// StrictMode compatibility
// -----------------------------------------------------------------------
describe("when rendering in StrictMode", () => {
it("should work correctly with StrictMode double-render", async () => {
const mock = mockRequest(request);
render(
Loading...}>
,
);
await waitFor(() => {
const el = screen.getByTestId("result");
expect(el.textContent).toContain(JSON.stringify(mock));
});
});
});
// -----------------------------------------------------------------------
// Dynamic parameter changes
// -----------------------------------------------------------------------
describe("when request parameters change", () => {
it("should re-suspend when cache key changes", async () => {
const request1 = createRequest({ endpoint: "/suspense-param-1" });
const request2 = createRequest({ endpoint: "/suspense-param-2" });
const mock1 = mockRequest(request1);
const mock2 = mockRequest(request2, { delay: 50 });
function ParamView({ req }: { req: any }) {
const { data } = useFetch(req, { suspense: true, dependencyTracking: false });
return {JSON.stringify(data)}
;
}
function Wrapper() {
const [req, setReq] = useState(request1);
return (
<>
Loading...}>
>
);
}
render();
await waitFor(() => {
expect(screen.getByTestId("param-result").textContent).toContain(JSON.stringify(mock1));
});
act(() => {
fireEvent.click(screen.getByTestId("switch-btn"));
});
await waitFor(() => {
expect(screen.getByTestId("param-result").textContent).toContain(JSON.stringify(mock2));
});
});
});
// -----------------------------------------------------------------------
// ErrorBoundary integration
// -----------------------------------------------------------------------
describe("when combined with ErrorBoundary", () => {
it("should not trigger ErrorBoundary on normal error responses", async () => {
mockRequest(request, { status: 400 });
render(
Caught!}>
Loading...}>
,
);
await waitFor(() => {
const el = screen.getByTestId("result");
expect(el.textContent).toContain("error:");
});
expect(screen.queryByTestId("error-boundary")).not.toBeInTheDocument();
});
});
// -----------------------------------------------------------------------
// Refetch after suspense
// -----------------------------------------------------------------------
describe("when refetching after initial suspense", () => {
it("should not re-suspend on refetch (data is already present)", async () => {
const mock = mockRequest(request);
const spy = vi.fn();
function RefetchView() {
const { data, refetch } = useFetch(request, {
suspense: true,
dependencyTracking: false,
});
spy(data);
return (
{JSON.stringify(data)}
);
}
render(
Loading...}>
,
);
await waitFor(() => {
expect(screen.getByTestId("refetch-data").textContent).toContain(JSON.stringify(mock));
});
const newMock = mockRequest(request, { data: { updated: true } });
act(() => {
fireEvent.click(screen.getByTestId("refetch-btn"));
});
// Should NOT show fallback during refetch
expect(screen.queryByTestId("fallback")).not.toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("refetch-data").textContent).toContain(JSON.stringify(newMock));
});
});
});
// -----------------------------------------------------------------------
// Non-suspense mode (default)
// -----------------------------------------------------------------------
describe("when suspense option is not set", () => {
it("should behave normally without suspending", async () => {
const mock = mockRequest(request);
function NormalView() {
const { data, loading } = useFetch(request, { dependencyTracking: false });
if (loading) return Loading...
;
return {JSON.stringify(data)}
;
}
render(
Suspense fallback}>
,
);
// Should show component's own loading state, not Suspense fallback
expect(screen.getByTestId("normal-loading")).toBeInTheDocument();
expect(screen.queryByTestId("fallback")).not.toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("normal-data").textContent).toContain(JSON.stringify(mock));
});
});
});
});