Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .chachalog/Zk8pQr3T.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
# Allowed version bumps: patch, minor, major
javascript-modules: minor
---

`<Island>` now accepts client components exported by name, not only default exports: the Vite plugin tags every export of a `*.client.{jsx,tsx}` file, and the browser loader picks the right one. A component that carries no hydration metadata (declared outside a client file, or re-exported through an `export { … }` list) now fails at render with an explicit message, instead of silently making the browser request `undefined.js`. (#706)
18 changes: 18 additions & 0 deletions jahia-test-module/src/client/components/SampleNamedExportReact.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useState } from "react";

/**
* Exported by name (not by default) on purpose: islands must hydrate the same way whichever export
* form the component uses.
*/
export function SampleNamedExportReact({ initialValue }: { initialValue: number }) {
const [count, setCount] = useState(initialValue);

return (
<div>
<p data-testid="named-export-count">Named count: {count}</p>
<button data-testid="named-export-button" onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Island, jahiaComponent } from "@jahia/javascript-modules-library";
import SampleHydrateInBrowserReact from "$client/components/SampleHydrateInBrowserReact";
import SampleRenderInBrowserReact from "$client/components/SampleRenderInBrowserReact";
import { SampleNamedExportReact } from "$client/components/SampleNamedExportReact";

jahiaComponent(
{
Expand All @@ -25,6 +26,7 @@ jahiaComponent(
>
<p data-testid="ssr-placeholder">Server-side placeholder</p>
</Island>
<Island component={SampleNamedExportReact} props={{ initialValue: 5 }} />
</>
);
},
Expand Down
8 changes: 7 additions & 1 deletion javascript-modules-engine/src/client/hydrate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ const load = async (element: HTMLElement) => {
const props = rawProps ? devalue.parse(rawProps) : {};
const hydrate = !element.dataset.clientOnly;

const { default: Component } = await import(entry);
// Islands built before named-export support carry no data-export attribute
const exportName = element.dataset.export ?? "default";
const { [exportName]: Component } = await import(entry);

if (!Component) {
throw new Error(`Client bundle ${entry} has no export named "${exportName}".`);
}

return {
hydrate,
Expand Down
24 changes: 22 additions & 2 deletions javascript-modules-library/src/components/render/Island.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ import { useServerContext } from "../../hooks/useServerContext.js";
import { buildModuleFileUrl } from "../../utils/urlBuilder/urlBuilder.js";
import { AddResources } from "../AddResources.js";

/**
* A component declared in a `*.client.{jsx,tsx}` file, tagged by the Vite plugin with the metadata
* needed to hydrate it: which client bundle to load, and which of its exports to pick up.
*/
type HydratableComponent = ComponentType<{ children?: ReactNode }> & {
__filename?: string;
__exportName?: string;
};

/**
* This component creates an island of interactivity on the page, following the [Island
* Architecture](https://www.jahia.com/blog/leveraging-the-island-architecture-in-jahia-cms)
Expand Down Expand Up @@ -123,9 +132,18 @@ export function Island({
/** Base path to all javascript-modules-engine resources. */
const base = buildModuleFileUrl("javascript", { moduleName: "javascript-modules-engine" });

const { __filename: file, __exportName: exportName } = Component as HydratableComponent;

if (!file) {
throw new Error(
`<Island> received a component without hydration metadata: ${
Component.displayName ?? Component.name ?? "anonymous component"
}. Client components must be declared in a *.client.{jsx,tsx} file and exported from it directly (\`export default\`, \`export const\` or \`export function\`); \`export { … }\` lists and re-exports are not supported.`,
);
}

/** JS entry point to the client bundle loader. */
// @ts-expect-error __filename is added by the vite plugin
const entry = buildModuleFileUrl(`${Component.__filename}.js`);
const entry = buildModuleFileUrl(`${file}.js`);

/**
* All translations that can be used by the component on the client side. (only ship the current
Expand Down Expand Up @@ -196,6 +214,8 @@ export function Island({
"style": { display: "contents" },
"data-client-only": clientOnly ? true : undefined,
"data-src": entry,
// Omitted for default exports, for which the client loader falls back to "default"
"data-export": exportName === "default" ? undefined : exportName,
"data-lang": language,
"data-bundle": bundleKey,
"children": [
Expand Down
20 changes: 19 additions & 1 deletion tests/cypress/e2e/ui/reactClientSideTest.cy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { addNode, publishAndWaitJobEnding } from "@jahia/cypress";
import { addSimplePage } from "../../utils/helpers";
import { GENERIC_SITE_KEY } from '../../support/constants';
import { GENERIC_SITE_KEY } from "../../support/constants";

describe("Verify client side component are rehydrated as expected", () => {
before("Create test contents", () => {
Expand Down Expand Up @@ -66,5 +66,23 @@ describe("Verify client side component are rehydrated as expected", () => {

cy.get('[data-testid="ssr-child"]').should("contain", "Server-side rendered");
});

it(`${workspace}: Check that a component exported by name is hydrated too`, () => {
// A missing hydration entry would be requested as <module>/undefined.js
cy.intercept("GET", "**/undefined.js", () => {
throw new Error("An island requested undefined.js: hydration metadata is missing");
});

cy.visit(
`/cms/render/${workspace}/en/sites/${GENERIC_SITE_KEY}/home/testHydrateInBrowser.html`,
);

cy.get('p[data-testid="named-export-count"]').should("contain", "Named count: 5");
cy.get('[data-hydrated="true"]').should("exist");

// Interactivity proves the named export was resolved in the client bundle
cy.get('button[data-testid="named-export-button"]').click();
cy.get('p[data-testid="named-export-count"]').should("contain", "Named count: 6");
});
}
});
1 change: 1 addition & 0 deletions vite-plugin/fixtures/expected/client/named.client.tsx.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 56 additions & 7 deletions vite-plugin/fixtures/expected/server/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion vite-plugin/fixtures/expected/server/index.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions vite-plugin/fixtures/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,27 @@ test("@jahia/vite-plugin output snapshot", () => {
assert.ok(fs.existsSync(assetPath), `Asset ${asset} is missing`);
}
});

