diff --git a/.chachalog/Zk8pQr3T.md b/.chachalog/Zk8pQr3T.md new file mode 100644 index 00000000..da469491 --- /dev/null +++ b/.chachalog/Zk8pQr3T.md @@ -0,0 +1,6 @@ +--- +# Allowed version bumps: patch, minor, major +javascript-modules: minor +--- + +`` 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) diff --git a/jahia-test-module/src/client/components/SampleNamedExportReact.tsx b/jahia-test-module/src/client/components/SampleNamedExportReact.tsx new file mode 100644 index 00000000..88fe5149 --- /dev/null +++ b/jahia-test-module/src/client/components/SampleNamedExportReact.tsx @@ -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 ( +
+

Named count: {count}

+ +
+ ); +} diff --git a/jahia-test-module/src/react/server/views/testReactClientSide/TestReactClientSide.tsx b/jahia-test-module/src/react/server/views/testReactClientSide/TestReactClientSide.tsx index 3831f5dc..21c2ef45 100644 --- a/jahia-test-module/src/react/server/views/testReactClientSide/TestReactClientSide.tsx +++ b/jahia-test-module/src/react/server/views/testReactClientSide/TestReactClientSide.tsx @@ -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( { @@ -25,6 +26,7 @@ jahiaComponent( >

Server-side placeholder

+ ); }, diff --git a/javascript-modules-engine/src/client/hydrate.tsx b/javascript-modules-engine/src/client/hydrate.tsx index 0b35cd13..c68a34f4 100644 --- a/javascript-modules-engine/src/client/hydrate.tsx +++ b/javascript-modules-engine/src/client/hydrate.tsx @@ -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, diff --git a/javascript-modules-library/src/components/render/Island.tsx b/javascript-modules-library/src/components/render/Island.tsx index 258b69bc..2d3fe28c 100644 --- a/javascript-modules-library/src/components/render/Island.tsx +++ b/javascript-modules-library/src/components/render/Island.tsx @@ -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) @@ -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( + ` 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 @@ -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": [ diff --git a/tests/cypress/e2e/ui/reactClientSideTest.cy.ts b/tests/cypress/e2e/ui/reactClientSideTest.cy.ts index ea1ac00e..99326211 100644 --- a/tests/cypress/e2e/ui/reactClientSideTest.cy.ts +++ b/tests/cypress/e2e/ui/reactClientSideTest.cy.ts @@ -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", () => { @@ -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 /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"); + }); } }); diff --git a/vite-plugin/fixtures/expected/client/named.client.tsx.js b/vite-plugin/fixtures/expected/client/named.client.tsx.js new file mode 100644 index 00000000..b0557c9e --- /dev/null +++ b/vite-plugin/fixtures/expected/client/named.client.tsx.js @@ -0,0 +1 @@ +import{jsx as e}from"react/jsx-runtime";function t(){return e(`pre`,{children:`Named export!`})}var n=()=>e(`pre`,{children:`Also a named export!`});export{n as AlsoNamed,t as Named}; \ No newline at end of file diff --git a/vite-plugin/fixtures/expected/server/index.js b/vite-plugin/fixtures/expected/server/index.js index a28bfe88..f1960145 100644 --- a/vite-plugin/fixtures/expected/server/index.js +++ b/vite-plugin/fixtures/expected/server/index.js @@ -13,9 +13,17 @@ var foo_module_default = { pre: "_pre_1cbxx_3" }; //#endregion //#region src/foo.client.tsx var foo_client_default = (function(v) { - if (typeof v === "function" || typeof v === "object" && v) Object.defineProperty(v, "__filename", { - value: "dist/client/foo.client.tsx", - enumerable: false + if (typeof v === "function" || typeof v === "object" && v) Object.defineProperties(v, { + __filename: { + value: "dist/client/foo.client.tsx", + enumerable: false, + configurable: true + }, + __exportName: { + value: "default", + enumerable: false, + configurable: true + } }); return v; })(function Foo() { @@ -25,14 +33,55 @@ var foo_client_default = (function(v) { }); }); //#endregion +//#region src/named.client.tsx +/** A client component exported by name, to check named exports carry hydration metadata. */ +function Named() { + return /* @__PURE__ */ jsx("pre", { children: "Named export!" }); +} +(function(v) { + if (typeof v === "function" || typeof v === "object" && v) Object.defineProperties(v, { + __filename: { + value: "dist/client/named.client.tsx", + enumerable: false, + configurable: true + }, + __exportName: { + value: "Named", + enumerable: false, + configurable: true + } + }); +})(Named); +/** A second named export in the same file, tagged independently. */ +var AlsoNamed = () => /* @__PURE__ */ jsx("pre", { children: "Also a named export!" }); +(function(v) { + if (typeof v === "function" || typeof v === "object" && v) Object.defineProperties(v, { + __filename: { + value: "dist/client/named.client.tsx", + enumerable: false, + configurable: true + }, + __exportName: { + value: "AlsoNamed", + enumerable: false, + configurable: true + } + }); +})(AlsoNamed); +//#endregion //#region src/foo.server.tsx jahiaComponent({ componentType: "view", nodeType: "fixtures:foo" -}, () => /* @__PURE__ */ jsxs(Layout, { children: [/* @__PURE__ */ jsx("img", { - src: buildModuleFileUrl(vite_default), - alt: "Vite logo" -}), /* @__PURE__ */ jsx(Island, { component: foo_client_default })] })); +}, () => /* @__PURE__ */ jsxs(Layout, { children: [ + /* @__PURE__ */ jsx("img", { + src: buildModuleFileUrl(vite_default), + alt: "Vite logo" + }), + /* @__PURE__ */ jsx(Island, { component: foo_client_default }), + /* @__PURE__ */ jsx(Island, { component: Named }), + /* @__PURE__ */ jsx(Island, { component: AlsoNamed }) +] })); //#endregion //#region src/process.env.server.tsx jahiaComponent({ diff --git a/vite-plugin/fixtures/expected/server/index.js.map b/vite-plugin/fixtures/expected/server/index.js.map index db49ff3d..da2377e4 100644 --- a/vite-plugin/fixtures/expected/server/index.js.map +++ b/vite-plugin/fixtures/expected/server/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","names":[],"sources":["../../src/vite.png","../../src/Layout.tsx","../../src/foo.module.css","../../src/foo.client.tsx","../../src/foo.server.tsx","../../src/process.env.server.tsx"],"sourcesContent":["export default \"__VITE_ASSET__VRAku6fjghkApIISiBWPzg__\"","import { AddResources, buildModuleFileUrl } from \"@jahia/javascript-modules-library\";\nimport \"modern-normalize\";\n\nexport const Layout = ({ children }: { children: React.ReactNode }) => (\n \n {children}\n \n \n);\n","@import \"@fontsource-variable/fira-code\";\n\n.pre {\n font-family: \"Fira Code Variable\";\n}\n","import { useEffect } from \"react\";\nimport classes from \"./foo.module.css\";\n\nexport default function Foo() {\n useEffect(() => {\n console.log(\"Foo component mounted\");\n });\n\n return
Hello World!
;\n}\n","import { buildModuleFileUrl, Island, jahiaComponent } from \"@jahia/javascript-modules-library\";\nimport vite from \"./vite.png\";\nimport { Layout } from \"./Layout.tsx\";\nimport Foo from \"./foo.client.tsx\";\n\njahiaComponent(\n {\n componentType: \"view\",\n nodeType: \"fixtures:foo\",\n },\n () => (\n \n \"Vite\n \n \n ),\n);\n","import { jahiaComponent } from \"@jahia/javascript-modules-library\";\n\njahiaComponent(\n {\n componentType: \"view\",\n nodeType: \"process:env\",\n },\n () =>

Mode: {process.env.NODE_ENV}

,\n);\n"],"mappings":";;;;AAAA,IAAA,eAAe;;;ACGf,IAAa,UAAU,EAAE,eACvB,qBAAC,QAAD,EAAA,UAAA,CACG,UACD,oBAAC,cAAD;CAAc,MAAK;CAAM,WAAW,mBAAmB,uBAAuB;AAAI,CAAA,CAC9E,EAAA,CAAA;;;;AEJR,IAAA,sBAAA,SAAA,GAAA;;;;;;GAAe,SAAS,MAAM;CAK5B,OAAO,oBAAC,OAAD;EAAK,WAAW,mBAAQ;YAAK;CAAiB,CAAA;AACvD,CAAA;;;ACJA,eACE;CACE,eAAe;CACf,UAAU;AACZ,SAEE,qBAAC,QAAD,EAAA,UAAA,CACE,oBAAC,OAAD;CAAK,KAAK,mBAAmB,YAAI;CAAG,KAAI;AAAa,CAAA,GACrD,oBAAC,QAAD,EAAQ,WAAW,mBAAM,CAAA,CACnB,EAAA,CAAA,CAEZ;;;ACdA,eACE;CACE,eAAe;CACf,UAAU;AACZ,SACM,qBAAC,MAAD,EAAA,UAAA,CAAI,UAAA,YAAgC,EAAA,CAAA,CAC5C"} \ No newline at end of file +{"version":3,"file":"index.js","names":[],"sources":["../../src/vite.png","../../src/Layout.tsx","../../src/foo.module.css","../../src/foo.client.tsx","../../src/named.client.tsx","../../src/foo.server.tsx","../../src/process.env.server.tsx"],"sourcesContent":["export default \"__VITE_ASSET__VRAku6fjghkApIISiBWPzg__\"","import { AddResources, buildModuleFileUrl } from \"@jahia/javascript-modules-library\";\nimport \"modern-normalize\";\n\nexport const Layout = ({ children }: { children: React.ReactNode }) => (\n \n {children}\n \n \n);\n","@import \"@fontsource-variable/fira-code\";\n\n.pre {\n font-family: \"Fira Code Variable\";\n}\n","import { useEffect } from \"react\";\nimport classes from \"./foo.module.css\";\n\nexport default function Foo() {\n useEffect(() => {\n console.log(\"Foo component mounted\");\n });\n\n return
Hello World!
;\n}\n","/** A client component exported by name, to check named exports carry hydration metadata. */\nexport function Named() {\n return
Named export!
;\n}\n\n/** A second named export in the same file, tagged independently. */\nexport const AlsoNamed = () =>
Also a named export!
;\n","import { buildModuleFileUrl, Island, jahiaComponent } from \"@jahia/javascript-modules-library\";\nimport vite from \"./vite.png\";\nimport { Layout } from \"./Layout.tsx\";\nimport Foo from \"./foo.client.tsx\";\nimport { Named, AlsoNamed } from \"./named.client.tsx\";\n\njahiaComponent(\n {\n componentType: \"view\",\n nodeType: \"fixtures:foo\",\n },\n () => (\n \n \"Vite\n \n \n \n \n ),\n);\n","import { jahiaComponent } from \"@jahia/javascript-modules-library\";\n\njahiaComponent(\n {\n componentType: \"view\",\n nodeType: \"process:env\",\n },\n () =>

Mode: {process.env.NODE_ENV}

,\n);\n"],"mappings":";;;;AAAA,IAAA,eAAe;;;ACGf,IAAa,UAAU,EAAE,eACvB,qBAAC,QAAD,EAAA,UAAA,CACG,UACD,oBAAC,cAAD;CAAc,MAAK;CAAM,WAAW,mBAAmB,uBAAuB;AAAI,CAAA,CAC9E,EAAA,CAAA;;;;AEJR,IAAA,sBAAA,SAAA,GAAA;;;;;;;;;;;;;;GAAe,SAAS,MAAM;CAK5B,OAAO,oBAAC,OAAD;EAAK,WAAW,mBAAQ;YAAK;CAAiB,CAAA;AACvD,CAAA;;;;ACRA,SAAgB,QAAQ;CACtB,OAAO,oBAAC,OAAD,EAAA,UAAK,gBAAkB,CAAA;AAChC;;;;;;;;;;;;;;;;AAGA,IAAa,kBAAkB,oBAAC,OAAD,EAAA,UAAK,uBAAyB,CAAA;;;;;;;;;;;;;;;;;ACA7D,eACE;CACE,eAAe;CACf,UAAU;AACZ,SAEE,qBAAC,QAAD,EAAA,UAAA;CACE,oBAAC,OAAD;EAAK,KAAK,mBAAmB,YAAI;EAAG,KAAI;CAAa,CAAA;CACrD,oBAAC,QAAD,EAAQ,WAAW,mBAAM,CAAA;CACzB,oBAAC,QAAD,EAAQ,WAAW,MAAQ,CAAA;CAC3B,oBAAC,QAAD,EAAQ,WAAW,UAAY,CAAA;AACzB,EAAA,CAAA,CAEZ;;;ACjBA,eACE;CACE,eAAe;CACf,UAAU;AACZ,SACM,qBAAC,MAAD,EAAA,UAAA,CAAI,UAAA,YAAgC,EAAA,CAAA,CAC5C"} \ No newline at end of file diff --git a/vite-plugin/fixtures/index.test.js b/vite-plugin/fixtures/index.test.js index 0d9e762f..706cd628 100644 --- a/vite-plugin/fixtures/index.test.js +++ b/vite-plugin/fixtures/index.test.js @@ -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/); +}); diff --git a/vite-plugin/fixtures/src/foo.server.tsx b/vite-plugin/fixtures/src/foo.server.tsx index 5afed394..1f0fa2fe 100644 --- a/vite-plugin/fixtures/src/foo.server.tsx +++ b/vite-plugin/fixtures/src/foo.server.tsx @@ -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( { @@ -12,6 +13,8 @@ jahiaComponent( Vite logo + + ), ); diff --git a/vite-plugin/fixtures/src/named.client.tsx b/vite-plugin/fixtures/src/named.client.tsx new file mode 100644 index 00000000..4d0ae52c --- /dev/null +++ b/vite-plugin/fixtures/src/named.client.tsx @@ -0,0 +1,7 @@ +/** A client component exported by name, to check named exports carry hydration metadata. */ +export function Named() { + return
Named export!
; +} + +/** A second named export in the same file, tagged independently. */ +export const AlsoNamed = () =>
Also a named export!
; diff --git a/vite-plugin/src/insert-filename.ts b/vite-plugin/src/insert-filename.ts index a29d7e15..626f218c 100644 --- a/vite-plugin/src/insert-filename.ts +++ b/vite-plugin/src/insert-filename.ts @@ -3,9 +3,32 @@ import MagicString from "magic-string"; import type { Plugin } from "rolldown"; /** - * This plugin adds a `__filename` property to all default exports. + * Builds the property descriptors attaching the hydration metadata to a component. * - * It allows mapping files between client and server, to perform partial hydration. + * Properties are `configurable` so that a component exported twice (e.g. `export const Foo = …; + * export default Foo;`) is tagged twice rather than throwing. + */ +const descriptors = (filename: string, exportName: string) => + `{ + __filename: { value: ${JSON.stringify(filename)}, enumerable: false, configurable: true }, + __exportName: { value: ${JSON.stringify(exportName)}, enumerable: false, configurable: true }, + }`; + +/** Statement tagging an already-declared binding, appended after its declaration. */ +const tagBinding = (identifier: string, filename: string, exportName: string) => + ` +;(function (v) { + if (typeof v === "function" || typeof v === "object" && v) + Object.defineProperties(v, ${descriptors(filename, exportName)}); +})(${identifier}); +`; + +/** + * This plugin adds `__filename` and `__exportName` properties to all exported components. + * + * They allow mapping files between client and server, to perform partial hydration: the server uses + * `__filename` to point the browser at the client bundle, and `__exportName` to tell it which + * export to pick up. * * ```js * export default function myFunction() { @@ -18,9 +41,9 @@ import type { Plugin } from "rolldown"; * ```js * export default (function (v) { * if (typeof v === "function" || (typeof v === "object" && v)) { - * Object.defineProperty(v, "__filename", { - * value: "path/to/file.js", - * enumerable: false, + * Object.defineProperties(v, { + * __filename: { value: "path/to/file.js", enumerable: false, configurable: true }, + * __exportName: { value: "default", enumerable: false, configurable: true }, * }); * } * return v; @@ -29,7 +52,13 @@ import type { Plugin } from "rolldown"; * }); * ``` * - * The typeof check is necessary because `Object.defineProperty` can only be called on objects. + * Named exports (`export function Foo() {}`, `export const Foo = …`, `export class Foo {}`) are + * tagged by a statement appended after their declaration, as a declaration cannot be wrapped in an + * expression. `export { … }` lists and re-exports are not supported: components exported that way + * carry no metadata, which `` reports as an error rather than letting the browser request + * an `undefined.js` bundle. + * + * The typeof check is necessary because `Object.defineProperties` can only be called on objects. * * @param root The root of the transformation. Files outside this directory will not be transformed, * files inside (and matching the glob) will have their inserted path relative to this directory. @@ -51,6 +80,7 @@ export function insertFilename( if (!filter(id)) return; const s = new MagicString(code); const ast = this.parse(code); + const filename = transform(id); for (const node of ast.body) { if (node.type === "ExportDefaultDeclaration") { const declaration = node.declaration; @@ -59,14 +89,31 @@ export function insertFilename( ` (function (v) { if (typeof v === "function" || typeof v === "object" && v) - Object.defineProperty(v, "__filename", { - value: ${JSON.stringify(transform(id))}, - enumerable: false, - }); + Object.defineProperties(v, ${descriptors(filename, "default")}); return v; })(`, ); s.appendRight(declaration.end, ")"); + } else if (node.type === "ExportNamedDeclaration" && node.declaration) { + const declaration = node.declaration; + // `export function Foo() {}` / `export class Foo {}` + if ( + (declaration.type === "FunctionDeclaration" || + declaration.type === "ClassDeclaration") && + declaration.id + ) { + const name = declaration.id.name; + s.appendRight(declaration.end, tagBinding(name, filename, name)); + } + // `export const Foo = …`, possibly declaring several bindings at once + else if (declaration.type === "VariableDeclaration") { + for (const declarator of declaration.declarations) { + // Destructuring patterns have no single name to tag, skip them + if (declarator.id.type !== "Identifier") continue; + const name = declarator.id.name; + s.appendRight(declaration.end, tagBinding(name, filename, name)); + } + } } } return {