diff --git a/packages/ts/react-i18n/src/index.ts b/packages/ts/react-i18n/src/index.ts index 8a87ec3be1..5ef8f73227 100644 --- a/packages/ts/react-i18n/src/index.ts +++ b/packages/ts/react-i18n/src/index.ts @@ -37,6 +37,15 @@ export class I18n { `The Hilla I18n API is currently considered experimental and may change in the future. To use it you need to explicitly enable it in Copilot or by adding com.vaadin.experimental.hillaI18n=true to vaadin-featureflags.properties`, ); } + // @ts-expect-error import.meta.hot does not have TS definitions + if (import.meta.hot) { + // @ts-expect-error import.meta.hot does not have TS definitions + // eslint-disable-next-line + import.meta.hot.on('translations-update', () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.reloadTranslations(); + }); + } } /** @@ -182,6 +191,32 @@ export class I18n { }); } + /** + * Reloads all translations for the current language. This method should only + * be used for HMR in development mode. + */ + private async reloadTranslations() { + const currentLanguage = this.#language.value; + if (!currentLanguage) { + return; + } + + let translationsResult: TranslationsResult; + try { + translationsResult = await this.#backend.loadTranslations(currentLanguage); + } catch (e) { + console.error(`Failed to reload translations for language: ${currentLanguage}`, e); + return; + } + + // Update all signals together to avoid triggering side effects multiple times + batch(() => { + this.#translations.value = translationsResult.translations; + this.#resolvedLanguage.value = translationsResult.resolvedLanguage; + this.#formatCache = new FormatCache(currentLanguage); + }); + } + /** * Returns a translated string for the given translation key. The key should * match a key in the loaded translations. If no translation is found for the diff --git a/packages/ts/react-i18n/test/i18n.spec.tsx b/packages/ts/react-i18n/test/i18n.spec.tsx index 2fe95fa0ee..555b06f9aa 100644 --- a/packages/ts/react-i18n/test/i18n.spec.tsx +++ b/packages/ts/react-i18n/test/i18n.spec.tsx @@ -605,6 +605,62 @@ describe('@vaadin/hilla-react-i18n', () => { expect(i18n.translate('param.time', { value: sampleDate })).to.equal('Value: 22:33:44'); }); }); + + describe('hmr', () => { + async function triggerHmrEvent() { + // @ts-expect-error import.meta.hot does not have TS definitions + // eslint-disable-next-line + import.meta.hot.hmrClient.notifyListeners('translations-update'); + // No promise to wait for, just delay the next test step a bit + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + } + + it('should not update translations if not initialized', async () => { + expect(i18n.translate('addresses.form.city.label')).to.equal('addresses.form.city.label'); + await triggerHmrEvent(); + expect(i18n.translate('addresses.form.city.label')).to.equal('addresses.form.city.label'); + }); + + it('should update translations on HMR event', async () => { + await i18n.configure({ language: 'en-US' }); + expect(i18n.translate('addresses.form.city.label')).to.equal('City'); + + fetchMock + .resetHistory() + .reset() + .get('./?v-r=i18n&langtag=en-US', { + body: { + 'addresses.form.city.label': 'City updated', + }, + status: 200, + headers: { 'X-Vaadin-Retrieved-Locale': 'und' }, + }); + + await triggerHmrEvent(); + + expect(i18n.translate('addresses.form.city.label')).to.equal('City updated'); + }); + + it('should update resolved language on HMR event', async () => { + await i18n.configure({ language: 'en-US' }); + expect(i18n.resolvedLanguage.value).to.equal('und'); + + fetchMock + .resetHistory() + .reset() + .get('*', { + body: {}, + status: 200, + headers: { 'X-Vaadin-Retrieved-Locale': 'en' }, + }); + + await triggerHmrEvent(); + + expect(i18n.resolvedLanguage.value).to.equal('en'); + }); + }); }); describe('FormatCache', () => {