Skip to content

Commit 068e441

Browse files
refactor(i18n): move to external store (#6006)
1 parent c2c3730 commit 068e441

File tree

8 files changed

+106
-151
lines changed

8 files changed

+106
-151
lines changed

packages/base/src/context/I18nContext.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.
Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,18 @@
11
'use client';
22

3-
import I18nBundle, { getI18nBundle } from '@ui5/webcomponents-base/dist/i18nBundle.js';
4-
import { useRef } from 'react';
5-
import { useI18nContext } from '../context/I18nContext.js';
6-
import { useIsomorphicLayoutEffect } from '../hooks/index.js';
3+
import I18nBundle from '@ui5/webcomponents-base/dist/i18nBundle.js';
4+
import { useEffect } from 'react';
5+
import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js';
6+
import { I18nStore } from '../stores/I18nStore.js';
77

88
const defaultBundle = new I18nBundle('defaultBundle');
99

1010
export const useI18nBundle = (bundleName: string): I18nBundle => {
11-
const i18nContext = useI18nContext();
11+
const bundles = useSyncExternalStore(I18nStore.subscribe, I18nStore.getSnapshot, I18nStore.getServerSnapshot);
1212

13-
if (!i18nContext) {
14-
throw new Error(`'useI18nBundle()' may be used only in the context of a '<ThemeProvider>' component.`);
15-
}
16-
const i18nRef = useRef(i18nContext);
17-
18-
useIsomorphicLayoutEffect(() => {
19-
const { i18nBundles, setI18nBundle } = i18nRef.current;
20-
let isMounted = true;
21-
if (!i18nBundles.hasOwnProperty(bundleName)) {
22-
getI18nBundle(bundleName).then(
23-
(internalBundle) => {
24-
if (isMounted) {
25-
setI18nBundle(bundleName, internalBundle);
26-
}
27-
},
28-
() => {
29-
// noop
30-
}
31-
);
32-
}
33-
return () => {
34-
isMounted = false;
35-
};
13+
useEffect(() => {
14+
I18nStore.loadBundle(bundleName);
3615
}, [bundleName]);
3716

38-
return i18nContext.i18nBundles[bundleName] ?? defaultBundle;
17+
return bundles[bundleName] ?? defaultBundle;
3918
};

packages/base/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { getI18nContext, I18nContext } from './context/I18nContext.js';
21
import * as Device from './Device/index.js';
32
import * as hooks from './hooks/index.js';
3+
import { I18nStore } from './stores/I18nStore.js';
44
import { StyleStore } from './stores/StyleStore.js';
55
import { ThemingParameters } from './styling/ThemingParameters.js';
66

77
export * from './styling/CssSizeVariables.js';
88
export * from './utils/index.js';
99
export * from './hooks/index.js';
1010

11-
export { getI18nContext, I18nContext, StyleStore, ThemingParameters, Device, hooks };
11+
export { I18nStore, StyleStore, ThemingParameters, Device, hooks };

packages/base/src/stores/I18nStore.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type I18nBundle from '@ui5/webcomponents-base/dist/i18nBundle.js';
2+
import { getI18nBundle } from '@ui5/webcomponents-base/dist/i18nBundle.js';
3+
4+
const STORE_SYMBOL_LISTENERS = Symbol.for('@ui5/webcomponents-react/I18nStore/Listeners');
5+
const STORE_SYMBOL = Symbol.for('@ui5/webcomponents-react/I18nStore');
6+
7+
const initialStore: Record<string, I18nBundle> = {};
8+
9+
function getListeners(): Array<() => void> {
10+
globalThis[STORE_SYMBOL_LISTENERS] ??= [];
11+
return globalThis[STORE_SYMBOL_LISTENERS];
12+
}
13+
14+
function emitChange() {
15+
for (const listener of getListeners()) {
16+
listener();
17+
}
18+
}
19+
20+
function getSnapshot(): Record<string, I18nBundle> {
21+
globalThis[STORE_SYMBOL] ??= initialStore;
22+
return globalThis[STORE_SYMBOL];
23+
}
24+
25+
function subscribe(listener: () => void) {
26+
const listeners = getListeners();
27+
globalThis[STORE_SYMBOL_LISTENERS] = [...listeners, listener];
28+
return () => {
29+
globalThis[STORE_SYMBOL_LISTENERS] = listeners.filter((l) => l !== listener);
30+
};
31+
}
32+
33+
export const I18nStore = {
34+
subscribe,
35+
getSnapshot,
36+
getServerSnapshot: () => {
37+
return initialStore;
38+
},
39+
loadBundle: (bundleName: string) => {
40+
const bundles = getSnapshot();
41+
if (!bundles.hasOwnProperty(bundleName)) {
42+
void getI18nBundle(bundleName).then((bundle) => {
43+
globalThis[STORE_SYMBOL] = {
44+
...globalThis[STORE_SYMBOL],
45+
[bundleName]: bundle
46+
};
47+
emitChange();
48+
});
49+
}
50+
},
51+
handleLanguageChange: async () => {
52+
const bundles = getSnapshot();
53+
const newBundles = await Promise.all(Object.keys(bundles).map((bundleName) => getI18nBundle(bundleName)));
54+
55+
globalThis[STORE_SYMBOL] = newBundles.reduce(
56+
(acc, bundle) => ({
57+
...acc,
58+
[bundle.packageName]: bundle
59+
}),
60+
{}
61+
);
62+
emitChange();
63+
}
64+
};

packages/main/src/internal/I18nProvider.cy.tsx renamed to packages/main/src/components/ThemeProvider/I18n.cy.tsx

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { registerI18nLoader } from '@ui5/webcomponents-base/dist/asset-registries/i18n.js';
22
import { setFetchDefaultLanguage, setLanguage } from '@ui5/webcomponents-base/dist/config/Language.js';
33
import { useI18nBundle } from '@ui5/webcomponents-react-base';
4-
import { mount } from 'cypress/react18';
54
import { useEffect, useRef } from 'react';
65

76
const TestComponent = () => {
@@ -22,6 +21,9 @@ describe('I18nProvider', () => {
2221
registerI18nLoader('myApp', 'en', async () => {
2322
return Promise.resolve({ TEST1: 'test text resource' });
2423
});
24+
registerI18nLoader('myApp', 'de', async () => {
25+
return Promise.resolve({ TEST1: 'Donaudampfschifffahrtsgesellschaft' });
26+
});
2527
setFetchDefaultLanguage(true);
2628
});
2729
after(() => {
@@ -31,29 +33,6 @@ describe('I18nProvider', () => {
3133
setLanguage('en');
3234
});
3335

34-
// ToDo: investigate how this test can be activated again
35-
it.skip('should throw error when context is not present', (done) => {
36-
cy.on('uncaught:exception', (err) => {
37-
if (err.message.includes(`'useI18nBundle()' may be used only in the context of a '<ThemeProvider>' component.`)) {
38-
done();
39-
}
40-
});
41-
mount(<TestComponent />).then(() => {
42-
done(new Error('Should throw error'));
43-
});
44-
});
45-
46-
it('should NOT throw error when context is present', (done) => {
47-
cy.on('uncaught:exception', (err) => {
48-
if (err.message.includes(`'useI18nBundle()' may be used only in the context of a '<ThemeProvider>' component.`)) {
49-
done(new Error('Should not throw error'));
50-
}
51-
});
52-
cy.mount(<TestComponent />).then(() => {
53-
done();
54-
});
55-
});
56-
5736
it('translate components', () => {
5837
cy.mount(<TestComponent />);
5938
cy.findByText('1: test text resource');
@@ -62,11 +41,24 @@ describe('I18nProvider', () => {
6241
<TestComponent />
6342
<TestComponent2 />
6443
<TestComponent3 />
44+
<button
45+
onClick={() => {
46+
setLanguage('de');
47+
}}
48+
>
49+
Switch to German
50+
</button>
6551
</>
6652
);
6753
cy.findByText('1: test text resource');
6854
cy.findByText('2: test text resource');
6955
cy.findByText('3: test text resource');
56+
57+
cy.findByText('Switch to German').click();
58+
59+
cy.findByText('1: Donaudampfschifffahrtsgesellschaft');
60+
cy.findByText('2: Donaudampfschifffahrtsgesellschaft');
61+
cy.findByText('3: Donaudampfschifffahrtsgesellschaft');
7062
});
7163

7264
it('Should update after changing the language', () => {

packages/main/src/components/ThemeProvider/index.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
'use client';
22

33
import { getTheme } from '@ui5/webcomponents-base/dist/config/Theme.js';
4+
import { attachLanguageChange, detachLanguageChange } from '@ui5/webcomponents-base/dist/locale/languageChange.js';
45
import { attachThemeLoaded, detachThemeLoaded } from '@ui5/webcomponents-base/dist/theming/ThemeLoaded.js';
5-
import { StyleStore, useIsomorphicId, useIsomorphicLayoutEffect, useStylesheet } from '@ui5/webcomponents-react-base';
6+
import {
7+
I18nStore,
8+
StyleStore,
9+
useIsomorphicId,
10+
useIsomorphicLayoutEffect,
11+
useStylesheet
12+
} from '@ui5/webcomponents-react-base';
613
import type { FC, ReactNode } from 'react';
7-
import { I18nProvider } from '../../internal/I18nProvider.js';
814
import { ModalsProvider } from '../Modals/ModalsProvider.js';
915
import { styleData } from './ThemeProvider.css.js';
1016

@@ -55,10 +61,17 @@ const ThemeProvider: FC<ThemeProviderPropTypes> = (props: ThemeProviderPropTypes
5561
StyleStore.setStaticCssInjected(staticCssInjected);
5662
}, [staticCssInjected]);
5763

64+
useIsomorphicLayoutEffect(() => {
65+
attachLanguageChange(I18nStore.handleLanguageChange);
66+
return () => {
67+
detachLanguageChange(I18nStore.handleLanguageChange);
68+
};
69+
}, []);
70+
5871
return (
5972
<>
6073
<ThemeProviderStyles />
61-
<I18nProvider>{withoutModalsProvider ? children : <ModalsProvider>{children}</ModalsProvider>}</I18nProvider>
74+
{withoutModalsProvider ? children : <ModalsProvider>{children}</ModalsProvider>}
6275
</>
6376
);
6477
};

packages/main/src/internal/I18nProvider.tsx

Lines changed: 0 additions & 71 deletions
This file was deleted.

types.d.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,7 @@ interface ModalState {
1414

1515
declare global {
1616
interface Window {
17-
CSSVarsPonyfill: {
18-
cssVars: (options: any) => void;
19-
};
20-
2117
['@ui5/webcomponents-react']: {
22-
I18nContext?: Context<any>;
2318
ModalsContext?: Context<any>;
2419
setModal?: Dispatch<UpdateModalStateAction>;
2520
};

0 commit comments

Comments
 (0)