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 \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 \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(
+
+
),
);
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 {