Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(i18n): move to external store #6006

Merged
merged 4 commits into from
Jul 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading