Skip to content

feat: reanimated bundle mode support - #1500

Open
DanNizy wants to merge 6 commits into
callstack:mainfrom
DanNizy:feat/worklets-bundle-mode-support
Open

DanNizy wants to merge 6 commits into
callstack:mainfrom
DanNizy:feat/worklets-bundle-mode-support

Conversation

@DanNizy

@DanNizy DanNizy commented Oct 14, 2026 •

Copy link
Copy Markdown

Summary

Introduces bundle mode support to the @callstack/repack-plugin-reanimated.

The implementation mimics the behaviour of what react-native-worklets/bundleMode does to metro config but it is aligned for rspack/webpack. More about implementation is available in agent_context/worklets-bundle-mode-oct2026/design.md

Enabling bundle mode

Assuming reanimated is fully installed and configured according to this doc

  1. Configure worklets plugin as mentioned here (only the first step is required)

Not implemented yet

  • fast refresh on worklet runtimes - when doing changes to worklet code a full app reload is currently required to properly see the changes

Testing

  • an automated test suite is available at tests/integration/src/plugins/__fixtures__/worklets-bundle-mode
  • for manual testing bundle mode there is a button added to the "tester app" called "Test bundle mode" which tries call scheduleOnUi() from worker runtime (which is not possible without bundle mode).

@changeset-bot

changeset-bot Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9ef4b18

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

This PR includes changesets to release 6 packages
Name Type
@callstack/repack-plugin-reanimated Minor
@callstack/repack-dev-server Minor
@callstack/repack-init Minor
@callstack/repack-plugin-expo-modules Minor
@callstack/repack-plugin-nativewind Minor
@callstack/repack Minor

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

@vercel

vercel Bot commented Oct 14, 2026

Copy link
Copy Markdown

@DanNizy is attempting to deploy a commit to the Callstack Team on Vercel.

A member of the Team first needs to authorize it.

@DanNizy DanNizy changed the title added react-native-worklets version check feat: reanimated bundle mode support Oct 14, 2026
@DanNizy
DanNizy marked this pull request as draft October 14, 2026 07:10
@DanNizy
DanNizy force-pushed the feat/worklets-bundle-mode-support branch from 2105858 to 05ed50c Compare October 14, 2026 10:29
@DanNizy
DanNizy marked this pull request as ready for review October 14, 2026 14:08
@CristiCeban

Copy link
Copy Markdown
Contributor

Crash: worklet in a lazy chunk kills the app in Release (iOS + Android)

I tested this PR in a fresh RN 0.86.3 app (Re.Pack 5.4.2 + Rspack, reanimated 4.7.1, worklets 0.13.0) on the iOS simulator and the Android emulator, in Debug and Release. Basic bundle mode works on both: worker → scheduleOnUI → RN, and forwarded imports inside worklets. Worklets that live in a lazy chunk break.

Repro

// App.tsx
const LazyScreen = React.lazy(() => import('./src/LazyScreen'));

// src/LazyScreen.tsx
import { lazyHelper } from './lazyOnlyHelper'; // only imported here, forwarded via importForwarding.relativePaths

scheduleOnRuntime(createWorkletRuntime('lazy'), () => {
  'worklet';
  const value = lazyHelper(21);
  scheduleOnUI(() => { 'worklet'; scheduleOnRN(report, value); });
});

What happens

  • Debug: RedBox. [Worklets] Unable to resolve worklet with hash 5265694329720 … RuntimeError: factory is undefined (./src/lazyOnlyHelper.ts)
  • Release: the app terminates (SIGABRT) on the lazy_queue worklet thread
    • iOS: __cxa_throw ← HermesRuntimeImpl::throwPendingError ← worklets::SerializableWorklet::toJSValue (SerializableWorklet.cpp:18) ← WorkletRuntime::schedule ← AsyncQueueImpl::runLoop
    • Android: terminating due to uncaught exception of type facebook::jni::JniException ← fbjni::ThreadScope::WithClassLoader ← libworklets.so AsyncQueueImpl (AsyncQueueImpl.cpp:70)
  • The build gives no warning.
Android Release ios Release
android-release-crash.mp4
ios-release-crash.mp4

Cause
The repackWorkletsBundleMode cache group pins .worklets/.js to main, but not the modules the factory imports. lazyOnlyHelper stays in src_LazyScreen_tsx-src_lazyOnlyHelper_ts.chunk.bundle, which worklet runtimes never load, so webpack_require fails there. Importing the helper from main as well makes it pass, which confirms this. warnAboutWorkletsOutsideMainChunk only checks the .worklets modules themselves, so the build stays silent.

Suggested fix
Pull the factories' dependencies into the entry chunk as well, or at least extend the warning to cover them. Metro doesn't hit this because it emits a single bundle.


stack trace:
android-release-crash-symbolicated.txt
ios-release-crash-stack.txt

