Skip to content

Conversation

@brkmustu
Copy link

@brkmustu brkmustu commented Sep 2, 2025

Description

Fixes duplicate variable declarations in ESM output when using export * combined with named exports.

Problem

When multiple modules re-export the same identifiers using different patterns (e.g., export * and export { name }), Farm was generating duplicate variable declarations in the final ESM output:

// Before fix - syntax error due to duplicate declarations
var shared=entry.shared;export { shared };
var shared=entry.shared;export { shared };  // Duplicate!

Solution

Modified the export generation logic in handle_entry_resources.rs to:
- Track exported variable names using a HashSet
- Only create var declaration for the first occurrence
- Generate only export statements for subsequent occurrences

// After fix - correct output
var shared=entry.shared;export { shared };
export { shared };  // No duplicate var declaration

Test Plan

Added comprehensive test cases in examples/duplicate-export-fix/:
- Complex re-export patterns with multiple libraries
- Namespace exports with collisions
- Verified no duplicate variable declarations in output
- All exports function correctly

Testing

- cargo test passes
- Test example builds successfully
- Manual verification of generated output

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- Bug Fixes
- Prevents duplicate variable declarations in ESM builds when combining wildcard and named exports, avoiding related runtime and bundling errors.

- Examples
- Added an example project demonstrating complex re-export scenarios with a demo page that verifies exported symbols at runtime.

- Tests
- Added end-to-end tests that validate build outputs contain no duplicate declarations and that the demo renders without console errors.

- Chores
- Added a changeset entry and .gitignore rules for the example.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

- Added deduplication logic in handle_entry_resources.rs to prevent duplicate variable declarations
- Only the first occurrence of an export creates a var declaration
- Subsequent exports with the same name only create export statements
- Added comprehensive test cases in examples/duplicate-export-fix
- Fixes issues with 'export *' combined with named exports
@changeset-bot
Copy link

changeset-bot bot commented Sep 2, 2025

🦋 Changeset detected

Latest commit: bcd775d

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link

coderabbitai bot commented Sep 2, 2025

Walkthrough

Deduplicates named ESM exports in plugin_runtime to prevent duplicate variable declarations; adds an example project exercising complex and namespace re-exports, a Farm config, HTML test page, and Vitest end-to-end tests; includes a changeset declaring a patch release for @farmfe/plugin-runtime.

Changes

Cohort / File(s) Summary
Runtime export generation
crates/plugin_runtime/src/handle_entry_resources.rs
ESM emission now deduplicates named exports using a seen_names set and accumulates result_parts to avoid duplicate var declarations; default and CJS handling adjusted for ESM/CJS paths; export_info still sorted by rank.
Example: source modules
examples/duplicate-export-fix/src/complex-entry.js, examples/duplicate-export-fix/src/namespace-entry.js, examples/duplicate-export-fix/src/lib/library1.js, examples/duplicate-export-fix/src/lib/library2.js, examples/duplicate-export-fix/src/shared/common.js, examples/duplicate-export-fix/src/shared/utils.js
Adds fixtures exercising wildcard re-exports, aliased named re-exports, namespace exports, and shared utilities to reproduce and validate duplicate-export scenarios.
Example: app, config & metadata
examples/duplicate-export-fix/index.html, examples/duplicate-export-fix/farm.config.mjs, examples/duplicate-export-fix/package.json, examples/duplicate-export-fix/.gitignore
Adds test HTML importing built ESM outputs, Farm config with two inputs and ESM output, package manifest with scripts, and .gitignore for the example.
Example: tests
examples/duplicate-export-fix/e2e.spec.ts
Adds Vitest E2E tests that build the example, assert no duplicate var declarations in generated ESM files, verify export lists, run a browser-like runtime check, and ensure no console errors.
Changeset
.changeset/lemon-snakes-jog.md
Declares a patch release for @farmfe/plugin-runtime documenting the fix to prevent duplicate variable declarations in ESM output when using export * with named exports.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Builder as Builder
  participant Runtime as get_export_info_code
  participant Info as export_info[]
  participant Emitter as CodeEmitter

  Builder->>Runtime: generate(export_info, EsModule)
  Runtime->>Runtime: sort export_info by rank
  Runtime->>Runtime: init seen_names, result_parts
  loop each export in export_info
    alt export is Default
      Runtime->>result_parts: append "export default entry.default || entry"
    else export is Named
      alt name not in seen_names
        Runtime->>result_parts: declare local var for name
        Runtime->>result_parts: append export (with alias if import_as)
        Runtime->>seen_names: add name
      else name already seen
        Runtime->>result_parts: append export only (with alias if import_as)
      end
    else export is CJS entry in ESM path
      Runtime->>result_parts: append "export default entry"
    end
  end
  Runtime->>Emitter: join result_parts -> ESM code
  Emitter-->>Builder: emit deduplicated ESM bundle
  Note right of Runtime: Deduplicate named exports to avoid duplicate var declarations
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • fix: cjs node export order #2149 — Modifies crates/plugin_runtime/src/handle_entry_resources.rs and ESM export-generation logic; closely related to the export-info and emission changes here.

