Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -20,48 +19,54 @@ jahiaComponent(
componentType: "view",
nodeType: "$NAMESPACE:helloWorld",
},
({ name }: Props, { renderContext }) => (
<section className={classes.section}>
<header className={classes.header}>
<h2>
({ 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 (
<section className={classes.section}>
<header className={classes.header}>
<h2>
<Trans
i18nKey="Mbvdf2LrCB5sW9PUFXO48"
values={{ name }}
components={{ mark: <mark /> }}
/>
</h2>
{renderContext.isEditMode() && (
<div className={classes.hint} style={{ alignItems: "center" }}>
<img src={buildModuleFileUrl(left)} alt="←" width="80" height="16" />
{t("0U2mp51dWjqWXje4x-eUV")}
</div>
)}
</header>
<p>{t("7l9zetMbU4cKpL4NxSOtL")}</p>
<div className={classes.grid}>
<RenderChildren />
</div>

<p className={classes.attribution}>
<Trans
i18nKey="8S0DVCRSnmQRKF9lZnNGj"
components={{ a: <a href="https://undraw.co/" /> }}
/>
</p>
<p>{t("OfBsezopuIko8aJ6X3kpw")}</p>
<Island component={Celebrate} />
<p>
<Trans
i18nKey="Mbvdf2LrCB5sW9PUFXO48"
values={{ name }}
components={{ mark: <mark /> }}
i18nKey="nr31fYHB-RqO06BCl4rYO"
components={{ a: <a href="https://jasonformat.com/islands-architecture/" /> }}
/>
</h2>
</p>
{renderContext.isEditMode() && (
<div className={classes.hint} style={{ alignItems: "center" }}>
<img src={buildModuleFileUrl(left)} alt="" width="80" height="16" />
{t("0U2mp51dWjqWXje4x-eUV")}
<div className={classes.hint} style={{ marginLeft: "calc(50% - 0.5rem)" }}>
<img src={buildModuleFileUrl(down)} alt="" width="70" height="100" />
{t("89D3xFLMZmCAencaqw68C")}
</div>
)}
</header>
<p>{t("7l9zetMbU4cKpL4NxSOtL")}</p>
<div className={classes.grid}>
<RenderChildren />
</div>

<p className={classes.attribution}>
<Trans
i18nKey="8S0DVCRSnmQRKF9lZnNGj"
components={{ a: <a href="https://undraw.co/" /> }}
/>
</p>
<p>{t("OfBsezopuIko8aJ6X3kpw")}</p>
<Island component={Celebrate} />
<p>
<Trans
i18nKey="nr31fYHB-RqO06BCl4rYO"
components={{ a: <a href="https://jasonformat.com/islands-architecture/" /> }}
/>
</p>
{renderContext.isEditMode() && (
<div className={classes.hint} style={{ marginLeft: "calc(50% - 0.5rem)" }}>
<img src={buildModuleFileUrl(down)} alt="↓" width="70" height="100" />
{t("89D3xFLMZmCAencaqw68C")}
</div>
)}
</section>
),
</section>
);
},
);
6 changes: 3 additions & 3 deletions javascript-modules-engine/src/server/init-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,7 +39,7 @@ server.registry.add("viewRenderer", "react", {
jcrSession={currentNode.getSession()}
bundleKey={bundleKey}
>
<I18nextProvider i18n={i18n}>
<I18nextProvider i18n={i18n} defaultNS={bundleKey}>
<View />
</I18nextProvider>
</ServerContextProvider>
Expand Down
6 changes: 3 additions & 3 deletions javascript-modules-library/src/components/render/Island.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function Island<Props>(
}
: "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,
Expand All @@ -83,7 +83,7 @@ export function Island<Props>(
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,
Expand Down Expand Up @@ -205,7 +205,7 @@ export function Island({
clientOnly ? (
children
) : (
<I18nextProvider i18n={i18n}>
<I18nextProvider i18n={i18n} defaultNS={bundleKey}>
<Component
{...props}
children={createElement("jsm-children", {
Expand Down
11 changes: 7 additions & 4 deletions samples/hydrogen/src/components/HelloWorld/default.server.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import {
Island,
RenderChildren,
buildModuleFileUrl,
Island,
jahiaComponent,
RenderChildren,
} from "@jahia/javascript-modules-library";
import { t } from "i18next";
import { Trans } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import Celebrate from "./Celebrate.client.jsx";
import classes from "./component.module.css";

Expand All @@ -16,6 +15,10 @@ jahiaComponent(
componentType: "view",
},
({ name }: { name: string }, { 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 (
<>
<section className={classes.section}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
{
Expand All @@ -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 (
<p style={{ textAlign: "center" }}>
Expand Down
79 changes: 56 additions & 23 deletions tests/cypress/e2e/ui/testI18n.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,22 @@ 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",
templateSet: "javascript-modules-engine-test-module",
locale: "en",
serverName: "localhost",
});
enableModule("hydrogen", siteKey); // allow adding components from the "hydrogen" module

addSimplePage(`/sites/${siteKey}/home`, "testPageI18N", "Test i18n en", "en", "simple", [
{
Expand All @@ -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 ?? [],
});
});
});

Expand All @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
Comment on lines +180 to +182
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SSR assertion for the Hydrogen component is coupled to the full English translation string via a p:contains("Welcome to Jahia! ...") selector. This will be brittle if the sample copy changes (even punctuation), causing unrelated i18n/SSR changes to fail the test. Consider selecting via a stable attribute (e.g., add a data-testid on the HelloWorld "welcome" paragraph) or assert on a smaller, intentionally stable substring.

Suggested change
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);
cy.get('p:contains("Welcome to Jahia!")').should("have.length", 2);

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well, we're testing translations 🤷

// - 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();
Expand Down
Loading