Skip to content

Commit 2a3fdcc

Browse files
fix: Properly render i18n translations server-side
1 parent ebc52f7 commit 2a3fdcc

6 files changed

Lines changed: 119 additions & 74 deletions

File tree

javascript-create-module/templates/hello-world/src/components/Hello/World/default.server.tsx

Lines changed: 46 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import {
44
buildModuleFileUrl,
55
jahiaComponent,
66
} from "@jahia/javascript-modules-library";
7-
import { t } from "i18next";
8-
import { Trans } from "react-i18next";
7+
import { Trans, useTranslation } from "react-i18next";
98
import down from "./arrows/down.svg";
109
import left from "./arrows/left.svg";
1110
import Celebrate from "./Celebrate.client.jsx";
@@ -20,48 +19,54 @@ jahiaComponent(
2019
componentType: "view",
2120
nodeType: "$NAMESPACE:helloWorld",
2221
},
23-
({ name }: Props, { renderContext }) => (
24-
<section className={classes.section}>
25-
<header className={classes.header}>
26-
<h2>
22+
({ name }: Props, { renderContext }) => {
23+
// IMPORTANT: Always use useTranslation() (not { t } from "i18next") in React components.
24+
// This ensures translations are context-aware, update on language/namespace changes,
25+
// and avoid hydration mismatches between server and client.
26+
const { t } = useTranslation();
27+
return (
28+
<section className={classes.section}>
29+
<header className={classes.header}>
30+
<h2>
31+
<Trans
32+
i18nKey="Mbvdf2LrCB5sW9PUFXO48"
33+
values={{ name }}
34+
components={{ mark: <mark /> }}
35+
/>
36+
</h2>
37+
{renderContext.isEditMode() && (
38+
<div className={classes.hint} style={{ alignItems: "center" }}>
39+
<img src={buildModuleFileUrl(left)} alt="←" width="80" height="16" />
40+
{t("0U2mp51dWjqWXje4x-eUV")}
41+
</div>
42+
)}
43+
</header>
44+
<p>{t("7l9zetMbU4cKpL4NxSOtL")}</p>
45+
<div className={classes.grid}>
46+
<RenderChildren />
47+
</div>
48+
49+
<p className={classes.attribution}>
50+
<Trans
51+
i18nKey="8S0DVCRSnmQRKF9lZnNGj"
52+
components={{ a: <a href="https://undraw.co/" /> }}
53+
/>
54+
</p>
55+
<p>{t("OfBsezopuIko8aJ6X3kpw")}</p>
56+
<Island component={Celebrate} />
57+
<p>
2758
<Trans
28-
i18nKey="Mbvdf2LrCB5sW9PUFXO48"
29-
values={{ name }}
30-
components={{ mark: <mark /> }}
59+
i18nKey="nr31fYHB-RqO06BCl4rYO"
60+
components={{ a: <a href="https://jasonformat.com/islands-architecture/" /> }}
3161
/>
32-
</h2>
62+
</p>
3363
{renderContext.isEditMode() && (
34-
<div className={classes.hint} style={{ alignItems: "center" }}>
35-
<img src={buildModuleFileUrl(left)} alt="" width="80" height="16" />
36-
{t("0U2mp51dWjqWXje4x-eUV")}
64+
<div className={classes.hint} style={{ marginLeft: "calc(50% - 0.5rem)" }}>
65+
<img src={buildModuleFileUrl(down)} alt="" width="70" height="100" />
66+
{t("89D3xFLMZmCAencaqw68C")}
3767
</div>
3868
)}
39-
</header>
40-
<p>{t("7l9zetMbU4cKpL4NxSOtL")}</p>
41-
<div className={classes.grid}>
42-
<RenderChildren />
43-
</div>
44-
45-
<p className={classes.attribution}>
46-
<Trans
47-
i18nKey="8S0DVCRSnmQRKF9lZnNGj"
48-
components={{ a: <a href="https://undraw.co/" /> }}
49-
/>
50-
</p>
51-
<p>{t("OfBsezopuIko8aJ6X3kpw")}</p>
52-
<Island component={Celebrate} />
53-
<p>
54-
<Trans
55-
i18nKey="nr31fYHB-RqO06BCl4rYO"
56-
components={{ a: <a href="https://jasonformat.com/islands-architecture/" /> }}
57-
/>
58-
</p>
59-
{renderContext.isEditMode() && (
60-
<div className={classes.hint} style={{ marginLeft: "calc(50% - 0.5rem)" }}>
61-
<img src={buildModuleFileUrl(down)} alt="↓" width="70" height="100" />
62-
{t("89D3xFLMZmCAencaqw68C")}
63-
</div>
64-
)}
65-
</section>
66-
),
69+
</section>
70+
);
71+
},
6772
);

javascript-modules-engine/src/server/init-react.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ server.registry.add("viewRenderer", "react", {
2222
const language = currentResource.getLocale().getLanguage();
2323
i18n.loadNamespaces(bundleKey);
2424
i18n.loadLanguages(language);
25-
// Set module namespace and current language
26-
i18n.setDefaultNamespace(bundleKey);
25+
// Not safe if multiple components use different languages on the same page.
26+
// But assumes a single language per page, so i18n.changeLanguage(lang) is safe in this context.
2727
i18n.changeLanguage(language);
2828

2929
// SSR
@@ -39,7 +39,7 @@ server.registry.add("viewRenderer", "react", {
3939
jcrSession={currentNode.getSession()}
4040
bundleKey={bundleKey}
4141
>
42-
<I18nextProvider i18n={i18n}>
42+
<I18nextProvider i18n={i18n} defaultNS={bundleKey}>
4343
<View />
4444
</I18nextProvider>
4545
</ServerContextProvider>

javascript-modules-library/src/components/render/Island.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export function Island<Props>(
6262
}
6363
: "children" extends keyof Props
6464
? // If the component has optional children, it may be client-only or not
65-
| {
65+
| {
6666
// In SSR mode, the children are passed to the component and must be of the correct type
6767
/**
6868
* If false or undefined, the component will be rendered on the server. If true,
@@ -83,7 +83,7 @@ export function Island<Props>(
8383
children?: ReactNode;
8484
}
8585
: // If the component has no children, it may be client-only or not
86-
| {
86+
| {
8787
// In SSR mode, the component cannot have children
8888
/**
8989
* If false or undefined, the component will be rendered on the server. If true,
@@ -205,7 +205,7 @@ export function Island({
205205
clientOnly ? (
206206
children
207207
) : (
208-
<I18nextProvider i18n={i18n}>
208+
<I18nextProvider i18n={i18n} defaultNS={bundleKey}>
209209
<Component
210210
{...props}
211211
children={createElement("jsm-children", {

samples/hydrogen/src/components/HelloWorld/default.server.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import {
2-
Island,
3-
RenderChildren,
42
buildModuleFileUrl,
3+
Island,
54
jahiaComponent,
5+
RenderChildren,
66
} from "@jahia/javascript-modules-library";
7-
import { t } from "i18next";
8-
import { Trans } from "react-i18next";
7+
import { Trans, useTranslation } from "react-i18next";
98
import Celebrate from "./Celebrate.client.jsx";
109
import classes from "./component.module.css";
1110

@@ -16,6 +15,10 @@ jahiaComponent(
1615
componentType: "view",
1716
},
1817
({ name }: { name: string }, { renderContext }) => {
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();
1922
return (
2023
<>
2124
<section className={classes.section}>

samples/hydrogen/src/components/LanguageSwitcher/default.server.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { buildNodeUrl, getSiteLocales, jahiaComponent } from "@jahia/javascript-modules-library";
2-
import { t } from "i18next";
32
import { Fragment } from "react";
43
import classes from "./component.module.css";
4+
import { useTranslation } from "react-i18next";
55

66
jahiaComponent(
77
{
@@ -12,6 +12,10 @@ jahiaComponent(
1212
properties: { "cache.timeout": "0" },
1313
},
1414
(_, { mainNode, currentResource }) => {
15+
// IMPORTANT: Always use useTranslation() (not { t } from "i18next") in React components.
16+
// This ensures translations are context-aware, update on language/namespace changes,
17+
// and avoid hydration mismatches between server and client.
18+
const { t } = useTranslation();
1519
const currentLanguage = currentResource.getLocale().toString();
1620
return (
1721
<p style={{ textAlign: "center" }}>

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

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,22 @@ const testData = {
2929
};
3030

3131
describe("Test i18n", () => {
32-
const createSiteWithContent = (siteKey: string) => {
32+
const createSiteWithContent = (
33+
siteKey: string,
34+
childrenNodes: {
35+
name: string;
36+
primaryNodeType: string;
37+
properties?: { name: string; value: string; language: string }[];
38+
}[],
39+
) => {
3340
deleteSite(siteKey); // cleanup from previous test runs
3441
createSite(siteKey, {
3542
languages: "en,fr_LU,fr,de",
3643
templateSet: "javascript-modules-engine-test-module",
3744
locale: "en",
3845
serverName: "localhost",
3946
});
47+
enableModule("hydrogen", siteKey); // allow adding components from the "hydrogen" module
4048

4149
addSimplePage(`/sites/${siteKey}/home`, "testPageI18N", "Test i18n en", "en", "simple", [
4250
{
@@ -56,10 +64,13 @@ describe("Test i18n", () => {
5664
mutationFile: "graphql/setProperties.graphql",
5765
});
5866

59-
addNode({
60-
parentPathOrId: `/sites/${siteKey}/home/testPageI18N/pagecontent`,
61-
name: "test",
62-
primaryNodeType: "javascriptExample:testI18n",
67+
childrenNodes.forEach((childNode) => {
68+
addNode({
69+
parentPathOrId: `/sites/${siteKey}/home/testPageI18N/pagecontent`,
70+
name: childNode.name,
71+
primaryNodeType: childNode.primaryNodeType,
72+
properties: childNode.properties ?? [],
73+
});
6374
});
6475
});
6576

@@ -68,7 +79,9 @@ describe("Test i18n", () => {
6879

6980
it("Test I18n values in various workspace/locales and various type of usage SSR/hydrate/rendered client side", () => {
7081
const siteKey = "javascriptI18NTestSite";
71-
createSiteWithContent(siteKey);
82+
createSiteWithContent(siteKey, [
83+
{ name: "test", primaryNodeType: "javascriptExample:testI18n" },
84+
]);
7285

7386
cy.login();
7487
["live", "default"].forEach((workspace) => {
@@ -131,16 +144,21 @@ describe("Test i18n", () => {
131144

132145
it("Support client-side i18n with components from multiple JS modules on the same page", () => {
133146
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"]);
147+
// create a page with alternating components from two different JS modules
148+
createSiteWithContent(siteKey, [
149+
{
150+
name: "hydrogen_1",
151+
primaryNodeType: "hydrogen:helloWorld",
152+
properties: [{ name: "name", value: "John Doe", language: "en" }],
153+
},
154+
{ name: "javascriptExample_1", primaryNodeType: "javascriptExample:testI18n" },
155+
{
156+
name: "hydrogen_2",
157+
primaryNodeType: "hydrogen:helloWorld",
158+
properties: [{ name: "name", value: "Jane Smith", language: "en" }],
159+
},
160+
{ name: "javascriptExample_2", primaryNodeType: "javascriptExample:testI18n" },
161+
]);
144162

145163
cy.login();
146164
["live", "default"].forEach((workspace) => {
@@ -157,14 +175,29 @@ describe("Test i18n", () => {
157175
expect(bundles).to.contain("hydrogen");
158176
});
159177

178+
// make sure the translations are rendered server-side
179+
// - for hydrogen:helloWorld :
180+
cy.get(
181+
'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:")',
182+
).should("have.length", 2);
183+
// - for javascriptExample:testI18n :
184+
cy.get('div[data-testid="i18n-server-side"] div[data-testid="i18n-simple"]')
185+
.should("contain", testData.translations.en.simple)
186+
.should("have.length", 2);
187+
160188
// make sure the translations are rendered client-side
161189
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-
);
190+
'div[data-testid="i18n-hydrated-client-side"] jsm-island[data-bundle="javascript-modules-engine-test-module"]' +
191+
' [data-testid="i18n-simple"]',
192+
)
193+
.should("contain", testData.translations.en.simple)
194+
.should("have.length", 2);
195+
cy.get('jsm-island[data-bundle="hydrogen"] [data-testid="i18n-client-only"]')
196+
.should(
197+
"contain",
198+
"Rendered client-side only", // the translated content
199+
)
200+
.should("have.length", 2);
168201
});
169202

170203
cy.logout();

0 commit comments

Comments
 (0)