diff --git a/packages/react/package.json b/packages/react/package.json index f4c51b441..287480777 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -63,14 +63,12 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@lingui/core": "4.4.1", - "use-sync-external-store": "^1.2.0" + "@lingui/core": "4.4.1" }, "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 2e4022177..3f7e73f91 100644 --- a/packages/react/src/I18nProvider.test.tsx +++ b/packages/react/src/I18nProvider.test.tsx @@ -132,6 +132,53 @@ 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", @@ -179,36 +226,4 @@ 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 bce3debbb..e3021cf88 100644 --- a/packages/react/src/I18nProvider.tsx +++ b/packages/react/src/I18nProvider.tsx @@ -1,11 +1,4 @@ -import React, { - ComponentType, - FunctionComponent, - useCallback, - useRef, -} from "react" -import { useSyncExternalStore } from "use-sync-external-store/shim" - +import React, { ComponentType, FunctionComponent } from "react" import type { I18n } from "@lingui/core" import type { TransRenderProps } from "./TransNoContext" @@ -38,6 +31,7 @@ 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 @@ -49,7 +43,7 @@ export const I18nProvider: FunctionComponent = ({ * * We can't use useMemo hook either, because we want to recalculate value manually. */ - const makeContext = useCallback( + const makeContext = React.useCallback( () => ({ i18n, defaultComponent, @@ -57,43 +51,34 @@ 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 getSnapshot = useCallback(() => { - return context.current - }, []) + const [context, setContext] = React.useState(makeContext()) /** * Subscribe for locale/message changes * - * the I18n object passed via props is the single source of truth for all i18n related + * I18n object from `@lingui/core` 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. */ - const contextObject = useSyncExternalStore( - subscribe, - getSnapshot, - getSnapshot - ) + 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]) - if (!contextObject.i18n.locale) { + if (!latestKnownLocale.current) { 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." + @@ -103,8 +88,6 @@ export const I18nProvider: FunctionComponent = ({ } return ( - - {children} - + {children} ) } diff --git a/yarn.lock b/yarn.lock index 5c377b4a1..9d0518ced 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3123,13 +3123,11 @@ __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 @@ -4397,13 +4395,6 @@ __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" @@ -14833,15 +14824,6 @@ __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"