test("client components carry hydration metadata, whether exported by default or by name", () => {
const server = fs.readFileSync(path.join("dist", "server", "index.js"), "utf8");

/** Extracts the (__filename, __exportName) pairs injected by the insert-filename plugin. */
const metadata = [
...server.matchAll(
/__filename: \{\s*value: "([^"]+)",[\s\S]*?__exportName: \{\s*value: "([^"]+)"/g,
),
].map(([, filename, exportName]) => ({ filename, exportName }));

assert.deepStrictEqual(metadata, [
// `export default function Foo()` in foo.client.tsx
{ filename: "dist/client/foo.client.tsx", exportName: "default" },
// `export function Named()` and `export const AlsoNamed` in named.client.tsx
{ filename: "dist/client/named.client.tsx", exportName: "Named" },
{ filename: "dist/client/named.client.tsx", exportName: "AlsoNamed" },
]);

// The client bundle must expose the named exports the server points at
const client = fs.readFileSync(path.join("dist", "client", "named.client.tsx.js"), "utf8");
assert.match(client, /as Named\b/);
assert.match(client, /as AlsoNamed\b/);
});
3 changes: 3 additions & 0 deletions vite-plugin/fixtures/src/foo.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { buildModuleFileUrl, Island, jahiaComponent } from "@jahia/javascript-mo
import vite from "./vite.png";
import { Layout } from "./Layout.tsx";
import Foo from "./foo.client.tsx";
import { Named, AlsoNamed } from "./named.client.tsx";

jahiaComponent(
{
Expand All @@ -12,6 +13,8 @@ jahiaComponent(
<Layout>
<img src={buildModuleFileUrl(vite)} alt="Vite logo" />
<Island component={Foo} />
<Island component={Named} />
<Island component={AlsoNamed} />
</Layout>
),
);
7 changes: 7 additions & 0 deletions vite-plugin/fixtures/src/named.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** A client component exported by name, to check named exports carry hydration metadata. */
export function Named() {
return <pre>Named export!</pre>;
}

/** A second named export in the same file, tagged independently. */
export const AlsoNamed = () => <pre>Also a named export!</pre>;
Loading
Loading