Skip to content

Commit 4d2359a

Browse files
verma-divyanshu-gitdivverma_ciscomaxprilutskiy
authored
feat(react): add Suspense fallback to LingoProviderWrapper (#1534)
Co-authored-by: divverma_cisco <[email protected]> Co-authored-by: Max Prilutskiy <[email protected]>
1 parent 4127bc4 commit 4d2359a

File tree

4 files changed

+186
-43
lines changed

4 files changed

+186
-43
lines changed

.changeset/suspense-fallback.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@lingo.dev/_react": minor
3+
---
4+
5+
add Suspense fallback to LingoProviderWrapper
6+

demo/vite-project/src/main.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@ import "./index.css";
44
import App from "./App.tsx";
55

66
// Compiler: add import
7-
import { LingoProviderWrapper, loadDictionary } from "lingo.dev/react/client";
7+
import {
8+
LingoProviderFallback,
9+
LingoProviderWrapper,
10+
loadDictionary,
11+
} from "lingo.dev/react/client";
812

913
createRoot(document.getElementById("root")!).render(
1014
<StrictMode>
11-
<LingoProviderWrapper loadDictionary={(locale) => loadDictionary(locale)}>
15+
<LingoProviderWrapper
16+
loadDictionary={(locale) => loadDictionary(locale)}
17+
fallback={<LingoProviderFallback />}
18+
>
1219
<App />
1320
</LingoProviderWrapper>
1421
</StrictMode>,

packages/react/src/client/provider.spec.tsx

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest";
2-
import { render, screen, waitFor } from "@testing-library/react";
2+
import { act, render, screen, waitFor } from "@testing-library/react";
33
import React from "react";
44
import { LingoProvider, LingoProviderWrapper } from "./provider";
55
import { LingoContext } from "./context";
@@ -52,10 +52,9 @@ describe("client/provider", () => {
5252
});
5353

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

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

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

68-
// initially null during loading
67+
// No fallback by default (renders nothing during load)
6968
expect(container.firstChild).toBeNull();
7069

70+
await act(async () => {
71+
deferred.resolve({ locale: "en", files: {} });
72+
await deferred.promise;
73+
});
74+
7175
await waitFor(() => expect(loadDictionary).toHaveBeenCalled());
7276
const child = await findByTestId("child");
73-
expect(child != null).toBe(true);
77+
expect(child).not.toBeNull();
7478
});
7579

76-
it("swallows load errors and stays null", async () => {
77-
const loadDictionary = vi.fn().mockRejectedValue(new Error("boom"));
78-
const { container } = render(
79-
<LingoProviderWrapper loadDictionary={loadDictionary}>
80+
it("supports a custom fallback", () => {
81+
const loadDictionary = vi.fn(() => new Promise(() => {}));
82+
83+
render(
84+
<LingoProviderWrapper
85+
loadDictionary={loadDictionary}
86+
fallback={<div data-testid="fallback">waiting</div>}
87+
>
8088
<div />
8189
</LingoProviderWrapper>,
8290
);
8391

84-
await vi.waitFor(() => expect(loadDictionary).toHaveBeenCalled());
85-
expect(container.firstChild).toBeNull();
92+
const fallback = screen.getByTestId("fallback");
93+
expect(fallback).not.toBeNull();
94+
expect(fallback.textContent).toBe("waiting");
95+
});
96+
97+
it("propagates load errors to the nearest error boundary", async () => {
98+
const loadDictionary = vi.fn().mockRejectedValue(new Error("boom"));
99+
const onError = vi.fn();
100+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
101+
102+
render(
103+
<TestErrorBoundary onError={onError}>
104+
<LingoProviderWrapper loadDictionary={loadDictionary}>
105+
<div />
106+
</LingoProviderWrapper>
107+
</TestErrorBoundary>,
108+
);
109+
110+
await waitFor(() => expect(onError).toHaveBeenCalled());
111+
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
112+
expect(onError.mock.calls[0][0].message).toBe("boom");
113+
114+
const errorBoundary = await screen.findByTestId("boundary-error");
115+
expect(errorBoundary).not.toBeNull();
116+
expect(errorBoundary.textContent).toBe("error");
117+
118+
consoleSpy.mockRestore();
86119
});
87120
});
88121
});
122+
123+
function createDeferred<T>() {
124+
let resolve!: (value: T | PromiseLike<T>) => void;
125+
let reject!: (reason?: unknown) => void;
126+
const promise = new Promise<T>((res, rej) => {
127+
resolve = res;
128+
reject = rej;
129+
});
130+
return { promise, resolve, reject };
131+
}
132+
133+
class TestErrorBoundary extends React.Component<
134+
{ onError: (error: Error) => void; children: React.ReactNode },
135+
{ hasError: boolean }
136+
> {
137+
state = { hasError: false };
138+
139+
static getDerivedStateFromError() {
140+
return { hasError: true };
141+
}
142+
143+
componentDidCatch(error: Error) {
144+
this.props.onError(error);
145+
}
146+
147+
render() {
148+
if (this.state.hasError) {
149+
return <div data-testid="boundary-error">error</div>;
150+
}
151+
152+
return this.props.children;
153+
}
154+
}

packages/react/src/client/provider.tsx

Lines changed: 92 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

3-
import { useEffect, useState } from "react";
3+
import { Suspense, useMemo } from "react";
4+
import type { ReactNode } from "react";
45
import { LingoContext } from "./context";
56
import { getLocaleFromCookies } from "./utils";
67

