Skip to content

Commit 47d4fdb

Browse files
authored
refactor: use useSyncExternalStore to subscribe for context updates (#1746)
1 parent 8232e13 commit 47d4fdb

4 files changed

Lines changed: 94 additions & 72 deletions

File tree

packages/react/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,14 @@
6363
},
6464
"dependencies": {
6565
"@babel/runtime": "^7.20.13",
66-
"@lingui/core": "4.4.0"
66+
"@lingui/core": "4.4.0",
67+
"use-sync-external-store": "^1.2.0"
6768
},
6869
"devDependencies": {
6970
"@lingui/jest-mocks": "*",
7071
"@testing-library/react": "^14.0.0",
7172
"@types/react": "^18.2.13",
73+
"@types/use-sync-external-store": "^0.0.3",
7274
"eslint-plugin-react": "^7.32.2",
7375
"eslint-plugin-react-hooks": "^4.6.0",
7476
"react": "^18.2.0",

packages/react/src/I18nProvider.test.tsx

Lines changed: 32 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -132,53 +132,6 @@ describe("I18nProvider", () => {
132132
expect(container.textContent).toEqual("1_cs2_cs")
133133
})
134134

135-
it(
136-
"given 'en' locale, if activate('cs') call happens before i18n.on-change subscription in useEffect(), " +
137-
"I18nProvider detects that it's stale and re-renders with the 'cs' locale value",
138-
() => {
139-
const i18n = setupI18n({
140-
locale: "en",
141-
messages: { en: {} },
142-
})
143-
let renderCount = 0
144-
145-
const CurrentLocaleContextConsumer = () => {
146-
const { i18n } = useLingui()
147-
renderCount++
148-
return <span data-testid="child">{i18n.locale}</span>
149-
}
150-
151-
/**
152-
* Note that we're doing exactly what the description says:
153-
* but to simulate the equivalent situation, we pass our own mock subscriber
154-
* to i18n.on("change", ...) and in it we call i18n.activate("cs") ourselves
155-
* so that the condition in useEffect() is met and the component re-renders
156-
* */
157-
const mockSubscriber = jest.fn(() => {
158-
i18n.load("cs", {})
159-
i18n.activate("cs")
160-
return () => {
161-
// unsubscriber - noop to make TS happy
162-
}
163-
})
164-
jest.spyOn(i18n, "on").mockImplementation(mockSubscriber)
165-
166-
const { getByTestId } = render(
167-
<I18nProvider i18n={i18n}>
168-
<CurrentLocaleContextConsumer />
169-
</I18nProvider>
170-
)
171-
172-
expect(mockSubscriber).toHaveBeenCalledWith(
173-
"change",
174-
expect.any(Function)
175-
)
176-
177-
expect(getByTestId("child").textContent).toBe("cs")
178-
expect(renderCount).toBe(2)
179-
}
180-
)
181-
182135
it("should render children", () => {
183136
const i18n = setupI18n({
184137
locale: "en",
@@ -226,4 +179,36 @@ describe("I18nProvider", () => {
226179

227180
expect(getByText("Ahoj světe")).toBeTruthy()
228181
})
182+
183+
it("when re-rendered with new i18n instance, it will forward it to children", () => {
184+
const i18nCs = setupI18n({
185+
locale: "cs",
186+
messages: { cs: {} },
187+
})
188+
189+
const i18nEn = setupI18n({
190+
locale: "en",
191+
messages: { en: {} },
192+
})
193+
194+
const CurrentLocaleContextConsumer = () => {
195+
const { i18n } = useLingui()
196+
return <span data-testid="dynamic">{i18n.locale}</span>
197+
}
198+
199+
const { container, rerender } = render(
200+
<I18nProvider i18n={i18nCs}>
201+
<CurrentLocaleContextConsumer />
202+
</I18nProvider>
203+
)
204+
205+
expect(container.textContent).toEqual("cs")
206+
207+
rerender(
208+
<I18nProvider i18n={i18nEn}>
209+
<CurrentLocaleContextConsumer />
210+
</I18nProvider>
211+
)
212+
expect(container.textContent).toEqual("en")
213+
})
229214
})

packages/react/src/I18nProvider.tsx

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import React, { ComponentType, FunctionComponent } from "react"
1+
import React, {
2+
ComponentType,
3+
FunctionComponent,
4+
useCallback,
5+
useRef,
6+
} from "react"
7+
import { useSyncExternalStore } from "use-sync-external-store/shim"
8+
29
import type { I18n } from "@lingui/core"
310
import type { TransRenderProps } from "./TransNoContext"
411

@@ -31,7 +38,6 @@ export const I18nProvider: FunctionComponent<I18nProviderProps> = ({
3138
defaultComponent,
3239
children,
3340
}) => {
34-
const latestKnownLocale = React.useRef<string | undefined>(i18n.locale)
3541
/**
3642
* We can't pass `i18n` object directly through context, because even when locale
3743
* or messages are changed, i18n object is still the same. Context provider compares
@@ -43,42 +49,51 @@ export const I18nProvider: FunctionComponent<I18nProviderProps> = ({
4349
*
4450
* We can't use useMemo hook either, because we want to recalculate value manually.
4551
*/
46-
const makeContext = React.useCallback(
52+
const makeContext = useCallback(
4753
() => ({
4854
i18n,
4955
defaultComponent,
5056
_: i18n.t.bind(i18n),
5157
}),
5258
[i18n, defaultComponent]
5359
)
60+
const context = useRef<I18nContext>(makeContext())
61+
62+
const subscribe = useCallback(
63+
(onStoreChange: () => void) => {
64+
const renderWithFreshContext = () => {
65+
context.current = makeContext()
66+
onStoreChange()
67+
}
68+
const propsChanged =
69+
context.current.i18n !== i18n ||
70+
context.current.defaultComponent !== defaultComponent
71+
if (propsChanged) {
72+
renderWithFreshContext()
73+
}
74+
return i18n.on("change", renderWithFreshContext)
75+
},
76+
[makeContext, i18n, defaultComponent]
77+
)
5478

55-
const [context, setContext] = React.useState<I18nContext>(makeContext())
79+
const getSnapshot = useCallback(() => {
80+
return context.current
81+
}, [])
5682

5783
/**
5884
* Subscribe for locale/message changes
5985
*
60-
* I18n object from `@lingui/core` is the single source of truth for all i18n related
86+
* the I18n object passed via props is the single source of truth for all i18n related
6187
* data (active locale, catalogs). When new messages are loaded or locale is changed
62-
* we need to trigger re-rendering of LinguiContext.Consumers.
88+
* we need to trigger re-rendering of LinguiContext consumers.
6389
*/
64-
React.useEffect(() => {
65-
const updateContext = () => {
66-
latestKnownLocale.current = i18n.locale
67-
setContext(makeContext())
68-
}
69-
const unsubscribe = i18n.on("change", updateContext)
70-
71-
/**
72-
* unlikely, but if the locale changes before the onChange listener
73-
* was added, we need to trigger a rerender
74-
* */
75-
if (latestKnownLocale.current !== i18n.locale) {
76-
updateContext()
77-
}
78-
return unsubscribe
79-
}, [i18n, makeContext])
90+
const contextObject = useSyncExternalStore(
91+
subscribe,
92+
getSnapshot,
93+
getSnapshot
94+
)
8095

81-
if (!latestKnownLocale.current) {
96+
if (!contextObject.i18n.locale) {
8297
process.env.NODE_ENV === "development" &&
8398
console.log(
8499
"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<I18nProviderProps> = ({
88103
}
89104

90105
return (
91-
<LinguiContext.Provider value={context}>{children}</LinguiContext.Provider>
106+
<LinguiContext.Provider value={contextObject}>
107+
{children}
108+
</LinguiContext.Provider>
92109
)
93110
}

yarn.lock

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3123,11 +3123,13 @@ __metadata:
31233123
"@lingui/jest-mocks": "*"
31243124
"@testing-library/react": ^14.0.0
31253125
"@types/react": ^18.2.13
3126+
"@types/use-sync-external-store": ^0.0.3
31263127
eslint-plugin-react: ^7.32.2
31273128
eslint-plugin-react-hooks: ^4.6.0
31283129
react: ^18.2.0
31293130
react-dom: ^18.2.0
31303131
unbuild: ^1.1.2
3132+
use-sync-external-store: ^1.2.0
31313133
peerDependencies:
31323134
react: ^16.8.0 || ^17.0.0 || ^18.0.0
31333135
languageName: unknown
@@ -4395,6 +4397,13 @@ __metadata:
43954397
languageName: node
43964398
linkType: hard
43974399

4400+
"@types/use-sync-external-store@npm:^0.0.3":
4401+
version: 0.0.3
4402+
resolution: "@types/use-sync-external-store@npm:0.0.3"
4403+
checksum: 161ddb8eec5dbe7279ac971531217e9af6b99f7783213566d2b502e2e2378ea19cf5e5ea4595039d730aa79d3d35c6567d48599f69773a02ffcff1776ec2a44e
4404+
languageName: node
4405+
linkType: hard
4406+
43984407
"@types/yargs-parser@npm:*":
43994408
version: 21.0.0
44004409
resolution: "@types/yargs-parser@npm:21.0.0"
@@ -14824,6 +14833,15 @@ __metadata:
1482414833
languageName: node
1482514834
linkType: hard
1482614835

14836+
"use-sync-external-store@npm:^1.2.0":
14837+
version: 1.2.0
14838+
resolution: "use-sync-external-store@npm:1.2.0"
14839+
peerDependencies:
14840+
react: ^16.8.0 || ^17.0.0 || ^18.0.0
14841+
checksum: 5c639e0f8da3521d605f59ce5be9e094ca772bd44a4ce7322b055a6f58eeed8dda3c94cabd90c7a41fb6fa852210092008afe48f7038792fd47501f33299116a
14842+
languageName: node
14843+
linkType: hard
14844+
1482714845
"util-deprecate@npm:^1.0.1, util-deprecate@npm:~1.0.1":
1482814846
version: 1.0.2
1482914847
resolution: "util-deprecate@npm:1.0.2"

0 commit comments

Comments
 (0)