From 52508b40e9ee2bbe0c66c20e539132370de8ab5e Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:18:07 +0200 Subject: [PATCH 01/28] fix(security): remove _WORKLET_RUNTIME pointer leak from RN runtime global (0056) Remove the ArrayBuffer-based raw pointer exposure that leaked the worklet runtime address into JavaScript-accessible global scope. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../reanimated/RuntimeDecorators/RNRuntimeDecorator.cpp | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/RuntimeDecorators/RNRuntimeDecorator.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/RuntimeDecorators/RNRuntimeDecorator.cpp index e5fea15207d1..62ef6ce0cde5 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/RuntimeDecorators/RNRuntimeDecorator.cpp +++ b/packages/react-native-reanimated/Common/cpp/reanimated/RuntimeDecorators/RNRuntimeDecorator.cpp @@ -10,14 +10,7 @@ void RNRuntimeDecorator::decorate( jsi::Runtime &rnRuntime, jsi::Runtime &uiRuntime, const std::shared_ptr &reanimatedModuleProxy) { - auto workletRuntimeValue = rnRuntime.global() - .getPropertyAsObject(rnRuntime, "ArrayBuffer") - .asFunction(rnRuntime) - .callAsConstructor(rnRuntime, {static_cast(sizeof(void *))}); - uintptr_t *workletRuntimeData = - reinterpret_cast(workletRuntimeValue.getObject(rnRuntime).getArrayBuffer(rnRuntime).data(rnRuntime)); - workletRuntimeData[0] = reinterpret_cast(&uiRuntime); - rnRuntime.global().setProperty(rnRuntime, "_WORKLET_RUNTIME", workletRuntimeValue); + // Security: _WORKLET_RUNTIME pointer leak removed (0056) #ifndef NDEBUG checkJSVersion(rnRuntime); From 50decef02695c8cb7e97fc60fba2ed07020ab0fb Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:18:14 +0200 Subject: [PATCH 02/28] fix(security): use safeAssign in runAnimations to prevent prototype pollution (0057+0067) Replace direct bracket assignment (result[key] = value) with Object.defineProperty-based safeAssign helper inside the runAnimations worklet, preventing __proto__ / constructor injection via animation keys. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/hook/useAnimatedStyle.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/react-native-reanimated/src/hook/useAnimatedStyle.ts b/packages/react-native-reanimated/src/hook/useAnimatedStyle.ts index 24a806c61322..d48604b96689 100644 --- a/packages/react-native-reanimated/src/hook/useAnimatedStyle.ts +++ b/packages/react-native-reanimated/src/hook/useAnimatedStyle.ts @@ -127,11 +127,28 @@ function runAnimations( forceCopyAnimation?: boolean ): boolean { 'worklet'; + + // Security: use defineProperty instead of bracket assignment to avoid + // prototype pollution via __proto__ or constructor keys (0057 + 0067) + function safeAssign( + obj: Record, + k: string, + value: unknown + ) { + Object.defineProperty(obj, k, { + value, + writable: true, + enumerable: true, + configurable: true, + __proto__: null, + } as PropertyDescriptor); + } + if (!animationsActive.value) { return true; } if (Array.isArray(animation)) { - result[key] = []; + safeAssign(result, String(key), []); let allFinished = true; forceCopyAnimation = key === 'boxShadow'; animation.forEach((entry, index) => { @@ -169,13 +186,13 @@ function runAnimations( * in rgba format, allowing the animation to run correctly. Additionally we need to check if user animated the whole boxShadow object or only one of its properties. */ if (forceCopyAnimation && typeof animation.current === 'object') { - result[key] = { ...animation.current }; + safeAssign(result, String(key), { ...animation.current }); } else { - result[key] = animation.current; + safeAssign(result, String(key), animation.current); } return finished; } else if (typeof animation === 'object') { - result[key] = {}; + safeAssign(result, String(key), {}); let allFinished = true; Object.keys(animation).forEach((k) => { if ( @@ -193,7 +210,7 @@ function runAnimations( }); return allFinished; } else { - result[key] = animation; + safeAssign(result, String(key), animation); return true; } } From c95d4fe9b51c3f24fd8fd15bf3a2e1779f07f226 Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:18:22 +0200 Subject: [PATCH 03/28] fix(security): guard dangerous keys in setPath to prevent prototype pollution (0058) Add early-return guards for __proto__, constructor, and prototype keys in the setPath loop body and final assignment to block prototype pollution via crafted animation paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/animation/styleAnimation.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/react-native-reanimated/src/animation/styleAnimation.ts b/packages/react-native-reanimated/src/animation/styleAnimation.ts index 16aa75c1f230..e7ca7c2f8432 100644 --- a/packages/react-native-reanimated/src/animation/styleAnimation.ts +++ b/packages/react-native-reanimated/src/animation/styleAnimation.ts @@ -49,6 +49,14 @@ function setPath( const keys: Path = Array.isArray(path) ? path : [path]; let currObj: NestedObjectValues = obj; for (let i = 0; i < keys.length - 1; i++) { + // Security: guard dangerous keys to prevent prototype pollution (0058) + if ( + keys[i] === '__proto__' || + keys[i] === 'constructor' || + keys[i] === 'prototype' + ) { + return; + } // creates entry if there isn't one currObj = currObj as { [key: string]: NestedObjectValues }; if (!(keys[i] in currObj)) { @@ -62,8 +70,16 @@ function setPath( currObj = currObj[keys[i]]; } - (currObj as { [key: string]: NestedObjectValues })[keys[keys.length - 1]] = - value; + // Security: guard final key as well (0058) + const lastKey = keys[keys.length - 1]; + if ( + lastKey === '__proto__' || + lastKey === 'constructor' || + lastKey === 'prototype' + ) { + return; + } + (currObj as { [key: string]: NestedObjectValues })[lastKey] = value; } interface NestedObjectEntry { From 2ae549256ab9dba47ca3da1ad1494488ce0c9892 Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:18:29 +0200 Subject: [PATCH 04/28] fix(security): iterative flattenArray and Object.hasOwn in has() (0073+0074) Replace recursive _flattenArray with iterative stack-based implementation to prevent stack overflow via deeply nested arrays. Switch has() from 'in' operator to Object.hasOwn() to avoid prototype chain lookups. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/createAnimatedComponent/utils.ts | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/react-native-reanimated/src/createAnimatedComponent/utils.ts b/packages/react-native-reanimated/src/createAnimatedComponent/utils.ts index feb44ab54fbc..daff9a95d035 100644 --- a/packages/react-native-reanimated/src/createAnimatedComponent/utils.ts +++ b/packages/react-native-reanimated/src/createAnimatedComponent/utils.ts @@ -3,25 +3,29 @@ import type { StyleProps } from '../commonTypes'; import type { CSSStyle } from '../css'; import type { NestedArray } from './commonTypes'; +// Security: iterative stack-based flatten to prevent stack overflow via +// deeply nested arrays (0073) export function flattenArray(array: NestedArray): T[] { if (!Array.isArray(array)) { return [array]; } - const resultArr: T[] = []; - - const _flattenArray = (arr: NestedArray[]): void => { - arr.forEach((item) => { - if (Array.isArray(item)) { - _flattenArray(item); - } else { - resultArr.push(item); + const result: T[] = []; + const stack = [...array]; + while (stack.length > 0) { + const item = stack.pop(); + if (Array.isArray(item)) { + for (let i = item.length - 1; i >= 0; i--) { + stack.push(item[i]); } - }); - }; - _flattenArray(array); - return resultArr; + } else { + result.push(item as T); + } + } + return result; } +// Security: use Object.hasOwn instead of `in` to avoid prototype chain +// lookups (0074) export const has = ( key: K, x: unknown @@ -30,7 +34,7 @@ export const has = ( if (x === null || x === undefined) { return false; } else { - return key in x; + return Object.hasOwn(x as object, key); } } return false; From 5f9d8c87ed0df2551b7d250971bb08d910c8f4b2 Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:18:37 +0200 Subject: [PATCH 05/28] fix(security): use Object.create(null) for accumulator objects (0074+0078) Replace {} with Object.create(null) for animation copies in decorateAnimation, props accumulator in PropsFilter, and inline props objects in InlinePropManager. Also switch for...in to Object.entries() in extractSharedValuesMapFromProps to avoid prototype chain traversal. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/react-native-reanimated/src/animation/util.ts | 10 +++++----- .../src/createAnimatedComponent/InlinePropManager.ts | 10 ++++++---- .../src/createAnimatedComponent/PropsFilter.tsx | 3 ++- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/react-native-reanimated/src/animation/util.ts b/packages/react-native-reanimated/src/animation/util.ts index 2df3a28be50f..dbdaf38fd183 100644 --- a/packages/react-native-reanimated/src/animation/util.ts +++ b/packages/react-native-reanimated/src/animation/util.ts @@ -203,7 +203,7 @@ function decorateAnimation( return; } - const animationCopy = Object.assign({}, animation); + const animationCopy = Object.assign(Object.create(null), animation); delete animationCopy.callback; const prefNumberSuffOnStart = ( @@ -283,7 +283,7 @@ function decorateAnimation( } } tab.forEach((i, index) => { - animation[i] = Object.assign({}, animationCopy); + animation[i] = Object.assign(Object.create(null), animationCopy); animation[i].current = RGBACurrent[index]; animation[i].toValue = RGBAToValue ? RGBAToValue[index] : undefined; animation[i].onStart( @@ -344,7 +344,7 @@ function decorateAnimation( // We set limits from 0 to 100 (instead of 0-1) to make spring look good // with default thresholds. - animation[0] = Object.assign({}, animationCopy); + animation[0] = Object.assign(Object.create(null), animationCopy); animation[0].current = 0; animation[0].toValue = 100; animation[0].onStart( @@ -424,7 +424,7 @@ function decorateAnimation( previousAnimation: Animation ): void => { value.forEach((v, i) => { - animation[i] = Object.assign({}, animationCopy); + animation[i] = Object.assign(Object.create(null), animationCopy); animation[i].current = v; animation[i].toValue = (animation.toValue as Array)[i]; animation[i].onStart( @@ -459,7 +459,7 @@ function decorateAnimation( previousAnimation: Animation ): void => { for (const key in value) { - animation[key] = Object.assign({}, animationCopy); + animation[key] = Object.assign(Object.create(null), animationCopy); animation[key].onStart = animation.onStart; animation[key].current = value[key]; diff --git a/packages/react-native-reanimated/src/createAnimatedComponent/InlinePropManager.ts b/packages/react-native-reanimated/src/createAnimatedComponent/InlinePropManager.ts index 3f698bfccafe..c5ad78b8cf07 100644 --- a/packages/react-native-reanimated/src/createAnimatedComponent/InlinePropManager.ts +++ b/packages/react-native-reanimated/src/createAnimatedComponent/InlinePropManager.ts @@ -47,7 +47,8 @@ function getInlinePropsUpdate(styleValue: StyleProps): unknown { return styleValue.map(getInlinePropsUpdate); } if (styleValue && typeof styleValue === 'object') { - const update: Record = {}; + // Security: use null-prototype object to prevent prototype pollution (0074) + const update: Record = Object.create(null); for (const [key, value] of Object.entries(styleValue)) { update[key] = getInlinePropsUpdate(value); } @@ -61,10 +62,11 @@ function extractSharedValuesMapFromProps( Record /* Initial component props */ > ): Record { - const inlineProps: Record = {}; + // Security: use null-prototype object + Object.entries to avoid prototype + // chain traversal (0074) + const inlineProps: Record = Object.create(null); - for (const key in props) { - const value = props[key]; + for (const [key, value] of Object.entries(props)) { if (key === 'style') { const styles = flattenArray(props.style ?? []); styles.forEach((style) => { diff --git a/packages/react-native-reanimated/src/createAnimatedComponent/PropsFilter.tsx b/packages/react-native-reanimated/src/createAnimatedComponent/PropsFilter.tsx index 1daef1a62caa..fe334d3cbe38 100644 --- a/packages/react-native-reanimated/src/createAnimatedComponent/PropsFilter.tsx +++ b/packages/react-native-reanimated/src/createAnimatedComponent/PropsFilter.tsx @@ -28,7 +28,8 @@ export class PropsFilter implements IPropsFilter { ): Record { const inputProps = component.props as AnimatedComponentProps; - const props: Record = {}; + // Security: use null-prototype object to prevent prototype pollution (0078) + const props: Record = Object.create(null); for (const key in inputProps) { const value = inputProps[key]; From 8036a016fd97901252339b6f36779f951231ba95 Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:18:44 +0200 Subject: [PATCH 06/28] fix(security): disable Keyframe export to prevent prototype pollution (0074) Comment out the Keyframe re-export from the package index as part of the security hardening against prototype pollution vectors. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/react-native-reanimated/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-native-reanimated/src/index.ts b/packages/react-native-reanimated/src/index.ts index 743549f34ca7..75e60186f6e0 100644 --- a/packages/react-native-reanimated/src/index.ts +++ b/packages/react-native-reanimated/src/index.ts @@ -212,7 +212,8 @@ export { FlipOutYLeft, FlipOutYRight, JumpingTransition, - Keyframe, + // Security: Keyframe export disabled to prevent prototype pollution (0074) + // Keyframe, // Transitions Layout, LightSpeedInLeft, From 094e2e35746ac120ea7cce5eeec0bb7ba919e93d Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:18:51 +0200 Subject: [PATCH 07/28] fix(security): add array bounds check in SensorSetter to prevent buffer overflow (0080) Add upper-bound clamping in the sensor data copy loop to ensure the JNI-provided size cannot exceed the fixed-size local array capacity. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../android/src/main/cpp/reanimated/android/SensorSetter.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-native-reanimated/android/src/main/cpp/reanimated/android/SensorSetter.h b/packages/react-native-reanimated/android/src/main/cpp/reanimated/android/SensorSetter.h index fc4b9d8b4ffc..50ab56a25669 100644 --- a/packages/react-native-reanimated/android/src/main/cpp/reanimated/android/SensorSetter.h +++ b/packages/react-native-reanimated/android/src/main/cpp/reanimated/android/SensorSetter.h @@ -17,7 +17,9 @@ class SensorSetter : public HybridClass { size_t size = value->size(); auto elements = value->getRegion(0, size); double array[7]; - for (size_t i = 0; i < size; i++) { + // Security: bounds check to prevent buffer overflow (0080) + size_t max = sizeof(array) / sizeof(array[0]); + for (size_t i = 0; i < size && i < max; i++) { array[i] = elements[i]; } callback_(array, orientationDegrees); From 491a0e0389176a51d4818b3464f566134b04e01b Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:21:20 +0200 Subject: [PATCH 08/28] fix(security): replace RegExp-based color parsing with manual string parsing (0074) Remove all RegExp objects (MATCHERS array, NUMBER/PERCENTAGE patterns, call/callWithSlashSeparator/commaSeparatedCall helpers) from Colors.ts and replace with manual character-by-character validation functions (isAllHexDigits, isAllDigits, isAllDigitsDot, isPercentage) and substring matching. This eliminates ReDoS risk and prevents potential code execution via the RegExp constructor in the worklet runtime. Port of the 3.x patch 0074 Colors.ts portion to reanimated 4.x. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../react-native-reanimated/src/Colors.ts | 457 +++++++++++------- 1 file changed, 295 insertions(+), 162 deletions(-) diff --git a/packages/react-native-reanimated/src/Colors.ts b/packages/react-native-reanimated/src/Colors.ts index 3d23c133aa92..76ebca569686 100644 --- a/packages/react-native-reanimated/src/Colors.ts +++ b/packages/react-native-reanimated/src/Colors.ts @@ -19,51 +19,6 @@ interface HSV { v: number; } -const NUMBER: string = '[-+]?\\d*\\.?\\d+'; -const PERCENTAGE = NUMBER + '%'; - -function call(...args: (RegExp | string)[]) { - return '\\(\\s*(' + args.join(')\\s*,?\\s*(') + ')\\s*\\)'; -} - -function callWithSlashSeparator(...args: (RegExp | string)[]) { - return ( - '\\(\\s*(' + - args.slice(0, args.length - 1).join(')\\s*,?\\s*(') + - ')\\s*/\\s*(' + - args[args.length - 1] + - ')\\s*\\)' - ); -} - -function commaSeparatedCall(...args: (RegExp | string)[]) { - return '\\(\\s*(' + args.join(')\\s*,\\s*(') + ')\\s*\\)'; -} - -const MATCHERS = { - rgb: new RegExp('rgb' + call(NUMBER, NUMBER, NUMBER)), - rgba: new RegExp( - 'rgba(' + - commaSeparatedCall(NUMBER, NUMBER, NUMBER, NUMBER) + - '|' + - callWithSlashSeparator(NUMBER, NUMBER, NUMBER, NUMBER) + - ')' - ), - hsl: new RegExp('hsl' + call(NUMBER, PERCENTAGE, PERCENTAGE)), - hsla: new RegExp( - 'hsla(' + - commaSeparatedCall(NUMBER, PERCENTAGE, PERCENTAGE, NUMBER) + - '|' + - callWithSlashSeparator(NUMBER, PERCENTAGE, PERCENTAGE, NUMBER) + - ')' - ), - hwb: new RegExp('hwb' + call(NUMBER, PERCENTAGE, PERCENTAGE)), - hex3: /^#([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/, - hex4: /^#([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/, - hex6: /^#([0-9a-fA-F]{6})$/, - hex8: /^#([0-9a-fA-F]{8})$/, -}; - function hue2rgb(p: number, q: number, t: number): number { 'worklet'; if (t < 0) { @@ -368,136 +323,314 @@ export function normalizeColor(color: unknown): number | null { return null; } - let match: RegExpExecArray | null | undefined; + let inputUntrimmed = color; + while (inputUntrimmed.includes(' ')) { + inputUntrimmed = inputUntrimmed.replace(' ', ' '); + } - // Ordered based on occurrences on Facebook codebase - if ((match = MATCHERS.hex6.exec(color))) { - return Number.parseInt(match[1] + 'ff', 16) >>> 0; + const input = inputUntrimmed.trim(); + if (input.length > 0 && inputUntrimmed[0] === ' ' && input[0] === '#') { + return null; } - if (color in names) { - return names[color]; + function isAllHexDigits(str: string): boolean { + for (let i = 0; i < str.length; i++) { + const c = str[i]; + const isHex = + (c >= '0' && c <= '9') || + (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'); + if (!isHex) { + return false; + } + } + return true; } - if ((match = MATCHERS.rgb.exec(color))) { - return ( - // b - ((parse255(match[1]) << 24) | // r - (parse255(match[2]) << 16) | // g - (parse255(match[3]) << 8) | - 0x000000ff) >>> // a - 0 - ); + function isAllDigits(str: string): boolean { + for (let i = 0; i < str.length; i++) { + const c = str[i]; + if (i === 0 && (c === '-' || c === '+')) { + continue; + } + + const isNum = c >= '0' && c <= '9'; + if (!isNum) { + return false; + } + } + return true; } - if ((match = MATCHERS.rgba.exec(color))) { - // rgba(R G B / A) notation - if (match[6] !== undefined) { - return ( - ((parse255(match[6]) << 24) | // r - (parse255(match[7]) << 16) | // g - (parse255(match[8]) << 8) | // b - parse1(match[9])) >>> // a - 0 - ); + function isAllDigitsDot(str: string): boolean { + const newStr = str.replace('.', ''); // only remove one '.' + return isAllDigits(newStr); + } + + function isPercentage(str: string): boolean { + if (!str.includes('%')) { + return false; } + const digitDot = str.replace('%', ''); + if (isAllDigitsDot(digitDot)) { + return true; + } + return false; + } + + if (names[input] !== undefined) { + return names[input]; + } - // rgba(R, G, B, A) notation - return ( - ((parse255(match[2]) << 24) | // r - (parse255(match[3]) << 16) | // g - (parse255(match[4]) << 8) | // b - parse1(match[5])) >>> // a - 0 - ); - } - - if ((match = MATCHERS.hex3.exec(color))) { - return ( - Number.parseInt( - match[1] + - match[1] + // r - match[2] + - match[2] + // g - match[3] + - match[3] + // b - 'ff', // a - 16 - ) >>> 0 - ); - } - - // https://drafts.csswg.org/css-color-4/#hex-notation - if ((match = MATCHERS.hex8.exec(color))) { - return Number.parseInt(match[1], 16) >>> 0; - } - - if ((match = MATCHERS.hex4.exec(color))) { - return ( - Number.parseInt( - match[1] + - match[1] + // r - match[2] + - match[2] + // g - match[3] + - match[3] + // b - match[4] + - match[4], // a - 16 - ) >>> 0 - ); - } - - if ((match = MATCHERS.hsl.exec(color))) { - return ( - (hslToRgb( - parse360(match[1]), // h - parsePercentage(match[2]), // s - parsePercentage(match[3]) // l - ) | - 0x000000ff) >>> // a - 0 - ); - } - - if ((match = MATCHERS.hsla.exec(color))) { - // hsla(H S L / A) notation - if (match[6] !== undefined) { - return ( - (hslToRgb( - parse360(match[6]), // h - parsePercentage(match[7]), // s - parsePercentage(match[8]) // l - ) | - parse1(match[9])) >>> // a - 0 - ); + // #RRGGBB => 7 chars total, e.g. "#1a2B3C" + if (input.startsWith('#') && input.length === 7) { + const hexPart = input.slice(1); // e.g. "1a2B3C" + if (isAllHexDigits(hexPart)) { + return Number.parseInt(hexPart + 'ff', 16) >>> 0; } + } + // rgb(R, G, B) or rgb(R G B) + if (input.startsWith('rgb(') && input.endsWith(')')) { + const inside = input.slice(4, -1).trim(); + let parts = inside.split(',').map((p) => p.trim()); - // hsla(H, S, L, A) notation - return ( - (hslToRgb( - parse360(match[2]), // h - parsePercentage(match[3]), // s - parsePercentage(match[4]) // l - ) | - parse1(match[5])) >>> // a - 0 - ); - } - - if ((match = MATCHERS.hwb.exec(color))) { - return ( - (hwbToRgb( - parse360(match[1]), // h - parsePercentage(match[2]), // w - parsePercentage(match[3]) // b - ) | - 0x000000ff) >>> // a - 0 - ); + if (parts.length !== 3) { + parts = inside.split(' ').map((p) => p.trim()); + if (parts.length !== 3) { + return null; + } + } + for (const part of parts) { + if (!isAllDigitsDot(part)) { + return null; + } + } + + const r = parse255(parts[0]); + const g = parse255(parts[1]); + const b = parse255(parts[2]); + if (r != null && g != null && b != null) { + return ((r << 24) | (g << 16) | (b << 8) | 0xff) >>> 0; + } + } + + // rgba(R, G, B, A) or rgba(R G B / A) + if (input.startsWith('rgba(') && input.endsWith(')')) { + const inside = input.slice(5, -1).trim(); + if (inside.includes('/')) { + // slash form + const [beforeSlash, alphaPart] = inside.split('/'); + if (beforeSlash && alphaPart) { + const rgbParts = beforeSlash + .trim() + .split(' ') + .map((x) => x.trim()); + if (rgbParts.length === 3) { + for (const rgbPart of rgbParts) { + if (!isAllDigitsDot(rgbPart)) { + return null; + } + } + if (!isAllDigitsDot(alphaPart.trim())) { + return null; + } + + const r = parse255(rgbParts[0]); + const g = parse255(rgbParts[1]); + const b = parse255(rgbParts[2]); + const a = parse1(alphaPart.trim()); + if (r != null && g != null && b != null && a != null) { + return ((r << 24) | (g << 16) | (b << 8) | a) >>> 0; + } + } + } + } else { + // comma form + const parts = inside.split(',').map((p) => p.trim()); + if (parts.length === 4) { + for (const part of parts) { + if (!isAllDigitsDot(part)) { + return null; + } + } + const r = parse255(parts[0]); + const g = parse255(parts[1]); + const b = parse255(parts[2]); + const a = parse1(parts[3]); + if (r != null && g != null && b != null && a != null) { + return ((r << 24) | (g << 16) | (b << 8) | a) >>> 0; + } + } + } + } + + // #RGB => length=4, e.g. "#F0c" + if (input.startsWith('#') && input.length === 4) { + const shortHex = input.slice(1); // e.g. "F0c" + if (shortHex.length === 3 && isAllHexDigits(shortHex)) { + // Expand => "FF00cc" + "ff" + const expanded = + shortHex[0] + + shortHex[0] + + shortHex[1] + + shortHex[1] + + shortHex[2] + + shortHex[2] + + 'ff'; + return Number.parseInt(expanded, 16) >>> 0; + } + } + + // #RRGGBBAA => length=9 + if (input.startsWith('#') && input.length === 9) { + const hexPart = input.slice(1); // e.g. "1a2b3cFF" + if (hexPart.length === 8 && isAllHexDigits(hexPart)) { + return Number.parseInt(hexPart, 16) >>> 0; + } + } + + // #RGBA => length=5 + if (input.startsWith('#') && input.length === 5) { + const shortHex = input.slice(1); // e.g. "F0cF" + if (shortHex.length === 4 && isAllHexDigits(shortHex)) { + const expanded = + shortHex[0] + + shortHex[0] + + shortHex[1] + + shortHex[1] + + shortHex[2] + + shortHex[2] + + shortHex[3] + + shortHex[3]; + return Number.parseInt(expanded, 16) >>> 0; + } + } + + // hsl(H, S%, L%) or hsl(H S% L%) + if (input.startsWith('hsl(') && input.endsWith(')')) { + const inside = input.slice(4, -1).trim(); + let parts = inside.split(',').map((p) => p.trim()); + + if (parts.length !== 3) { + parts = inside.split(' ').map((p) => p.trim()); + if (parts.length !== 3) { + return null; + } + } + if (!isAllDigitsDot(parts[0])) { + return null; + } + if (!isPercentage(parts[1])) { + return null; + } + if (!isPercentage(parts[2])) { + return null; + } + + const h = parse360(parts[0]); // can be negative, wraps via mod + const s = parsePercentage(parts[1]); + const l = parsePercentage(parts[2]); + if (h != null && s != null && l != null) { + const rgb = hslToRgb(h, s, l); + return (rgb | 0xff) >>> 0; // alpha=255 + } + } + + // hsla(H, S%, L%, A) or hsla(H S% L% / A) + if (input.startsWith('hsla(') && input.endsWith(')')) { + const inside = input.slice(5, -1).trim(); + if (inside.includes('/')) { + // slash form => "H, S%, L% / A" + const [beforeSlash, alphaPart] = inside.split('/'); + if (beforeSlash && alphaPart) { + const hslParts = beforeSlash + .trim() + .split(' ') + .map((p) => p.trim()); + if (hslParts.length === 3) { + if (!isAllDigitsDot(hslParts[0])) { + return null; + } + if (!isPercentage(hslParts[1])) { + return null; + } + if (!isPercentage(hslParts[2])) { + return null; + } + if (!isAllDigitsDot(alphaPart.trim())) { + return null; + } + + const h = parse360(hslParts[0]); + const s = parsePercentage(hslParts[1]); + const l = parsePercentage(hslParts[2]); + const a = parse1(alphaPart.trim()); + if (h != null && s != null && l != null && a != null) { + const rgb = hslToRgb(h, s, l); + return (rgb | a) >>> 0; + } + } + } + } else { + // comma form => "H, S%, L%, A" + const parts = inside.split(',').map((p) => p.trim()); + if (parts.length === 4) { + if (!isAllDigitsDot(parts[0])) { + return null; + } + if (!isPercentage(parts[1])) { + return null; + } + if (!isPercentage(parts[2])) { + return null; + } + if (!isAllDigitsDot(parts[3])) { + return null; + } + + const h = parse360(parts[0]); + const s = parsePercentage(parts[1]); + const l = parsePercentage(parts[2]); + const a = parse1(parts[3]); + if (h != null && s != null && l != null && a != null) { + const rgb = hslToRgb(h, s, l); + return (rgb | a) >>> 0; + } + } + } + } + + // hwb(H, W%, B%) or hwb(H W% B%) -- angle can be negative + if (input.startsWith('hwb(') && input.endsWith(')')) { + const inside = input.slice(4, -1).trim(); + let parts = inside.split(',').map((p) => p.trim()); + + if (parts.length !== 3) { + parts = inside.split(' ').map((p) => p.trim()); + if (parts.length !== 3) { + return null; + } + } + if (!isAllDigitsDot(parts[0])) { + return null; + } + if (!isPercentage(parts[1])) { + return null; + } + if (!isPercentage(parts[2])) { + return null; + } + + const h = parse360(parts[0]); + const w = parsePercentage(parts[1]); + const b = parsePercentage(parts[2]); + if (h != null && w != null && b != null) { + const rgb = hwbToRgb(h, w, b); + return (rgb | 0xff) >>> 0; // alpha=255 + } } + // Nothing matched => invalid return null; } From 11511f7fb221875886819b4d160482191e29d597 Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:21:37 +0200 Subject: [PATCH 09/28] feat(worklets): wrap worklet code under Symbol.for in Babel plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes the worklet initData to store code under Symbol.for("__reanimated_workletCode") via an object wrapper (__reanimated_workletCodeWrapper) instead of a plain "code" property. This hardens function sharing by preventing plain-text access to worklet source code. Port of 3.x patch 0076 (function sharing hardening) — plugin portion. --- .../plugin/src/workletFactory.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/react-native-worklets/plugin/src/workletFactory.ts b/packages/react-native-worklets/plugin/src/workletFactory.ts index efd10fc0aa8e..decb1900cdee 100644 --- a/packages/react-native-worklets/plugin/src/workletFactory.ts +++ b/packages/react-native-worklets/plugin/src/workletFactory.ts @@ -12,6 +12,7 @@ import { arrayExpression, assignmentExpression, blockStatement, + callExpression, cloneNode, expressionStatement, functionExpression, @@ -160,8 +161,24 @@ export function makeWorkletFactory( `worklet_${workletHash}_init_data` ); + const symbolForWorkletCode = callExpression( + memberExpression(identifier('Symbol'), identifier('for')), + [stringLiteral('__reanimated_workletCode')] + ); + + const workletCodeProperty = objectProperty( + symbolForWorkletCode, + stringLiteral(funString), + true + ); + + const reanimatedWorkletCodeProperty = objectProperty( + identifier('__reanimated_workletCodeWrapper'), + objectExpression([workletCodeProperty]) + ); + const initDataObjectExpression = objectExpression([ - objectProperty(identifier('code'), stringLiteral(funString)), + reanimatedWorkletCodeProperty, ]); // When testing with jest I noticed that environment variables are set later From 5498992d4a3999cb8dadf329ca856bf40f8b3c39 Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:21:44 +0200 Subject: [PATCH 10/28] fix(security): disable RegExp serialization in worklet runtime (0074) Replace the cloneRegExp function in serializable.native.ts with a throw that prevents RegExp instances from being transferred to the UI/worklet runtime. This blocks potential ReDoS and code execution vectors via the RegExp constructor on the worklet thread. Port of the 3.x patch 0074 shareables.ts portion to worklets 4.x. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/memory/serializable.native.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/react-native-worklets/src/memory/serializable.native.ts b/packages/react-native-worklets/src/memory/serializable.native.ts index a48d2fd9a076..2d1ad07ebe1a 100644 --- a/packages/react-native-worklets/src/memory/serializable.native.ts +++ b/packages/react-native-worklets/src/memory/serializable.native.ts @@ -603,19 +603,12 @@ function cloneSet>( } function cloneRegExp( - value: TValue + _value: TValue ): SerializableRef { - const pattern = value.source; - const flags = value.flags; - const handle = cloneInitializer({ - __init: () => { - 'worklet'; - return new RegExp(pattern, flags); - }, - }) as unknown as SerializableRef; - serializableMappingCache.set(value, handle); - - return handle; + // disabled, contact appsec if needed: https://github.com/ExodusMovement/exodus-mobile/pull/24699#issuecomment-2709694172 + throw new WorkletsError( + 'RegExp has been disabled. Contact AppSec if needed.' + ); } function cloneError( From 64967cfd034ae48899a48775ccb7612026a7c65b Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:22:31 +0200 Subject: [PATCH 11/28] feat(worklets): handle Symbol-wrapped worklet code in serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds handling for the __reanimated_workletCodeWrapper property in the JS serialization layer and the C++ Symbol property extraction in makeSerializableClone. Uses Object.create(null) and assignReadOnly for hardened property assignment during cloning. Port of 3.x patches 0076 + 0082 — serialization portion. --- .../cpp/worklets/SharedItems/Serializable.cpp | 15 +++++- .../src/memory/serializable.native.ts | 46 +++++++++++++++---- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/packages/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.cpp b/packages/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.cpp index 62672f75b852..47d9bdcd0b60 100644 --- a/packages/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.cpp +++ b/packages/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.cpp @@ -10,6 +10,15 @@ using namespace facebook; namespace worklets { +jsi::PropNameID workletCodePropName(jsi::Runtime &rt) { + jsi::Function symbolFor = rt.global() + .getPropertyAsObject(rt, "Symbol") + .getPropertyAsFunction(rt, "for"); + jsi::Value val = symbolFor.call(rt, jsi::String::createFromAscii(rt, "__reanimated_workletCode")); + jsi::Symbol sym = val.asSymbol(rt); + return jsi::PropNameID::forSymbol(rt, sym); +} + jsi::Function getValueUnpacker(jsi::Runtime &rt) { auto valueUnpacker = rt.global().getProperty(rt, "__valueUnpacker"); react_native_assert(valueUnpacker.isObject() && "valueUnpacker not found"); @@ -24,7 +33,11 @@ jsi::Value makeSerializableClone( std::shared_ptr serializable; if (value.isObject()) { auto object = value.asObject(rt); - if (!object.getProperty(rt, "__workletHash").isUndefined()) { + jsi::PropNameID prop = workletCodePropName(rt); + if (object.hasProperty(rt, prop)) { + jsi::Value code = object.getProperty(rt, prop); + serializable = std::make_shared(code.asString(rt).utf8(rt)); + } else if (!object.getProperty(rt, "__workletHash").isUndefined()) { // We pass `false` because this function is invoked only // by `makeSerializableCloneOnUIRecursive` which doesn't // make Retaining Serializables. diff --git a/packages/react-native-worklets/src/memory/serializable.native.ts b/packages/react-native-worklets/src/memory/serializable.native.ts index 2d1ad07ebe1a..1caed81ca87c 100644 --- a/packages/react-native-worklets/src/memory/serializable.native.ts +++ b/packages/react-native-worklets/src/memory/serializable.native.ts @@ -379,12 +379,28 @@ function cloneNull(): SerializableRef { return WorkletsModule.createSerializableNull(); } +const assignReadOnly = ( + obj: Record, + key: string, + value: unknown +) => { + const descriptor = { + __proto__: null, + value, + writable: false, + enumerable: true, + configurable: false, + }; + Object.defineProperty(obj, key, descriptor); + return obj; +}; + function cloneObjectProperties( value: T, shouldPersistRemote: boolean, depth: number ): Record { - const clonedProps: Record = {}; + const clonedProps: Record = Object.create(null); for (const [key, element] of Object.entries(value)) { // We don't need to clone __initData field as it contains long strings // representing the worklet code, source map, and location, and we will @@ -392,10 +408,22 @@ function cloneObjectProperties( if (key === '__initData' && clonedProps.__initData !== undefined) { continue; } - clonedProps[key] = createSerializable( - element, - shouldPersistRemote, - depth + 1 + if (key === '__reanimated_workletCodeWrapper') { + assignReadOnly( + clonedProps, + '__reanimated_workletCode', + WorkletsModule.createSerializable( + element, + shouldPersistRemote, + value + ) + ); + continue; + } + assignReadOnly( + clonedProps, + key, + createSerializable(element, shouldPersistRemote, depth + 1) ); } return clonedProps; @@ -491,10 +519,10 @@ function cloneWorklet( // that the __initData field that contains long strings representing the // worklet code, source map, and location, will always be // serialized/deserialized once. - clonedProps.__initData = createSerializable( - value.__initData, - true, - depth + 1 + assignReadOnly( + clonedProps, + '__initData', + createSerializable(value.__initData, true, depth + 1) ); const clone = WorkletsModule.createSerializableWorklet( From 972e6dc5a6e22b06b37ae2a93f17ceba834bfbaf Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:23:33 +0200 Subject: [PATCH 12/28] fix(worklets): remove eval from value unpacker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the eval('(' + code + '\n)') call and the evalWithSourceUrl/ evalWithSourceMap code paths with a single evaluateWorkletFunction?.() call. The 4th parameter is a C++ JSI function that calls rt.evaluateJavaScript() directly, avoiding any JS-level eval. Port of 3.x patch 0082 (eval removal) — value unpacker portion. --- .../src/memory/valueUnpacker.native.ts | 38 ++----------------- packages/react-native-worklets/src/types.ts | 7 +++- 2 files changed, 9 insertions(+), 36 deletions(-) diff --git a/packages/react-native-worklets/src/memory/valueUnpacker.native.ts b/packages/react-native-worklets/src/memory/valueUnpacker.native.ts index 5850da48f99f..b1c9c9ab76e3 100644 --- a/packages/react-native-worklets/src/memory/valueUnpacker.native.ts +++ b/packages/react-native-worklets/src/memory/valueUnpacker.native.ts @@ -2,15 +2,6 @@ import type { ValueUnpacker, WorkletFunction } from '../types'; -declare global { - var evalWithSourceMap: - | ((js: string, sourceURL: string, sourceMap: string) => () => unknown) - | undefined; - var evalWithSourceUrl: - | ((js: string, sourceURL: string) => () => unknown) - | undefined; -} - function __installUnpacker() { const workletsCache = new Map unknown>(); const handleCache = new WeakMap(); @@ -18,7 +9,8 @@ function __installUnpacker() { function valueUnpacker( objectToUnpack: ObjectToUnpack, category?: string, - remoteFunctionName?: string + remoteFunctionName?: string, + evaluateWorkletFunction?: () => (() => unknown) ): unknown { // eslint-disable-next-line strict 'use strict'; @@ -26,31 +18,7 @@ function __installUnpacker() { if (workletHash !== undefined) { let workletFun = workletsCache.get(workletHash); if (workletFun === undefined) { - const initData = objectToUnpack.__initData; - if (globalThis.evalWithSourceMap) { - // if the runtime (hermes only for now) supports loading source maps - // we want to use the proper filename for the location as it guarantees - // that debugger understands and loads the source code of the file where - // the worklet is defined. - workletFun = globalThis.evalWithSourceMap( - '(' + initData!.code + '\n)', - initData!.location!, - initData!.sourceMap! - ); - } else if (globalThis.evalWithSourceUrl) { - // if the runtime doesn't support loading source maps, in dev mode we - // can pass source url when evaluating the worklet. Now, instead of using - // the actual file location we use worklet hash, as it the allows us to - // properly symbolicate traces (see errors.ts for details) - workletFun = globalThis.evalWithSourceUrl( - '(' + initData!.code + '\n)', - `worklet_${workletHash}` - ); - } else { - // in release we use the regular eval to save on JSI calls - // eslint-disable-next-line no-eval - workletFun = eval('(' + initData!.code + '\n)'); - } + workletFun = evaluateWorkletFunction?.() as (() => unknown) | undefined; workletsCache.set(workletHash, workletFun!); } const functionInstance = workletFun!.bind(objectToUnpack); diff --git a/packages/react-native-worklets/src/types.ts b/packages/react-native-worklets/src/types.ts index be3b269ac177..3841af1cda1e 100644 --- a/packages/react-native-worklets/src/types.ts +++ b/packages/react-native-worklets/src/types.ts @@ -85,7 +85,12 @@ export interface WorkletFactory< } export type ValueUnpacker = WorkletFunction< - [objectToUnpack: unknown, category?: string], + [ + objectToUnpack: unknown, + category?: string, + remoteFunctionName?: string, + evaluateWorkletFunction?: () => (() => unknown), + ], unknown >; From a6fed006ef340c12e1d9e187d6cfc36619e62162 Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:24:06 +0200 Subject: [PATCH 13/28] fix(worklets): pass evaluateWorkletFunction from C++ to value unpacker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In SerializableWorklet::toJSValue, creates a jsi::Function that calls rt.evaluateJavaScript() with the worklet code and sourceURL, then passes it as a 4th argument to the value unpacker. This eliminates eval() from the worklet evaluation path entirely. Port of 3.x patch 0082 (eval removal) — C++ portion. --- .../cpp/worklets/SharedItems/Serializable.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.cpp b/packages/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.cpp index 47d9bdcd0b60..dadeb3158a97 100644 --- a/packages/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.cpp +++ b/packages/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.cpp @@ -234,7 +234,24 @@ jsi::Value SerializableWorklet::toJSValue(jsi::Runtime &rt) { std::any_of(data_.cbegin(), data_.cend(), [](const auto &item) { return item.first == "__workletHash"; }) && "SerializableWorklet doesn't have `__workletHash` property"); jsi::Value obj = SerializableObject::toJSValue(rt); - return getValueUnpacker(rt).call(rt, obj, jsi::String::createFromAscii(rt, "Worklet")); + auto initData = obj.asObject(rt).getProperty(rt, "__initData").asObject(rt); + auto code = std::make_shared( + "(" + initData.getProperty(rt, "__reanimated_workletCode").asString(rt).utf8(rt) + "\n)"); + + auto locationValue = initData.getProperty(rt, "location"); + std::string sourceURL = locationValue.isString() ? locationValue.asString(rt).utf8(rt) : "worklet"; + auto evaluateWorkletFunction = jsi::Function::createFromHostFunction( + rt, + jsi::PropNameID::forAscii(rt, "evaluateWorkletFunction"), + 0, + [&](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *, size_t) + -> jsi::Value { return rt.evaluateJavaScript(code, sourceURL); }); + return getValueUnpacker(rt).call( + rt, + obj, + jsi::String::createFromAscii(rt, "Worklet"), + jsi::Value::undefined(), + evaluateWorkletFunction); } jsi::Value SerializableImport::toJSValue(jsi::Runtime &rt) { From 502ea8e5d14fbf5052033926b2001643d1d9b567 Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:24:31 +0200 Subject: [PATCH 14/28] fix(worklets): remove evalWithSourceUrl and evalWithSourceMap from runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the evalWithSourceUrl installation from WorkletRuntimeDecorator and the evalWithSourceMap installation from WorkletHermesRuntime. These are no longer needed since worklet code evaluation now goes through evaluateWorkletFunction passed from C++ via rt.evaluateJavaScript(). Port of 3.x patch 0082 (eval removal) — runtime decorator portion. --- .../WorkletRuntime/WorkletHermesRuntime.cpp | 21 ------------------- .../WorkletRuntimeDecorator.cpp | 17 --------------- 2 files changed, 38 deletions(-) diff --git a/packages/react-native-worklets/Common/cpp/worklets/WorkletRuntime/WorkletHermesRuntime.cpp b/packages/react-native-worklets/Common/cpp/worklets/WorkletRuntime/WorkletHermesRuntime.cpp index 22388b6b7136..2bd07a8e4ec6 100644 --- a/packages/react-native-worklets/Common/cpp/worklets/WorkletRuntime/WorkletHermesRuntime.cpp +++ b/packages/react-native-worklets/Common/cpp/worklets/WorkletRuntime/WorkletHermesRuntime.cpp @@ -67,27 +67,6 @@ WorkletHermesRuntime::WorkletHermesRuntime( debugToken_ = chrome::enableDebugging(std::move(adapter), name); #endif // HERMES_ENABLE_DEBUGGER && !defined(HERMES_V1_ENABLED) -#ifndef NDEBUG - facebook::hermes::HermesRuntime *wrappedRuntime = runtime_.get(); - jsi::Value evalWithSourceMap = jsi::Function::createFromHostFunction( - *runtime_, - jsi::PropNameID::forAscii(*runtime_, "evalWithSourceMap"), - 3, - [wrappedRuntime]( - jsi::Runtime &rt, const jsi::Value &thisValue, const jsi::Value *args, size_t count) -> jsi::Value { - auto code = std::make_shared(args[0].asString(rt).utf8(rt)); - std::string sourceURL; - if (count > 1 && args[1].isString()) { - sourceURL = args[1].asString(rt).utf8(rt); - } - std::shared_ptr sourceMap; - if (count > 2 && args[2].isString()) { - sourceMap = std::make_shared(args[2].asString(rt).utf8(rt)); - } - return wrappedRuntime->evaluateJavaScriptWithSourceMap(code, sourceMap, sourceURL); - }); - runtime_->global().setProperty(*runtime_, "evalWithSourceMap", evalWithSourceMap); -#endif // NDEBUG } WorkletHermesRuntime::~WorkletHermesRuntime() { diff --git a/packages/react-native-worklets/Common/cpp/worklets/WorkletRuntime/WorkletRuntimeDecorator.cpp b/packages/react-native-worklets/Common/cpp/worklets/WorkletRuntime/WorkletRuntimeDecorator.cpp index 914811d73916..a7f3bee9d4fa 100644 --- a/packages/react-native-worklets/Common/cpp/worklets/WorkletRuntime/WorkletRuntimeDecorator.cpp +++ b/packages/react-native-worklets/Common/cpp/worklets/WorkletRuntime/WorkletRuntimeDecorator.cpp @@ -91,23 +91,6 @@ void WorkletRuntimeDecorator::decorate( rt.global().setProperty(rt, "__workletsModuleProxy", std::move(jsiWorkletsModuleProxy)); -#ifndef NDEBUG - auto evalWithSourceUrl = - [](jsi::Runtime &rt, const jsi::Value &thisValue, const jsi::Value *args, size_t count) -> jsi::Value { - auto code = std::make_shared(args[0].asString(rt).utf8(rt)); - std::string url; - if (count > 1 && args[1].isString()) { - url = args[1].asString(rt).utf8(rt); - } - return rt.evaluateJavaScript(code, url); - }; - rt.global().setProperty( - rt, - "evalWithSourceUrl", - jsi::Function::createFromHostFunction( - rt, jsi::PropNameID::forAscii(rt, "evalWithSourceUrl"), 1, evalWithSourceUrl)); -#endif // NDEBUG - jsi_utils::installJsiFunction( rt, "_log", [](jsi::Runtime &rt, const jsi::Value &value) { PlatformLogger::log(stringifyJSIValue(rt, value)); }); From c2c126b34e9d4deda7da9ac3d2d81477069dccbc Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:26:39 +0200 Subject: [PATCH 15/28] chore(worklets): regenerate ValueUnpacker.cpp from modified TS source Regenerated using export-unpackers.js script. The generated code now includes the evaluateWorkletFunction parameter and removes all eval, evalWithSourceUrl, and evalWithSourceMap code paths. --- .../Common/cpp/worklets/Resources/ValueUnpacker.cpp | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/react-native-worklets/Common/cpp/worklets/Resources/ValueUnpacker.cpp b/packages/react-native-worklets/Common/cpp/worklets/Resources/ValueUnpacker.cpp index 661cf4aa3072..bac876e1535b 100644 --- a/packages/react-native-worklets/Common/cpp/worklets/Resources/ValueUnpacker.cpp +++ b/packages/react-native-worklets/Common/cpp/worklets/Resources/ValueUnpacker.cpp @@ -10,21 +10,14 @@ const char ValueUnpackerCode[] = R"DELIMITER__((function () { var workletsCache = new Map(); var handleCache = new WeakMap(); - function valueUnpacker(objectToUnpack, category, remoteFunctionName) { + function valueUnpacker(objectToUnpack, category, remoteFunctionName, evaluateWorkletFunction) { 'use strict'; var workletHash = objectToUnpack.__workletHash; if (workletHash !== undefined) { var workletFun = workletsCache.get(workletHash); if (workletFun === undefined) { - var initData = objectToUnpack.__initData; - if (globalThis.evalWithSourceMap) { - workletFun = globalThis.evalWithSourceMap('(' + initData.code + '\n)', initData.location, initData.sourceMap); - } else if (globalThis.evalWithSourceUrl) { - workletFun = globalThis.evalWithSourceUrl('(' + initData.code + '\n)', "worklet_".concat(workletHash)); - } else { - workletFun = eval('(' + initData.code + '\n)'); - } + workletFun = evaluateWorkletFunction === null || evaluateWorkletFunction === void 0 ? void 0 : evaluateWorkletFunction(); workletsCache.set(workletHash, workletFun); } var functionInstance = workletFun.bind(objectToUnpack); From 1b31671285cf9ee9b99378c813db99fd2553df7a Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:27:42 +0200 Subject: [PATCH 16/28] fix(worklets): update WorkletInitData type and getWorkletCode for Symbol wrapping Updates the WorkletInitData interface to reflect the new __reanimated_workletCodeWrapper structure and makes code optional. Updates getWorkletCode to extract code from the Symbol-wrapped property when available, with fallback to the legacy code property. --- .../src/memory/serializable.native.ts | 10 +++++++++- packages/react-native-worklets/src/types.ts | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/react-native-worklets/src/memory/serializable.native.ts b/packages/react-native-worklets/src/memory/serializable.native.ts index 1caed81ca87c..13f720e42249 100644 --- a/packages/react-native-worklets/src/memory/serializable.native.ts +++ b/packages/react-native-worklets/src/memory/serializable.native.ts @@ -748,7 +748,15 @@ function inaccessibleObject( const WORKLET_CODE_THRESHOLD = 255; function getWorkletCode(value: WorkletFunction) { - const code = value?.__initData?.code; + const initData = value?.__initData; + if (!initData) { + return 'unknown'; + } + // With Symbol-wrapped code, extract from __reanimated_workletCodeWrapper + const wrapper = initData.__reanimated_workletCodeWrapper; + const code = wrapper + ? wrapper[Symbol.for('__reanimated_workletCode')] + : initData.code; if (!code) { return 'unknown'; } diff --git a/packages/react-native-worklets/src/types.ts b/packages/react-native-worklets/src/types.ts index 3841af1cda1e..b877dc5c395b 100644 --- a/packages/react-native-worklets/src/types.ts +++ b/packages/react-native-worklets/src/types.ts @@ -51,7 +51,8 @@ export type WorkletStackDetails = [ export type WorkletClosure = Record; interface WorkletInitData { - code: string; + code?: string; + __reanimated_workletCodeWrapper?: Record; /** Only in dev builds. */ location?: string; /** Only in dev builds. */ From d84a5cef6151a416a08ed47bd009a17f05dee34a Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:29:24 +0200 Subject: [PATCH 17/28] chore(worklets): rebuild plugin bundle with Symbol wrapping changes Regenerated plugin/index.js from the compiled TypeScript source to include the Symbol.for("__reanimated_workletCode") wrapping in the bundled output. --- packages/react-native-worklets/plugin/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-native-worklets/plugin/index.js b/packages/react-native-worklets/plugin/index.js index 1eb7f4025505..476d185d108b 100644 --- a/packages/react-native-worklets/plugin/index.js +++ b/packages/react-native-worklets/plugin/index.js @@ -964,8 +964,11 @@ var require_workletFactory = __commonJS({ (0, assert_1.strict)(pathForStringDefinitions, "[Reanimated] `pathForStringDefinitions` is null."); (0, assert_1.strict)(pathForStringDefinitions.parentPath, "[Reanimated] `pathForStringDefinitions.parentPath` is null."); const initDataId = pathForStringDefinitions.parentPath.scope.generateUidIdentifier(`worklet_${workletHash}_init_data`); + const symbolForWorkletCode = (0, types_12.callExpression)((0, types_12.memberExpression)((0, types_12.identifier)("Symbol"), (0, types_12.identifier)("for")), [(0, types_12.stringLiteral)("__reanimated_workletCode")]); + const workletCodeProperty = (0, types_12.objectProperty)(symbolForWorkletCode, (0, types_12.stringLiteral)(funString), true); + const reanimatedWorkletCodeProperty = (0, types_12.objectProperty)((0, types_12.identifier)("__reanimated_workletCodeWrapper"), (0, types_12.objectExpression)([workletCodeProperty])); const initDataObjectExpression = (0, types_12.objectExpression)([ - (0, types_12.objectProperty)((0, types_12.identifier)("code"), (0, types_12.stringLiteral)(funString)) + reanimatedWorkletCodeProperty ]); const shouldInjectLocation = !(0, utils_1.isRelease)(); if (shouldInjectLocation) { From bad7cc01c4c9f0cbb812edec5dd060aae1f95c46 Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:30:56 +0200 Subject: [PATCH 18/28] chore: scope under @exodus and set versions for RN 0.85 upgrade - @exodus/react-native-reanimated@4.3.0-exodus.0 - @exodus/react-native-worklets@0.9.0-exodus.0 - Updated peerDep: react-native-worklets -> @exodus/react-native-worklets --- packages/react-native-reanimated/package.json | 6 +++--- packages/react-native-worklets/package.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/react-native-reanimated/package.json b/packages/react-native-reanimated/package.json index fb9706ea7bcf..6f772aeb0958 100644 --- a/packages/react-native-reanimated/package.json +++ b/packages/react-native-reanimated/package.json @@ -1,6 +1,6 @@ { - "name": "react-native-reanimated", - "version": "4.3.0", + "name": "@exodus/react-native-reanimated", + "version": "4.3.0-exodus.0", "description": "More powerful alternative to Animated library for React Native.", "keywords": [ "react-native", @@ -98,7 +98,7 @@ "peerDependencies": { "react": "*", "react-native": "0.81 - 0.85", - "react-native-worklets": "0.8.x" + "@exodus/react-native-worklets": "0.9.x" }, "devDependencies": { "@babel/core": "7.28.4", diff --git a/packages/react-native-worklets/package.json b/packages/react-native-worklets/package.json index 04f6f329a22e..ca350cf5fb64 100644 --- a/packages/react-native-worklets/package.json +++ b/packages/react-native-worklets/package.json @@ -1,6 +1,6 @@ { - "name": "react-native-worklets", - "version": "0.9.0-main", + "name": "@exodus/react-native-worklets", + "version": "0.9.0-exodus.0", "description": "The React Native multithreading library", "keywords": [ "react-native", From 7c3bf1a17b45a3dba66c0ab327b4d6a59f370ee1 Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:40:50 +0200 Subject: [PATCH 19/28] chore: fix workspace references for @exodus/ scoped package names - Update build scripts to reference @exodus/react-native-worklets - Update apps/ workspace references to @exodus/react-native-reanimated - Bump tsconfig lib to ES2022 for Object.hasOwn support Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/common-app/package.json | 2 +- apps/macos-example/package.json | 2 +- apps/next-example/package.json | 2 +- apps/tvos-example/package.json | 2 +- package.json | 2 +- packages/react-native-reanimated/package.json | 6 +++--- tsconfig.json | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/common-app/package.json b/apps/common-app/package.json index 18ee878637ef..fc4fb2308299 100644 --- a/apps/common-app/package.json +++ b/apps/common-app/package.json @@ -16,6 +16,7 @@ "react-native": "*" }, "dependencies": { + "@exodus/react-native-reanimated": "workspace:*", "@fortawesome/fontawesome-svg-core": "6.5.2", "@fortawesome/free-solid-svg-icons": "6.5.2", "@fortawesome/react-native-fontawesome": "0.3.2", @@ -38,7 +39,6 @@ "react-native-gesture-handler": "2.28.0", "react-native-mmkv": "4.0.0", "react-native-nitro-modules": "0.31.9", - "react-native-reanimated": "workspace:*", "react-native-safe-area-context": "5.6.1", "react-native-screens": "4.19.0-nightly-20251125-46052f31e", "react-native-svg": "patch:react-native-svg@npm%3A15.15.3#~/.yarn/patches/react-native-svg-npm-15.15.3-0699a4dc13.patch", diff --git a/apps/macos-example/package.json b/apps/macos-example/package.json index eb98024c32a6..cf0e9921c99d 100644 --- a/apps/macos-example/package.json +++ b/apps/macos-example/package.json @@ -11,11 +11,11 @@ "type:check": "tsc --noEmit" }, "dependencies": { + "@exodus/react-native-reanimated": "workspace:*", "common-app": "workspace:*", "react": "19.1.4", "react-native": "0.81.4", "react-native-macos": "patch:react-native-macos@npm%3A0.81.4#~/.yarn/patches/react-native-macos-npm-0.81.4.patch", - "react-native-reanimated": "workspace:*", "react-native-worklets": "0.8.1" }, "devDependencies": { diff --git a/apps/next-example/package.json b/apps/next-example/package.json index 6f34fa9f78c9..be52af2dbe35 100644 --- a/apps/next-example/package.json +++ b/apps/next-example/package.json @@ -15,12 +15,12 @@ "format": "prettier --write --list-different ." }, "dependencies": { + "@exodus/react-native-reanimated": "workspace:*", "expo": "54.0.13", "next": "15.5.4", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.85.0-rc.1", - "react-native-reanimated": "workspace:*", "react-native-web": "0.21.1", "react-native-worklets": "0.8.1", "webpack": "5.102.1" diff --git a/apps/tvos-example/package.json b/apps/tvos-example/package.json index 19e9387fa357..9ec679e5ac48 100644 --- a/apps/tvos-example/package.json +++ b/apps/tvos-example/package.json @@ -13,9 +13,9 @@ "type:check": "tsc --noEmit" }, "dependencies": { + "@exodus/react-native-reanimated": "workspace:*", "react": "19.2.3", "react-native": "npm:react-native-tvos@0.84.1-0", - "react-native-reanimated": "workspace:*", "react-native-worklets": "0.8.1" }, "devDependencies": { diff --git a/package.json b/package.json index 3df798d778da..e69489fe27fa 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "private": true, "scripts": { "build": "husky && yarn build-packages", - "build-packages": "yarn workspace react-native-reanimated build", + "build-packages": "yarn workspace @exodus/react-native-reanimated build", "build-all": "husky && yarn workspaces foreach --all --parallel --topological-dev run build", "format": "yarn format:root && yarn format:workspaces", "format:root": "prettier --cache --write --list-different --ignore-path .gitignore --ignore-path .prettierignore --ignore-path .prettiereslintignore .", diff --git a/packages/react-native-reanimated/package.json b/packages/react-native-reanimated/package.json index 6f772aeb0958..deb8fa1651fc 100644 --- a/packages/react-native-reanimated/package.json +++ b/packages/react-native-reanimated/package.json @@ -34,7 +34,7 @@ "type:check:strict": "yarn type:check:strict:src && yarn type:check:strict:app", "type:check:strict:src": "yarn tsc --noEmit --customConditions react-native-strict-api", "type:check:strict:app": "yarn workspace common-app type:check:strict", - "build": "yarn workspace react-native-worklets build && bob build", + "build": "yarn workspace @exodus/react-native-worklets build && bob build", "circular-dependency-check": "yarn madge --extensions js,jsx --circular lib", "tree-shake:check:web": "yarn is-tree-shakable --resolution web", "validate-peers": "node ../../scripts/validate-compatibility-peer-dependencies.js" @@ -96,9 +96,9 @@ "semver": "^7.7.3" }, "peerDependencies": { + "@exodus/react-native-worklets": "0.9.x", "react": "*", - "react-native": "0.81 - 0.85", - "@exodus/react-native-worklets": "0.9.x" + "react-native": "0.81 - 0.85" }, "devDependencies": { "@babel/core": "7.28.4", diff --git a/tsconfig.json b/tsconfig.json index c2bfab94ea60..86a34d670c45 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "skipLibCheck": true, "allowSyntheticDefaultImports": true, "moduleResolution": "bundler", - "lib": ["es6", "dom", "ES2021"], + "lib": ["es6", "dom", "ES2022"], "esModuleInterop": true, "strict": true, "forceConsistentCasingInFileNames": true, From 9a2b92cc531158ad0291569f401c129dbabfb2da Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:45:13 +0200 Subject: [PATCH 20/28] chore: regenerate lockfile after workspace renames --- yarn.lock | 192 +++++++++++++++++++++++++++--------------------------- 1 file changed, 96 insertions(+), 96 deletions(-) diff --git a/yarn.lock b/yarn.lock index 3b3f10c1efa6..b59cb881e09d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4089,6 +4089,98 @@ __metadata: languageName: node linkType: hard +"@exodus/react-native-reanimated@workspace:*, @exodus/react-native-reanimated@workspace:packages/react-native-reanimated": + version: 0.0.0-use.local + resolution: "@exodus/react-native-reanimated@workspace:packages/react-native-reanimated" + dependencies: + "@babel/core": "npm:7.28.4" + "@babel/preset-env": "npm:7.28.3" + "@react-native/babel-preset": "npm:0.85.0-rc.1" + "@react-native/eslint-config": "npm:0.85.0-rc.1" + "@react-native/jest-preset": "patch:@react-native/jest-preset@npm%3A0.85.0-rc.1#~/.yarn/patches/@react-native-jest-preset-npm-0.85.0-rc.1-b8e9692393.patch" + "@react-native/metro-config": "npm:0.85.0-rc.1" + "@react-native/typescript-config": "npm:0.85.0-rc.1" + "@shopify/flash-list": "npm:2.1.0" + "@testing-library/dom": "npm:10.4.0" + "@testing-library/jest-native": "npm:5.4.3" + "@testing-library/react": "npm:16.1.0" + "@testing-library/react-hooks": "npm:8.0.1" + "@testing-library/react-native": "npm:13.3.3" + "@types/convert-source-map": "npm:2.0.3" + "@types/jest": "npm:30.0.0" + "@types/node": "npm:24.0.14" + "@types/react": "npm:19.2.2" + "@types/react-test-renderer": "npm:19.1.0" + "@types/semver": "npm:7.7.1" + clang-format-node: "npm:1.3.5" + eslint: "npm:9.37.0" + is-tree-shakable: "npm:0.5.0" + jest: "npm:30.2.0" + jest-expo: "npm:52.0.5" + knip: "npm:5.61.3" + madge: "npm:8.0.0" + prettier: "npm:3.6.2" + react: "npm:19.2.3" + react-dom: "npm:19.2.3" + react-native: "npm:0.85.0-rc.1" + react-native-builder-bob: "npm:0.40.13" + react-native-is-edge-to-edge: "npm:^1.3.1" + react-native-svg: "patch:react-native-svg@npm%3A15.15.3#~/.yarn/patches/react-native-svg-npm-15.15.3-0699a4dc13.patch" + react-native-web: "npm:0.21.1" + react-native-worklets: "npm:0.8.1" + react-test-renderer: "npm:19.2.3" + semver: "npm:^7.7.3" + typescript: "npm:5.8.3" + peerDependencies: + "@exodus/react-native-worklets": 0.9.x + react: "*" + react-native: 0.81 - 0.85 + languageName: unknown + linkType: soft + +"@exodus/react-native-worklets@workspace:packages/react-native-worklets": + version: 0.0.0-use.local + resolution: "@exodus/react-native-worklets@workspace:packages/react-native-worklets" + dependencies: + "@babel/cli": "npm:7.28.3" + "@babel/core": "npm:7.28.4" + "@babel/plugin-transform-arrow-functions": "npm:^7.27.1" + "@babel/plugin-transform-class-properties": "npm:^7.28.6" + "@babel/plugin-transform-classes": "npm:^7.28.6" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.28.6" + "@babel/plugin-transform-optional-chaining": "npm:^7.28.6" + "@babel/plugin-transform-shorthand-properties": "npm:^7.27.1" + "@babel/plugin-transform-template-literals": "npm:^7.27.1" + "@babel/plugin-transform-unicode-regex": "npm:^7.27.1" + "@babel/preset-typescript": "npm:^7.28.5" + "@react-native-community/cli": "npm:20.1.0" + "@react-native/eslint-config": "npm:0.83.0" + "@react-native/jest-preset": "patch:@react-native/jest-preset@npm%3A0.85.0-rc.1#~/.yarn/patches/@react-native-jest-preset-npm-0.85.0-rc.1-b8e9692393.patch" + "@types/jest": "npm:30.0.0" + "@types/node": "npm:24.7.0" + "@types/react": "npm:19.2.2" + clang-format-node: "npm:1.3.5" + code-tag: "npm:1.2.0" + convert-source-map: "npm:^2.0.0" + eslint: "npm:9.37.0" + is-tree-shakable: "npm:0.5.0" + jest: "npm:30.2.0" + knip: "npm:5.61.3" + madge: "npm:8.0.0" + prettier: "npm:3.6.2" + react: "npm:19.2.3" + react-native: "npm:0.85.0-rc.1" + react-native-builder-bob: "npm:0.40.13" + semver: "npm:^7.7.4" + typescript: "npm:5.8.3" + peerDependencies: + "@babel/core": "*" + "@react-native/metro-config": "*" + react: "*" + react-native: 0.81 - 0.85 + languageName: unknown + linkType: soft + "@expo/cli@npm:54.0.11": version: 54.0.11 resolution: "@expo/cli@npm:54.0.11" @@ -13698,6 +13790,7 @@ __metadata: "@babel/core": "npm:7.28.4" "@babel/preset-env": "npm:7.28.3" "@babel/runtime": "npm:7.28.4" + "@exodus/react-native-reanimated": "workspace:*" "@fortawesome/fontawesome-svg-core": "npm:6.5.2" "@fortawesome/free-solid-svg-icons": "npm:6.5.2" "@fortawesome/react-native-fontawesome": "npm:0.3.2" @@ -13737,7 +13830,6 @@ __metadata: react-native-gesture-handler: "npm:2.28.0" react-native-mmkv: "npm:4.0.0" react-native-nitro-modules: "npm:0.31.9" - react-native-reanimated: "workspace:*" react-native-safe-area-context: "npm:5.6.1" react-native-screens: "npm:4.19.0-nightly-20251125-46052f31e" react-native-svg: "patch:react-native-svg@npm%3A15.15.3#~/.yarn/patches/react-native-svg-npm-15.15.3-0699a4dc13.patch" @@ -22089,6 +22181,7 @@ __metadata: "@babel/core": "npm:7.28.4" "@babel/preset-env": "npm:7.28.3" "@babel/runtime": "npm:7.28.4" + "@exodus/react-native-reanimated": "workspace:*" "@react-native-community/cli": "npm:20.0.0" "@react-native-community/cli-platform-ios": "npm:20.0.0" "@react-native/babel-preset": "npm:0.81.4" @@ -22101,7 +22194,6 @@ __metadata: react: "npm:19.1.4" react-native: "npm:0.81.4" react-native-macos: "patch:react-native-macos@npm%3A0.81.4#~/.yarn/patches/react-native-macos-npm-0.81.4.patch" - react-native-reanimated: "workspace:*" react-native-worklets: "npm:0.8.1" typescript: "npm:5.8.3" languageName: unknown @@ -24989,6 +25081,7 @@ __metadata: resolution: "next-example@workspace:apps/next-example" dependencies: "@babel/core": "npm:7.28.4" + "@exodus/react-native-reanimated": "workspace:*" "@expo/next-adapter": "npm:6.0.0" "@next/bundle-analyzer": "npm:15.5.4" cypress: "patch:cypress@npm%3A15.4.0#~/.yarn/patches/cypress-npm-15.4.0-c92384506d.patch" @@ -25000,7 +25093,6 @@ __metadata: react: "npm:19.2.3" react-dom: "npm:19.2.3" react-native: "npm:0.85.0-rc.1" - react-native-reanimated: "workspace:*" react-native-web: "npm:0.21.1" react-native-worklets: "npm:0.8.1" start-server-and-test: "npm:2.1.2" @@ -28103,55 +28195,6 @@ __metadata: languageName: node linkType: hard -"react-native-reanimated@workspace:*, react-native-reanimated@workspace:packages/react-native-reanimated": - version: 0.0.0-use.local - resolution: "react-native-reanimated@workspace:packages/react-native-reanimated" - dependencies: - "@babel/core": "npm:7.28.4" - "@babel/preset-env": "npm:7.28.3" - "@react-native/babel-preset": "npm:0.85.0-rc.1" - "@react-native/eslint-config": "npm:0.85.0-rc.1" - "@react-native/jest-preset": "patch:@react-native/jest-preset@npm%3A0.85.0-rc.1#~/.yarn/patches/@react-native-jest-preset-npm-0.85.0-rc.1-b8e9692393.patch" - "@react-native/metro-config": "npm:0.85.0-rc.1" - "@react-native/typescript-config": "npm:0.85.0-rc.1" - "@shopify/flash-list": "npm:2.1.0" - "@testing-library/dom": "npm:10.4.0" - "@testing-library/jest-native": "npm:5.4.3" - "@testing-library/react": "npm:16.1.0" - "@testing-library/react-hooks": "npm:8.0.1" - "@testing-library/react-native": "npm:13.3.3" - "@types/convert-source-map": "npm:2.0.3" - "@types/jest": "npm:30.0.0" - "@types/node": "npm:24.0.14" - "@types/react": "npm:19.2.2" - "@types/react-test-renderer": "npm:19.1.0" - "@types/semver": "npm:7.7.1" - clang-format-node: "npm:1.3.5" - eslint: "npm:9.37.0" - is-tree-shakable: "npm:0.5.0" - jest: "npm:30.2.0" - jest-expo: "npm:52.0.5" - knip: "npm:5.61.3" - madge: "npm:8.0.0" - prettier: "npm:3.6.2" - react: "npm:19.2.3" - react-dom: "npm:19.2.3" - react-native: "npm:0.85.0-rc.1" - react-native-builder-bob: "npm:0.40.13" - react-native-is-edge-to-edge: "npm:^1.3.1" - react-native-svg: "patch:react-native-svg@npm%3A15.15.3#~/.yarn/patches/react-native-svg-npm-15.15.3-0699a4dc13.patch" - react-native-web: "npm:0.21.1" - react-native-worklets: "npm:0.8.1" - react-test-renderer: "npm:19.2.3" - semver: "npm:^7.7.3" - typescript: "npm:5.8.3" - peerDependencies: - react: "*" - react-native: 0.81 - 0.85 - react-native-worklets: 0.8.x - languageName: unknown - linkType: soft - "react-native-safe-area-context@npm:5.6.1": version: 5.6.1 resolution: "react-native-safe-area-context@npm:5.6.1" @@ -28288,49 +28331,6 @@ __metadata: languageName: node linkType: hard -"react-native-worklets@workspace:packages/react-native-worklets": - version: 0.0.0-use.local - resolution: "react-native-worklets@workspace:packages/react-native-worklets" - dependencies: - "@babel/cli": "npm:7.28.3" - "@babel/core": "npm:7.28.4" - "@babel/plugin-transform-arrow-functions": "npm:^7.27.1" - "@babel/plugin-transform-class-properties": "npm:^7.28.6" - "@babel/plugin-transform-classes": "npm:^7.28.6" - "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.28.6" - "@babel/plugin-transform-optional-chaining": "npm:^7.28.6" - "@babel/plugin-transform-shorthand-properties": "npm:^7.27.1" - "@babel/plugin-transform-template-literals": "npm:^7.27.1" - "@babel/plugin-transform-unicode-regex": "npm:^7.27.1" - "@babel/preset-typescript": "npm:^7.28.5" - "@react-native-community/cli": "npm:20.1.0" - "@react-native/eslint-config": "npm:0.83.0" - "@react-native/jest-preset": "patch:@react-native/jest-preset@npm%3A0.85.0-rc.1#~/.yarn/patches/@react-native-jest-preset-npm-0.85.0-rc.1-b8e9692393.patch" - "@types/jest": "npm:30.0.0" - "@types/node": "npm:24.7.0" - "@types/react": "npm:19.2.2" - clang-format-node: "npm:1.3.5" - code-tag: "npm:1.2.0" - convert-source-map: "npm:^2.0.0" - eslint: "npm:9.37.0" - is-tree-shakable: "npm:0.5.0" - jest: "npm:30.2.0" - knip: "npm:5.61.3" - madge: "npm:8.0.0" - prettier: "npm:3.6.2" - react: "npm:19.2.3" - react-native: "npm:0.85.0-rc.1" - react-native-builder-bob: "npm:0.40.13" - semver: "npm:^7.7.4" - typescript: "npm:5.8.3" - peerDependencies: - "@babel/core": "*" - "@react-native/metro-config": "*" - react: "*" - react-native: 0.81 - 0.85 - languageName: unknown - linkType: soft - "react-native@npm:0.81.4": version: 0.81.4 resolution: "react-native@npm:0.81.4" @@ -31854,6 +31854,7 @@ __metadata: "@babel/core": "npm:7.28.4" "@babel/preset-env": "npm:7.28.3" "@babel/runtime": "npm:7.28.4" + "@exodus/react-native-reanimated": "workspace:*" "@react-native/babel-preset": "npm:0.84.1" "@react-native/eslint-config": "npm:0.84.1" "@react-native/metro-config": "npm:0.84.1" @@ -31866,7 +31867,6 @@ __metadata: prettier: "npm:3.6.2" react: "npm:19.2.3" react-native: "npm:react-native-tvos@0.84.1-0" - react-native-reanimated: "workspace:*" react-native-worklets: "npm:0.8.1" react-test-renderer: "npm:19.2.3" typescript: "npm:5.8.3" From 0f1ec8ea52fe6bd770e269e60c657a008a3bf1b0 Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:28:22 +0200 Subject: [PATCH 21/28] fix(android): align worklets references with @exodus/react-native-worklets fork name Our worklets fork is published as @exodus/react-native-worklets and is autolinked under the Gradle subproject name :exodus_react-native-worklets. Update Reanimated's references to match so the build resolves the fork without postinstall patching. - android/build.gradle: rewrite "react-native-worklets" subproject lookups and ":react-native-worklets" project refs to "exodus_react-native-worklets". - android/CMakeLists.txt: switch find_package() and target link from react-native-worklets / react-native-worklets::worklets to exodus_react-native-worklets / exodus_react-native-worklets::worklets. - plugin/index.js: require('@exodus/react-native-worklets/plugin') instead of require('react-native-worklets/plugin'); babel module aliases do not apply to plugin loading. Bumps version to 4.3.0-exodus.1. --- .../react-native-reanimated/android/CMakeLists.txt | 4 ++-- packages/react-native-reanimated/android/build.gradle | 10 +++++----- packages/react-native-reanimated/package.json | 2 +- packages/react-native-reanimated/plugin/index.js | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/react-native-reanimated/android/CMakeLists.txt b/packages/react-native-reanimated/android/CMakeLists.txt index 3f87cab8570b..f5ff690373f4 100644 --- a/packages/react-native-reanimated/android/CMakeLists.txt +++ b/packages/react-native-reanimated/android/CMakeLists.txt @@ -45,7 +45,7 @@ file(GLOB_RECURSE REANIMATED_ANDROID_CPP_SOURCES CONFIGURE_DEPENDS find_package(fbjni REQUIRED CONFIG) find_package(ReactAndroid REQUIRED CONFIG) -find_package(react-native-worklets REQUIRED CONFIG) +find_package(exodus_react-native-worklets REQUIRED CONFIG) add_library(reanimated SHARED ${REANIMATED_COMMON_CPP_SOURCES} ${REANIMATED_ANDROID_CPP_SOURCES}) @@ -99,4 +99,4 @@ target_link_libraries( ReactAndroid::jsi fbjni::fbjni android - react-native-worklets::worklets) + exodus_react-native-worklets::worklets) diff --git a/packages/react-native-reanimated/android/build.gradle b/packages/react-native-reanimated/android/build.gradle index 8167d16df2b2..c8909ce49933 100644 --- a/packages/react-native-reanimated/android/build.gradle +++ b/packages/react-native-reanimated/android/build.gradle @@ -317,8 +317,8 @@ dependencies { if (project == rootProject) { // This is needed for linting in Reanimated's repo. } else { - if (rootProject.subprojects.find { it.name == "react-native-worklets" }) { - implementation project(":react-native-worklets") + if (rootProject.subprojects.find { it.name == "exodus_react-native-worklets" }) { + implementation project(":exodus_react-native-worklets") } else { throw new GradleException("[Reanimated] `react-native-worklets` library not found. Please install it as a dependency in your project. Install `react-native-worklets` with your package manager, i.e. `yarn add react-native-worklets` or `npm i react-native-worklets`. Read the documentation for more details: https://docs.swmansion.com/react-native-reanimated/docs/guides/troubleshooting#unable-to-find-a-specification-for-rnworklets-depended-upon-by-rnreanimated") } @@ -328,14 +328,14 @@ dependencies { preBuild.dependsOn(prepareReanimatedHeadersForPrefabs) if (project != rootProject) { - evaluationDependsOn(":react-native-worklets") + evaluationDependsOn(":exodus_react-native-worklets") afterEvaluate { tasks.named("externalNativeBuildDebug").configure { - dependsOn(findProject(":react-native-worklets").tasks.named("externalNativeBuildDebug")) + dependsOn(findProject(":exodus_react-native-worklets").tasks.named("externalNativeBuildDebug")) } tasks.named("externalNativeBuildRelease").configure { - dependsOn(findProject(":react-native-worklets").tasks.named("externalNativeBuildRelease")) + dependsOn(findProject(":exodus_react-native-worklets").tasks.named("externalNativeBuildRelease")) } tasks.named("clean") { it.finalizedBy(cleanCMakeCache) diff --git a/packages/react-native-reanimated/package.json b/packages/react-native-reanimated/package.json index deb8fa1651fc..13480569ca03 100644 --- a/packages/react-native-reanimated/package.json +++ b/packages/react-native-reanimated/package.json @@ -1,6 +1,6 @@ { "name": "@exodus/react-native-reanimated", - "version": "4.3.0-exodus.0", + "version": "4.3.0-exodus.1", "description": "More powerful alternative to Animated library for React Native.", "keywords": [ "react-native", diff --git a/packages/react-native-reanimated/plugin/index.js b/packages/react-native-reanimated/plugin/index.js index 0f5fd05847d5..0790631d578b 100644 --- a/packages/react-native-reanimated/plugin/index.js +++ b/packages/react-native-reanimated/plugin/index.js @@ -1,4 +1,4 @@ // @ts-ignore plugin type isn't exposed -const plugin = require('react-native-worklets/plugin'); +const plugin = require('@exodus/react-native-worklets/plugin'); module.exports = plugin; From 4cbcc1e170a374eebb9450282cd81e2aa153ec0e Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:02:37 +0200 Subject: [PATCH 22/28] fix(reanimated): align jsVersion constant with published exodus version Read jsVersion from the package's own package.json so it stays in sync with the published `@exodus/react-native-reanimated` fork version. The runtime asserts the JS-side jsVersion matches the native build's REAL_VERSION at startup; previously the constant was hard-coded at upstream `4.3.0` while the fork is published at `4.3.0-exodus.X`, causing ReanimatedError on first turbomodule access. --- .../react-native-reanimated/src/platform-specific/jsVersion.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-native-reanimated/src/platform-specific/jsVersion.ts b/packages/react-native-reanimated/src/platform-specific/jsVersion.ts index bc8488607682..705cc477152e 100644 --- a/packages/react-native-reanimated/src/platform-specific/jsVersion.ts +++ b/packages/react-native-reanimated/src/platform-specific/jsVersion.ts @@ -1,7 +1,8 @@ 'use strict'; +import { version } from '../../package.json'; /** * We hardcode the version of Reanimated here in order to compare it with the * version used to build the native part of the library in runtime. Remember to * keep this in sync with the version declared in `package.json` */ -export const jsVersion = '4.3.0'; +export const jsVersion = version; From 6e0689d1e25264cf77bb993aba8f07d7d7547ac7 Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:03:05 +0200 Subject: [PATCH 23/28] fix(reanimated): inline captured RegExp literals into worklets to avoid cloneRegExp serializer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reanimated 4 ships several CSS-style parsers as worklets whose closures captured module-level RegExp constants (SHADOW_PARTS_REGEX, SHADOW_SPLIT_REGEX, FILTER_REGEX, FILTER_VALUE_REGEX, DROP_SHADOW_REGEX). The Exodus AppSec hardening in `@exodus/react-native-worklets` explicitly throws when a worklet's serializer encounters a captured RegExp instance (`cloneRegExp` in serializable.native.ts), so any render mounting a component with `box-shadow` / `filter` / `drop-shadow` styles via reanimated trips `WorkletsError: RegExp has been disabled`. Move each captured RegExp literal into the worklet body that uses it so the literal lives in the worklet's serialized source and the UI runtime evaluates a fresh RegExp instance when the worklet runs. This bypasses `cloneRegExp` entirely without weakening the AppSec hardening — captured RegExps remain banned everywhere else. - common/utils/parsers.ts: SHADOW_PARTS_REGEX + SHADOW_SPLIT_REGEX moved into parseBoxShadowString. - common/style/processors/filter.ts: FILTER_VALUE_REGEX + DROP_SHADOW_REGEX moved into parseDropShadowString; FILTER_VALUE_REGEX also redeclared in parseFilterProperty (each worklet needs its own copy in its serialized source); FILTER_REGEX moved into parseFilterString. --- .../src/common/style/processors/filter.ts | 22 ++++++++++++++----- .../src/common/utils/parsers.ts | 9 +++++--- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/react-native-reanimated/src/common/style/processors/filter.ts b/packages/react-native-reanimated/src/common/style/processors/filter.ts index 7f8b714d2915..807b5d72bec3 100644 --- a/packages/react-native-reanimated/src/common/style/processors/filter.ts +++ b/packages/react-native-reanimated/src/common/style/processors/filter.ts @@ -11,12 +11,13 @@ import type { import { isLength, isNumber } from '../../utils/guards'; import { processColor } from './colors'; -// Capture filter functions and their content eg "brightness(0.5) opacity(1)" => [["brightness(0.5)", "brightness", "0.5"], ["opacity(1)", "opacity", "1"]] -const FILTER_REGEX = /([\w-]+)\(([^()]*|\([^()]*\)|[^()]*\([^()]*\)[^()]*)\)/g; -// Capture two groups: current transform value and optional unit -> "21.37px" => ["21.37px", "21.37", "px"] + accepts scientific notation like 'e-14' -const FILTER_VALUE_REGEX = /^([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)([a-z%]*)$/; -// Capture drop-shadow parts "10px 5px 5px #888888" => ["10px", "5px", "5px", "#888888"] -const DROP_SHADOW_REGEX = /[^,\s()]+(?:\([^()]*\))?/g; +// NOTE: The RegExp literals previously declared here at module level +// (FILTER_REGEX, FILTER_VALUE_REGEX, DROP_SHADOW_REGEX) are now declared +// inside the worklet bodies that use them. The Exodus AppSec hardening in +// `@exodus/react-native-worklets` blocks cloning of captured RegExp +// instances during worklet serialization; keeping the literal inside the +// worklet keeps it in the worklet's serialized source so each runtime +// evaluates a fresh RegExp instance. type SingleFilterValue = { numberValue: number; @@ -54,6 +55,10 @@ const LENGTH_MAPPINGS = ['offsetX', 'offsetY', 'standardDeviation'] as const; const parseDropShadowString = (value: string): DropShadowValue | null => { 'worklet'; + // Capture two groups: current transform value and optional unit -> "21.37px" => ["21.37px", "21.37", "px"] + accepts scientific notation like 'e-14' + const FILTER_VALUE_REGEX = /^([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)([a-z%]*)$/; + // Capture drop-shadow parts "10px 5px 5px #888888" => ["10px", "5px", "5px", "#888888"] + const DROP_SHADOW_REGEX = /[^,\s()]+(?:\([^()]*\))?/g; const match = value.match(DROP_SHADOW_REGEX) ?? []; const result: DropShadowValue = { offsetX: 0, offsetY: 0 }; let foundLengthsCount = 0; @@ -114,6 +119,8 @@ const parseFilterProperty = ( context: ValueProcessorContext | undefined ): ParsedFilterFunction | null => { 'worklet'; + // Capture two groups: current transform value and optional unit -> "21.37px" => ["21.37px", "21.37", "px"] + accepts scientific notation like 'e-14' + const FILTER_VALUE_REGEX = /^([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)([a-z%]*)$/; // We need to handle dropShadow separately because of its complex structure if (filterName === 'dropShadow') { const dropShadow = parseDropShadow( @@ -180,6 +187,9 @@ const parseFilterString = ( context: ValueProcessorContext | undefined ): FilterArray => { 'worklet'; + // Capture filter functions and their content eg "brightness(0.5) opacity(1)" => [["brightness(0.5)", "brightness", "0.5"], ["opacity(1)", "opacity", "1"]] + const FILTER_REGEX = + /([\w-]+)\(([^()]*|\([^()]*\)|[^()]*\([^()]*\)[^()]*)\)/g; const matches = Array.from(value.matchAll(FILTER_REGEX)); const filterArray: FilterArray = []; diff --git a/packages/react-native-reanimated/src/common/utils/parsers.ts b/packages/react-native-reanimated/src/common/utils/parsers.ts index cca1a5c15b9d..5316d8b08b25 100644 --- a/packages/react-native-reanimated/src/common/utils/parsers.ts +++ b/packages/react-native-reanimated/src/common/utils/parsers.ts @@ -10,11 +10,14 @@ const LENGTH_MAPPINGS = [ 'spreadDistance', ] as const; -const SHADOW_PARTS_REGEX = /(?:[^\s()]+|\([^()]*\))+/g; -const SHADOW_SPLIT_REGEX = /(?:[^,()]+|\([^)]*\))+(?=\s*,|$)/g; - export function parseBoxShadowString(value: string) { 'worklet'; + // NOTE: Captured RegExp literals cannot be cloned by the Exodus AppSec + // `cloneRegExp` hardening in `@exodus/react-native-worklets`. Declaring + // them inside the worklet body keeps the literal in the worklet's + // serialized source so each runtime evaluates a fresh RegExp instance. + const SHADOW_PARTS_REGEX = /(?:[^\s()]+|\([^()]*\))+/g; + const SHADOW_SPLIT_REGEX = /(?:[^,()]+|\([^)]*\))+(?=\s*,|$)/g; if (value === 'none') { return []; } From d86d862bcb74cdf2bd29771d4a55be2118e35efb Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:03:12 +0200 Subject: [PATCH 24/28] fix(worklets): self-reference uses scoped @exodus/react-native-worklets package name `createSerializable.__bundleData.source` is set via `require.resolveWeak('react-native-worklets')` for bundle-mode runtimes, but `babel-plugin-module-resolver` does not rewrite `resolveWeak` arguments. With the fork published as `@exodus/react-native-worklets` Metro fails to resolve the upstream module name. Use the scoped name directly so the self-reference resolves at bundle time. --- .../react-native-worklets/src/memory/serializable.native.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-worklets/src/memory/serializable.native.ts b/packages/react-native-worklets/src/memory/serializable.native.ts index 13f720e42249..39718892eea8 100644 --- a/packages/react-native-worklets/src/memory/serializable.native.ts +++ b/packages/react-native-worklets/src/memory/serializable.native.ts @@ -247,7 +247,7 @@ if (globalThis._WORKLETS_BUNDLE_MODE_ENABLED) { // TODO: Do it programmatically. createSerializable.__bundleData = { imported: 'createSerializable', - source: require.resolveWeak('react-native-worklets'), + source: require.resolveWeak('@exodus/react-native-worklets'), }; } From dc14b69f395f1999fd1301c33a1a47217e270653 Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:03:18 +0200 Subject: [PATCH 25/28] fix(worklets): align jsVersion constant with published exodus version Read jsVersion from the package's own package.json so it stays in sync with the published `@exodus/react-native-worklets` fork version. The runtime asserts the JS-side jsVersion matches the babel plugin's REAL_VERSION at startup; previously the constant was hard-coded at upstream `0.9.0-main` while the fork is published at `0.9.0-exodus.X`, throwing WorkletsError at startup. --- packages/react-native-worklets/src/debug/jsVersion.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-native-worklets/src/debug/jsVersion.ts b/packages/react-native-worklets/src/debug/jsVersion.ts index b7476668b3a4..84b99af9519b 100644 --- a/packages/react-native-worklets/src/debug/jsVersion.ts +++ b/packages/react-native-worklets/src/debug/jsVersion.ts @@ -1,8 +1,10 @@ 'use strict'; +import { version } from '../../package.json'; + /** * We hardcode the version of Worklets here in order to compare it with the * version used to build the native part of the library in runtime. Remember to * keep this in sync with the version declared in `package.json` */ -export const jsVersion = '0.9.0-main'; +export const jsVersion = version; From 9be32ca5a805dd1cc974e2e2eeba75099882ec8f Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:40:44 +0200 Subject: [PATCH 26/28] =?UTF-8?q?chore:=20bump=20versions=20=E2=80=94=20re?= =?UTF-8?q?animated=204.3.0-exodus.2=20+=20worklets=200.9.0-exodus.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/react-native-reanimated/package.json | 2 +- packages/react-native-worklets/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native-reanimated/package.json b/packages/react-native-reanimated/package.json index 13480569ca03..af7fa2d5d50c 100644 --- a/packages/react-native-reanimated/package.json +++ b/packages/react-native-reanimated/package.json @@ -1,6 +1,6 @@ { "name": "@exodus/react-native-reanimated", - "version": "4.3.0-exodus.1", + "version": "4.3.0-exodus.2", "description": "More powerful alternative to Animated library for React Native.", "keywords": [ "react-native", diff --git a/packages/react-native-worklets/package.json b/packages/react-native-worklets/package.json index ca350cf5fb64..572dc8582e8e 100644 --- a/packages/react-native-worklets/package.json +++ b/packages/react-native-worklets/package.json @@ -1,6 +1,6 @@ { "name": "@exodus/react-native-worklets", - "version": "0.9.0-exodus.0", + "version": "0.9.0-exodus.1", "description": "The React Native multithreading library", "keywords": [ "react-native", From b03042e5be48db7653593231c9c6eb5ffec2352f Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Wed, 13 May 2026 02:40:01 +0200 Subject: [PATCH 27/28] feat(featureFlags): make FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS runtime-toggleable --- .../Fabric/updates/AnimatedPropsRegistry.cpp | 2 +- .../NativeModules/ReanimatedModuleProxy.cpp | 13 +++++++++---- .../Common/cpp/reanimated/Tools/FeatureFlags.cpp | 12 ++++++++++++ .../Common/cpp/reanimated/Tools/FeatureFlags.h | 12 ++++++++++++ .../src/PropsRegistryGarbageCollector.ts | 4 ++++ .../src/featureFlags/index.ts | 2 ++ 6 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.cpp index 6d0d7204c522..c08672e6aea5 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.cpp +++ b/packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.cpp @@ -23,7 +23,7 @@ void AnimatedPropsRegistry::update(jsi::Runtime &rt, const jsi::Value &operation const jsi::Value &updates = item.getProperty(rt, "updates"); addUpdatesToBatch(shadowNode, jsi::dynamicFromValue(rt, updates)); - if constexpr (StaticFeatureFlags::getFlag("FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS")) { + if (RuntimeFeatureFlags::forceReactRenderForSettledAnimations()) { timestampMap_[shadowNode->getTag()] = timestamp; } } diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp index 313c66d175d7..d382a76092e1 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp +++ b/packages/react-native-reanimated/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp @@ -343,7 +343,12 @@ jsi::Value ReanimatedModuleProxy::getStaticFeatureFlag(jsi::Runtime &rt, const j jsi::Value ReanimatedModuleProxy::setDynamicFeatureFlag(jsi::Runtime &rt, const jsi::Value &name, const jsi::Value &value) { - reanimated::DynamicFeatureFlags::setFlag(name.asString(rt).utf8(rt), value.asBool()); + const auto flagName = name.asString(rt).utf8(rt); + const auto flagValue = value.asBool(); + if (flagName == "FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS") { + reanimated::RuntimeFeatureFlags::setForceReactRenderForSettledAnimations(flagValue); + } + reanimated::DynamicFeatureFlags::setFlag(flagName, flagValue); return jsi::Value::undefined(); } @@ -528,9 +533,9 @@ void ReanimatedModuleProxy::unregisterCSSTransition(jsi::Runtime &rt, const jsi: } jsi::Value ReanimatedModuleProxy::getSettledUpdates(jsi::Runtime &rt) { - react_native_assert( - StaticFeatureFlags::getFlag("FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS") && - "getSettledUpdates requires FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS static feature flag to be enabled"); + if (!RuntimeFeatureFlags::forceReactRenderForSettledAnimations()) { + return jsi::Array(rt, 0); + } // TODO(future): use unified timestamp const auto currentTimestamp = getAnimationTimestamp_(); diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/Tools/FeatureFlags.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/Tools/FeatureFlags.cpp index 307681d48936..84fd38bc62cc 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/Tools/FeatureFlags.cpp +++ b/packages/react-native-reanimated/Common/cpp/reanimated/Tools/FeatureFlags.cpp @@ -1,5 +1,6 @@ #include +#include #include #include @@ -15,4 +16,15 @@ void DynamicFeatureFlags::setFlag(const std::string &name, bool value) { flags_[name] = value; } +std::atomic RuntimeFeatureFlags::forceReactRenderForSettledAnimations_{ + StaticFeatureFlags::getFlag("FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS")}; + +bool RuntimeFeatureFlags::forceReactRenderForSettledAnimations() { + return forceReactRenderForSettledAnimations_.load(std::memory_order_relaxed); +} + +void RuntimeFeatureFlags::setForceReactRenderForSettledAnimations(bool value) { + forceReactRenderForSettledAnimations_.store(value, std::memory_order_relaxed); +} + } // namespace reanimated diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/Tools/FeatureFlags.h b/packages/react-native-reanimated/Common/cpp/reanimated/Tools/FeatureFlags.h index b42d68554d13..85f7be9a833f 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/Tools/FeatureFlags.h +++ b/packages/react-native-reanimated/Common/cpp/reanimated/Tools/FeatureFlags.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include @@ -41,4 +42,15 @@ class DynamicFeatureFlags { static std::unordered_map flags_; }; +// Per-flag runtime override for select flags that we need to toggle at runtime. +// Defaults to the compile-time StaticFeatureFlags value. Thread-safe via atomic. +class RuntimeFeatureFlags { + public: + static bool forceReactRenderForSettledAnimations(); + static void setForceReactRenderForSettledAnimations(bool value); + + private: + static std::atomic forceReactRenderForSettledAnimations_; +}; + } // namespace reanimated diff --git a/packages/react-native-reanimated/src/PropsRegistryGarbageCollector.ts b/packages/react-native-reanimated/src/PropsRegistryGarbageCollector.ts index f917ce5a8586..1a1507dc7bef 100644 --- a/packages/react-native-reanimated/src/PropsRegistryGarbageCollector.ts +++ b/packages/react-native-reanimated/src/PropsRegistryGarbageCollector.ts @@ -6,6 +6,7 @@ import { } from './common/style/processors/colors'; import type { StyleProps } from './commonTypes'; import type { IAnimatedComponentInternal } from './createAnimatedComponent/commonTypes'; +import { DynamicFlags } from './featureFlags'; import { ReanimatedModule } from './ReanimatedModule'; const FLUSH_INTERVAL_MS = 500; @@ -40,6 +41,9 @@ export const PropsRegistryGarbageCollector = { }, syncPropsBackToReact() { + if (!DynamicFlags.FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS) { + return; + } const settledUpdates = ReanimatedModule.getSettledUpdates(); for (const { viewTag, styleProps } of settledUpdates) { if (styleProps === null) { diff --git a/packages/react-native-reanimated/src/featureFlags/index.ts b/packages/react-native-reanimated/src/featureFlags/index.ts index 6eaf47da9c1d..0879d5b04808 100644 --- a/packages/react-native-reanimated/src/featureFlags/index.ts +++ b/packages/react-native-reanimated/src/featureFlags/index.ts @@ -5,6 +5,7 @@ import type StaticFeatureFlagsJSON from './staticFlags.json'; type DynamicFlagsType = { EXAMPLE_DYNAMIC_FLAG: boolean; + FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS: boolean; init(): void; setFlag(name: DynamicFlagName, value: boolean): void; getFlag(name: DynamicFlagName): boolean; @@ -17,6 +18,7 @@ type DynamicFlagName = keyof Omit< /** @knipIgnore */ export const DynamicFlags: DynamicFlagsType = { EXAMPLE_DYNAMIC_FLAG: true, + FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS: true, init() { Object.keys(DynamicFlags).forEach((key) => { From 584ff4c935c05c7c43d639d66e3c6cf969ce0c24 Mon Sep 17 00:00:00 2001 From: raxodus <251626932+raxodus@users.noreply.github.com> Date: Wed, 13 May 2026 02:40:38 +0200 Subject: [PATCH 28/28] chore: reanimated 4.3.0-exodus.3 --- packages/react-native-reanimated/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-reanimated/package.json b/packages/react-native-reanimated/package.json index af7fa2d5d50c..2cf509b62736 100644 --- a/packages/react-native-reanimated/package.json +++ b/packages/react-native-reanimated/package.json @@ -1,6 +1,6 @@ { "name": "@exodus/react-native-reanimated", - "version": "4.3.0-exodus.2", + "version": "4.3.0-exodus.3", "description": "More powerful alternative to Animated library for React Native.", "keywords": [ "react-native",