From 264f71620ca4865ab55e0a4d13f9531504102b67 Mon Sep 17 00:00:00 2001 From: Baptiste Grimaud Date: Thu, 29 Jan 2026 20:19:31 +0100 Subject: [PATCH 1/2] fix(i18n): properly share i18n instance across JS modules --- .../hello-world/settings/locales/en.json | 1 + .../hello-world/settings/locales/fr.json | 1 + .../Hello/World/Celebrate.client.tsx | 26 ++++- .../src/client/hydrate.tsx | 19 +-- .../src/client/i18next.ts | 5 - samples/hydrogen/settings/locales/en.json | 1 + samples/hydrogen/settings/locales/fr.json | 1 + .../HelloWorld/Celebrate.client.tsx | 24 +++- tests/cypress/e2e/ui/testI18n.cy.ts | 110 +++++++++++------- 9 files changed, 126 insertions(+), 62 deletions(-) diff --git a/javascript-create-module/templates/hello-world/settings/locales/en.json b/javascript-create-module/templates/hello-world/settings/locales/en.json index d044c762..a1ddde5d 100644 --- a/javascript-create-module/templates/hello-world/settings/locales/en.json +++ b/javascript-create-module/templates/hello-world/settings/locales/en.json @@ -6,6 +6,7 @@ "89D3xFLMZmCAencaqw68C": "Click this button to add a new content node", "hBBhbeE0-a4KS9HVFPsOz": "Hydrated client-side", "AI95bg1EJsr48SR-4f_pl": "Rendered server-side", + "0yvhe06c8uhrPuqm15zq6": "Rendered client-side only", "OfBsezopuIko8aJ6X3kpw": "Despite being written with React, this page is fully rendered server-side. No JavaScript is sent to the client by default! This does not mean you cannot use client-side code:", "nr31fYHB-RqO06BCl4rYO": "This pattern is named Islands Architecture." } diff --git a/javascript-create-module/templates/hello-world/settings/locales/fr.json b/javascript-create-module/templates/hello-world/settings/locales/fr.json index 13a9ed65..b31e0309 100644 --- a/javascript-create-module/templates/hello-world/settings/locales/fr.json +++ b/javascript-create-module/templates/hello-world/settings/locales/fr.json @@ -5,6 +5,7 @@ "8S0DVCRSnmQRKF9lZnNGj": "Illustrations par Katerina Limpitsouni", "89D3xFLMZmCAencaqw68C": "Cliquez ce bouton pour ajouter un nouveau contenu", "AI95bg1EJsr48SR-4f_pl": "Rendu par le serveur", + "0yvhe06c8uhrPuqm15zq6": "Rendu côté client uniquement", "OfBsezopuIko8aJ6X3kpw": "Bien que cette page soit codée avec React, elle est entièrement rendue par le serveur. Le JavaScript côté client n'est pas nécessaire par défaut ! Vous pouvez tout de même vous en servir quand vous le souhaitez :", "hBBhbeE0-a4KS9HVFPsOz": "Hydraté côté client", "nr31fYHB-RqO06BCl4rYO": "Cela s'appelle la Conception en archipel (en)." diff --git a/javascript-create-module/templates/hello-world/src/components/Hello/World/Celebrate.client.tsx b/javascript-create-module/templates/hello-world/src/components/Hello/World/Celebrate.client.tsx index 28c5892f..019959ad 100644 --- a/javascript-create-module/templates/hello-world/src/components/Hello/World/Celebrate.client.tsx +++ b/javascript-create-module/templates/hello-world/src/components/Hello/World/Celebrate.client.tsx @@ -1,17 +1,31 @@ import clsx from "clsx"; -import { t } from "i18next"; import { useEffect, useState } from "react"; -import classes from "./styles.module.css"; +import classes from "./component.module.css"; +import { useTranslation } from "react-i18next"; export default function () { const [confetti, setConfetti] = useState(); + const [isClient, setIsClient] = useState(false); useEffect(() => { + let mounted = true; // This library only works client-side, import it dynamically in an effect import("canvas-confetti").then(({ default: confetti }) => { - setConfetti(() => confetti); + if (mounted) setConfetti(() => confetti); }); - }); + // Use a microtask to avoid direct setState in useEffect + Promise.resolve().then(() => { + if (mounted) setIsClient(true); + }); + return () => { + mounted = false; + }; + }, []); + + // IMPORTANT: Always use useTranslation() (not { t } from "i18next") in React components. + // This ensures translations are context-aware, update on language/namespace changes, + // and avoid hydration mismatches between server and client. + const { t } = useTranslation(); return ( ); } diff --git a/javascript-modules-engine/src/client/hydrate.tsx b/javascript-modules-engine/src/client/hydrate.tsx index cc80df15..0b35cd13 100644 --- a/javascript-modules-engine/src/client/hydrate.tsx +++ b/javascript-modules-engine/src/client/hydrate.tsx @@ -1,7 +1,8 @@ import * as devalue from "devalue"; -import i18next from "i18next"; +import i18n from "i18next"; import type { ComponentType } from "react"; import { createRoot, hydrateRoot } from "react-dom/client"; +import { I18nextProvider } from "react-i18next"; /** Ensures the component is hydrated with the right i18next context */ const ComponentWrapper = ({ @@ -19,14 +20,16 @@ const ComponentWrapper = ({ /** Props object for the app component */ props: Record; }) => { - i18next.setDefaultNamespace(ns); - i18next.changeLanguage(lang); - + // Not thread-safe if multiple hydrated components use different languages on the same page. + // But assumes a single language per page, so i18n.changeLanguage(lang) is safe in this context. + i18n.changeLanguage(lang); return ( - - {/* @ts-expect-error This is an hydration border: hydration will stop here */} - - + + + {/* @ts-expect-error This is an hydration border: hydration will stop here */} + + + ); }; diff --git a/javascript-modules-engine/src/client/i18next.ts b/javascript-modules-engine/src/client/i18next.ts index 9c826e0e..b2231e6b 100644 --- a/javascript-modules-engine/src/client/i18next.ts +++ b/javascript-modules-engine/src/client/i18next.ts @@ -4,18 +4,14 @@ import * as devalue from "devalue"; i18n.use(initReactI18next).init({ fallbackLng: "en", - ns: "javascript-modules-engine", - defaultNS: "javascript-modules-engine", initImmediate: false, react: { useSuspense: false }, }); const initialI18nStore: Record> = {}; -const namespaces: string[] = []; for (const store of document.querySelectorAll("script[data-i18n-store]")) { const namespace = store.dataset.i18nStore; - namespaces.push(namespace); const allTranslations = devalue.parse(store.textContent); for (const [lang, translations] of Object.entries(allTranslations)) { @@ -26,4 +22,3 @@ for (const store of document.querySelectorAll("script[data-i1 // Init i18n internal store i18n.services.resourceStore.data = initialI18nStore; -i18n.options.ns = namespaces; diff --git a/samples/hydrogen/settings/locales/en.json b/samples/hydrogen/settings/locales/en.json index b5980cef..ab83d78e 100644 --- a/samples/hydrogen/settings/locales/en.json +++ b/samples/hydrogen/settings/locales/en.json @@ -6,6 +6,7 @@ "89D3xFLMZmCAencaqw68C": "Click this button to add a new content node", "hBBhbeE0-a4KS9HVFPsOz": "Hydrated client-side", "AI95bg1EJsr48SR-4f_pl": "Rendered server-side", + "0yvhe06c8uhrPuqm15zq6": "Rendered client-side only", "OfBsezopuIko8aJ6X3kpw": "Despite being written with React, this page is fully rendered server-side. No JavaScript is sent to the client by default! This does not mean you cannot use client-side code:", "nr31fYHB-RqO06BCl4rYO": "This pattern is named Islands Architecture.", "JI87mYV8J5pAEST4RIUcb": "This page is available in:" diff --git a/samples/hydrogen/settings/locales/fr.json b/samples/hydrogen/settings/locales/fr.json index d068529e..057c89e6 100644 --- a/samples/hydrogen/settings/locales/fr.json +++ b/samples/hydrogen/settings/locales/fr.json @@ -5,6 +5,7 @@ "8S0DVCRSnmQRKF9lZnNGj": "Illustrations par Katerina Limpitsouni", "89D3xFLMZmCAencaqw68C": "Cliquez ce bouton pour ajouter un nouveau contenu", "AI95bg1EJsr48SR-4f_pl": "Rendu par le serveur", + "0yvhe06c8uhrPuqm15zq6": "Rendu côté client uniquement", "OfBsezopuIko8aJ6X3kpw": "Bien que cette page soit codée avec React, elle est entièrement rendue par le serveur. Le JavaScript côté client n'est pas nécessaire par défaut ! Vous pouvez tout de même vous en servir quand vous le souhaitez :", "hBBhbeE0-a4KS9HVFPsOz": "Hydraté côté client", "nr31fYHB-RqO06BCl4rYO": "Cela s'appelle la Conception en archipel (en).", diff --git a/samples/hydrogen/src/components/HelloWorld/Celebrate.client.tsx b/samples/hydrogen/src/components/HelloWorld/Celebrate.client.tsx index d6aad4b3..019959ad 100644 --- a/samples/hydrogen/src/components/HelloWorld/Celebrate.client.tsx +++ b/samples/hydrogen/src/components/HelloWorld/Celebrate.client.tsx @@ -1,17 +1,31 @@ import clsx from "clsx"; -import { t } from "i18next"; import { useEffect, useState } from "react"; import classes from "./component.module.css"; +import { useTranslation } from "react-i18next"; export default function () { const [confetti, setConfetti] = useState(); + const [isClient, setIsClient] = useState(false); useEffect(() => { + let mounted = true; // This library only works client-side, import it dynamically in an effect import("canvas-confetti").then(({ default: confetti }) => { - setConfetti(() => confetti); + if (mounted) setConfetti(() => confetti); }); - }); + // Use a microtask to avoid direct setState in useEffect + Promise.resolve().then(() => { + if (mounted) setIsClient(true); + }); + return () => { + mounted = false; + }; + }, []); + + // IMPORTANT: Always use useTranslation() (not { t } from "i18next") in React components. + // This ensures translations are context-aware, update on language/namespace changes, + // and avoid hydration mismatches between server and client. + const { t } = useTranslation(); return ( ); } diff --git a/tests/cypress/e2e/ui/testI18n.cy.ts b/tests/cypress/e2e/ui/testI18n.cy.ts index bed29976..f06f75a6 100644 --- a/tests/cypress/e2e/ui/testI18n.cy.ts +++ b/tests/cypress/e2e/ui/testI18n.cy.ts @@ -1,4 +1,10 @@ -import { addNode, createSite, deleteSite, publishAndWaitJobEnding } from "@jahia/cypress"; +import { + addNode, + createSite, + deleteSite, + enableModule, + publishAndWaitJobEnding, +} from "@jahia/cypress"; import { addSimplePage } from "../../utils/helpers"; const testData = { @@ -23,30 +29,24 @@ const testData = { }; describe("Test i18n", () => { - before("Create test site/contents", () => { - createSite("javascriptI18NTestSite", { + const createSiteWithContent = (siteKey: string) => { + deleteSite(siteKey); // cleanup from previous test runs + createSite(siteKey, { languages: "en,fr_LU,fr,de", templateSet: "javascript-modules-engine-test-module", locale: "en", serverName: "localhost", }); - addSimplePage( - "/sites/javascriptI18NTestSite/home", - "testPageI18N", - "Test i18n en", - "en", - "simple", - [ - { - name: "pagecontent", - primaryNodeType: "jnt:contentList", - }, - ], - ).then(() => { + addSimplePage(`/sites/${siteKey}/home`, "testPageI18N", "Test i18n en", "en", "simple", [ + { + name: "pagecontent", + primaryNodeType: "jnt:contentList", + }, + ]).then(() => { cy.apollo({ variables: { - pathOrId: "/sites/javascriptI18NTestSite/home/testPageI18N", + pathOrId: `/sites/${siteKey}/home/testPageI18N`, properties: [ { name: "jcr:title", value: "Test i18n fr_LU", language: "fr_LU" }, { name: "jcr:title", value: "Test i18n fr", language: "fr" }, @@ -57,57 +57,45 @@ describe("Test i18n", () => { }); addNode({ - parentPathOrId: "/sites/javascriptI18NTestSite/home/testPageI18N/pagecontent", + parentPathOrId: `/sites/${siteKey}/home/testPageI18N/pagecontent`, name: "test", primaryNodeType: "javascriptExample:testI18n", }); }); - publishAndWaitJobEnding("/sites/javascriptI18NTestSite/home/testPageI18N", [ - "en", - "fr_LU", - "fr", - "de", - ]); - }); + publishAndWaitJobEnding(`/sites/${siteKey}/home/testPageI18N`, ["en", "fr_LU", "fr", "de"]); + }; it("Test I18n values in various workspace/locales and various type of usage SSR/hydrate/rendered client side", () => { + const siteKey = "javascriptI18NTestSite"; + createSiteWithContent(siteKey); + cy.login(); ["live", "default"].forEach((workspace) => { ["en", "fr_LU", "fr", "de"].forEach((locale) => { - cy.visit( - `/cms/render/${workspace}/${locale}/sites/javascriptI18NTestSite/home/testPageI18N.html`, - ); - testI18n( - workspace, - locale, - 'div[data-testid="i18n-server-side"]', - "We are server side !", - false, - ); + cy.visit(`/cms/render/${workspace}/${locale}/sites/${siteKey}/home/testPageI18N.html`); + testI18n(locale, 'div[data-testid="i18n-server-side"]', "We are server side !", false); testI18n( - workspace, locale, 'div[data-testid="i18n-hydrated-client-side"]', "We are hydrated client side !", true, ); testI18n( - workspace, locale, 'div[data-testid="i18n-rendered-client-side"]', "We are rendered client side !", true, ); }); - cy.get('[data-testid="getSiteLocales"]').should("contain", "de,en,fr,fr_LU"); }); cy.logout(); + + deleteSite(siteKey); }); const testI18n = ( - workspace: string, locale: string, mainSelector: string, placeholderIntialValue: string, @@ -141,8 +129,46 @@ describe("Test i18n", () => { } }; - after("Cleanup", () => { - cy.visit("/start", { failOnStatusCode: false }); - deleteSite("javascriptI18NTestSite"); + it("Support client-side i18n with components from multiple JS modules on the same page", () => { + const siteKey = "javascriptI18NMultiModuleTestSite"; + createSiteWithContent(siteKey); + // add a component from another JS module + enableModule("hydrogen", siteKey); + addNode({ + parentPathOrId: `/sites/${siteKey}/home/testPageI18N/pagecontent`, + name: "testOtherModule", + primaryNodeType: "hydrogen:helloWorld", + properties: [{ name: "name", value: "John Doe", language: "en" }], + }); + publishAndWaitJobEnding(`/sites/${siteKey}/home/testPageI18N`, ["en"]); + + cy.login(); + ["live", "default"].forEach((workspace) => { + cy.visit(`/cms/render/${workspace}/en/sites/${siteKey}/home/testPageI18N.html`); + + // make sure the 2 modules are present on the page with their i18n store + cy.get('script[data-i18n-store="javascript-modules-engine-test-module"]').should("exist"); + cy.get('script[data-i18n-store="hydrogen"]').should("exist"); + cy.get("jsm-island").then(($islands) => { + // get unique "data-bundle" values from all islands elements + const bundles = new Set($islands.get().map((el) => el.getAttribute("data-bundle"))); + expect(bundles.size).to.eq(2); + expect(bundles).to.contain("javascript-modules-engine-test-module"); + expect(bundles).to.contain("hydrogen"); + }); + + // make sure the translations are rendered client-side + cy.get( + 'jsm-island[data-bundle="javascript-modules-engine-test-module"] [data-testid="i18n-simple"]', + ).should("contain", testData.translations.en.simple); + cy.get('jsm-island[data-bundle="hydrogen"] [data-testid="i18n-client-only"]').should( + "contain", + "Rendered client-side only", // the translated content + ); + }); + + cy.logout(); + + deleteSite(siteKey); }); }); From dbaacc9ed1953edaa6ede760a8807a94d56c8798 Mon Sep 17 00:00:00 2001 From: Baptiste Grimaud Date: Fri, 30 Jan 2026 17:12:52 +0100 Subject: [PATCH 2/2] address feedback --- .../hello-world/settings/locales/en.json | 1 - .../hello-world/settings/locales/fr.json | 1 - .../Hello/World/Celebrate.client.tsx | 19 +++---------------- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/javascript-create-module/templates/hello-world/settings/locales/en.json b/javascript-create-module/templates/hello-world/settings/locales/en.json index a1ddde5d..d044c762 100644 --- a/javascript-create-module/templates/hello-world/settings/locales/en.json +++ b/javascript-create-module/templates/hello-world/settings/locales/en.json @@ -6,7 +6,6 @@ "89D3xFLMZmCAencaqw68C": "Click this button to add a new content node", "hBBhbeE0-a4KS9HVFPsOz": "Hydrated client-side", "AI95bg1EJsr48SR-4f_pl": "Rendered server-side", - "0yvhe06c8uhrPuqm15zq6": "Rendered client-side only", "OfBsezopuIko8aJ6X3kpw": "Despite being written with React, this page is fully rendered server-side. No JavaScript is sent to the client by default! This does not mean you cannot use client-side code:", "nr31fYHB-RqO06BCl4rYO": "This pattern is named Islands Architecture." } diff --git a/javascript-create-module/templates/hello-world/settings/locales/fr.json b/javascript-create-module/templates/hello-world/settings/locales/fr.json index b31e0309..13a9ed65 100644 --- a/javascript-create-module/templates/hello-world/settings/locales/fr.json +++ b/javascript-create-module/templates/hello-world/settings/locales/fr.json @@ -5,7 +5,6 @@ "8S0DVCRSnmQRKF9lZnNGj": "Illustrations par Katerina Limpitsouni", "89D3xFLMZmCAencaqw68C": "Cliquez ce bouton pour ajouter un nouveau contenu", "AI95bg1EJsr48SR-4f_pl": "Rendu par le serveur", - "0yvhe06c8uhrPuqm15zq6": "Rendu côté client uniquement", "OfBsezopuIko8aJ6X3kpw": "Bien que cette page soit codée avec React, elle est entièrement rendue par le serveur. Le JavaScript côté client n'est pas nécessaire par défaut ! Vous pouvez tout de même vous en servir quand vous le souhaitez :", "hBBhbeE0-a4KS9HVFPsOz": "Hydraté côté client", "nr31fYHB-RqO06BCl4rYO": "Cela s'appelle la Conception en archipel (en)." diff --git a/javascript-create-module/templates/hello-world/src/components/Hello/World/Celebrate.client.tsx b/javascript-create-module/templates/hello-world/src/components/Hello/World/Celebrate.client.tsx index 019959ad..137fe343 100644 --- a/javascript-create-module/templates/hello-world/src/components/Hello/World/Celebrate.client.tsx +++ b/javascript-create-module/templates/hello-world/src/components/Hello/World/Celebrate.client.tsx @@ -1,26 +1,17 @@ import clsx from "clsx"; import { useEffect, useState } from "react"; -import classes from "./component.module.css"; +import classes from "./styles.module.css"; import { useTranslation } from "react-i18next"; export default function () { const [confetti, setConfetti] = useState(); - const [isClient, setIsClient] = useState(false); useEffect(() => { - let mounted = true; // This library only works client-side, import it dynamically in an effect import("canvas-confetti").then(({ default: confetti }) => { - if (mounted) setConfetti(() => confetti); + setConfetti(() => confetti); }); - // Use a microtask to avoid direct setState in useEffect - Promise.resolve().then(() => { - if (mounted) setIsClient(true); - }); - return () => { - mounted = false; - }; - }, []); + }); // IMPORTANT: Always use useTranslation() (not { t } from "i18next") in React components. // This ensures translations are context-aware, update on language/namespace changes, @@ -39,10 +30,6 @@ export default function () { > {t("AI95bg1EJsr48SR-4f_pl")} {t("hBBhbeE0-a4KS9HVFPsOz")} - {/*add a client-side-only text for testing and demo purposes*/} - ); }