tl;dr – A tiny helper (
infiniteThenable) inside react-freeze made React’s Fabric scheduler loop forever, so the JS → UI microtask bridge never got a chance to flush. We patched the scheduler to flush manually and replaced the culprit with React 19’sOffscreen– animations are smooth again.
- Animations powered by Reanimated randomly froze.
- We could see
global.queueMicrotaskenqueueing work, but the microtask never firing up. - Dropping a single
console.loginto the code “fixed” the bug – classic Heisenbug territory.
At this point I was all over the place, trying to figure out what Hermes queueMicrotask is doing below the hood and who's responsible for flushing it.
| Step | What happens | Where in code |
|---|---|---|
| A | react-freeze (v 1.0.4) suspends a subtree by throwing an infiniteThenable. |
library code |
| B | React Native’s renderer hits commitRootImpl over and over again because the promise never resolves (link). |
JS |
| C | Each commit schedules new work via Scheduler.unstable_scheduleCallback. |
C++ binding |
| D | Native side enters RuntimeScheduler_Legacy::startWorkLoop… |
C++ |
| E | …and gets stuck inside while (!taskQueue_.empty()) { … } – tasks are re-queued endlessly, the loop never exits. |
same file |
Result: the JS thread never regains control → microtasks stay unflushed → Reanimated’s animation worklets never run.
console.log forced a flush on the JS queue, accidentally running the starved microtasks – a textbook Heisenbug.
- Polyfill setup –
setUpTimers.jswiresqueueMicrotaskto RN internals (link). - Queue storage –
JSTimers.jsholdsqueueReactNativeMicrotask. - Flushing –
MessageQueue.__callReactNativeMicrotasks(link) runs the queue only when native yields back to JS (e.g.callFunctionReturnFlushedQueue).
The endless C++ loop meant JS never got that chance.
We exposed a helper to JS:
// injected on the JS side
global._flushReactNativeMicrotasks = () => {
// reuse existing RN flush
return JSTimers.callReactNativeMicrotasks();
};…and called it native-side once the work-loop should have finished:
// RuntimeScheduler_Legacy.cpp (after the endless while-loop)
flushReactNativeMicrotasks(runtime); // <— new lineFull context: file link.
React 19 gives us <Offscreen />, so we rewrote Freeze:
import {Offscreen as OffscreenType} from 'react';
export function Freeze({freeze, children}) {
return (
<OffscreenType mode={freeze ? 'hidden' : 'visible'}>
{children}
</OffscreenType>
);
}No more dangling promises – the scheduler loop completes, and the microtask pipeline flows naturally.
| Purpose | File / Directory |
|---|---|
| React core (scheduler, renderer) | https://github.com/facebook/react |
| React Native Fabric scheduler (C++) | RuntimeScheduler_Legacy.cpp |
| Scheduler ↔ JS binding | RuntimeSchedulerBinding.cpp |
| Timers & microtasks | setUpTimers.js |
| Bridge flush | MessageQueue.js |
| Reanimated’s queue (JS → UI) | threads.ts |
| react-freeze culprit | src/index.tsx |
| Hermes JS engine | https://github.com/facebook/hermes |