Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/improved-loading-error-states.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@lingo.dev/_react": minor
---

Add optional loading and error component support to LingoProviderWrapper

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.

**New Features:**
- `loadingComponent`: Optional prop to display custom loading UI while the dictionary loads
- `errorComponent`: Optional prop to display custom error UI when dictionary loading fails

**Benefits:**
- Better user experience with visible loading states
- Proper error handling with customizable error displays
- Backward compatible - maintains null behavior when props are not provided
- Type-safe error handling with Error object passed to error component

**Example Usage:**
```tsx
<LingoProviderWrapper
loadDictionary={loadDictionary}
loadingComponent={<div>Loading translations...</div>}
errorComponent={({ error }) => <div>Error: {error.message}</div>}
>
<App />
</LingoProviderWrapper>
```
98 changes: 98 additions & 0 deletions packages/react/src/client/provider.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,103 @@ describe("client/provider", () => {
await vi.waitFor(() => expect(loadDictionary).toHaveBeenCalled());
expect(container.firstChild).toBeNull();
});

it("renders custom loading component while loading", async () => {
const loadDictionary = vi
.fn()
.mockResolvedValue({ locale: "en", files: {} });

const LoadingComponent = () => (
<div data-testid="loading">Loading...</div>
);

const { getByTestId, findByTestId } = render(
<LingoProviderWrapper
loadDictionary={loadDictionary}
loadingComponent={<LoadingComponent />}
>
<div data-testid="child">Content</div>
</LingoProviderWrapper>,
);

// should show loading component initially
expect(getByTestId("loading")).toBeTruthy();

// wait for dictionary to load
await waitFor(() => expect(loadDictionary).toHaveBeenCalled());

// should show child content after loading
const child = await findByTestId("child");
expect(child).toBeTruthy();
});

it("renders custom error component when loading fails", async () => {
const testError = new Error("Network error");
const loadDictionary = vi.fn().mockRejectedValue(testError);

const ErrorComponent = ({ error }: { error: Error }) => (
<div data-testid="error">Error: {error.message}</div>
);

const { findByTestId } = render(
<LingoProviderWrapper
loadDictionary={loadDictionary}
errorComponent={ErrorComponent}
>
<div>Content</div>
</LingoProviderWrapper>,
);

await waitFor(() => expect(loadDictionary).toHaveBeenCalled());

const errorEl = await findByTestId("error");
expect(errorEl.textContent).toBe("Error: Network error");
});

it("converts non-Error objects to Error in error state", async () => {
const loadDictionary = vi.fn().mockRejectedValue("string error");

const ErrorComponent = ({ error }: { error: Error }) => (
<div data-testid="error">{error.message}</div>
);

const { findByTestId } = render(
<LingoProviderWrapper
loadDictionary={loadDictionary}
errorComponent={ErrorComponent}
>
<div>Content</div>
</LingoProviderWrapper>,
);

await waitFor(() => expect(loadDictionary).toHaveBeenCalled());

const errorEl = await findByTestId("error");
expect(errorEl.textContent).toBe("string error");
});

it("transitions from loading to loaded to error states correctly", async () => {
const loadDictionary = vi
.fn()
.mockResolvedValue({ locale: "en", files: {} });

const { findByTestId, rerender } = render(
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rerender destructured from render() is assigned but never used in this test. This suggests either incomplete test implementation or unnecessary variable assignment.

Consider removing the unused rerender variable:

const { findByTestId } = render(
Suggested change
const { findByTestId, rerender } = render(
const { findByTestId } = render(

Copilot uses AI. Check for mistakes.
Copy link
Author

@Agarwalchetan Agarwalchetan Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improved Old Problems

<LingoProviderWrapper
loadDictionary={loadDictionary}
loadingComponent={<div data-testid="loading">Loading</div>}
>
<div data-testid="content">Content</div>
</LingoProviderWrapper>,
);

// verify loading state
expect(await findByTestId("loading")).toBeTruthy();

// wait for successful load
await waitFor(() => expect(loadDictionary).toHaveBeenCalled());

// verify loaded state
expect(await findByTestId("content")).toBeTruthy();
});
});
});
58 changes: 52 additions & 6 deletions packages/react/src/client/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,38 @@ export type LingoProviderWrapperProps<D> = {
* The child components containing localizable content.
*/
children: React.ReactNode;
/**
* Optional component to render while the dictionary is loading.
* If not provided, renders `null` during loading (default behavior).
*
* @example
* ```tsx
* <LingoProviderWrapper
* loadDictionary={loadDictionary}
* loadingComponent={<div>Loading translations...</div>}
* >
* <App />
* </LingoProviderWrapper>
* ```
*/
loadingComponent?: React.ReactNode;
/**
* Optional component to render when dictionary loading fails.
* Receives the error as a prop. If not provided, renders `null` on error (default behavior).
*
* @example
* ```tsx
* <LingoProviderWrapper
* loadDictionary={loadDictionary}
* errorComponent={({ error }) => (
* <div>Failed to load translations: {error.message}</div>
* )}
* >
* <App />
* </LingoProviderWrapper>
* ```
*/
errorComponent?: React.ComponentType<{ error: Error }>;
};

/**
Expand Down Expand Up @@ -137,7 +169,11 @@ export type LingoProviderWrapperProps<D> = {
* ```
*/
export function LingoProviderWrapper<D>(props: LingoProviderWrapperProps<D>) {
const [dictionary, setDictionary] = useState<D | null>(null);
const [state, setState] = useState<
| { status: "loading" }
| { status: "loaded"; dictionary: D }
| { status: "error"; error: Error }
>({ status: "loading" });

// for client-side rendered apps, the dictionary is also loaded on the client
useEffect(() => {
Expand All @@ -148,19 +184,29 @@ export function LingoProviderWrapper<D>(props: LingoProviderWrapperProps<D>) {
`[Lingo.dev] Loading dictionary file for locale ${locale}...`,
);
const localeDictionary = await props.loadDictionary(locale);
setDictionary(localeDictionary);
setState({ status: "loaded", dictionary: localeDictionary });
} catch (error) {
console.log("[Lingo.dev] Failed to load dictionary:", error);
setState({
status: "error",
error: error instanceof Error ? error : new Error(String(error)),
});
}
})();
}, []);

// TODO: handle case when the dictionary is loading (throw suspense?)
if (!dictionary) {
return null;
if (state.status === "loading") {
return props.loadingComponent ?? null;
}

if (state.status === "error") {
const ErrorComponent = props.errorComponent;
return ErrorComponent ? <ErrorComponent error={state.error} /> : null;
}

return (
<LingoProvider dictionary={dictionary}>{props.children}</LingoProvider>
<LingoProvider dictionary={state.dictionary}>
{props.children}
</LingoProvider>
);
}
Loading