Describe the bug
When a tracked component's children contain JSX returned from a .map() callback, the plugin emits untransformed JSX into the compiled output. The bundle builds without error, but Hermes then fails to compile it at runtime:
[runtime not ready]: Error: Non-js exception: Compiling JS failed:
493948:38:invalid expression (possible JSX: pass -parse-jsx to parse)
Cause
To derive the action name, the plugin synthesizes a getContent closure containing a copy of the tracked element's children and passes it to __ddExtractText. In that copy, the outer _jsx(_Fragment, ...) is converted correctly, but JSX inside a .map() callback is left as raw JSX:
"getContent": () => {
return __ddExtractText(_jsx(_Fragment, {
children: ITEMS.map(i => i ? <Text key={i}>{i}</Text> : null) // <-- never transformed
}), []);
},
Babel merges all plugins into a single traversal. The copied nodes are inserted at a position the JSX transform's JSXElement visitor has already passed, so they are never converted. Plugin ordering within plugins: [...] cannot fix this, because the visitors are interleaved in one walk rather than run sequentially.
Impact
The failure is silent at build time — Metro reports a successful bundle and only Hermes rejects it, so the error surfaces as an opaque line/column in a multi-hundred-thousand-line bundle with no reference to the plugin or the source file. In our app this produced exactly 2 bad sites in a 571,542-line bundle, and tracking them back to the plugin took a full bisect.
A repo-wide sweep of 162 .tsx files using a tracked component found 3 affected files. The pattern is ordinary React, so any codebase mapping a list inside a tracked component can hit it.
Conditions
All three must hold together — removing any one produces clean output:
- The component is a function declaration (an arrow-function component does not reproduce)
- The handler is extracted into a
useCallback variable (an inline handler does not reproduce)
- The tracked component's children contain JSX returned from a
.map() callback
Reproduction steps
Self-contained Node repro — no React Native app, simulator, or native build required. The bug is entirely in the Babel transform, so it reproduces with @babel/core alone.
Setup
mkdir dd-jsx-repro && cd dd-jsx-repro
npm init -y
npm install @babel/core@7.27.4 @babel/preset-react @datadog/mobile-react-native-babel-plugin@3.5.3
repro.js
const { transformSync, parseSync } = require("@babel/core");
const CODE = `
import { useCallback } from "react";
import { Pressable, Text } from "react-native";
const ITEMS = [1, 2, 3];
function Row({ item, onPress }) {
const handlePress = useCallback(() => onPress(item), [onPress, item]);
return (
<Pressable onPress={handlePress} accessibilityLabel="Row">
{ITEMS.map((i) => (i ? <Text key={i}>{i}</Text> : null))}
</Pressable>
);
}
export default Row;
`;
const out = transformSync(CODE, {
filename: "/src/Row.jsx",
babelrc: false,
configFile: false,
presets: [["@babel/preset-react", { runtime: "automatic" }]],
plugins: [
["@datadog/mobile-react-native-babel-plugin", {
components: {
tracked: [{ name: "Pressable", handlers: [{ event: "onPress", action: "TAP" }] }],
},
}],
],
}).code;
console.log(out);
// Parse the output as plain JS — this is what Hermes does.
try {
parseSync(out, {
babelrc: false, configFile: false, sourceType: "module", parserOpts: { plugins: [] },
});
console.log("\nOK - output is valid JS");
} catch (e) {
console.log("\nFAIL - " + e.message.split("\n")[0]);
}
Run
Actual result
--- Does the OUTPUT still contain raw JSX? ---
FAIL - unknown: Support for the experimental syntax 'jsx' isn't currently enabled (20:40)
The offending output is the synthesized getContent closure — note the outer Fragment is converted but the .map() body is not:
"getContent": () => {
return __ddExtractText(_jsx(_Fragment, {
children: ITEMS.map(i => i ? <Text key={i}>{i}</Text> : null)
}), []);
},
Toggling any one condition makes it pass
| Change |
Result |
| unchanged (as above) |
FAIL |
const Row = ({ ... }) => { ... } (arrow component) |
OK |
onPress={() => onPress(item)} (drop useCallback) |
OK |
replace the .map() with a plain <Text> child |
OK |
move the .map() into a child component |
OK |
In a real app
Metro reports a successful bundle; the failure only appears when Hermes compiles it:
[runtime not ready]: Error: Non-js exception: Compiling JS failed:
493948:38:invalid expression (possible JSX: pass -parse-jsx to parse)
SDK logs
This is a build-time transform bug, so there are no runtime SDK logs — the app never reaches the point of initializing the SDK. The only diagnostic output is the Hermes compile failure and the (successful) Metro bundle that precedes it.
JS runtime — the only error surfaced
[runtime not ready]: Error: Non-js exception: Compiling JS failed:
493948:38:invalid expression (possible JSX: pass -parse-jsx to parse)
Metro — reports success, no warning
iOS Bundled 11667ms apps/…/index.js (4482 modules)
No error, no warning. The bad output is emitted into a bundle Metro considers valid.
Confirming the bundle is the source
Fetching the bundle directly and parsing it as plain JS reproduces the Hermes failure at the same location:
curl -s -o bundle.js 'http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false'
require("@babel/core").parseSync(require("fs").readFileSync("bundle.js", "utf8"), {
babelrc: false, configFile: false, sourceType: "script", parserOpts: { plugins: [] },
});
unknown: Support for the experimental syntax 'jsx' isn't currently enabled (493947:38)
Line 493947 in the raw bundle corresponds to Hermes's reported 493948 (Hermes counts an additional prelude line). Reading that line shows the untransformed JSX in the plugin-generated getContent closure, surrounded by correctly transformed jsx() calls.
Native logs
Not applicable — the failure occurs during JS compilation, before any native SDK code runs.
Expected behavior
The plugin should emit valid JavaScript for any valid React input. Instrumenting a tracked component should never depend on the shape of its children.
Concretely, the JSX copied into the synthesized getContent closure should be fully transformed, the same as the JSX in the component's own return value:
"getContent": () => {
return __ddExtractText(_jsx(_Fragment, {
children: ITEMS.map(i => i ? _jsx(Text, { children: i }, i) : null)
}), []);
},
so the module compiles under Hermes and the tap is tracked as normal.
Secondary: fail loudly rather than silently
If a case genuinely can't be transformed, it would be much better to emit a Babel error or warning naming the file than to write invalid JSX into the bundle. As it stands Metro reports success and the only signal is a line/column in the generated bundle, with nothing pointing at the plugin, the component, or the source file. That turned a one-line defect into a lengthy bisect.
Skipping instrumentation for an unsupported shape — leaving the handler unwrapped and the JSX intact — would also be preferable to producing a bundle that cannot run.
Affected SDK versions
3.5.4 (issue seems to affect all previous versions as well)
Latest working SDK version
N/A
Did you confirm if the latest SDK version fixes the bug?
Yes
Integration Methods
NPM
React Native Version
0.81.5
Package.json Contents
The repro above needs only @babel/core, @babel/preset-react, and the babel plugin, so most of this is incidental. Relevant dependencies from the React Native app, internal workspace packages omitted:
Resolved versions actually installed: @babel/core@7.27.4, babel-preset-expo@54.0.10, metro@0.83.3.
babel.config.js
More relevant than the dependency list, since this is a transform bug:
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
plugins: [
["module-resolver", { root: ["./"], alias: { "tailwind.config": "./tailwind.config.js" } }],
["babel-plugin-inline-import", { extensions: [".raw.js", ".raw.html"] }],
[
"@datadog/mobile-react-native-babel-plugin",
{
components: {
tracked: [
// ~14 entries of the form:
// { name: "SomeButton", contentProp: "accessibilityLabel",
// handlers: [{ event: "onPress", action: "TAP" }] }
],
},
},
],
"react-native-worklets/plugin",
],
};
};
Reproduces with the Datadog plugin as the only entry in plugins, and with a single tracked component — the other plugins and the rest of the registry are not required.
Package manager: Yarn 4.10.3, nodeLinker: node-modules (the standalone repro uses npm and fails identically, so this isn't a resolution artifact).
iOS Setup
not applicable, transform-time only
Android Setup
not applicable, transform-time only
Device Information
Not device-dependent. The bug occurs at build time in the Babel transform, so it is independent of device, OS version, network, and power state — any Hermes-enabled runtime rejects the resulting bundle.
Reproduced on:
- iOS Simulator, Hermes enabled, debug build, Metro dev server
- Plain Node with
@babel/core and no device involved at all (see the repro above)
Host: macOS, Node 24.15.0.
Other relevant information
Workaround
Plugin ordering within plugins: [...] does not help — Babel merges every plugin into a single traversal, so the visitors interleave rather than running in sequence. Running the plugin's JSX walk to completion in Program.enter, before any other plugin's JSXElement visitor sees the tree, does fix it:
// datadog-separate-pass.cjs
module.exports = function ddSeparatePass(api, options) {
const { pluginPath, ...ddOptions } = options;
const factory = require(pluginPath);
const real = (typeof factory === "function" ? factory : factory.default)(api, ddOptions);
const v = real.visitor || {};
const jsxVisitor =
typeof v.JSXElement === "function" ? v.JSXElement : v.JSXElement && v.JSXElement.enter;
return {
name: "datadog-rum-separate-pass",
pre(file) { if (real.pre) real.pre.call(this, file); },
post(file) { if (real.post) real.post.call(this, file); },
visitor: {
Program: {
enter(path, state) {
if (v.Program && v.Program.enter) v.Program.enter.call(this, path, state);
if (jsxVisitor) {
const self = this;
path.traverse({ JSXElement(p) { jsxVisitor.call(self, p, state); } });
}
},
// Left on exit so the plugin's setup-flag / import insertion keeps its original ordering.
exit(path, state) {
if (v.Program && v.Program.exit) v.Program.exit.call(this, path, state);
},
},
},
};
};
Used in place of a direct plugin entry:
[
"./babel/datadog-separate-pass.cjs",
{
pluginPath: "@datadog/mobile-react-native-babel-plugin",
components: { tracked: [/* ... */] },
},
],
This is offered as a diagnostic pointing at where the fix likely belongs, not as a suggested upstream patch — it reaches into the plugin's visitor object, which is obviously not a supported interface.
Verification of the workaround
Compared wrapped vs unwrapped output across all 162 .tsx files in our codebase that use a tracked component:
- 3 files produced invalid JSX before, 0 after
- 0 files newly broken
- 0 loss of instrumentation — identical
wrapRumAction call-site count (207) and identical setup-flag insertion across the whole bundle
- Bundle parses as plain JS after the change; fails at
493947:38 before
So the instrumentation appears semantically unchanged; only the traversal timing differs.
Note on api.cache(true)
Worth flagging for anyone else debugging this: with api.cache(true) in babel.config.js, Metro must be restarted with --reset-cache after any plugin-option change, or you are testing a stale transform.
Possibly related
The one detail I could not explain is why a function declaration component reproduces while an otherwise identical arrow function component does not, given that the handler-resolution path looks for both. That asymmetry may be the most direct route to the root cause.
Describe the bug
When a tracked component's children contain JSX returned from a
.map()callback, the plugin emits untransformed JSX into the compiled output. The bundle builds without error, but Hermes then fails to compile it at runtime:Cause
To derive the action name, the plugin synthesizes a
getContentclosure containing a copy of the tracked element's children and passes it to__ddExtractText. In that copy, the outer_jsx(_Fragment, ...)is converted correctly, but JSX inside a.map()callback is left as raw JSX:Babel merges all plugins into a single traversal. The copied nodes are inserted at a position the JSX transform's
JSXElementvisitor has already passed, so they are never converted. Plugin ordering withinplugins: [...]cannot fix this, because the visitors are interleaved in one walk rather than run sequentially.Impact
The failure is silent at build time — Metro reports a successful bundle and only Hermes rejects it, so the error surfaces as an opaque line/column in a multi-hundred-thousand-line bundle with no reference to the plugin or the source file. In our app this produced exactly 2 bad sites in a 571,542-line bundle, and tracking them back to the plugin took a full bisect.
A repo-wide sweep of 162
.tsxfiles using a tracked component found 3 affected files. The pattern is ordinary React, so any codebase mapping a list inside a tracked component can hit it.Conditions
All three must hold together — removing any one produces clean output:
useCallbackvariable (an inline handler does not reproduce).map()callbackReproduction steps
Self-contained Node repro — no React Native app, simulator, or native build required. The bug is entirely in the Babel transform, so it reproduces with
@babel/corealone.Setup
repro.jsRun
Actual result
The offending output is the synthesized
getContentclosure — note the outer Fragment is converted but the.map()body is not:Toggling any one condition makes it pass
const Row = ({ ... }) => { ... }(arrow component)onPress={() => onPress(item)}(dropuseCallback).map()with a plain<Text>child.map()into a child componentIn a real app
Metro reports a successful bundle; the failure only appears when Hermes compiles it:
SDK logs
This is a build-time transform bug, so there are no runtime SDK logs — the app never reaches the point of initializing the SDK. The only diagnostic output is the Hermes compile failure and the (successful) Metro bundle that precedes it.
JS runtime — the only error surfaced
Metro — reports success, no warning
No error, no warning. The bad output is emitted into a bundle Metro considers valid.
Confirming the bundle is the source
Fetching the bundle directly and parsing it as plain JS reproduces the Hermes failure at the same location:
curl -s -o bundle.js 'http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false'Line
493947in the raw bundle corresponds to Hermes's reported493948(Hermes counts an additional prelude line). Reading that line shows the untransformed JSX in the plugin-generatedgetContentclosure, surrounded by correctly transformedjsx()calls.Native logs
Not applicable — the failure occurs during JS compilation, before any native SDK code runs.
Expected behavior
The plugin should emit valid JavaScript for any valid React input. Instrumenting a tracked component should never depend on the shape of its children.
Concretely, the JSX copied into the synthesized
getContentclosure should be fully transformed, the same as the JSX in the component's own return value:so the module compiles under Hermes and the tap is tracked as normal.
Secondary: fail loudly rather than silently
If a case genuinely can't be transformed, it would be much better to emit a Babel error or warning naming the file than to write invalid JSX into the bundle. As it stands Metro reports success and the only signal is a line/column in the generated bundle, with nothing pointing at the plugin, the component, or the source file. That turned a one-line defect into a lengthy bisect.
Skipping instrumentation for an unsupported shape — leaving the handler unwrapped and the JSX intact — would also be preferable to producing a bundle that cannot run.
Affected SDK versions
3.5.4 (issue seems to affect all previous versions as well)
Latest working SDK version
N/A
Did you confirm if the latest SDK version fixes the bug?
Yes
Integration Methods
NPM
React Native Version
0.81.5
Package.json Contents
The repro above needs only
@babel/core,@babel/preset-react, and the babel plugin, so most of this is incidental. Relevant dependencies from the React Native app, internal workspace packages omitted:{ "dependencies": { "@datadog/mobile-react-native": "3.5.3", "@datadog/mobile-react-navigation": "3.5.3", "@datadog/mobile-react-native-webview": "3.5.3", "expo-datadog": "54.0.1", "expo": "54.0.34", "react": "19.1.0", "react-native": "0.81.5", "react-dom": "19.1.0", "nativewind": "4.2.3", "react-native-css-interop": "^0.2.3", "react-native-reanimated": "~4.1.1", "react-native-worklets": "0.5.1", "react-native-svg": "15.12.1", "@react-navigation/native": "7.2.2", "@react-navigation/native-stack": "7.14.12" }, "devDependencies": { "@babel/core": "^7.27.1", "@babel/runtime": "^7.20.0", "@datadog/mobile-react-native-babel-plugin": "3.5.3", "@react-native/babel-preset": "^0.81.5", "babel-preset-expo": "54.0.10", "babel-plugin-module-resolver": "^5.0.2", "babel-plugin-inline-import": "^3.0.0", "metro": "0.83.3" } }Resolved versions actually installed:
@babel/core@7.27.4,babel-preset-expo@54.0.10,metro@0.83.3.babel.config.jsMore relevant than the dependency list, since this is a transform bug:
Reproduces with the Datadog plugin as the only entry in
plugins, and with a single tracked component — the other plugins and the rest of the registry are not required.Package manager: Yarn 4.10.3,
nodeLinker: node-modules(the standalone repro uses npm and fails identically, so this isn't a resolution artifact).iOS Setup
not applicable, transform-time only
Android Setup
not applicable, transform-time only
Device Information
Not device-dependent. The bug occurs at build time in the Babel transform, so it is independent of device, OS version, network, and power state — any Hermes-enabled runtime rejects the resulting bundle.
Reproduced on:
@babel/coreand no device involved at all (see the repro above)Host: macOS, Node 24.15.0.
Other relevant information
Workaround
Plugin ordering within
plugins: [...]does not help — Babel merges every plugin into a single traversal, so the visitors interleave rather than running in sequence. Running the plugin's JSX walk to completion inProgram.enter, before any other plugin'sJSXElementvisitor sees the tree, does fix it:Used in place of a direct plugin entry:
This is offered as a diagnostic pointing at where the fix likely belongs, not as a suggested upstream patch — it reaches into the plugin's visitor object, which is obviously not a supported interface.
Verification of the workaround
Compared wrapped vs unwrapped output across all 162
.tsxfiles in our codebase that use a tracked component:wrapRumActioncall-site count (207) and identical setup-flag insertion across the whole bundle493947:38beforeSo the instrumentation appears semantically unchanged; only the traversal timing differs.
Note on
api.cache(true)Worth flagging for anyone else debugging this: with
api.cache(true)inbabel.config.js, Metro must be restarted with--reset-cacheafter any plugin-option change, or you are testing a stale transform.Possibly related
The one detail I could not explain is why a function declaration component reproduces while an otherwise identical arrow function component does not, given that the handler-resolution path looks for both. That asymmetry may be the most direct route to the root cause.