From 0d083e3f3bc01de6c30640085a494eb6d6e5b821 Mon Sep 17 00:00:00 2001 From: Baptiste Grimaud Date: Thu, 19 Feb 2026 17:52:28 +0100 Subject: [PATCH] fix: Properly render i18n translations server-side --- .../components/Hello/World/default.server.tsx | 87 ++++++++++--------- .../src/server/init-react.tsx | 6 +- .../src/components/render/Island.tsx | 6 +- .../components/HelloWorld/default.server.tsx | 11 ++- .../LanguageSwitcher/default.server.tsx | 6 +- tests/cypress/e2e/ui/testI18n.cy.ts | 79 ++++++++++++----- 6 files changed, 120 insertions(+), 75 deletions(-) diff --git a/javascript-create-module/templates/hello-world/src/components/Hello/World/default.server.tsx b/javascript-create-module/templates/hello-world/src/components/Hello/World/default.server.tsx index f0983bdd..59b2f389 100644 --- a/javascript-create-module/templates/hello-world/src/components/Hello/World/default.server.tsx +++ b/javascript-create-module/templates/hello-world/src/components/Hello/World/default.server.tsx @@ -4,8 +4,7 @@ import { buildModuleFileUrl, jahiaComponent, } from "@jahia/javascript-modules-library"; -import { t } from "i18next"; -import { Trans } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import down from "./arrows/down.svg"; import left from "./arrows/left.svg"; import Celebrate from "./Celebrate.client.jsx"; @@ -20,48 +19,54 @@ jahiaComponent( componentType: "view", nodeType: "$NAMESPACE:helloWorld", }, - ({ name }: Props, { renderContext }) => ( -
-
-

+ ({ name }: Props, { renderContext }) => { + // 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 ( +
+
+

+ }} + /> +

+ {renderContext.isEditMode() && ( +
+ ← + {t("0U2mp51dWjqWXje4x-eUV")} +
+ )} +
+

{t("7l9zetMbU4cKpL4NxSOtL")}

+
+ +
+ +

+ }} + /> +

+

{t("OfBsezopuIko8aJ6X3kpw")}

+ +

}} + i18nKey="nr31fYHB-RqO06BCl4rYO" + components={{ a: }} /> -

+

{renderContext.isEditMode() && ( -
- ← - {t("0U2mp51dWjqWXje4x-eUV")} +
+ ↓ + {t("89D3xFLMZmCAencaqw68C")}
)} -
-

{t("7l9zetMbU4cKpL4NxSOtL")}

-
- -
- -

- }} - /> -

-

{t("OfBsezopuIko8aJ6X3kpw")}

- -

- }} - /> -

- {renderContext.isEditMode() && ( -
- ↓ - {t("89D3xFLMZmCAencaqw68C")} -
- )} -
- ), + + ); + }, ); diff --git a/javascript-modules-engine/src/server/init-react.tsx b/javascript-modules-engine/src/server/init-react.tsx index 28d995c4..4c525f66 100644 --- a/javascript-modules-engine/src/server/init-react.tsx +++ b/javascript-modules-engine/src/server/init-react.tsx @@ -22,8 +22,8 @@ server.registry.add("viewRenderer", "react", { const language = currentResource.getLocale().getLanguage(); i18n.loadNamespaces(bundleKey); i18n.loadLanguages(language); - // Set module namespace and current language - i18n.setDefaultNamespace(bundleKey); + // Not safe if multiple 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(language); // SSR @@ -39,7 +39,7 @@ server.registry.add("viewRenderer", "react", { jcrSession={currentNode.getSession()} bundleKey={bundleKey} > - + diff --git a/javascript-modules-library/src/components/render/Island.tsx b/javascript-modules-library/src/components/render/Island.tsx index dbad5946..258b69bc 100644 --- a/javascript-modules-library/src/components/render/Island.tsx +++ b/javascript-modules-library/src/components/render/Island.tsx @@ -62,7 +62,7 @@ export function Island( } : "children" extends keyof Props ? // If the component has optional children, it may be client-only or not - | { + | { // In SSR mode, the children are passed to the component and must be of the correct type /** * If false or undefined, the component will be rendered on the server. If true, @@ -83,7 +83,7 @@ export function Island( children?: ReactNode; } : // If the component has no children, it may be client-only or not - | { + | { // In SSR mode, the component cannot have children /** * If false or undefined, the component will be rendered on the server. If true, @@ -205,7 +205,7 @@ export function Island({ clientOnly ? ( children ) : ( - + { + // 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/samples/hydrogen/src/components/LanguageSwitcher/default.server.tsx b/samples/hydrogen/src/components/LanguageSwitcher/default.server.tsx index 6e81cddc..d8b42799 100644 --- a/samples/hydrogen/src/components/LanguageSwitcher/default.server.tsx +++ b/samples/hydrogen/src/components/LanguageSwitcher/default.server.tsx @@ -1,7 +1,7 @@ import { buildNodeUrl, getSiteLocales, jahiaComponent } from "@jahia/javascript-modules-library"; -import { t } from "i18next"; import { Fragment } from "react"; import classes from "./component.module.css"; +import { useTranslation } from "react-i18next"; jahiaComponent( { @@ -12,6 +12,10 @@ jahiaComponent( properties: { "cache.timeout": "0" }, }, (_, { mainNode, currentResource }) => { + // 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(); const currentLanguage = currentResource.getLocale().toString(); return (

diff --git a/tests/cypress/e2e/ui/testI18n.cy.ts b/tests/cypress/e2e/ui/testI18n.cy.ts index f06f75a6..a793b49a 100644 --- a/tests/cypress/e2e/ui/testI18n.cy.ts +++ b/tests/cypress/e2e/ui/testI18n.cy.ts @@ -29,7 +29,14 @@ const testData = { }; describe("Test i18n", () => { - const createSiteWithContent = (siteKey: string) => { + const createSiteWithContent = ( + siteKey: string, + childrenNodes: { + name: string; + primaryNodeType: string; + properties?: { name: string; value: string; language: string }[]; + }[], + ) => { deleteSite(siteKey); // cleanup from previous test runs createSite(siteKey, { languages: "en,fr_LU,fr,de", @@ -37,6 +44,7 @@ describe("Test i18n", () => { locale: "en", serverName: "localhost", }); + enableModule("hydrogen", siteKey); // allow adding components from the "hydrogen" module addSimplePage(`/sites/${siteKey}/home`, "testPageI18N", "Test i18n en", "en", "simple", [ { @@ -56,10 +64,13 @@ describe("Test i18n", () => { mutationFile: "graphql/setProperties.graphql", }); - addNode({ - parentPathOrId: `/sites/${siteKey}/home/testPageI18N/pagecontent`, - name: "test", - primaryNodeType: "javascriptExample:testI18n", + childrenNodes.forEach((childNode) => { + addNode({ + parentPathOrId: `/sites/${siteKey}/home/testPageI18N/pagecontent`, + name: childNode.name, + primaryNodeType: childNode.primaryNodeType, + properties: childNode.properties ?? [], + }); }); }); @@ -68,7 +79,9 @@ describe("Test i18n", () => { it("Test I18n values in various workspace/locales and various type of usage SSR/hydrate/rendered client side", () => { const siteKey = "javascriptI18NTestSite"; - createSiteWithContent(siteKey); + createSiteWithContent(siteKey, [ + { name: "test", primaryNodeType: "javascriptExample:testI18n" }, + ]); cy.login(); ["live", "default"].forEach((workspace) => { @@ -129,18 +142,23 @@ describe("Test i18n", () => { } }; - it("Support client-side i18n with components from multiple JS modules on the same page", () => { + it("Support 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"]); + // create a page with alternating components from two different JS modules + createSiteWithContent(siteKey, [ + { + name: "hydrogen_1", + primaryNodeType: "hydrogen:helloWorld", + properties: [{ name: "name", value: "John Doe", language: "en" }], + }, + { name: "javascriptExample_1", primaryNodeType: "javascriptExample:testI18n" }, + { + name: "hydrogen_2", + primaryNodeType: "hydrogen:helloWorld", + properties: [{ name: "name", value: "Jane Smith", language: "en" }], + }, + { name: "javascriptExample_2", primaryNodeType: "javascriptExample:testI18n" }, + ]); cy.login(); ["live", "default"].forEach((workspace) => { @@ -157,14 +175,29 @@ describe("Test i18n", () => { expect(bundles).to.contain("hydrogen"); }); + // make sure the translations are rendered server-side + // - for hydrogen:helloWorld : + cy.get( + 'p:contains("Welcome to Jahia! You successfully created a new JavaScript Module and a Jahia Website built with it. Here are a few things you can do now:")', + ).should("have.length", 2); + // - for javascriptExample:testI18n : + cy.get('div[data-testid="i18n-server-side"] div[data-testid="i18n-simple"]') + .should("contain", testData.translations.en.simple) + .should("have.length", 2); + // 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 - ); + 'div[data-testid="i18n-hydrated-client-side"] jsm-island[data-bundle="javascript-modules-engine-test-module"]' + + ' [data-testid="i18n-simple"]', + ) + .should("contain", testData.translations.en.simple) + .should("have.length", 2); + cy.get('jsm-island[data-bundle="hydrogen"] [data-testid="i18n-client-only"]') + .should( + "contain", + "Rendered client-side only", // the translated content + ) + .should("have.length", 2); }); cy.logout();