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)
2 changes: 1 addition & 1 deletion docs/2-guides/2-island-architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Server files, files in `.server.tsx`, are used as entry points for your server c

Client files, files in `.client.tsx`, are used as entry points for your client code. All client files, as well as all their imports, will be made available for the browser to download. They should not contain any sensitive information, and cannot import server APIs (`@jahia/javascript-modules-library` and `.server.tsx` files).

In client files, **only the default export** can be used to create an island. For instance, here is a minimal interactive button:
In client files, any component **exported directly under a name** — the default export or a named export (`export const Foo = …`, `export function Foo() {}`) — can be used to create an island; `export { … }` lists, re-exports and destructured exports are not supported. For instance, here is a minimal interactive button:

```tsx
// Button.client.tsx
Expand Down
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 under a name (\`export default\`, \`export const Foo = …\` or \`export function Foo() {}\`); \`export { … }\` lists, re-exports and destructured exports (\`export const { Foo } = …\`) 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
24 changes: 23 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,27 @@ 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");
// Wait for THIS island's hydration (another island's data-hydrated must not unblock the
// click, which would race the handler attachment)
cy.get('button[data-testid="named-export-button"]')
.closest("jsm-island")
.should("have.attr", "data-hydrated", "true");

// 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