diff --git a/packages/react/src/client/provider.spec.tsx b/packages/react/src/client/provider.spec.tsx
index b0f803b75..3184dae98 100644
--- a/packages/react/src/client/provider.spec.tsx
+++ b/packages/react/src/client/provider.spec.tsx
@@ -1,7 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import React from "react";
-import { LingoProvider, LingoProviderWrapper } from "./provider";
+import {
+ LingoProvider,
+ LingoProviderWrapper,
+ clearDictionaryCache,
+} from "./provider";
import { LingoContext } from "./context";
vi.mock("./utils", async (orig) => {
@@ -15,6 +19,7 @@ vi.mock("./utils", async (orig) => {
describe("client/provider", () => {
beforeEach(() => {
vi.clearAllMocks();
+ clearDictionaryCache();
});
describe("LingoProvider", () => {
@@ -52,37 +57,46 @@ describe("client/provider", () => {
});
describe("LingoProviderWrapper", () => {
- it("loads dictionary and renders children; returns null while loading", async () => {
+ it("loads dictionary with Suspense and renders children", async () => {
const loadDictionary = vi
.fn()
.mockResolvedValue({ locale: "en", files: {} });
const Child = () =>
ok
;
- const { container, findByTestId } = render(
+ const { findByTestId } = render(
,
);
- // initially null during loading
- expect(container.firstChild).toBeNull();
-
await waitFor(() => expect(loadDictionary).toHaveBeenCalled());
const child = await findByTestId("child");
- expect(child != null).toBe(true);
+ expect(child).toBeTruthy();
});
- it("swallows load errors and stays null", async () => {
- const loadDictionary = vi.fn().mockRejectedValue(new Error("boom"));
- const { container } = render(
-
-
+ it("supports custom fallback UI", async () => {
+ const loadDictionary = vi
+ .fn()
+ .mockResolvedValue({ locale: "en", files: {} });
+
+ const CustomFallback = () => (
+ Custom loading
+ );
+ const Child = () => ok
;
+
+ const { findByTestId } = render(
+ }
+ >
+
,
);
- await vi.waitFor(() => expect(loadDictionary).toHaveBeenCalled());
- expect(container.firstChild).toBeNull();
+ const child = await findByTestId("child");
+ expect(child).toBeTruthy();
+ expect(loadDictionary).toHaveBeenCalledWith("en");
});
});
});
diff --git a/packages/react/src/client/provider.tsx b/packages/react/src/client/provider.tsx
index ed39b7e17..2ed8910f2 100644
--- a/packages/react/src/client/provider.tsx
+++ b/packages/react/src/client/provider.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState } from "react";
+import React, { Suspense, ReactNode } from "react";
import { LingoContext } from "./context";
import { getLocaleFromCookies } from "./utils";
@@ -78,7 +78,6 @@ export type LingoProviderProps = {
* ```
*/
export function LingoProvider(props: LingoProviderProps) {
- // TODO: handle case when no dictionary is provided - throw suspense? return null / other fallback?
if (!props.dictionary) {
throw new Error("LingoProvider: dictionary is not provided.");
}
@@ -91,6 +90,52 @@ export function LingoProvider(props: LingoProviderProps) {
);
}
+/**
+ * A simple default fallback component displayed while the dictionary is loading.
+ */
+function DefaultLoadingFallback() {
+ return (
+
+
+
+
+
Loading translations...
+
+
+ );
+}
+
/**
* The props for the `LingoProviderWrapper` component.
*/
@@ -107,6 +152,11 @@ export type LingoProviderWrapperProps = {
* The child components containing localizable content.
*/
children: React.ReactNode;
+ /**
+ * Optional fallback UI to display while the dictionary is loading.
+ * If not provided, a default loading spinner will be shown.
+ */
+ fallback?: ReactNode;
};
/**
@@ -116,10 +166,12 @@ export type LingoProviderWrapperProps = {
*
* - Should be placed at the top of the component tree
* - Should be used in purely client-side rendered applications (e.g., Vite-based apps)
+ * - Uses React Suspense internally to handle async dictionary loading
+ * - Shows a loading fallback while the dictionary is being fetched
*
* @template D - The type of the dictionary object containing localized content.
*
- * @example Use in a Vite application
+ * @example Use in a Vite application with default fallback
* ```tsx file="src/main.tsx"
* import { LingoProviderWrapper, loadDictionary } from "lingo.dev/react/client";
* import { StrictMode } from 'react'
@@ -135,32 +187,78 @@ export type LingoProviderWrapperProps = {
* ,
* );
* ```
+ *
+ * @example Use with custom loading fallback
+ * ```tsx
+ * loadDictionary(locale)}
+ * fallback={Loading your language...
}
+ * >
+ *
+ *
+ * ```
*/
-export function LingoProviderWrapper(props: LingoProviderWrapperProps) {
- const [dictionary, setDictionary] = useState(null);
+/**
+ * Cache to store loaded dictionaries to maintain stable references
+ */
+const dictionaryCache = new Map();
+const loadingPromises = new Map>();
- // for client-side rendered apps, the dictionary is also loaded on the client
- useEffect(() => {
- (async () => {
- try {
- const locale = getLocaleFromCookies();
- console.log(
- `[Lingo.dev] Loading dictionary file for locale ${locale}...`,
- );
- const localeDictionary = await props.loadDictionary(locale);
- setDictionary(localeDictionary);
- } catch (error) {
- console.log("[Lingo.dev] Failed to load dictionary:", error);
- }
- })();
- }, []);
+/**
+ * Clears the dictionary cache.
+ * @internal
+ */
+export function clearDictionaryCache() {
+ dictionaryCache.clear();
+ loadingPromises.clear();
+}
+
+/**
+ * Internal component that loads the dictionary and suspends until ready.
+ */
+function LingoProviderWrapperInternal(
+ props: Omit, "fallback">,
+) {
+ const locale = getLocaleFromCookies();
+ const cacheKey = `dictionary-${locale}`;
- // TODO: handle case when the dictionary is loading (throw suspense?)
- if (!dictionary) {
- return null;
+ // Check if dictionary is already loaded
+ if (dictionaryCache.has(cacheKey)) {
+ const dictionary = dictionaryCache.get(cacheKey);
+ return (
+ {props.children}
+ );
}
+ // Check if we're currently loading
+ if (!loadingPromises.has(cacheKey)) {
+ console.log(`[Lingo.dev] Loading dictionary file for locale ${locale}...`);
+ const promise = props
+ .loadDictionary(locale)
+ .then((dict) => {
+ console.log(`[Lingo.dev] Dictionary loaded successfully`);
+ dictionaryCache.set(cacheKey, dict);
+ loadingPromises.delete(cacheKey);
+ return dict;
+ })
+ .catch((error) => {
+ console.error("[Lingo.dev] Failed to load dictionary:", error);
+ loadingPromises.delete(cacheKey);
+ throw error;
+ });
+ loadingPromises.set(cacheKey, promise);
+ }
+
+ // Throw the promise to trigger Suspense
+ throw loadingPromises.get(cacheKey)!;
+}
+
+export function LingoProviderWrapper(props: LingoProviderWrapperProps) {
+ const { fallback = , ...rest } = props;
+
return (
- {props.children}
+
+
+
);
}