Skip to content

Commit 35837a1

Browse files
committed
feat: add loading and error component support to LingoProviderWrapper
1 parent cdaa0f8 commit 35837a1

File tree

3 files changed

+178
-6
lines changed

3 files changed

+178
-6
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@lingo.dev/_react": minor
3+
---
4+
5+
Add optional loading and error component support to LingoProviderWrapper
6+
7+
This enhancement adds `loadingComponent` and `errorComponent` props to the `LingoProviderWrapper` component, enabling developers to provide custom UI for loading and error states instead of the default null behavior.
8+
9+
**New Features:**
10+
- `loadingComponent`: Optional prop to display custom loading UI while the dictionary loads
11+
- `errorComponent`: Optional prop to display custom error UI when dictionary loading fails
12+
13+
**Benefits:**
14+
- Better user experience with visible loading states
15+
- Proper error handling with customizable error displays
16+
- Backward compatible - maintains null behavior when props are not provided
17+
- Type-safe error handling with Error object passed to error component
18+
19+
**Example Usage:**
20+
```tsx
21+
<LingoProviderWrapper
22+
loadDictionary={loadDictionary}
23+
loadingComponent={<div>Loading translations...</div>}
24+
errorComponent={({ error }) => <div>Error: {error.message}</div>}
25+
>
26+
<App />
27+
</LingoProviderWrapper>
28+
```

packages/react/src/client/provider.spec.tsx

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,5 +84,103 @@ describe("client/provider", () => {
8484
await vi.waitFor(() => expect(loadDictionary).toHaveBeenCalled());
8585
expect(container.firstChild).toBeNull();
8686
});
87+
88+
it("renders custom loading component while loading", async () => {
89+
const loadDictionary = vi
90+
.fn()
91+
.mockResolvedValue({ locale: "en", files: {} });
92+
93+
const LoadingComponent = () => (
94+
<div data-testid="loading">Loading...</div>
95+
);
96+
97+
const { getByTestId, findByTestId } = render(
98+
<LingoProviderWrapper
99+
loadDictionary={loadDictionary}
100+
loadingComponent={<LoadingComponent />}
101+
>
102+
<div data-testid="child">Content</div>
103+
</LingoProviderWrapper>,
104+
);
105+
106+
// should show loading component initially
107+
expect(getByTestId("loading")).toBeTruthy();
108+
109+
// wait for dictionary to load
110+
await waitFor(() => expect(loadDictionary).toHaveBeenCalled());
111+
112+
// should show child content after loading
113+
const child = await findByTestId("child");
114+
expect(child).toBeTruthy();
115+
});
116+
117+
it("renders custom error component when loading fails", async () => {
118+
const testError = new Error("Network error");
119+
const loadDictionary = vi.fn().mockRejectedValue(testError);
120+
121+
const ErrorComponent = ({ error }: { error: Error }) => (
122+
<div data-testid="error">Error: {error.message}</div>
123+
);
124+
125+
const { findByTestId } = render(
126+
<LingoProviderWrapper
127+
loadDictionary={loadDictionary}
128+
errorComponent={ErrorComponent}
129+
>
130+
<div>Content</div>
131+
</LingoProviderWrapper>,
132+
);
133+
134+
await waitFor(() => expect(loadDictionary).toHaveBeenCalled());
135+
136+
const errorEl = await findByTestId("error");
137+
expect(errorEl.textContent).toBe("Error: Network error");
138+
});
139+
140+
it("converts non-Error objects to Error in error state", async () => {
141+
const loadDictionary = vi.fn().mockRejectedValue("string error");
142+
143+
const ErrorComponent = ({ error }: { error: Error }) => (
144+
<div data-testid="error">{error.message}</div>
145+
);
146+
147+
const { findByTestId } = render(
148+
<LingoProviderWrapper
149+
loadDictionary={loadDictionary}
150+
errorComponent={ErrorComponent}
151+
>
152+
<div>Content</div>
153+
</LingoProviderWrapper>,
154+
);
155+
156+
await waitFor(() => expect(loadDictionary).toHaveBeenCalled());
157+
158+
const errorEl = await findByTestId("error");
159+
expect(errorEl.textContent).toBe("string error");
160+
});
161+
162+
it("transitions from loading to loaded to error states correctly", async () => {
163+
const loadDictionary = vi
164+
.fn()
165+
.mockResolvedValue({ locale: "en", files: {} });
166+
167+
const { findByTestId, rerender } = render(
168+
<LingoProviderWrapper
169+
loadDictionary={loadDictionary}
170+
loadingComponent={<div data-testid="loading">Loading</div>}
171+
>
172+
<div data-testid="content">Content</div>
173+
</LingoProviderWrapper>,
174+
);
175+
176+
// verify loading state
177+
expect(await findByTestId("loading")).toBeTruthy();
178+
179+
// wait for successful load
180+
await waitFor(() => expect(loadDictionary).toHaveBeenCalled());
181+
182+
// verify loaded state
183+
expect(await findByTestId("content")).toBeTruthy();
184+
});
87185
});
88186
});