Suggested reviewers

  • wre232114
  • ErKeLost
  • shulandmimi

Poem

A rabbit hops through tangled exports with cheer,
I tidy named bindings so each var appears once here.
Wildcards and aliases neatly aligned,
No duplication left for me to find.
I twitch my nose — the ESM fields are clear. 🐇✨


📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b238699 and bcd775d.

📒 Files selected for processing (2)
  • .changeset/lemon-snakes-jog.md (1 hunks)
  • examples/duplicate-export-fix/.gitignore (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • .changeset/lemon-snakes-jog.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • examples/duplicate-export-fix/.gitignore
✨ Finishing Touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (11)
crates/plugin_runtime/src/handle_entry_resources.rs (2)

176-186: Minor: make the sort deterministic and intention-revealing

If ordering matters for reproducibility, prefer an explicitly stable sort (it also clarifies intent for readers).

Apply:

-    export_info.sort_by_key(|v| v.rank());
+    export_info.sort_by_key(|v| v.rank()); // stable ordering desired
+    // If non-stable sort is ever used here, switch to sort_by with a tie-breaker.

115-151: Potential incorrect handling of string-named exports

ModuleExportName::Str values may not be valid JS identifiers. Generating var {name} = ... can yield invalid code. Consider normalizing to a safe identifier (e.g., __exp_<hash>) and always access with bracket notation.

If you adopt the alias approach above, you can emit:

- result_parts.push(format!("var {name}=__farm_entry__.{name};"));
+ let safe = to_safe_ident(name); // e.g., transform/escape or hash if needed
+ result_parts.push(format!("var {safe}=__farm_entry__[{key}];",
+   key = serde_json::to_string(name).unwrap() // quoted string key
+));
+ // and export { safe as <exported> }

Happy to provide a small to_safe_ident helper if you want to pursue this.

.changeset/lemon-snakes-jog.md (2)

2-2: Use patch instead of minor for a bugfix.

This is a behavior fix without API changes; a patch release seems more appropriate.

-"@farmfe/runtime": minor
+"@farmfe/runtime": patch

5-5: Format code terms in backticks.

Improves readability in the changelog entry.

-Fix duplicate variable declarations in ESM output when using export \* with named exports
+Fix duplicate variable declarations in ESM output when using `export *` with named exports
examples/duplicate-export-fix/package.json (1)

1-14: Mark the example package as private.

Prevents accidental publish from the workspace.

 {
   "name": "duplicate-export-fix",
   "version": "1.0.0",
+  "private": true,
   "type": "module",
examples/duplicate-export-fix/farm.config.mjs (1)

1-16: Resolve paths relative to the config file to avoid CWD sensitivity.

Helps when running builds/tests from different working directories.

-import { defineConfig } from '@farmfe/core';
+import { defineConfig } from '@farmfe/core';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
 
 export default defineConfig({
   compilation: {
     input: {
-      complex: './src/complex-entry.js',
-      namespace: './src/namespace-entry.js'
+      complex: resolve(__dirname, './src/complex-entry.js'),
+      namespace: resolve(__dirname, './src/namespace-entry.js')
     },
     output: {
-      path: './dist',
+      path: resolve(__dirname, './dist'),
       format: 'esm'
     },
     minify: false,
     sourcemap: false
   }
 });
examples/duplicate-export-fix/index.html (1)

28-31: Avoid innerHTML; prefer textContent for test messages.

Reduces risk of accidental HTML injection in examples.

-      results.innerHTML = '<p style="color: green;">✓ All exports are working correctly!</p>';
+      results.textContent = '✓ All exports are working correctly!';
+      results.style.color = 'green';
     } else {
-      results.innerHTML = '<p style="color: red;">✗ Some exports are not working</p>';
+      results.textContent = '✗ Some exports are not working';
+      results.style.color = 'red';
examples/duplicate-export-fix/e2e.spec.ts (4)

24-27: Escape identifier and tighten regex to avoid false positives.

Prevents regex surprises and matches var <id> = with whitespace tolerance.

Apply this diff:

-    const countVarDeclarations = (content: string, varName: string) => {
-      const regex = new RegExp(`var ${varName}=`, 'g');
-      return (content.match(regex) || []).length;
-    };
+    const countVarDeclarations = (content: string, varName: string) => {
+      const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+      const regex = new RegExp(`\\bvar\\s+${escaped}\\s*=`, 'gm');
+      return (content.match(regex) || []).length;
+    };

20-21: Use path.join segments for cross-platform paths.

Minor, but avoids mixed separators.

Apply this diff:

-    const complexOutput = readFileSync(join(examplePath, 'dist/complex.js'), 'utf-8');
-    const namespaceOutput = readFileSync(join(examplePath, 'dist/namespace.js'), 'utf-8');
+    const complexOutput = readFileSync(join(examplePath, 'dist', 'complex.js'), 'utf-8');
+    const namespaceOutput = readFileSync(join(examplePath, 'dist', 'namespace.js'), 'utf-8');

39-42: Make export checks resilient to formatting.

Use regex to ignore whitespace differences.

Apply this diff:

-    expect(complexOutput).toContain('export { shared }');
-    expect(namespaceOutput).toContain('export { formatDate }');
-    expect(namespaceOutput).toContain('export { formatTime }');
+    expect(complexOutput).toMatch(/export\s*{\s*shared\s*}/);
+    expect(namespaceOutput).toMatch(/export\s*{\s*formatDate\s*}/);
+    expect(namespaceOutput).toMatch(/export\s*{\s*formatTime\s*}/);

49-63: Attach console listeners before waiting to catch early errors.

Ensures syntax/runtime errors during initial load are recorded.

Apply this diff:

-        // Wait for the test results div
-        await page.waitForSelector('#test-results', { timeout: 10000 });
-        
-        // Check that all exports are working
-        const resultsText = await page.$eval('#test-results', el => el.textContent);
-        expect(resultsText).toContain('All exports are working correctly!');
-        
-        // Verify no JavaScript errors occurred
-        const consoleErrors: string[] = [];
-        page.on('console', msg => {
-          if (msg.type() === 'error') {
-            consoleErrors.push(msg.text());
-          }
-        });
+        // Capture console errors early
+        const consoleErrors: string[] = [];
+        page.on('console', (msg) => {
+          if (msg.type() === 'error') {
+            consoleErrors.push(msg.text());
+          }
+        });
+
+        // Wait for the test results div
+        await page.waitForSelector('#test-results', { timeout: 10000 });
+        
+        // Check that all exports are working
+        const resultsText = await page.$eval('#test-results', (el) => el.textContent);
+        expect(resultsText).toContain('All exports are working correctly!');
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between fffb78b and b238699.

📒 Files selected for processing (13)
  • .changeset/lemon-snakes-jog.md (1 hunks)
  • crates/plugin_runtime/src/handle_entry_resources.rs (1 hunks)
  • examples/duplicate-export-fix/.gitignore (1 hunks)
  • examples/duplicate-export-fix/e2e.spec.ts (1 hunks)
  • examples/duplicate-export-fix/farm.config.mjs (1 hunks)
  • examples/duplicate-export-fix/index.html (1 hunks)
  • examples/duplicate-export-fix/package.json (1 hunks)
  • examples/duplicate-export-fix/src/complex-entry.js (1 hunks)
  • examples/duplicate-export-fix/src/lib/library1.js (1 hunks)
  • examples/duplicate-export-fix/src/lib/library2.js (1 hunks)
  • examples/duplicate-export-fix/src/namespace-entry.js (1 hunks)
  • examples/duplicate-export-fix/src/shared/common.js (1 hunks)
  • examples/duplicate-export-fix/src/shared/utils.js (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
examples/duplicate-export-fix/e2e.spec.ts (2)
scripts/build.mjs (1)
  • examplePath (52-52)
e2e/vitestSetup.ts (1)
  • startProjectAndTest (87-178)
examples/duplicate-export-fix/src/namespace-entry.js (1)
crates/compiler/tests/fixtures/script/import_equals/output.js (1)
  • utils (78-78)
🪛 ast-grep (0.38.6)
examples/duplicate-export-fix/e2e.spec.ts

[warning] 24-24: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(var ${varName}=, 'g')
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🔇 Additional comments (9)
examples/duplicate-export-fix/.gitignore (1)

1-5: LGTM for example ignores

Covers typical Node artifacts for the example app.

examples/duplicate-export-fix/src/shared/common.js (1)

1-3: LGTM: clear, minimal fixtures

Simple constants are fine for exercising export generation.

examples/duplicate-export-fix/src/shared/utils.js (1)

1-4: LGTM: concise helpers for namespace export case

Sufficient for testing re-export/namespace behavior.

crates/plugin_runtime/src/handle_entry_resources.rs (1)

223-239: CJS branch ok, but consider symmetry tests

CJS-to-ESM default interop is handled via export default entry;. Add a test ensuring import default from a CJS entry works as expected and that no named exports are generated for CJS.

You can extend the example or add a new one where the entry is CJS and assert that the built ESM only has a default export.

examples/duplicate-export-fix/src/lib/library1.js (1)

1-4: LGTM — re-export pattern correctly exercises the duplicate-decl scenario.

examples/duplicate-export-fix/src/lib/library2.js (1)

2-4: LGTM: Re-export + alias pattern correctly exercises the dedup path.

This fixture is minimal and appropriate for the scenario.

examples/duplicate-export-fix/src/namespace-entry.js (1)

2-5: LGTM: Namespace + star re-exports cover the intended collision cases.

Good coverage for both export * as and export * interactions.

examples/duplicate-export-fix/src/complex-entry.js (1)

3-6: LGTM: Complex re-exports create the expected duplicate names for testing.

This should reliably hit the deduplication logic.

examples/duplicate-export-fix/e2e.spec.ts (1)

14-17: Keep using pnpm for the build. pnpm is present in PATH (/usr/local/bin/pnpm), so switching to npm run build or adding a fallback isn’t necessary.

Comment on lines +187 to +239

// For ESM format, deduplicate named exports to prevent duplicate variable declarations
if matches!(context.config.output.format, ModuleFormat::EsModule) {
let mut seen_names = HashSet::new();
let mut result_parts = Vec::new();

for export in export_info.iter() {
match export {
ExportInfoOfEntryModule::Default => {
result_parts.push("export default entry.default || entry;".to_string());
},
ExportInfoOfEntryModule::Named { name, import_as } => {
// Only add if we haven't seen this variable name before
if seen_names.insert(name.clone()) {
if let Some(import_as) = import_as {
result_parts.push(format!("var {name}=entry.{name};export {{ {name} as {import_as} }};"));
} else {
result_parts.push(format!("var {name}=entry.{name};export {{ {name} }};"));
}
} else {
// If we've seen this name before, only add the export without the var declaration
if let Some(import_as) = import_as {
result_parts.push(format!("export {{ {name} as {import_as} }};"));
} else {
result_parts.push(format!("export {{ {name} }};"));
}
}
} else {
match context.config.output.format {
ModuleFormat::CommonJs => format!("module.exports.{name} = entry.{name};"),
ModuleFormat::EsModule => format!("var {name}=entry.{name};export {{ {name} }};"),
}
},
ExportInfoOfEntryModule::CJS => {
result_parts.push("export default entry;".to_string());
}
}
ExportInfoOfEntryModule::CJS => match context.config.output.format {
ModuleFormat::CommonJs => "module.exports = entry;".to_string(),
ModuleFormat::EsModule => "export default entry;".to_string(),
},
})
.collect::<Vec<String>>()
.join("")
}

result_parts.join("")
} else {
// For CommonJS, keep the original logic
export_info
.iter()
.map(|export| match export {
ExportInfoOfEntryModule::Default => "module.exports = entry.default || entry;".to_string(),
ExportInfoOfEntryModule::Named { name, import_as } => {
if let Some(import_as) = import_as {
format!("module.exports.{import_as} = entry.{name};")
} else {
format!("module.exports.{name} = entry.{name};")
}
}
ExportInfoOfEntryModule::CJS => "module.exports = entry;".to_string(),
})
.collect::<Vec<String>>()
.join("")
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Still emits duplicate export statements; also risks shadowing the entry binding

  • The new logic dedupes var declarations, but still emits repeated export { name } for subsequent occurrences of the same export, which is a SyntaxError in ESM (duplicate export of name).
  • If a module exports a symbol literally named entry, the generated var entry = entry.entry; will reassign your entry object and break subsequent exports.

Recommend:

  • Deduplicate both declarations and export statements.
  • Use a safe alias for the exports object to avoid collisions with user exports named entry.

Proposed patch:

@@
-    if matches!(context.config.output.format, ModuleFormat::EsModule) {
-      let mut seen_names = HashSet::new();
-      let mut result_parts = Vec::new();
-      
-      for export in export_info.iter() {
+    if matches!(context.config.output.format, ModuleFormat::EsModule) {
+      let mut declared = HashSet::new(); // names we've created locals for
+      let mut exported = HashSet::new(); // names we've actually exported
+      let mut result_parts = Vec::new();
+      // avoid shadowing if user exports a symbol named `entry`
+      result_parts.push("const __farm_entry__ = entry;".to_string());
+
+      for export in export_info.iter() {
         match export {
           ExportInfoOfEntryModule::Default => {
-            result_parts.push("export default entry.default || entry;".to_string());
+            result_parts.push("export default entry.default || entry;".to_string());
           },
           ExportInfoOfEntryModule::Named { name, import_as } => {
-            // Only add if we haven't seen this variable name before
-            if seen_names.insert(name.clone()) {
-              if let Some(import_as) = import_as {
-                result_parts.push(format!("var {name}=entry.{name};export {{ {name} as {import_as} }};"));
-              } else {
-                result_parts.push(format!("var {name}=entry.{name};export {{ {name} }};"));
-              }
-            } else {
-              // If we've seen this name before, only add the export without the var declaration
-              if let Some(import_as) = import_as {
-                result_parts.push(format!("export {{ {name} as {import_as} }};"));
-              } else {
-                result_parts.push(format!("export {{ {name} }};"));
-              }
-            }
+            // declare once
+            if declared.insert(name.clone()) {
+              result_parts.push(format!("var {name}=__farm_entry__.{name};"));
+            }
+            // export once
+            let exported_name = import_as.as_ref().unwrap_or(name);
+            if exported.insert(exported_name.clone()) {
+              if let Some(import_as) = import_as {
+                result_parts.push(format!("export {{ {name} as {import_as} }};"));
+              } else {
+                result_parts.push(format!("export {{ {name} }};"));
+              }
+            }
           },
           ExportInfoOfEntryModule::CJS => {
             result_parts.push("export default entry;".to_string());
           }
         }
       }
       
       result_parts.join("")

This prevents duplicate exports and avoids entry shadowing.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In crates/plugin_runtime/src/handle_entry_resources.rs around lines 187 to 239,
the ESM branch currently only deduplicates var declarations but still emits
duplicate "export { name }" statements (causing SyntaxError) and risks shadowing
a user export named `entry`; change the generation so you deduplicate both the
declaration and the export by tracking emitted export names (skip emitting any
export if the name was already emitted), and stop referencing the input `entry`
identifier directly — read properties from a safe internal alias (e.g., rename
the incoming binding to something like __entry or assign const __entry = entry)
so we never generate `var entry = entry.entry` or reassign the original; ensure
default/CJS handling remains equivalent and use one-time declaration + export
emission per unique name.

@wre232114
Copy link
Member

Thanks! I'll merge it when all checks passed. BTW, you can set output.targetEnv to library, which would produce pure esm bundle without complext farm runtime, current way to bundle scripts is based on farm runtime

@brkmustu
Copy link
Author

brkmustu commented Sep 3, 2025

Frankly, it's good to hear that there's a solution without having to go through such complex processes, because I was just trying to help solve a problem I was having while using it, and I didn't know about targetEnv :)

Still, if it might be helpful to someone else, I'd be happy to contribute.

@wre232114
Copy link
Member

Thank you very much for your contribution, could you help resolve the merge conflicts

- Added .gitignore to exclude build outputs and node_modules
- Keeps repository clean from generated files
@brkmustu brkmustu force-pushed the dublicate-export-generation branch from b238699 to bcd775d Compare September 8, 2025 10:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants