Skip to content

Commit 2d07cff

Browse files
fix(i18n): properly share i18n instance across JS modules
1 parent 2f8dc5f commit 2d07cff

File tree

8 files changed

+109
-62
lines changed

8 files changed

+109
-62
lines changed

jahia-test-module/src/client/components/SampleI18n.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useState } from "react";
22
import { useTranslation } from "react-i18next";
33

44
export default function SampleI18n({ placeholder = "" }) {
5-
const { t } = useTranslation();
5+
const { t } = useTranslation("javascript-modules-engine-test-module");
66
const [updatedPlaceholder, setUpdatedPlaceholder] = useState(placeholder);
77

88
return (

javascript-create-module/templates/hello-world/src/components/Hello/World/Celebrate.client.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
import clsx from "clsx";
2-
import { t } from "i18next";
32
import { useEffect, useState } from "react";
4-
import classes from "./styles.module.css";
3+
import classes from "./component.module.css";
4+
import { useTranslation } from "react-i18next";
55

66
export default function () {
77
const [confetti, setConfetti] = useState<typeof import("canvas-confetti")>();
8+
const [isClient, setIsClient] = useState(false);
89

910
useEffect(() => {
1011
// This library only works client-side, import it dynamically in an effect
1112
import("canvas-confetti").then(({ default: confetti }) => {
1213
setConfetti(() => confetti);
1314
});
14-
});
15+
setIsClient(true);
16+
}, []);
17+
18+
// IMPORTANT: Always use useTranslation() (not { t } from "i18next") in React components.
19+
// This ensures translations are context-aware, update on language/namespace changes,
20+
// and avoid hydration mismatches between server and client.
21+
const { t } = useTranslation();
1522

1623
return (
1724
<button
@@ -25,6 +32,10 @@ export default function () {
2532
>
2633
<span className={classes.before}>{t("AI95bg1EJsr48SR-4f_pl")}</span>
2734
<span className={classes.after}>{t("hBBhbeE0-a4KS9HVFPsOz")}</span>
35+
{/*add a client-side-only text for testing and demo purposes*/}
36+
<span hidden data-testid="i18n-client-only">
37+
{isClient ? t("QWErty123zxcvi4uhDas3") : ""}
38+
</span>
2839
</button>
2940
);
3041
}

javascript-modules-engine/src/client/hydrate.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import * as devalue from "devalue";
2-
import i18next from "i18next";
2+
import i18n from "i18next";
33
import type { ComponentType } from "react";
44
import { createRoot, hydrateRoot } from "react-dom/client";
5+
import { I18nextProvider } from "react-i18next";
56

67
/** Ensures the component is hydrated with the right i18next context */
78
const ComponentWrapper = ({
@@ -19,14 +20,16 @@ const ComponentWrapper = ({
1920
/** Props object for the app component */
2021
props: Record<string, unknown>;
2122
}) => {
22-
i18next.setDefaultNamespace(ns);
23-
i18next.changeLanguage(lang);
24-
23+
// Not thread-safe if multiple hydrated components use different languages on the same page.
24+
// But assumes a single language per page, so i18n.changeLanguage(lang) is safe in this context.
25+
i18n.changeLanguage(lang);
2526
return (
26-
<Component {...props}>
27-
{/* @ts-expect-error This is an hydration border: hydration will stop here */}
28-
<jsm-children dangerouslySetInnerHTML={{ __html: "" }} suppressHydrationWarning />
29-
</Component>
27+
<I18nextProvider i18n={i18n} defaultNS={ns}>
28+
<Component {...props}>
29+
{/* @ts-expect-error This is an hydration border: hydration will stop here */}
30+
<jsm-children dangerouslySetInnerHTML={{ __html: "" }} suppressHydrationWarning />
31+
</Component>
32+
</I18nextProvider>
3033
);
3134
};
3235

javascript-modules-engine/src/client/i18next.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,14 @@ import * as devalue from "devalue";
44

55
i18n.use(initReactI18next).init({
66
fallbackLng: "en",
7-
ns: "javascript-modules-engine",
8-
defaultNS: "javascript-modules-engine",
97
initImmediate: false,
108
react: { useSuspense: false },
119
});
1210

1311
const initialI18nStore: Record<string, Record<string, unknown>> = {};
14-
const namespaces: string[] = [];
1512

1613
for (const store of document.querySelectorAll<HTMLScriptElement>("script[data-i18n-store]")) {
1714
const namespace = store.dataset.i18nStore;
18-
namespaces.push(namespace);
1915

2016
const allTranslations = devalue.parse(store.textContent);
2117
for (const [lang, translations] of Object.entries(allTranslations)) {
@@ -26,4 +22,3 @@ for (const store of document.querySelectorAll<HTMLScriptElement>("script[data-i1
2622

2723
// Init i18n internal store
2824
i18n.services.resourceStore.data = initialI18nStore;
29-
i18n.options.ns = namespaces;

samples/hydrogen/settings/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"89D3xFLMZmCAencaqw68C": "Click this button to add a new content node",
77
"hBBhbeE0-a4KS9HVFPsOz": "Hydrated client-side",
88
"AI95bg1EJsr48SR-4f_pl": "Rendered server-side",
9+
"QWErty123zxcvi4uhDas3": "Rendered client-side only",
910
"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:",
1011
"nr31fYHB-RqO06BCl4rYO": "This pattern is named <a>Islands Architecture.</a>",
1112
"JI87mYV8J5pAEST4RIUcb": "This page is available in:"

samples/hydrogen/src/components/HelloWorld/Celebrate.client.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
import clsx from "clsx";
2-
import { t } from "i18next";
32
import { useEffect, useState } from "react";
43
import classes from "./component.module.css";
4+
import { useTranslation } from "react-i18next";
55

66
export default function () {
77
const [confetti, setConfetti] = useState<typeof import("canvas-confetti")>();
8+
const [isClient, setIsClient] = useState(false);
89

910
useEffect(() => {
1011
// This library only works client-side, import it dynamically in an effect
1112
import("canvas-confetti").then(({ default: confetti }) => {
1213
setConfetti(() => confetti);
1314
});
14-
});
15+
setIsClient(true);
16+
}, []);
17+
18+
// IMPORTANT: Always use useTranslation() (not { t } from "i18next") in React components.
19+
// This ensures translations are context-aware, update on language/namespace changes,
20+
// and avoid hydration mismatches between server and client.
21+
const { t } = useTranslation();
1522

1623
return (
1724
<button
@@ -25,6 +32,10 @@ export default function () {
2532
>
2633
<span className={classes.before}>{t("AI95bg1EJsr48SR-4f_pl")}</span>
2734
<span className={classes.after}>{t("hBBhbeE0-a4KS9HVFPsOz")}</span>
35+
{/*add a client-side-only text for testing and demo purposes*/}
36+
<span hidden data-testid="i18n-client-only">
37+
{isClient ? t("QWErty123zxcvi4uhDas3") : ""}
38+
</span>
2839
</button>
2940
);
3041
}

tests/cypress/e2e/ui/testI18n.cy.ts

Lines changed: 68 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { addNode, createSite, deleteSite, publishAndWaitJobEnding } from "@jahia/cypress";
1+
import {
2+
addNode,
3+
createSite,
4+
deleteSite,
5+
enableModule,
6+
publishAndWaitJobEnding,
7+
} from "@jahia/cypress";
28
import { addSimplePage } from "../../utils/helpers";
39

410
const testData = {
@@ -23,30 +29,24 @@ const testData = {
2329
};
2430

2531
describe("Test i18n", () => {
26-
before("Create test site/contents", () => {
27-
createSite("javascriptI18NTestSite", {
32+
const createSiteWithContent = (siteKey: string) => {
33+
deleteSite(siteKey); // cleanup from previous test runs
34+
createSite(siteKey, {
2835
languages: "en,fr_LU,fr,de",
2936
templateSet: "javascript-modules-engine-test-module",
3037
locale: "en",
3138
serverName: "localhost",
3239
});
3340

34-
addSimplePage(
35-
"/sites/javascriptI18NTestSite/home",
36-
"testPageI18N",
37-
"Test i18n en",
38-
"en",
39-
"simple",
40-
[
41-
{
42-
name: "pagecontent",
43-
primaryNodeType: "jnt:contentList",
44-
},
45-
],
46-
).then(() => {
41+
addSimplePage(`/sites/${siteKey}/home`, "testPageI18N", "Test i18n en", "en", "simple", [
42+
{
43+
name: "pagecontent",
44+
primaryNodeType: "jnt:contentList",
45+
},
46+
]).then(() => {
4747
cy.apollo({
4848
variables: {
49-
pathOrId: "/sites/javascriptI18NTestSite/home/testPageI18N",
49+
pathOrId: `/sites/${siteKey}/home/testPageI18N`,
5050
properties: [
5151
{ name: "jcr:title", value: "Test i18n fr_LU", language: "fr_LU" },
5252
{ name: "jcr:title", value: "Test i18n fr", language: "fr" },
@@ -57,57 +57,45 @@ describe("Test i18n", () => {
5757
});
5858

5959
addNode({
60-
parentPathOrId: "/sites/javascriptI18NTestSite/home/testPageI18N/pagecontent",
60+
parentPathOrId: `/sites/${siteKey}/home/testPageI18N/pagecontent`,
6161
name: "test",
6262
primaryNodeType: "javascriptExample:testI18n",
6363
});
6464
});
6565

66-
publishAndWaitJobEnding("/sites/javascriptI18NTestSite/home/testPageI18N", [
67-
"en",
68-
"fr_LU",
69-
"fr",
70-
"de",
71-
]);
72-
});
66+
publishAndWaitJobEnding(`/sites/${siteKey}/home/testPageI18N`, ["en", "fr_LU", "fr", "de"]);
67+
};
7368

7469
it("Test I18n values in various workspace/locales and various type of usage SSR/hydrate/rendered client side", () => {
70+
const siteKey = "javascriptI18NTestSite";
71+
createSiteWithContent(siteKey);
72+
7573
cy.login();
7674
["live", "default"].forEach((workspace) => {
7775
["en", "fr_LU", "fr", "de"].forEach((locale) => {
78-
cy.visit(
79-
`/cms/render/${workspace}/${locale}/sites/javascriptI18NTestSite/home/testPageI18N.html`,
80-
);
81-
testI18n(
82-
workspace,
83-
locale,
84-
'div[data-testid="i18n-server-side"]',
85-
"We are server side !",
86-
false,
87-
);
76+
cy.visit(`/cms/render/${workspace}/${locale}/sites/${siteKey}/home/testPageI18N.html`);
77+
testI18n(locale, 'div[data-testid="i18n-server-side"]', "We are server side !", false);
8878
testI18n(
89-
workspace,
9079
locale,
9180
'div[data-testid="i18n-hydrated-client-side"]',
9281
"We are hydrated client side !",
9382
true,
9483
);
9584
testI18n(
96-
workspace,
9785
locale,
9886
'div[data-testid="i18n-rendered-client-side"]',
9987
"We are rendered client side !",
10088
true,
10189
);
10290
});
103-
10491
cy.get('[data-testid="getSiteLocales"]').should("contain", "de,en,fr,fr_LU");
10592
});
10693
cy.logout();
94+
95+
deleteSite(siteKey);
10796
});
10897

10998
const testI18n = (
110-
workspace: string,
11199
locale: string,
112100
mainSelector: string,
113101
placeholderIntialValue: string,
@@ -141,8 +129,46 @@ describe("Test i18n", () => {
141129
}
142130
};
143131

144-
after("Cleanup", () => {
145-
cy.visit("/start", { failOnStatusCode: false });
146-
deleteSite("javascriptI18NTestSite");
132+
it.only("Support client-side i18n with components from multiple JS modules on the same page", () => {
133+
const siteKey = "javascriptI18NMultiModuleTestSite";
134+
createSiteWithContent(siteKey);
135+
// add a component from another JS module
136+
enableModule("hydrogen", siteKey);
137+
addNode({
138+
parentPathOrId: `/sites/${siteKey}/home/testPageI18N/pagecontent`,
139+
name: "testOtherModule",
140+
primaryNodeType: "hydrogen:helloWorld",
141+
properties: [{ name: "name", value: "John Doe", language: "en" }],
142+
});
143+
publishAndWaitJobEnding(`/sites/${siteKey}/home/testPageI18N`, ["en"]);
144+
145+
cy.login();
146+
["live", "default"].forEach((workspace) => {
147+
cy.visit(`/cms/render/${workspace}/en/sites/${siteKey}/home/testPageI18N.html`);
148+
149+
// make sure the 2 modules are present on the page with their i18n store
150+
cy.get('script[data-i18n-store="javascript-modules-engine-test-module"]').should("exist");
151+
cy.get('script[data-i18n-store="hydrogen"]').should("exist");
152+
cy.get("jsm-island").then(($islands) => {
153+
// get unique "data-bundle" values from all islands elements
154+
const bundles = new Set($islands.get().map((el) => el.getAttribute("data-bundle")));
155+
expect(bundles.size).to.eq(2);
156+
expect(bundles).to.contain("javascript-modules-engine-test-module");
157+
expect(bundles).to.contain("hydrogen");
158+
});
159+
160+
// make sure the translations are rendered client-side
161+
cy.get(
162+
'jsm-island[data-bundle="javascript-modules-engine-test-module"] [data-testid="i18n-simple"]',
163+
).should("contain", testData.translations.en.simple);
164+
cy.get('jsm-island[data-bundle="hydrogen"] [data-testid="i18n-client-only"]').should(
165+
"contain",
166+
"Rendered client-side only", // the translated content
167+
);
168+
});
169+
170+
cy.logout();
171+
172+
deleteSite(siteKey);
147173
});
148174
});

tests/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"jahia-module"
1010
],
1111
"scripts": {
12-
"e2e:ci": "cypress run --browser chrome",
12+
"e2e:ci": "cypress run",
1313
"e2e:debug": "cypress open",
1414
"instrument": "nyc instrument --compact=false cypress instrumented",
1515
"lint": "echo 'linting is available at the monorepo root'",

0 commit comments

Comments
 (0)