Skip to content

Commit 0500fad

Browse files
author
divverma_cisco
committed
feat(react): add Suspense fallback to LingoProviderWrapper
1 parent cdaa0f8 commit 0500fad

File tree

4 files changed

+183
-44
lines changed

4 files changed

+183
-44
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: 81 additions & 15 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,37 +52,103 @@ 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("shows the default fallback while loading and then renders 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

62-
const { container, findByTestId } = render(
61+
const { findByTestId } = render(
6362
<LingoProviderWrapper loadDictionary={loadDictionary}>
6463
<Child />
6564
</LingoProviderWrapper>,
6665
);
6766

68-
// initially null during loading
69-
expect(container.firstChild).toBeNull();
67+
const fallback = screen.getByRole("status");
68+
expect(fallback.textContent).toBe("Loading translations...");
69+
70+
await act(async () => {
71+
deferred.resolve({ locale: "en", files: {} });
72+
await deferred.promise;
73+
});
7074

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: 87 additions & 27 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,107 @@ 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 so you can provide a custom fallback UI
119124
*
120125
* @template D - The type of the dictionary object containing localized content.
121126
*
122127
* @example Use in a Vite application
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);
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+
);
141157

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-
}, []);
158+
return (
159+
<Suspense fallback={props.fallback ?? <LingoProviderFallback />}>
160+
<DictionaryBoundary resource={resource}>{props.children}</DictionaryBoundary>
161+
</Suspense>
162+
);
163+
}
157164

158-
// TODO: handle case when the dictionary is loading (throw suspense?)
159-
if (!dictionary) {
160-
return null;
161-
}
165+
function DictionaryBoundary<D>(props: {
166+
resource: DictionaryResource<D>;
167+
children: ReactNode;
168+
}) {
169+
const dictionary = props.resource.read();
170+
return <LingoProvider dictionary={dictionary}>{props.children}</LingoProvider>;
171+
}
172+
173+
type DictionaryResource<D> = {
174+
read(): D;
175+
};
176+
177+
function createDictionaryResource<D>(options: {
178+
load: () => Promise<D>;
179+
locale: string | null;
180+
}): DictionaryResource<D> {
181+
let status: "pending" | "success" | "error" = "pending";
182+
let value: D;
183+
let error: unknown;
184+
185+
const { locale } = options;
186+
console.log(`[Lingo.dev] Loading dictionary file for locale ${locale}...`);
187+
188+
const suspender = options
189+
.load()
190+
.then((result) => {
191+
value = result;
192+
status = "success";
193+
return result;
194+
})
195+
.catch((err) => {
196+
console.log("[Lingo.dev] Failed to load dictionary:", err);
197+
error = err;
198+
status = "error";
199+
throw err;
200+
});
201+
202+
return {
203+
read(): D {
204+
if (status === "pending") {
205+
throw suspender;
206+
}
207+
if (status === "error") {
208+
throw error;
209+
}
210+
return value;
211+
},
212+
};
213+
}
162214

215+
export function LingoProviderFallback() {
163216
return (
164-
<LingoProvider dictionary={dictionary}>{props.children}</LingoProvider>
217+
<div
218+
role="status"
219+
aria-live="polite"
220+
aria-busy="true"
221+
className="lingo-provider-fallback"
222+
>
223+
Loading translations...
224+
</div>
165225
);
166226
}

0 commit comments

Comments
 (0)