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} + + + ); }