Skip to content

Named exports in .client.tsx silently break island hydration (undefined.js) #706

Description

@romain-pm

Part of EPIC #698.

Repro:

// src/components/ContactForm/Form.client.tsx
export function Form() { ... }        // named export

// default.server.tsx
import { Form } from "./Form.client.jsx";
<Island component={Form} />

Builds without warning, renders fine server-side. In the browser the island requests /modules/<module>/undefined.js and never hydrates — for a form, the submit falls back to a native GET and the page reloads. Console: <Island> failed to load ... Failed to fetch dynamically imported module: .../undefined.js.

Cause: vite-plugin/src/insert-filename.ts only attaches the hydration metadata (__filename) to ExportDefaultDeclaration nodes; Island.tsx then builds the URL from Component.__filename"undefined.js". Switching to export default function Form() fixes it.

Expected (either):

  1. Support named exports — tag all exported function/const declarations in .client.{jsx,tsx} files; or
  2. Fail fast — build error (or at minimum a loud warning) when a .client.* file has no default export, and a server-side render error when <Island> receives a component without __filename, instead of the runtime undefined.js fetch.

Found during a hands-on DX evaluation; cost ~20 minutes to diagnose with codebase knowledge. Astro supports named exports for islands (component-export attribute).


Technical plan (agent-executable)

Written for execution by a Claude (Opus/Sonnet) agent inside the Jahia Cortex harness.

Harness setup

  1. Open Cortex; cortex-sourcesjavascript-modules @ main.
  2. Unit/snapshot work needs no Jahia. For the e2e step, jahia-docker up an instance, build the engine (mvn install -pl javascript-modules-engine -am -DskipTests), deploy with jahia-deploy.

Design decision

Do BOTH halves — they protect different failure points:

  1. Support named exports (feature parity, removes the trap entirely).
  2. Fail fast when metadata is missing (server-side guard so any future gap is a loud render error, never a runtime undefined.js).

Code map — the three coordinated pieces (all in this monorepo, released together)

File Today Change
vite-plugin/src/insert-filename.ts Tags only ExportDefaultDeclaration with __filename Also tag named exports; add __exportName
javascript-modules-library/src/components/render/Island.tsx buildModuleFileUrl(${Component.__filename}.js)"undefined.js" when untagged; emits data-src Throw a descriptive error when __filename is missing; emit data-export
javascript-modules-engine/src/client/hydrate.tsx (line ~53) const { default: Component } = await import(entry); — hardcodes the default export Resolve module[element.dataset.export ?? "default"]

Implementation steps

  1. insert-filename.ts: in the AST walk, additionally handle ExportNamedDeclaration nodes whose declaration is a FunctionDeclaration or a VariableDeclaration (each declarator with an Identifier id). For each exported binding, append after the statement:
    Object.defineProperties(<name>, { __filename: {...}, __exportName: { value: "<name>", enumerable: false } });
    (appending statements avoids the wrap-expression trick used for default exports, which cannot wrap declarations). Keep the default-export wrapping as-is but extend it to also set __exportName: "default". Skip non-identifier patterns (destructuring exports) — out of scope, they fall into the fail-fast path.
    ⚠️ Guard the defineProperties call with the existing typeof check (only functions/objects).
  2. Island.tsx:
    • const file = Component.__filename; if (file === undefined) throw new Error(\ received a component without hydration metadata. Client components must be exported from a *.client.{jsx,tsx} file (named or default export). Got: ${Component.displayName ?? Component.name ?? "anonymous"}`);`
    • Emit "data-export": Component.__exportName on the jsm-island element (omit when "default" to keep markup stable).
  3. hydrate.tsx: const exportName = element.dataset.export ?? "default"; const Component = (await import(entry))[exportName]; if (!Component) throw new Error(...) — include entry + exportName in the message.
  4. Type surface: the @ts-expect-error __filename is added by the vite plugin comment in Island.tsx — extend the internal type to { __filename?: string; __exportName?: string } and drop the expect-error if it becomes representable.

Verification

  1. Snapshot tests (vite-plugin/fixtures, run yarn workspace @jahia/vite-plugin test — it builds fixtures then diffs dist/ against expected/):
    • Add fixtures/src/named.client.tsx with a named export used via <Island> from a server fixture, and one file with BOTH a named and a default export.
    • Rebuild and update expected/ snapshots; review the diff by hand — assert __exportName strings appear for both export kinds.
  2. Unit-ish: assert in the fixture test that the built server bundle contains __filename for the named export (string match is fine, matching the snapshot style).
  3. e2e (tests/ cypress, jahia-cypress tool): extend the island/hydration spec — a named-export island hydrates (button click mutates DOM). Assert no undefined.js request (cy.intercept on **/undefined.js failing the test).
  4. Fail-fast path: a server view passing a plain (non-client) component to <Island> → render error mentioning "hydration metadata" (with Dev error surfacing: visible errors, source-mapped stacks #700's dev box, visible on page; otherwise assert on the server log / module error comment).
  5. Regression: full fixtures snapshot + existing island e2e stay green; samples/hydrogen builds unchanged (its Celebrate.client.tsx uses a default export — snapshot should show __exportName: "default" added, an expected diff).

Acceptance criteria

  • Named-export .client.tsx components hydrate correctly end-to-end.
  • A component without metadata fails at server render with an actionable message — undefined.js can no longer reach the browser.
  • Mixed named+default export files work; each export hydrates independently.
  • Snapshots + e2e cover both export kinds and the failure path.

Risks / notes

  • The three pieces are version-coupled (plugin injects, library emits, engine loader consumes). They ship from one monorepo — note in the PR that modules must rebuild with the matching plugin version to use named exports; OLD modules keep working (loader falls back to default when data-export is absent).
  • magic-string + this.parse (rolldown AST) are already in place — no new dependencies.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions