Skip to content

Tree shaking: getNativeInjectionCode emits raw ESM, hermesc fails with 'requires module mode' #414

Open

Description

@mikevocalz

Summary

getNativeInjectionCode() emits its virtual module as a raw ESM string. Under Expo's experimental Metro tree shaking the serializer re-emits modules from their ESM form, so that module reaches the concatenated bundle with import/export intact. hermesc compiles the bundle in script mode, so both statements are hard errors and no production build can be produced.

Versions

  • react-native-css 3.0.7
  • nativewind 5.0.0-preview.4
  • expo 57.0.1, @expo/cli 57.0.13, @expo/metro-config 57.0.7
  • metro 0.84.4, react-native 0.86.0

Reproduction

EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH=1 EXPO_UNSTABLE_TREE_SHAKING=1 \
  npx expo export --platform android
Failed to generate Hermes bytecode for: node_modules/expo-router/entry.js
index.js:844745:1: error: 'import' statement requires module mode
index.js:853525:1: error: 'export' statement requires module mode
hermesc ... exited with non-zero code: 2

Adding --no-bytecode succeeds, and running hermesc by hand on that output pins it to a single line:

entry-<hash>.js
5663:14:     error: 'import' statement requires module mode
5663:128908: error: 'export' statement requires module mode

That line:

"use strict";import{StyleCollection}from"react-native-css/native-internal";
StyleCollection.inject({s:[["pointer-events-auto",...]]});
...
},5259,[244]);export{};

Cause

dist/commonjs/metro/injection-code.js:

function getNativeInjectionCode(cssFilePaths, values) {
  const importStatements = cssFilePaths.map(f => `import "${f}";`).join("\n");
  const contents = values.map(v => `StyleCollection.inject(${JSON.stringify(v)});`).join("\n");
  return Buffer.from(
    `import { StyleCollection } from "react-native-css/native-internal";\n` +
    `${importStatements}\n${contents};export {};`
  );
}

Without the flags this synthetic source is transformed to CJS like any other module, so the problem is invisible. With EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH + EXPO_UNSTABLE_TREE_SHAKING it is not.

Ruled out

Hypothesis Test Result
Expo Atlas instrumentation perturbs the serializer reran with EXPO_ATLAS unset same failure
Only multi-platform exports affected Android alone same failure
The bundle is fine, bytecode step at fault ran hermesc by hand on --no-bytecode output same two errors, so the bundle really does contain ESM

Unrelated to expo/expo#41620 — the build fails at bytecode generation, well before an app runs.

Impact

Measured on a production app, comparing plain JS both ways so the two sides are like-for-like:

Android bundle Bytes
Baseline 15,968,353
Tree-shaken 13,007,862
Delta -2,960,491 (-18.54%)

Module count is 7,828 either way, so this is dead-export elimination inside modules. That 18.54% is unreachable for any app using this package until the injection module survives the pass.

Possible fix

Emit the injection module as CJS, or register it so the optimize-graph pass runs it through the ESM to CJS transform before concatenation. Happy to test a patch.

