Skip to content

Commit

Permalink
refactor(i18n): move to external store (#6006)
Browse files Browse the repository at this point in the history
  • Loading branch information
MarcusNotheis authored Jul 3, 2024
1 parent c2c3730 commit 068e441
Show file tree
Hide file tree
Showing 8 changed files with 106 additions and 151 deletions.
17 changes: 0 additions & 17 deletions packages/base/src/context/I18nContext.ts

This file was deleted.

37 changes: 8 additions & 29 deletions packages/base/src/hooks/useI18nBundle.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,18 @@
'use client';

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

const defaultBundle = new I18nBundle('defaultBundle');

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

if (!i18nContext) {
throw new Error(`'useI18nBundle()' may be used only in the context of a '<ThemeProvider>' component.`);
}
const i18nRef = useRef(i18nContext);

useIsomorphicLayoutEffect(() => {
const { i18nBundles, setI18nBundle } = i18nRef.current;
let isMounted = true;
if (!i18nBundles.hasOwnProperty(bundleName)) {
getI18nBundle(bundleName).then(
(internalBundle) => {
if (isMounted) {
setI18nBundle(bundleName, internalBundle);
}
},
() => {
// noop
}
);
}
return () => {
isMounted = false;
};
useEffect(() => {
I18nStore.loadBundle(bundleName);
}, [bundleName]);

return i18nContext.i18nBundles[bundleName] ?? defaultBundle;
return bundles[bundleName] ?? defaultBundle;
};
4 changes: 2 additions & 2 deletions packages/base/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { getI18nContext, I18nContext } from './context/I18nContext.js';
import * as Device from './Device/index.js';
import * as hooks from './hooks/index.js';
import { I18nStore } from './stores/I18nStore.js';
import { StyleStore } from './stores/StyleStore.js';
import { ThemingParameters } from './styling/ThemingParameters.js';

export * from './styling/CssSizeVariables.js';
export * from './utils/index.js';
export * from './hooks/index.js';

export { getI18nContext, I18nContext, StyleStore, ThemingParameters, Device, hooks };
export { I18nStore, StyleStore, ThemingParameters, Device, hooks };
64 changes: 64 additions & 0 deletions packages/base/src/stores/I18nStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type I18nBundle from '@ui5/webcomponents-base/dist/i18nBundle.js';
import { getI18nBundle } from '@ui5/webcomponents-base/dist/i18nBundle.js';

const STORE_SYMBOL_LISTENERS = Symbol.for('@ui5/webcomponents-react/I18nStore/Listeners');
const STORE_SYMBOL = Symbol.for('@ui5/webcomponents-react/I18nStore');

const initialStore: Record<string, I18nBundle> = {};

function getListeners(): Array<() => void> {
globalThis[STORE_SYMBOL_LISTENERS] ??= [];
return globalThis[STORE_SYMBOL_LISTENERS];
}

function emitChange() {
for (const listener of getListeners()) {
listener();
}
}

function getSnapshot(): Record<string, I18nBundle> {
globalThis[STORE_SYMBOL] ??= initialStore;
return globalThis[STORE_SYMBOL];
}

function subscribe(listener: () => void) {
const listeners = getListeners();
globalThis[STORE_SYMBOL_LISTENERS] = [...listeners, listener];
return () => {
globalThis[STORE_SYMBOL_LISTENERS] = listeners.filter((l) => l !== listener);
};
}

export const I18nStore = {
subscribe,
getSnapshot,
getServerSnapshot: () => {
return initialStore;
},
loadBundle: (bundleName: string) => {
const bundles = getSnapshot();
if (!bundles.hasOwnProperty(bundleName)) {
void getI18nBundle(bundleName).then((bundle) => {
globalThis[STORE_SYMBOL] = {
...globalThis[STORE_SYMBOL],
[bundleName]: bundle
};
emitChange();
});
}
},
handleLanguageChange: async () => {
const bundles = getSnapshot();
const newBundles = await Promise.all(Object.keys(bundles).map((bundleName) => getI18nBundle(bundleName)));

globalThis[STORE_SYMBOL] = newBundles.reduce(
(acc, bundle) => ({
...acc,
[bundle.packageName]: bundle
}),
{}
);
emitChange();
}
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { registerI18nLoader } from '@ui5/webcomponents-base/dist/asset-registries/i18n.js';
import { setFetchDefaultLanguage, setLanguage } from '@ui5/webcomponents-base/dist/config/Language.js';
import { useI18nBundle } from '@ui5/webcomponents-react-base';
import { mount } from 'cypress/react18';
import { useEffect, useRef } from 'react';

const TestComponent = () => {
Expand All @@ -22,6 +21,9 @@ describe('I18nProvider', () => {
registerI18nLoader('myApp', 'en', async () => {
return Promise.resolve({ TEST1: 'test text resource' });
});
registerI18nLoader('myApp', 'de', async () => {
return Promise.resolve({ TEST1: 'Donaudampfschifffahrtsgesellschaft' });
});
setFetchDefaultLanguage(true);
});
after(() => {
Expand All @@ -31,29 +33,6 @@ describe('I18nProvider', () => {
setLanguage('en');
});

// ToDo: investigate how this test can be activated again
it.skip('should throw error when context is not present', (done) => {
cy.on('uncaught:exception', (err) => {
if (err.message.includes(`'useI18nBundle()' may be used only in the context of a '<ThemeProvider>' component.`)) {
done();
}
});
mount(<TestComponent />).then(() => {
done(new Error('Should throw error'));
});
});

it('should NOT throw error when context is present', (done) => {
cy.on('uncaught:exception', (err) => {
if (err.message.includes(`'useI18nBundle()' may be used only in the context of a '<ThemeProvider>' component.`)) {
done(new Error('Should not throw error'));
}
});
cy.mount(<TestComponent />).then(() => {
done();
});
});

it('translate components', () => {
cy.mount(<TestComponent />);
cy.findByText('1: test text resource');
Expand All @@ -62,11 +41,24 @@ describe('I18nProvider', () => {
<TestComponent />
<TestComponent2 />
<TestComponent3 />
<button
onClick={() => {
setLanguage('de');
}}
>
Switch to German
</button>
</>
);
cy.findByText('1: test text resource');
cy.findByText('2: test text resource');
cy.findByText('3: test text resource');

cy.findByText('Switch to German').click();

cy.findByText('1: Donaudampfschifffahrtsgesellschaft');
cy.findByText('2: Donaudampfschifffahrtsgesellschaft');
cy.findByText('3: Donaudampfschifffahrtsgesellschaft');
});

it('Should update after changing the language', () => {
Expand Down
19 changes: 16 additions & 3 deletions packages/main/src/components/ThemeProvider/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
'use client';

import { getTheme } from '@ui5/webcomponents-base/dist/config/Theme.js';
import { attachLanguageChange, detachLanguageChange } from '@ui5/webcomponents-base/dist/locale/languageChange.js';
import { attachThemeLoaded, detachThemeLoaded } from '@ui5/webcomponents-base/dist/theming/ThemeLoaded.js';
import { StyleStore, useIsomorphicId, useIsomorphicLayoutEffect, useStylesheet } from '@ui5/webcomponents-react-base';
import {
I18nStore,
StyleStore,
useIsomorphicId,
useIsomorphicLayoutEffect,
useStylesheet
} from '@ui5/webcomponents-react-base';
import type { FC, ReactNode } from 'react';
import { I18nProvider } from '../../internal/I18nProvider.js';
import { ModalsProvider } from '../Modals/ModalsProvider.js';
import { styleData } from './ThemeProvider.css.js';

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

useIsomorphicLayoutEffect(() => {
attachLanguageChange(I18nStore.handleLanguageChange);
return () => {
detachLanguageChange(I18nStore.handleLanguageChange);
};
}, []);

return (
<>
<ThemeProviderStyles />
<I18nProvider>{withoutModalsProvider ? children : <ModalsProvider>{children}</ModalsProvider>}</I18nProvider>
{withoutModalsProvider ? children : <ModalsProvider>{children}</ModalsProvider>}
</>
);
};
Expand Down
71 changes: 0 additions & 71 deletions packages/main/src/internal/I18nProvider.tsx

This file was deleted.

5 changes: 0 additions & 5 deletions types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,7 @@ interface ModalState {

declare global {
interface Window {
CSSVarsPonyfill: {
cssVars: (options: any) => void;
};

['@ui5/webcomponents-react']: {
I18nContext?: Context<any>;
ModalsContext?: Context<any>;
setModal?: Dispatch<UpdateModalStateAction>;
};
Expand Down

0 comments on commit 068e441

Please sign in to comment.