@@ -15,7 +16,7 @@ export type LingoProviderProps<D> = {
1516
/**
1617
* The child components containing localizable content.
1718
*/
18-
children: React.ReactNode;
19+
children: ReactNode;
1920
};
2021

2122
/**
@@ -78,7 +79,6 @@ export type LingoProviderProps<D> = {
7879
* ```
7980
*/
8081
export function LingoProvider<D>(props: LingoProviderProps<D>) {
81-
// TODO: handle case when no dictionary is provided - throw suspense? return null / other fallback?
8282
if (!props.dictionary) {
8383
throw new Error("LingoProvider: dictionary is not provided.");
8484
}
@@ -106,7 +106,11 @@ export type LingoProviderWrapperProps<D> = {
106106
/**
107107
* The child components containing localizable content.
108108
*/
109-
children: React.ReactNode;
109+
children: ReactNode;
110+
/**
111+
* Optional fallback element rendered while the dictionary is loading.
112+
*/
113+
fallback?: ReactNode;
110114
};
111115

112116
/**
@@ -116,51 +120,111 @@ export type LingoProviderWrapperProps<D> = {
116120
*
117121
* - Should be placed at the top of the component tree
118122
* - Should be used in purely client-side rendered applications (e.g., Vite-based apps)
123+
* - Suspends rendering while the dictionary loads (no UI by default, opt-in with `fallback` prop)
119124
*
120125
* @template D - The type of the dictionary object containing localized content.
121126
*
122-
* @example Use in a Vite application
127+
* @example Use in a Vite application with loading UI
123128
* ```tsx file="src/main.tsx"
124-
* import { LingoProviderWrapper, loadDictionary } from "lingo.dev/react/client";
129+
* import { LingoProviderFallback, LingoProviderWrapper, loadDictionary } from "lingo.dev/react/client";
125130
* import { StrictMode } from 'react'
126131
* import { createRoot } from 'react-dom/client'
127132
* import './index.css'
128133
* import App from './App.tsx'
129134
*
130135
* createRoot(document.getElementById('root')!).render(
131136
* <StrictMode>
132-
* <LingoProviderWrapper loadDictionary={(locale) => loadDictionary(locale)}>
137+
* <LingoProviderWrapper
138+
* loadDictionary={(locale) => loadDictionary(locale)}
139+
* fallback={<LingoProviderFallback />}
140+
* >
133141
* <App />
134142
* </LingoProviderWrapper>
135143
* </StrictMode>,
136144
* );
137145
* ```
138146
*/
139147
export function LingoProviderWrapper<D>(props: LingoProviderWrapperProps<D>) {
140-
const [dictionary, setDictionary] = useState<D | null>(null);
141-
142-
// for client-side rendered apps, the dictionary is also loaded on the client
143-
useEffect(() => {
144-
(async () => {
145-
try {
146-
const locale = getLocaleFromCookies();
147-
console.log(
148-
`[Lingo.dev] Loading dictionary file for locale ${locale}...`,
149-
);
150-
const localeDictionary = await props.loadDictionary(locale);
151-
setDictionary(localeDictionary);
152-
} catch (error) {
153-
console.log("[Lingo.dev] Failed to load dictionary:", error);
154-
}
155-
})();
156-
}, []);
148+
const locale = useMemo(() => getLocaleFromCookies(), []);
149+
const resource = useMemo(
150+
() =>
151+
createDictionaryResource({
152+
load: () => props.loadDictionary(locale),
153+
locale,
154+
}),
155+
[props.loadDictionary, locale],
156+
);
157157

158-
// TODO: handle case when the dictionary is loading (throw suspense?)
159-
if (!dictionary) {
160-
return null;
161-
}
158+
return (
159+
<Suspense fallback={props.fallback}>
160+
<DictionaryBoundary resource={resource}>
161+
{props.children}
162+
</DictionaryBoundary>
163+
</Suspense>
164+
);
165+
}
162166

167+
function DictionaryBoundary<D>(props: {
168+
resource: DictionaryResource<D>;
169+
children: ReactNode;
170+
}) {
171+
const dictionary = props.resource.read();
163172
return (
164173
<LingoProvider dictionary={dictionary}>{props.children}</LingoProvider>
165174
);
166175
}
176+
177+
type DictionaryResource<D> = {
178+
read(): D;
179+
};
180+
181+
function createDictionaryResource<D>(options: {
182+
load: () => Promise<D>;
183+
locale: string | null;
184+
}): DictionaryResource<D> {
185+
let status: "pending" | "success" | "error" = "pending";
186+
let value: D;
187+
let error: unknown;
188+
189+
const { locale } = options;
190+
console.log(`[Lingo.dev] Loading dictionary file for locale ${locale}...`);
191+
192+
const suspender = options
193+
.load()
194+
.then((result) => {
195+
value = result;
196+
status = "success";
197+
return result;
198+
})
199+
.catch((err) => {
200+
console.log("[Lingo.dev] Failed to load dictionary:", err);
201+
error = err;
202+
status = "error";
203+
throw err;
204+
});
205+
206+
return {
207+
read(): D {
208+
if (status === "pending") {
209+
throw suspender;
210+
}
211+
if (status === "error") {
212+
throw error;
213+
}
214+
return value;
215+
},
216+
};
217+
}
218+
219+
export function LingoProviderFallback() {
220+
return (
221+
<div
222+
role="status"
223+
aria-live="polite"
224+
aria-busy="true"
225+
className="lingo-provider-fallback"
226+
>
227+
Loading translations...
228+
</div>
229+
);
230+
}

0 commit comments

Comments
 (0)