Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
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
6 changes: 6 additions & 0 deletions .changeset/suspense-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@lingo.dev/_react": minor
---

add Suspense fallback to LingoProviderWrapper

11 changes: 9 additions & 2 deletions demo/vite-project/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ import "./index.css";
import App from "./App.tsx";

// Compiler: add import
import { LingoProviderWrapper, loadDictionary } from "lingo.dev/react/client";
import {
LingoProviderFallback,
LingoProviderWrapper,
loadDictionary,
} from "lingo.dev/react/client";

createRoot(document.getElementById("root")!).render(
<StrictMode>
<LingoProviderWrapper loadDictionary={(locale) => loadDictionary(locale)}>
<LingoProviderWrapper
loadDictionary={(locale) => loadDictionary(locale)}
fallback={<LingoProviderFallback />}
>
<App />
</LingoProviderWrapper>
</StrictMode>,
Expand Down
92 changes: 79 additions & 13 deletions packages/react/src/client/provider.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { LingoProvider, LingoProviderWrapper } from "./provider";
import { LingoContext } from "./context";
Expand Down Expand Up @@ -52,10 +52,9 @@ describe("client/provider", () => {
});

describe("LingoProviderWrapper", () => {
it("loads dictionary and renders children; returns null while loading", async () => {
const loadDictionary = vi
.fn()
.mockResolvedValue({ locale: "en", files: {} });
it("renders nothing while loading by default, then shows children", async () => {
const deferred = createDeferred<{ locale: string; files: Record<string, unknown> }>();
const loadDictionary = vi.fn(() => deferred.promise);

const Child = () => <div data-testid="child">ok</div>;

Expand All @@ -65,24 +64,91 @@ describe("client/provider", () => {
</LingoProviderWrapper>,
);

// initially null during loading
// No fallback by default (renders nothing during load)
expect(container.firstChild).toBeNull();

await act(async () => {
deferred.resolve({ locale: "en", files: {} });
await deferred.promise;
});

await waitFor(() => expect(loadDictionary).toHaveBeenCalled());
const child = await findByTestId("child");
expect(child != null).toBe(true);
expect(child).not.toBeNull();
});

it("swallows load errors and stays null", async () => {
const loadDictionary = vi.fn().mockRejectedValue(new Error("boom"));
const { container } = render(
<LingoProviderWrapper loadDictionary={loadDictionary}>
it("supports a custom fallback", () => {
const loadDictionary = vi.fn(() => new Promise(() => {}));

render(
<LingoProviderWrapper
loadDictionary={loadDictionary}
fallback={<div data-testid="fallback">waiting</div>}
>
<div />
</LingoProviderWrapper>,
);

await vi.waitFor(() => expect(loadDictionary).toHaveBeenCalled());
expect(container.firstChild).toBeNull();
const fallback = screen.getByTestId("fallback");
expect(fallback).not.toBeNull();
expect(fallback.textContent).toBe("waiting");
});

it("propagates load errors to the nearest error boundary", async () => {
const loadDictionary = vi.fn().mockRejectedValue(new Error("boom"));
const onError = vi.fn();
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});

render(
<TestErrorBoundary onError={onError}>
<LingoProviderWrapper loadDictionary={loadDictionary}>
<div />
</LingoProviderWrapper>
</TestErrorBoundary>,
);

await waitFor(() => expect(onError).toHaveBeenCalled());
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
expect(onError.mock.calls[0][0].message).toBe("boom");

const errorBoundary = await screen.findByTestId("boundary-error");
expect(errorBoundary).not.toBeNull();
expect(errorBoundary.textContent).toBe("error");

consoleSpy.mockRestore();
});
});
});

function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

class TestErrorBoundary extends React.Component<
{ onError: (error: Error) => void; children: React.ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };

static getDerivedStateFromError() {
return { hasError: true };
}

componentDidCatch(error: Error) {
this.props.onError(error);
}

render() {
if (this.state.hasError) {
return <div data-testid="boundary-error">error</div>;
}

return this.props.children;
}
}
120 changes: 92 additions & 28 deletions packages/react/src/client/provider.tsx
Copy link
Contributor

Choose a reason for hiding this comment

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

This introduces breaking changes. The documentation would also need to be updated (which only the team can do), the AdonisJS demo would likely break, and users may have to make changes to their code.
Because of this, I think feedback from the team is necessary (it might be better if the team made a change like this).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hey, thanks for the feedback! This doesn't break anything, existing code keeps working as is. The only change users see is a loading message instead of blank screen while the dictionary loads.

The fallback prop is optional. If you don't pass it, you get the default. If you pass null, you get the old behavior (no UI during load).

I added this because of the TODO comments in the code. If you'd rather handle it differently or want to discuss the approach first, just let me know and I can close this.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah to me it looks like error handling will be different now because of the Suspense but I'll test it locally. Give me a sec.

Copy link
Contributor

Choose a reason for hiding this comment

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

I tested the Vite and Next.js demos locally and they seem to be working fine. I can't test the AdonisJS one right now... but I think it will be okay ✔️

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Awesome, thanks! AdonisJS uses the same client provider so should be fine, but let me know if you want me to verify anything specific.

Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useState } from "react";
import { Suspense, useMemo } from "react";
import type { ReactNode } from "react";
import { LingoContext } from "./context";
import { getLocaleFromCookies } from "./utils";

Expand All @@ -15,7 +16,7 @@ export type LingoProviderProps<D> = {
/**
* The child components containing localizable content.
*/
children: React.ReactNode;
children: ReactNode;
};

/**
Expand Down Expand Up @@ -78,7 +79,6 @@ export type LingoProviderProps<D> = {
* ```
*/
export function LingoProvider<D>(props: LingoProviderProps<D>) {
// 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.");
}
Expand Down Expand Up @@ -106,7 +106,11 @@ export type LingoProviderWrapperProps<D> = {
/**
* The child components containing localizable content.
*/
children: React.ReactNode;
children: ReactNode;
/**
* Optional fallback element rendered while the dictionary is loading.
*/
fallback?: ReactNode;
};

/**
Expand All @@ -116,51 +120,111 @@ export type LingoProviderWrapperProps<D> = {
*
* - Should be placed at the top of the component tree
* - Should be used in purely client-side rendered applications (e.g., Vite-based apps)
* - Suspends rendering while the dictionary loads (no UI by default, opt-in with `fallback` prop)
*
* @template D - The type of the dictionary object containing localized content.
*
* @example Use in a Vite application
* @example Use in a Vite application with loading UI
* ```tsx file="src/main.tsx"
* import { LingoProviderWrapper, loadDictionary } from "lingo.dev/react/client";
* import { LingoProviderFallback, LingoProviderWrapper, loadDictionary } from "lingo.dev/react/client";
* import { StrictMode } from 'react'
* import { createRoot } from 'react-dom/client'
* import './index.css'
* import App from './App.tsx'
*
* createRoot(document.getElementById('root')!).render(
* <StrictMode>
* <LingoProviderWrapper loadDictionary={(locale) => loadDictionary(locale)}>
* <LingoProviderWrapper
* loadDictionary={(locale) => loadDictionary(locale)}
* fallback={<LingoProviderFallback />}
* >
* <App />
* </LingoProviderWrapper>
* </StrictMode>,
* );
* ```
*/
export function LingoProviderWrapper<D>(props: LingoProviderWrapperProps<D>) {
const [dictionary, setDictionary] = useState<D | null>(null);

// 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);
}
})();
}, []);
const locale = useMemo(() => getLocaleFromCookies(), []);
const resource = useMemo(
() =>
createDictionaryResource({
load: () => props.loadDictionary(locale),
locale,
}),
[props.loadDictionary, locale],
Comment on lines +149 to +155
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 resource will be recreated whenever props.loadDictionary changes identity (e.g., on parent re-renders if an inline arrow function is passed). This causes the dictionary to reload unnecessarily. The dependency array should likely only include locale, or the function reference should be stabilized with useCallback in the calling code. Consider adding a comment documenting that loadDictionary should be stable, or use useRef to store the load function and only recreate the resource when locale changes.

Copilot uses AI. Check for mistakes.
);

