Skip to content

fix(core): stop routing every createElement call through the host's factory - #18

Merged
Fiona2016 merged 2 commits into
publishfrom
fix/never-take-over-host-accessors
Aug 27, 2026
Merged

fix(core): stop routing every createElement call through the host's factory#18
Fiona2016 merged 2 commits into
publishfrom
fix/never-take-over-host-accessors

Conversation

@Fiona2016

@Fiona2016 Fiona2016 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes a regression introduced in 0.1.4. On an app using a css-interop based styling library (nativewind), 0.1.4 with trackInteractions: true crashes at startup — Hermes GCBase::oom, heap growing without bound.

⚠️ Do not merge on the strength of the reasoning below. The mechanism was misdiagnosed twice before landing on this, and the only thing that can confirm it is a release build on a device. See Verification still owed.

The defect

These two lines were not talking about the same property:

const originalCreateElement = React.createElement;      // member expression
replaceProperty(reactModule, 'createElement', wrapper); // computed access

react-native-css-interop ships a Babel plugin that rewrites React.createElement to ReactNativeCSSInterop.createInteropElement in every file except react's and its own — the SDK's files included. Compiling the SDK's published lib/ with such an app's Babel config shows exactly that:

var originalCreateElement = _ReactNativeCSSInterop.createInteropElement;   // read: rewritten
replaceProperty(reactModule, 'createElement', function(){...})             // write: untouched

So the SDK saved the host's wrapper and replaced React's own factory with something that calls it. From then on every createElement call in the process — React's internals included — went through the host's wrapper, which is input it was never written to receive.

The same mismatch applied to the value stopTracking restores from, so uninstalling made it worse rather than better.

Why 0.1.3 did not crash

In 0.1.3 both sides were member expressions, so both were rewritten together: the assignment hit a getter-only property and threw, and the factory was never actually replaced. 0.1.4 changed the write to a computed access to remove that exception — and in doing so replaced the factory for the first time, with a mismatched original. The exception it removed was load-bearing.

The fix

Both sides now read through the module object with a computed access, which no plugin of that kind can rewrite. Verified by recompiling the built SDK with such an app's Babel config: createInteropElement no longer appears anywhere in the output.

This branch also keeps a second, independent change: an accessor is left alone rather than redefined, because a host that puts a getter in that slot is managing it. That one was written for the same crash and does not explain it — on this path React.createElement is a plain writable property — but it is correct on its own terms and is covered by tests.

Verification still owed

Repo-side gates pass: 883 tests, lerna run prepare across all 9 projects, lint clean, and the compiled-output check above. None of that exercises the crash. Before this ships, a release build with trackInteractions: true on an app using nativewind needs to start cleanly and record actions, with the pre-fix build as the negative control.

0.1.4 started redefining a factory that refused a plain assignment. That was the wrong
reading of the failure. A host framework that exposes an element factory through a
getter is not merely storing a function in that slot - it is managing the slot, and the
value it hands out participates in bookkeeping of its own. Redefining it as a plain
property does succeed, and from then on that bookkeeping operates on something the host
no longer controls. On an app using a css-interop based styling library the result was
the heap growing without bound until Hermes aborted during startup.

So this is worse than what 0.1.3 did. There the assignment threw, which was crude but at
least left the host intact; 0.1.4 removed the exception and replaced it with silent
corruption.

Not writable is not an obstacle to push harder against. It is the host saying the slot is
taken, and the right response is to leave it alone and say so. A plain property that
merely refuses writes carries no such logic behind it, so redefining that one stays.

Nothing is lost by stepping back here: under the automatic JSX transform an app never
calls React.createElement, so what actually instruments taps is the runtime it declares
through `jsxRuntimes` - and those factories are plain properties. The one visible side
effect, now documented, is that leaving `memo` alone costs the memoization of components
whose onPress gets wrapped.
`const original = React.createElement` and the replacement that follows it were not
talking about the same property. A host's Babel plugin can rewrite that member
expression to its own factory - react-native-css-interop rewrites `React.createElement`
to `ReactNativeCSSInterop.createInteropElement` in every file outside react and itself,
the SDK's included - while the replacement, written as a computed access, is invisible
to that plugin and lands on React's own property.

So the SDK saved the host's wrapper and then replaced React's factory with something
that calls it. From that point every createElement call in the process, React's own
internals included, was routed through the host's wrapper: input it was never written
to receive. On an app using such a library this shows up as the heap growing without
bound until Hermes aborts during startup.

Both sides now read through the module object with a computed access, which no plugin
of that kind can rewrite, so `original` is what was actually replaced. The same applies
to the saved value `stopTracking` restores from.

Worth noting what this means for 0.1.3 and 0.1.4. In 0.1.3 both sides were member
expressions: the read and the assignment were rewritten together, the assignment hit a
getter-only property and threw, and the factory was never actually replaced. 0.1.4
changed the write to a computed access to stop that exception - and by doing so replaced
the factory for the first time, with a mismatched original. The exception it removed was
load-bearing.
@Fiona2016 Fiona2016 changed the title fix(core): never take over an element factory the host owns fix(core): stop routing every createElement call through the host's factory Aug 27, 2026
@Fiona2016
Fiona2016 merged commit 6ff18ec into publish Aug 27, 2026
1 check passed
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.

1 participant