Activity

  1. github-actions commented on Aug 14, 2026

    @github-actions

    🤖 Auto-triage

    Status: CONFIRMED
    Type: bug
    Version: v5 (react-native-css 3.0.7, nativewind 5.0.0-preview.4)

    Findings

    Verified against src/metro/injection-code.ts:19-34: getNativeInjectionCode() emits a raw ESM string (import { StyleCollection } from "react-native-css/native-internal"; ... export {};). Without EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH + EXPO_UNSTABLE_TREE_SHAKING, Metro rewrites this to CJS transparently, but with those flags the serializer re-emits modules from their ESM form, causing hermesc (script mode) to fail with 'import'/'export' statement requires module mode. The reporter's diagnosis (repro command, offending source line, ruled-out hypotheses, and ~18.5% bundle-size impact) is coherent and points squarely at the concatenated injection module.

    What I tested

    • Read src/metro/injection-code.ts — confirmed the raw ESM emission described in the report.
    • Confirmed getNativeInjectionCode is invoked from src/metro/metro-transformer.ts as the production JS path (wrapped in Buffer.from(...) and handed to worker.transform).
    • Confirmed current published version is 3.0.7, matching the report.
    • Did not attempt a Jest repro: this bug lives in the Metro serializer + hermesc pipeline (with experimental Expo tree-shaking flags), which is out of scope for registerCSS()-based unit tests.

    Next steps

    • Reproducing requires an Expo example export with EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH=1 EXPO_UNSTABLE_TREE_SHAKING=1 — not covered by the existing Jest suite; consider adding an example-app build smoke test.
    • Fix options suggested by the reporter: (a) emit CJS from getNativeInjectionCode (e.g. const { StyleCollection } = require("react-native-css/native-internal"); with require("<cssFile>") for imports), or (b) ensure the synthetic module is registered so the optimize-graph pass routes it through the ESM→CJS transform. Option (a) is the smaller, safer patch and matches how the untransformed output already looks in non-flagged builds.
    • Reporter offered to test a patch — worth pinging them once a candidate PR exists.

    This is an automated triage. See auto-triage.yml.

  2. added
    auto-triagedIssue has been automatically triaged by the auto-triage workflow
    bugSomething isn't working
    confirmedBug reproduced and confirmed by triage
    needs-deep-triageNeeds Tier 2 triage (runtime/simulator, self-hosted runner)
    on Aug 14, 2026
  3. mikevocalz commented on Aug 16, 2026

    @mikevocalz
    Author

    Follow-up: I tested the obvious workaround on a physical device and it does not work — flagging it here so nobody ships it.

    Changing getNativeInjectionCode() to emit CJS instead of ESM:

    const { StyleCollection } = require("react-native-css/native-internal");
    StyleCollection.inject({...});
    module.exports = {};

    makes hermesc succeed and the build go green. The Android bundle dropped ~18.5% and an iOS/Android EAS build completed with zero requires module mode errors. But the resulting app crashes on every launch:

    Unhandled JS Exception: [runtime not ready]:
    ReferenceError: Property 'require' doesn't exist
      at global (main.jsbundle:1:171569)   jsEngine: hermes, __DEV__: false
    App terminated due to signal 6.
    

    The reason is the same one that causes the original bug: under the optimize-graph serializer this module is emitted at the top level of the bundle, outside any __d(...) factory. That scope is why the ESM statements survive to hermesc in the first place — and it is also a scope where Metro's require is not defined. So swapping the syntax only converts a build-time error into a runtime crash, which is worse because it ships silently.

    Verified on iPad Pro 12.9 (iPad8,7), iOS 26.5.2, Hermes, release build, __DEV__: false.

    So a fix probably has to change where the module lands rather than what syntax it uses — emit it inside a module factory so both require and the module registry are in scope, or keep the synthetic module out of the optimized graph entirely. Happy to test a patch on real hardware.

  4. YevheniiKotyrlo commented on Nov 11, 2026

    @YevheniiKotyrlo
    Contributor

    The patch I run on react-native-css 3.1.0-rc.0 makes #419's change in the published dist: the native injection code loads react-native-css/native-internal and each stylesheet with require in place of import:

    diff --git a/dist/commonjs/metro/injection-code.js b/dist/commonjs/metro/injection-code.js
    --- a/dist/commonjs/metro/injection-code.js
    +++ b/dist/commonjs/metro/injection-code.js
    @@ -20,8 +20,8 @@ function getWebInjectionCode(filePaths) {
       return Buffer.from(importStatements);
     }
     function getNativeInjectionCode(cssFilePaths, values) {
    -  const importStatements = cssFilePaths.map(filePath => `import "${filePath}";`).join("\n");
    +  const importStatements = cssFilePaths.map(filePath => `require("${filePath}");`).join("\n");
       const contents = values.map(value => `StyleCollection.inject(${JSON.stringify(value)});`).join("\n");
    -  return Buffer.from(`import { StyleCollection } from "react-native-css/native-internal";\n${importStatements}\n${contents};export {};`);
    +  return Buffer.from(`const { StyleCollection } = require("react-native-css/native-internal");\n${importStatements}\n${contents}`);
     }
     //# sourceMappingURL=injection-code.js.map
    \ No newline at end of file
    diff --git a/dist/module/metro/injection-code.js b/dist/module/metro/injection-code.js
    --- a/dist/module/metro/injection-code.js
    +++ b/dist/module/metro/injection-code.js
    @@ -15,8 +15,8 @@ export function getWebInjectionCode(filePaths) {
       return Buffer.from(importStatements);
     }
     export function getNativeInjectionCode(cssFilePaths, values) {
    -  const importStatements = cssFilePaths.map(filePath => `import "${filePath}";`).join("\n");
    +  const importStatements = cssFilePaths.map(filePath => `require("${filePath}");`).join("\n");
       const contents = values.map(value => `StyleCollection.inject(${JSON.stringify(value)});`).join("\n");
    -  return Buffer.from(`import { StyleCollection } from "react-native-css/native-internal";\n${importStatements}\n${contents};export {};`);
    +  return Buffer.from(`const { StyleCollection } = require("react-native-css/native-internal");\n${importStatements}\n${contents}`);
     }
     //# sourceMappingURL=injection-code.js.map
    \ No newline at end of file
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    auto-triagedIssue has been automatically triaged by the auto-triage workflowbugSomething isn't workingconfirmedBug reproduced and confirmed by triageneeds-deep-triageNeeds Tier 2 triage (runtime/simulator, self-hosted runner)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions