Skip to content
Merged
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
@@ -1,7 +1,7 @@
import clsx from "clsx";
import { t } from "i18next";
import { useEffect, useState } from "react";
import classes from "./styles.module.css";
import { useTranslation } from "react-i18next";

export default function () {
const [confetti, setConfetti] = useState<typeof import("canvas-confetti")>();
Expand All @@ -13,6 +13,11 @@ export default function () {
});
});

// 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 (
<button
className={clsx(classes.button, confetti && classes.hydrated)}
Expand Down
19 changes: 11 additions & 8 deletions javascript-modules-engine/src/client/hydrate.tsx
Original file line number Diff line number Diff line change
@@ -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 = ({
Expand All @@ -19,14 +20,16 @@ const ComponentWrapper = ({
/** Props object for the app component */
props: Record<string, unknown>;
}) => {
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 (
<Component {...props}>
{/* @ts-expect-error This is an hydration border: hydration will stop here */}
<jsm-children dangerouslySetInnerHTML={{ __html: "" }} suppressHydrationWarning />
</Component>
<I18nextProvider i18n={i18n} defaultNS={ns}>
<Component {...props}>
{/* @ts-expect-error This is an hydration border: hydration will stop here */}
<jsm-children dangerouslySetInnerHTML={{ __html: "" }} suppressHydrationWarning />
</Component>
</I18nextProvider>
);
};

Expand Down
5 changes: 0 additions & 5 deletions javascript-modules-engine/src/client/i18next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, unknown>> = {};
const namespaces: string[] = [];

for (const store of document.querySelectorAll<HTMLScriptElement>("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)) {
Expand All @@ -26,4 +22,3 @@ for (const store of document.querySelectorAll<HTMLScriptElement>("script[data-i1

// Init i18n internal store
i18n.services.resourceStore.data = initialI18nStore;
i18n.options.ns = namespaces;
1 change: 1 addition & 0 deletions samples/hydrogen/settings/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a>Islands Architecture.</a>",
"JI87mYV8J5pAEST4RIUcb": "This page is available in:"
Expand Down
1 change: 1 addition & 0 deletions samples/hydrogen/settings/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"8S0DVCRSnmQRKF9lZnNGj": "Illustrations par <a>Katerina Limpitsouni</a>",
"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 <a>Conception en archipel (en)</a>.",
Expand Down
24 changes: 21 additions & 3 deletions samples/hydrogen/src/components/HelloWorld/Celebrate.client.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("canvas-confetti")>();
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 (
<button
Expand All @@ -25,6 +39,10 @@ export default function () {
>
<span className={classes.before}>{t("AI95bg1EJsr48SR-4f_pl")}</span>
<span className={classes.after}>{t("hBBhbeE0-a4KS9HVFPsOz")}</span>
{/*add a client-side-only text for testing and demo purposes*/}
<span hidden data-testid="i18n-client-only">
{isClient ? t("0yvhe06c8uhrPuqm15zq6") : ""}
</span>
</button>
);
}
110 changes: 68 additions & 42 deletions tests/cypress/e2e/ui/testI18n.cy.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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" },
Expand All @@ -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,
Expand Down Expand Up @@ -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);
});
});
Loading