Comment on lines +522 to +536
const deferredHashes: number[] = [];

for (const module of compilation.modules) {
const resource = getModuleResource(module);
if (typeof resource !== 'string' || entryModules.has(module)) {
continue;
}
const workletsModuleId = getWorkletsModuleId(resource, workletsRoot);
if (
workletsModuleId !== undefined &&
workletsModuleId !== WORKLETS_ENTRY_MODULE_ID
) {
deferredHashes.push(workletsModuleId);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Issue: The build stays silent in case of the crash -> because the check only looks at the .worklets modules themselves.
Evidence: The repro build had zero bundle-mode warnings
Suggestion: Also warn when a factory's dependencies sit outside the entry chunk

Comment on lines +587 to +612
const workletModuleIds: Record<number, string | number | null> = {};
let polyfillModuleId: string | number | null | undefined;

if (winner) {
workletModuleIds[WORKLETS_ENTRY_MODULE_ID] = chunkGraph.getModuleId(
winner.module
);
}

for (const module of chunkModules) {
const resource = getModuleResource(module);
if (typeof resource !== 'string') {
continue;
}
if (toPosixPath(path.normalize(resource)) === polyfillPath) {
polyfillModuleId = chunkGraph.getModuleId(module);
continue;
}
const workletsModuleId = getWorkletsModuleId(resource, workletsRoot);
if (
workletsModuleId !== undefined &&
workletsModuleId !== WORKLETS_ENTRY_MODULE_ID
) {
workletModuleIds[workletsModuleId] = chunkGraph.getModuleId(module);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Issue: createSerializable.__bundleData.source comes from require.resolveWeak('react-native-worklets'). Metro returns -2 there; webpack returns its own module id,
which isn't in the __r map.
Evidence: Dev, on device: Exception in HostFunction: Value is a string, expected a number. Prod, in the emitted bundle: source: 32355 but the map only has "-2":
32355.
Suggestion: Rewrite that resolveWeak to -2 (parser hook or loader on memory/serializable.native.*), or map the entry's real id too

Comment on lines +647 to +655
`if (globalThis.nativeModuleProxy === undefined) {`,
Template.asString([
`globalThis.nativeModuleProxy = {`,
Template.asString([
`__workletsModuleProxy: globalThis.__workletsModuleProxy,`,
]),
`};`,
]),
`}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Issue: Only partial Metro parity. Metro redirects react-native to bundleMode/shims/reactNativeShim.js, which sets __RUNTIME_KIND = 1 and, in DEV, gives a clear
"Perhaps you tried to access a Turbo Module?" error. Webpack also hoists requires where Metro inlines them, so forwarded import graphs run eagerly on the UI
runtime.
Evidence: With broad importForwarding, Reanimated internals hit TurboModuleRegistry.getEnforcing(...): 'PlatformConstants' could not be found every frame. Not
checked against Metro.
Suggestion: Alias react-native to the shim like Metro does; document the eager-evaluation difference

Comment on lines +96 to +123
export function detectWorkletsBundleMode(context: string): boolean {
const babel = require('@babel/core') as typeof import('@babel/core');
try {
// `loadPartialConfig` is used instead of `loadOptions`, because only the former
// keeps the request the plugin was configured with. Once Babel has resolved the
// config, the Worklets plugin is only identified by the name it returns
// ('worklets'), which says nothing about where it came from.
const config = babel.loadPartialConfig({
caller: { name: '@callstack/repack' },
filename: path.join(context, 'index.js'),
root: context,
envName: process.env.BABEL_ENV || process.env.NODE_ENV || 'development',
});
const plugins = (config?.options?.plugins ??
[]) as WorkletsBabelPluginItem[];
return plugins.some((plugin) => {
const request = [plugin?.name, plugin?.file?.request]
.filter((value): value is string => typeof value === 'string')
.join(' ');
return (
WORKLETS_BABEL_PLUGIN_RE.test(request) &&
plugin?.options?.bundleMode === true
);
});
} catch {
return false;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Issue: Detection can disagree with the Babel config actually used: envName falls back to 'development', the filename is fixed to index.js, and a preset that adds
the worklets plugin itself (e.g. Expo's) isn't seen. Babel then flips the flags but no runtime bridge is emitted.
Evidence: Code reading
Suggestion: Detect from the loader's actual options, or let users force it with an explicit plugin option


This will disable adding now redundant `babel-loader` rules, and defer the transformation to Babel through `babel-swc-loader`.

### Enabling Bundle Mode

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Issue: The docs only show the Babel snippet. Missing: worklets ≥ 0.13 requirement; importForwarding (without it, imports in worklets become remote functions);
lazy-chunk caveat; full reload needed after JS changes; ScriptManager resolver for lazy chunks.
Evidence: My first device run failed on exactly the importForwarding point
Suggestion: Add a "Limitations" section

This branch has not been deployed

No deployments
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