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/docs/2-guides/2-island-architecture/README.md b/docs/2-guides/2-island-architecture/README.md index e0a4d380..d2634163 100644 --- a/docs/2-guides/2-island-architecture/README.md +++ b/docs/2-guides/2-island-architecture/README.md @@ -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 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..8318d908 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 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 @@ -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..4c201274 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,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 /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"); + }); } }); 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..dc1e9993 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.isExtensible(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.isExtensible(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.isExtensible(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..f324df70 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.isExtensible(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,15 @@ 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, + * and the `Object.isExtensible` check skips frozen/sealed exports (e.g. a frozen config object in a + * client file), which would otherwise throw and break the whole bundle evaluation. * * @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 +82,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; @@ -58,15 +90,32 @@ export function insertFilename( declaration.start, ` (function (v) { - if (typeof v === "function" || typeof v === "object" && v) - Object.defineProperty(v, "__filename", { - value: ${JSON.stringify(transform(id))}, - enumerable: false, - }); + if ((typeof v === "function" || typeof v === "object" && v) && Object.isExtensible(v)) + 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 {