Skip to content

Commit 98d54d0

Browse files
feat(web): add DataState wrapper for RQ loading/empty/error/stale
Introduces a generic DataState component that unifies the four states every React Query hook can be in (loading, empty, error, success) into a single declarative slot API. Closes diagnostic 2026-05-03-web-deep-dive/01-frontend-ergonomics.md sec 3.2 (no system-wide skeleton policy).
1 parent e2a4e48 commit 98d54d0

5 files changed

Lines changed: 412 additions & 3 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/** @vitest-environment jsdom */
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
4+
5+
import { DataState } from "./DataState";
6+
7+
afterEach(cleanup);
8+
9+
/**
10+
* Contract tests for the DataState wrapper. Locks the precedence
11+
* (error → loading → empty → success), the `refetch` plumbing, and
12+
* the `stale` slot behaviour for background refetches.
13+
*/
14+
describe("DataState", () => {
15+
it("renders the skeleton while the query is loading", () => {
16+
render(
17+
<DataState
18+
query={{ data: undefined, isLoading: true }}
19+
skeleton={<div data-testid="skeleton"></div>}
20+
>
21+
{(data: number[]) => <span data-testid="body">{data.length}</span>}
22+
</DataState>,
23+
);
24+
expect(screen.getByTestId("skeleton")).toBeTruthy();
25+
expect(screen.queryByTestId("body")).toBeNull();
26+
});
27+
28+
it("renders the empty slot when data is an empty array", () => {
29+
render(
30+
<DataState
31+
query={{ data: [] as number[], isLoading: false }}
32+
empty={<div data-testid="empty">Порожньо</div>}
33+
>
34+
{(data) => <span data-testid="body">{data.length}</span>}
35+
</DataState>,
36+
);
37+
expect(screen.getByTestId("empty")).toBeTruthy();
38+
expect(screen.queryByTestId("body")).toBeNull();
39+
});
40+
41+
it("treats undefined data as empty when an empty slot is present", () => {
42+
// Use a custom isEmpty so `data === null` counts as empty for an
43+
// envelope-shaped response; default would also do so for plain
44+
// `undefined`, but we exercise the custom path here.
45+
render(
46+
<DataState
47+
query={{ data: { items: [] }, isLoading: false }}
48+
isEmpty={(d) => d.items.length === 0}
49+
empty={<div data-testid="empty">Нема</div>}
50+
>
51+
{(d) => <span data-testid="body">{d.items.length}</span>}
52+
</DataState>,
53+
);
54+
expect(screen.getByTestId("empty")).toBeTruthy();
55+
});
56+
57+
it("renders the error slot and forwards refetch via the retry callback", () => {
58+
const refetch = vi.fn();
59+
const errorRenderer = vi.fn((err: Error, retry: () => void) => (
60+
<button data-testid="retry" onClick={retry}>
61+
{err.message}
62+
</button>
63+
));
64+
65+
render(
66+
<DataState
67+
query={{
68+
data: undefined,
69+
isError: true,
70+
error: new Error("boom"),
71+
refetch,
72+
}}
73+
error={errorRenderer}
74+
>
75+
{() => <span data-testid="body" />}
76+
</DataState>,
77+
);
78+
79+
const retryBtn = screen.getByTestId("retry");
80+
expect(retryBtn.textContent).toBe("boom");
81+
fireEvent.click(retryBtn);
82+
expect(refetch).toHaveBeenCalledTimes(1);
83+
});
84+
85+
it("error wins even when stale data is present in the cache", () => {
86+
render(
87+
<DataState
88+
query={{
89+
data: [1, 2, 3],
90+
isError: true,
91+
error: new Error("network down"),
92+
}}
93+
>
94+
{(data: number[]) => <span data-testid="body">{data.length}</span>}
95+
</DataState>,
96+
);
97+
// Default fallback shows "Помилка" + the message.
98+
expect(screen.getByRole("alert")).toBeTruthy();
99+
expect(screen.queryByTestId("body")).toBeNull();
100+
});
101+
102+
it("renders body + stale slot when data is fresh and a refetch is in flight", () => {
103+
render(
104+
<DataState
105+
query={{ data: [1], isLoading: false, isFetching: true }}
106+
stale={(_data, isStale) =>
107+
isStale ? <span data-testid="stale">refreshing</span> : null
108+
}
109+
>
110+
{(data: number[]) => <span data-testid="body">{data.length}</span>}
111+
</DataState>,
112+
);
113+
expect(screen.getByTestId("stale")).toBeTruthy();
114+
expect(screen.getByTestId("body").textContent).toBe("1");
115+
});
116+
117+
it("renders nothing when query is indeterminate (no data, no error, not loading)", () => {
118+
const { container } = render(
119+
<DataState query={{ data: undefined, isLoading: false }}>
120+
{() => <span data-testid="body" />}
121+
</DataState>,
122+
);
123+
expect(container.firstChild).toBeNull();
124+
});
125+
126+
it("default error fallback exposes a retry button that calls refetch", () => {
127+
const refetch = vi.fn();
128+
render(
129+
<DataState
130+
query={{
131+
data: undefined,
132+
isError: true,
133+
error: new Error("oops"),
134+
refetch,
135+
}}
136+
>
137+
{() => <span data-testid="body" />}
138+
</DataState>,
139+
);
140+
fireEvent.click(screen.getByRole("button", { name: /спробувати/i }));
141+
expect(refetch).toHaveBeenCalledTimes(1);
142+
});
143+
144+
it("falls through to children when no empty slot is provided even for an empty array", () => {
145+
render(
146+
<DataState query={{ data: [] as number[], isLoading: false }}>
147+
{(data) => <span data-testid="body">len={data.length}</span>}
148+
</DataState>,
149+
);
150+
// No `empty` prop ⇒ DataState should NOT swallow the call. Body
151+
// owns the decision so callers can render their own zero-state.
152+
expect(screen.getByTestId("body").textContent).toBe("len=0");
153+
});
154+
155+
it("falls back to React Query v5 `isPending` when `isLoading` is absent", () => {
156+
render(
157+
<DataState
158+
query={{ data: undefined, isPending: true }}
159+
skeleton={<div data-testid="skeleton"></div>}
160+
>
161+
{() => <span data-testid="body" />}
162+
</DataState>,
163+
);
164+
expect(screen.getByTestId("skeleton")).toBeTruthy();
165+
});
166+
});

0 commit comments

Comments
 (0)