-
Notifications
You must be signed in to change notification settings - Fork 666
feat(react): add Suspense fallback to LingoProviderWrapper #1534
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
60d5ff7
416a694
f45b0ab
182a981
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "@lingo.dev/_react": minor | ||
| --- | ||
|
|
||
| add Suspense fallback to LingoProviderWrapper | ||
|
|
| 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"; | ||
|
|
||
|
|
@@ -15,7 +16,7 @@ export type LingoProviderProps<D> = { | |
| /** | ||
| * The child components containing localizable content. | ||
| */ | ||
| children: React.ReactNode; | ||
| children: ReactNode; | ||
| }; | ||
|
|
||
| /** | ||
|
|
@@ -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."); | ||
| } | ||
|
|
@@ -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; | ||
| }; | ||
|
|
||
| /** | ||
|
|
@@ -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
|
||
| ); | ||
|
|
||
| // 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> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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
fallbackprop is optional. If you don't pass it, you get the default. If you passnull, 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 ✔️
There was a problem hiding this comment.
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.