packages/react/src/client/provider.tsx

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,38 @@ export type LingoProviderWrapperProps<D> = {
107107
* The child components containing localizable content.
108108
*/
109109
children: React.ReactNode;
110+
/**
111+
* Optional component to render while the dictionary is loading.
112+
* If not provided, renders `null` during loading (default behavior).
113+
*
114+
* @example
115+
* ```tsx
116+
* <LingoProviderWrapper
117+
* loadDictionary={loadDictionary}
118+
* loadingComponent={<div>Loading translations...</div>}
119+
* >
120+
* <App />
121+
* </LingoProviderWrapper>
122+
* ```
123+
*/
124+
loadingComponent?: React.ReactNode;
125+
/**
126+
* Optional component to render when dictionary loading fails.
127+
* Receives the error as a prop. If not provided, renders `null` on error (default behavior).
128+
*
129+
* @example
130+
* ```tsx
131+
* <LingoProviderWrapper
132+
* loadDictionary={loadDictionary}
133+
* errorComponent={({ error }) => (
134+
* <div>Failed to load translations: {error.message}</div>
135+
* )}
136+
* >
137+
* <App />
138+
* </LingoProviderWrapper>
139+
* ```
140+
*/
141+
errorComponent?: React.ComponentType<{ error: Error }>;
110142
};
111143

112144
/**
@@ -137,7 +169,11 @@ export type LingoProviderWrapperProps<D> = {
137169
* ```
138170
*/
139171
export function LingoProviderWrapper<D>(props: LingoProviderWrapperProps<D>) {
140-
const [dictionary, setDictionary] = useState<D | null>(null);
172+
const [state, setState] = useState<
173+
| { status: "loading" }
174+
| { status: "loaded"; dictionary: D }
175+
| { status: "error"; error: Error }
176+
>({ status: "loading" });
141177

142178
// for client-side rendered apps, the dictionary is also loaded on the client
143179
useEffect(() => {
@@ -148,19 +184,29 @@ export function LingoProviderWrapper<D>(props: LingoProviderWrapperProps<D>) {
148184
`[Lingo.dev] Loading dictionary file for locale ${locale}...`,
149185
);
150186
const localeDictionary = await props.loadDictionary(locale);
151-
setDictionary(localeDictionary);
187+
setState({ status: "loaded", dictionary: localeDictionary });
152188
} catch (error) {
153189
console.log("[Lingo.dev] Failed to load dictionary:", error);
190+
setState({
191+
status: "error",
192+
error: error instanceof Error ? error : new Error(String(error)),
193+
});
154194
}
155195
})();
156196
}, []);
157197

158-
// TODO: handle case when the dictionary is loading (throw suspense?)
159-
if (!dictionary) {
160-
return null;
198+
if (state.status === "loading") {
199+
return props.loadingComponent ?? null;
200+
}
201+
202+
if (state.status === "error") {
203+
const ErrorComponent = props.errorComponent;
204+
return ErrorComponent ? <ErrorComponent error={state.error} /> : null;
161205
}
162206

163207
return (
164-
<LingoProvider dictionary={dictionary}>{props.children}</LingoProvider>
208+
<LingoProvider dictionary={state.dictionary}>
209+
{props.children}
210+
</LingoProvider>
165211
);
166212
}

0 commit comments

Comments
 (0)