// TODO: handle case when the dictionary is loading (throw suspense?)
if (!dictionary) {
return null;
}
return (
<Suspense fallback={props.fallback}>
<DictionaryBoundary resource={resource}>
{props.children}
</DictionaryBoundary>
</Suspense>
);
}

function DictionaryBoundary<D>(props: {
resource: DictionaryResource<D>;
children: ReactNode;
}) {
const dictionary = props.resource.read();
return (
<LingoProvider dictionary={dictionary}>{props.children}</LingoProvider>
);
}

type DictionaryResource<D> = {
read(): D;
};

function createDictionaryResource<D>(options: {
load: () => Promise<D>;
locale: string | null;
}): DictionaryResource<D> {
let status: "pending" | "success" | "error" = "pending";
let value: D;
let error: unknown;

const { locale } = options;
console.log(`[Lingo.dev] Loading dictionary file for locale ${locale}...`);

const suspender = options
.load()
.then((result) => {
value = result;
status = "success";
return result;
})
.catch((err) => {
console.log("[Lingo.dev] Failed to load dictionary:", err);
error = err;
status = "error";
throw err;
});

return {
read(): D {
if (status === "pending") {
throw suspender;
}
if (status === "error") {
throw error;
}
return value;
},
};
}

export function LingoProviderFallback() {
return (
<div
role="status"
aria-live="polite"
aria-busy="true"
className="lingo-provider-fallback"
>
Loading translations...
</div>
);
}
Loading