diff --git a/packages/react/package.json b/packages/react/package.json index b365d82ec..db1e8b999 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -63,12 +63,14 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@lingui/core": "4.4.0" + "@lingui/core": "4.4.0", + "use-sync-external-store": "^1.2.0" }, "devDependencies": { "@lingui/jest-mocks": "*", "@testing-library/react": "^14.0.0", "@types/react": "^18.2.13", + "@types/use-sync-external-store": "^0.0.3", "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "react": "^18.2.0", diff --git a/packages/react/src/I18nProvider.test.tsx b/packages/react/src/I18nProvider.test.tsx index 3f7e73f91..2e4022177 100644 --- a/packages/react/src/I18nProvider.test.tsx +++ b/packages/react/src/I18nProvider.test.tsx @@ -132,53 +132,6 @@ describe("I18nProvider", () => { expect(container.textContent).toEqual("1_cs2_cs") }) - it( - "given 'en' locale, if activate('cs') call happens before i18n.on-change subscription in useEffect(), " + - "I18nProvider detects that it's stale and re-renders with the 'cs' locale value", - () => { - const i18n = setupI18n({ - locale: "en", - messages: { en: {} }, - }) - let renderCount = 0 - - const CurrentLocaleContextConsumer = () => { - const { i18n } = useLingui() - renderCount++ - return {i18n.locale} - } - - /** - * Note that we're doing exactly what the description says: - * but to simulate the equivalent situation, we pass our own mock subscriber - * to i18n.on("change", ...) and in it we call i18n.activate("cs") ourselves - * so that the condition in useEffect() is met and the component re-renders - * */ - const mockSubscriber = jest.fn(() => { - i18n.load("cs", {}) - i18n.activate("cs") - return () => { - // unsubscriber - noop to make TS happy - } - }) - jest.spyOn(i18n, "on").mockImplementation(mockSubscriber) - - const { getByTestId } = render( - - - - ) - - expect(mockSubscriber).toHaveBeenCalledWith( - "change", - expect.any(Function) - ) - - expect(getByTestId("child").textContent).toBe("cs") - expect(renderCount).toBe(2) - } - ) - it("should render children", () => { const i18n = setupI18n({ locale: "en", @@ -226,4 +179,36 @@ describe("I18nProvider", () => { expect(getByText("Ahoj světe")).toBeTruthy() }) + + it("when re-rendered with new i18n instance, it will forward it to children", () => { + const i18nCs = setupI18n({ + locale: "cs", + messages: { cs: {} }, + }) + + const i18nEn = setupI18n({ + locale: "en", + messages: { en: {} }, + }) + + const CurrentLocaleContextConsumer = () => { + const { i18n } = useLingui() + return {i18n.locale} + } + + const { container, rerender } = render( + + + + ) + + expect(container.textContent).toEqual("cs") + + rerender( + + + + ) + expect(container.textContent).toEqual("en") + }) }) diff --git a/packages/react/src/I18nProvider.tsx b/packages/react/src/I18nProvider.tsx index 4caddc849..3850117bf 100644 --- a/packages/react/src/I18nProvider.tsx +++ b/packages/react/src/I18nProvider.tsx @@ -1,4 +1,11 @@ -import React, { ComponentType, FunctionComponent } from "react" +import React, { + ComponentType, + FunctionComponent, + useCallback, + useRef, +} from "react" +import { useSyncExternalStore } from "use-sync-external-store/shim" + import type { I18n } from "@lingui/core" import type { TransRenderProps } from "./Trans" @@ -31,7 +38,6 @@ export const I18nProvider: FunctionComponent = ({ defaultComponent, children, }) => { - const latestKnownLocale = React.useRef(i18n.locale) /** * We can't pass `i18n` object directly through context, because even when locale * or messages are changed, i18n object is still the same. Context provider compares @@ -43,7 +49,7 @@ export const I18nProvider: FunctionComponent = ({ * * We can't use useMemo hook either, because we want to recalculate value manually. */ - const makeContext = React.useCallback( + const makeContext = useCallback( () => ({ i18n, defaultComponent, @@ -51,34 +57,43 @@ export const I18nProvider: FunctionComponent = ({ }), [i18n, defaultComponent] ) + const context = useRef(makeContext()) + + const subscribe = useCallback( + (onStoreChange: () => void) => { + const renderWithFreshContext = () => { + context.current = makeContext() + onStoreChange() + } + const propsChanged = + context.current.i18n !== i18n || + context.current.defaultComponent !== defaultComponent + if (propsChanged) { + renderWithFreshContext() + } + return i18n.on("change", renderWithFreshContext) + }, + [makeContext, i18n, defaultComponent] + ) - const [context, setContext] = React.useState(makeContext()) + const getSnapshot = useCallback(() => { + return context.current + }, []) /** * Subscribe for locale/message changes * - * I18n object from `@lingui/core` is the single source of truth for all i18n related + * the I18n object passed via props is the single source of truth for all i18n related * data (active locale, catalogs). When new messages are loaded or locale is changed - * we need to trigger re-rendering of LinguiContext.Consumers. + * we need to trigger re-rendering of LinguiContext consumers. */ - React.useEffect(() => { - const updateContext = () => { - latestKnownLocale.current = i18n.locale - setContext(makeContext()) - } - const unsubscribe = i18n.on("change", updateContext) - - /** - * unlikely, but if the locale changes before the onChange listener - * was added, we need to trigger a rerender - * */ - if (latestKnownLocale.current !== i18n.locale) { - updateContext() - } - return unsubscribe - }, [i18n, makeContext]) + const contextObject = useSyncExternalStore( + subscribe, + getSnapshot, + getSnapshot + ) - if (!latestKnownLocale.current) { + if (!contextObject.i18n.locale) { process.env.NODE_ENV === "development" && console.log( "I18nProvider rendered `null`. A call to `i18n.activate` needs to happen in order for translations to be activated and for the I18nProvider to render." + @@ -88,6 +103,8 @@ export const I18nProvider: FunctionComponent = ({ } return ( - {children} + + {children} + ) } diff --git a/yarn.lock b/yarn.lock index 53b025041..2a3b0c200 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3123,11 +3123,13 @@ __metadata: "@lingui/jest-mocks": "*" "@testing-library/react": ^14.0.0 "@types/react": ^18.2.13 + "@types/use-sync-external-store": ^0.0.3 eslint-plugin-react: ^7.32.2 eslint-plugin-react-hooks: ^4.6.0 react: ^18.2.0 react-dom: ^18.2.0 unbuild: ^1.1.2 + use-sync-external-store: ^1.2.0 peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 languageName: unknown @@ -4395,6 +4397,13 @@ __metadata: languageName: node linkType: hard +"@types/use-sync-external-store@npm:^0.0.3": + version: 0.0.3 + resolution: "@types/use-sync-external-store@npm:0.0.3" + checksum: 161ddb8eec5dbe7279ac971531217e9af6b99f7783213566d2b502e2e2378ea19cf5e5ea4595039d730aa79d3d35c6567d48599f69773a02ffcff1776ec2a44e + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.0 resolution: "@types/yargs-parser@npm:21.0.0" @@ -14824,6 +14833,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.2.0": + version: 1.2.0 + resolution: "use-sync-external-store@npm:1.2.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 5c639e0f8da3521d605f59ce5be9e094ca772bd44a4ce7322b055a6f58eeed8dda3c94cabd90c7a41fb6fa852210092008afe48f7038792fd47501f33299116a + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2"