fix(HeaderStandardAnimated): inline scroll update to fix Reanimated 4 UI-thread error#1185
Conversation
📖 Storybook Preview |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Dead code: updateScrollYFromEvent no longer called in production
- Removed the unused updateScrollYFromEvent function and its test, and adjusted the file’s Jest coverage thresholds to match the remaining untestable worklet line.
Or push these changes by commenting:
@cursor push d4ba8ba5e9
Preview (d4ba8ba5e9)
diff --git a/packages/design-system-react-native/jest.config.js b/packages/design-system-react-native/jest.config.js
--- a/packages/design-system-react-native/jest.config.js
+++ b/packages/design-system-react-native/jest.config.js
@@ -23,15 +23,14 @@
statements: 100,
},
// useAnimatedScrollHandler wraps onScroll in a Reanimated worklet; Jest uses the
- // reanimated mock, which does not execute that worklet body. Scroll logic is covered
- // via updateScrollYFromEvent unit tests, but the hook line that forwards scrollEvent
- // into updateScrollYFromEvent stays uncovered here—so statements/lines/functions sit
- // below 100% while branches remain fully exercised.
+ // reanimated mock, which does not execute that worklet body. As a result, the
+ // inline scroll handler line remains uncovered in unit tests—so statements/lines/
+ // functions sit below 100% while branches remain fully exercised.
'./src/components/HeaderStandardAnimated/useHeaderStandardAnimated.ts': {
branches: 100,
- functions: 75,
- lines: 87,
- statements: 87,
+ functions: 66,
+ lines: 85,
+ statements: 85,
},
// pressed && !isDisabled branch in getPressableStyle is not unit-testable without
// react-test-renderer internals (see https://github.com/MetaMask/metamask-design-system/issues/1182).
diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.test.ts b/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.test.ts
--- a/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.test.ts
+++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.test.ts
@@ -1,12 +1,8 @@
// Third party dependencies.
import { renderHook, act } from '@testing-library/react-native';
-import type { SharedValue } from 'react-native-reanimated';
// Internal dependencies.
-import {
- updateScrollYFromEvent,
- useHeaderStandardAnimated,
-} from './useHeaderStandardAnimated';
+import { useHeaderStandardAnimated } from './useHeaderStandardAnimated';
jest.mock('react-native-reanimated', () =>
jest.requireActual('react-native-reanimated/mock'),
@@ -68,11 +64,6 @@
});
});
- describe('updateScrollYFromEvent', () => {
- it('writes contentOffset.y to the shared value', () => {
- const scrollYValue = { value: 0 } as unknown as SharedValue<number>;
- updateScrollYFromEvent(scrollYValue, 82);
- expect(scrollYValue.value).toBe(82);
- });
- });
+ // onScroll is a Reanimated worklet; the jest reanimated mock does not execute
+ // the worklet body, so the scrollY write is not unit-testable here.
});
diff --git a/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.ts b/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.ts
--- a/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.ts
+++ b/packages/design-system-react-native/src/components/HeaderStandardAnimated/useHeaderStandardAnimated.ts
@@ -1,6 +1,5 @@
// Third party dependencies.
import { useCallback } from 'react';
-import type { SharedValue } from 'react-native-reanimated';
import {
useSharedValue,
useAnimatedScrollHandler,
@@ -10,19 +9,6 @@
import type { UseHeaderStandardAnimatedReturn } from './HeaderStandardAnimated.types';
/**
- * Writes a vertical content offset into the scroll shared value.
- *
- * @param scrollYValue - Shared value for vertical scroll offset.
- * @param contentOffsetY - `contentOffset.y` from the scroll event.
- */
-export function updateScrollYFromEvent(
- scrollYValue: SharedValue<number>,
- contentOffsetY: number,
-) {
- scrollYValue.value = contentOffsetY;
-}
-
-/**
* Hook for managing HeaderStandardAnimated scroll-linked animations.
* Use with HeaderStandardAnimated placed outside the ScrollView as a sibling.
* Use the returned onScroll with Animated.ScrollView for UI-thread scroll updates (zero lag).You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 10ba1e0. Configure here.
The function had zero production callers after inlining the scroll update directly into the useAnimatedScrollHandler worklet. Its only remaining consumer was its own unit test, which was testing an isolated helper disconnected from the real code path. Also lowers jest.config.js coverage thresholds for the file to reflect the true testable surface — the onScroll worklet callback runs on the UI thread and is never invoked by Reanimated's Jest mock.
📖 Storybook Preview |
| @@ -62,8 +48,9 @@ export function useHeaderStandardAnimated(): UseHeaderStandardAnimatedReturn { | |||
| ); | |||
|
|
|||
| const onScroll = useAnimatedScrollHandler({ | |||
There was a problem hiding this comment.
Inline arrow functions passed to useAnimatedScrollHandler are automatically workletized by the Reanimated Babel plugin — no explicit 'worklet' directive is needed. This means the function body executes directly on the UI thread, never crossing the JS/UI boundary. The previous approach called updateScrollYFromEvent (a plain JS function) from this UI-thread context, which Reanimated 4 now rejects with a hard error. See Worklets and migration from 3.x.
| } from './useHeaderStandardAnimated'; | ||
| import { useHeaderStandardAnimated } from './useHeaderStandardAnimated'; | ||
|
|
||
| jest.mock('react-native-reanimated', () => |
There was a problem hiding this comment.
Reanimated must be mocked in Jest because its internals depend on native modules and a separate UI-thread worklet runtime — neither of which exist in Node.js. The mock provided by react-native-reanimated/mock stubs out hooks like useAnimatedScrollHandler so the hook can be rendered, but it does not invoke the callbacks passed to them. This means the onScroll worklet body is never executed by Jest and cannot be unit tested — any bugs inside it will only surface at runtime on a real device or simulator. See Reanimated testing guide.
| functions: 75, | ||
| lines: 87, | ||
| statements: 87, | ||
| functions: 65, |
There was a problem hiding this comment.
Functions coverage is set to 65% (2 of 3 functions covered) because the onScroll inline worklet callback is one of three functions in the file and is never invoked by Jest — see the mock above. Branches remain at 100% since all conditional logic outside the worklet is fully exercised. These thresholds represent the realistic ceiling for this file; raising them would require Jest to execute worklet code, which is not possible with the current Reanimated mock.
📖 Storybook Preview |
## Release 42.0.0 This release curates the changelogs for three published packages into consumer-facing Keep a Changelog entries. It adds the `FlashFilled` icon and `SelectButtonSize` across platforms, adds the `TextField` component to React web, and ships two React Native breaking changes: the `panGestureHandlerProps` removal (part of the `react-native-gesture-handler` v2 migration) and the removal of the variant-based title API from `HeaderBase`/`BottomSheetHeader`. ### 📦 Package Versions - `@metamask/design-system-shared`: **0.20.0** (was 0.19.0) - `@metamask/design-system-react`: **0.24.0** (was 0.23.1) - `@metamask/design-system-react-native`: **0.27.0** (was 0.26.0) > `@metamask/design-tokens`, `@metamask/design-system-tailwind-preset`, and `@metamask/design-system-twrnc-preset` are unchanged in this release. ### 🔄 Shared Type Updates (0.20.0) #### Component Type Additions (#1191, #1177, #1170) **What Changed:** - Added `FlashFilled` to the `IconName` const so the filled lightning bolt is available on both platforms. - Added `SelectButtonSize` so `SelectButton` exposes a semantic size type shared across platforms. - Added `TextFieldPropsShared` for the cross-platform text field input contract. **Impact:** - Additive only — no breaking changes to the shared package. - Continues the ADR-0003/0004 const-object + centralized-types pattern. ### 🌐 React Web Updates (0.24.0) #### Added - Added `TextField` for labeled text entry with optional helper and validation text, exposing `TextFieldSize` and `TextFieldType` (#1170) - Added `FlashFilled` icon (filled lightning bolt) to `IconName` (#1191) ### 📱 React Native Updates (0.27.0) #### Added - Added `FlashFilled` icon (filled lightning bolt) to `IconName` (#1191) - Added `SelectButtonSize` so `SelectButton` exposes a semantic size type (#1177) #### Changed - **BREAKING:** Removed `panGestureHandlerProps` from `BottomSheet` and `BottomSheetDialog` following the migration to the `react-native-gesture-handler` v2 `GestureDetector`/`Gesture.Pan()` API (#1165) - Migration: [From version 0.26.0 to 0.27.0](./packages/design-system-react-native/MIGRATION.md#from-version-0260-to-0270) - **BREAKING:** Removed the variant-based title API from `HeaderBase` and `BottomSheetHeader` — `variant`, `HeaderBaseVariant`, `BottomSheetHeaderVariant`, and `HeaderBase`'s `titleTestID` (#1103) - String titles now render with a centered `HeadingSm` treatment; pass custom `children` for bespoke title layouts and use `textProps.testID` in place of `titleTestID` - Migration: [From version 0.26.0 to 0.27.0](./packages/design-system-react-native/MIGRATION.md#from-version-0260-to-0270) - Reduced the default `SegmentGroup` segment spacing from `gap-3` to `gap-1` for tighter grouped segments (#1194) #### Fixed - Fixed a `HeaderStandardAnimated` runtime crash under React Native Reanimated 4 by inlining the scroll-handler worklet (#1185) - Fixed React Native Web rendering for `BottomSheet`, `BottomSheetOverlay`, `Icon`, and the animated `ButtonAnimated` and `Spinner` components (#1187) ###⚠️ Breaking Changes #### Removed `panGestureHandlerProps` from `BottomSheet` / `BottomSheetDialog` (React Native Only) **What Changed:** - Removed the `panGestureHandlerProps` prop. The components migrated from the deprecated RNGH v1 `PanGestureHandler` JSX component to the v2 `GestureDetector` + `Gesture.Pan()` API. - `simultaneousHandlers` (the only real-world use case) was never wired up under the old shim, so no working behavior is lost. **Migration:** ```tsx // Before (0.26.0) <BottomSheet goBack={goBack} panGestureHandlerProps={{ simultaneousHandlers: scrollViewRef }} > {children} </BottomSheet> // After (0.27.0) <BottomSheet goBack={goBack}>{children}</BottomSheet> ``` #### Removed variant-based title API from `HeaderBase` / `BottomSheetHeader` (React Native Only) **What Changed:** - Removed `variant`, `HeaderBaseVariant`, `BottomSheetHeaderVariant`, and `HeaderBase`'s `titleTestID`. - String titles now render with a centered `HeadingSm` treatment; custom layouts use `children`, and `titleTestID` is replaced by `textProps.testID`. **Migration:** ```tsx // Before (0.26.0) import { HeaderBase, HeaderBaseVariant } from '@metamask/design-system-react-native'; <HeaderBase variant={HeaderBaseVariant.Display} titleTestID="title"> Account details </HeaderBase> // After (0.27.0) import { HeaderBase } from '@metamask/design-system-react-native'; <HeaderBase textProps={{ testID: 'title' }}>Account details</HeaderBase> ``` See migration guide for complete instructions: - [React Native Migration Guide — 0.26.0 to 0.27.0](./packages/design-system-react-native/MIGRATION.md#from-version-0260-to-0270) ### 🔗 Consumer note: MetaMask Mobile MetaMask Mobile currently consumes `@metamask/design-system-react-native@0.20.0` and applies a local yarn patch — `.yarn/patches/@metamask-design-system-react-native-npm-0.20.0-2ae4d6f1dd.patch` — to migrate `BottomSheetDialog` from `PanGestureHandler` to `GestureDetector` for its React Native Reanimated 4 upgrade ([MetaMask/metamask-mobile#29470](MetaMask/metamask-mobile#29470)). This release **upstreams that exact migration natively** (#1165). Once mobile bumps to `0.27.0`, it can **drop that yarn patch** — the package now uses the RNGH v2 `GestureDetector`/`Gesture.Pan()` API on its own. Compatibility note: the package keeps `react-native-reanimated` at `peerDependencies: >=3.17.0` (unchanged). It was validated against mobile's current Reanimated 3.19 and is forward-compatible with the incoming Reanimated 4.1.x (the #1185 worklet fix works on both), so no peer-dependency bump is required. ### ✅ Checklist - [x] Changelogs updated with human-readable descriptions - [x] Changelog validation passed (`yarn changelog:validate`) - [x] Version bumps follow semantic versioning - [x] design-system-shared: minor (0.19.0 → 0.20.0) - additive type exports - [x] design-system-react: minor (0.23.1 → 0.24.0) - new `TextField` component + icon - [x] design-system-react-native: minor (0.26.0 → 0.27.0) - pre-1.0 minor with breaking changes - [x] Breaking changes documented with migration guidance - [x] Migration guides updated with before/after examples (if breaking changes) - [x] PR references included in changelog entries ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) - [x] I've reviewed the [Release Workflow](./.cursor/rules/release-workflow.md) cursor rule - [ ] All tests pass (`yarn build && yarn test && yarn lint`) - [x] Changelog validation passes (`yarn changelog:validate`) ## **Pre-merge reviewer checklist** - [ ] I've reviewed the [Reviewing Release PRs](./docs/reviewing-release-prs.md) guide - [ ] Package versions follow semantic versioning - [ ] Changelog entries are consumer-facing (not commit message regurgitation) - [ ] Breaking changes are documented in MIGRATION.md with examples - [ ] All unreleased changes are accounted for in changelogs <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > No runtime code in the diff, but the published React Native 0.27.0 changelog documents breaking BottomSheet and Header API changes that require consumer migrations. > > **Overview** > **Release 42.0.0** bumps the monorepo to **42.0.0** and publishes **@metamask/design-system-shared@0.20.0**, **@metamask/design-system-react@0.24.0**, and **@metamask/design-system-react-native@0.27.0** with finalized Keep a Changelog entries and compare links. > > The diff is mostly **release packaging**: version fields in root and package `package.json` files, new changelog sections for those versions, and doc updates. **React Native** docs drop **`panGestureHandlerProps`** from `BottomSheet` / `BottomSheetDialog` READMEs; **`MIGRATION.md`** adds a **0.26.0 → 0.27.0** section (bottom-sheet gesture prop removal, header title API) and moves **BannerBase** guidance under **0.24.0 → 0.25.0**. > > What consumers get in this release (documented in changelogs, not new code in this PR): shared **`FlashFilled`**, **`SelectButtonSize`**, **`TextFieldPropsShared`**; web **`TextField`** and **`FlashFilled`**; native **`FlashFilled`**, **`SelectButtonSize`**, two **breaking** API removals (`panGestureHandlerProps`, header **`variant`** / **`titleTestID`**), tighter **`SegmentGroup`** spacing, Reanimated 4 / RN Web fixes. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6710621. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->


Description
Fixes a runtime crash introduced by the React Native 0.81.5 / Reanimated 4.1.x upgrade (PR #1165).
Reanimated 4 enforces strict UI-thread isolation via
react-native-worklets. Functions called from within auseAnimatedScrollHandlercallback run on the UI thread and must be compiled as worklets. Previously,updateScrollYFromEventwas called from the scroll handler but was a plain JS function — Reanimated 3 was lenient about this boundary, but Reanimated 4 throws a hard error:The fix inlines the single-line update directly in the handler callback. Inline arrow functions inside
useAnimatedScrollHandlerare automatically compiled as worklets by the Reanimated Babel plugin — no'worklet'directive needed, no Metro cache dependency.The dead
updateScrollYFromEventfunction and its unit test have also been removed (see below).Why unit tests didn't catch this
The test suite mocks Reanimated entirely:
This replaces
useAnimatedScrollHandlerwith a stub that never executes the callback passed to it. The worklet body — including the call toupdateScrollYFromEvent— never runs in Jest. The UI-thread isolation error only surfaces at runtime when a real scroll event fires and Reanimated attempts to invoke a non-worklet function across the JS/UI thread boundary.This is a known limitation of testing Reanimated worklets. There is no clean Jest-based solution — the community generally accepts that worklet execution paths require manual testing or integration/E2E tests (e.g. Maestro, Detox, or Storybook-driven automation). Any future changes to components that use
useAnimatedScrollHandleror other Reanimated worklet APIs should be verified manually on device/simulator, as unit tests cannot exercise this code path.Dead code cleanup
After inlining,
updateScrollYFromEventhad zero production callers — its only consumer was its own unit test, which was testing an isolated helper disconnected from the real code path. The function and its test have been removed. Coverage thresholds injest.config.jshave been updated to reflect the true testable surface, with a comment explaining that theonScrollworklet callback is untestable in Jest.Related issues
Fixes: (regression from #1165)
Manual testing steps
yarn storybook:ioson a device/simulatorHeaderStandardAnimated(e.g.Components/HeaderStandardAnimated/Default)Screenshots/Recordings
Before
App crashes on scroll.
Simulator.Screen.Recording.-.iPhone.15.Pro.-.2026-05-22.at.11.25.47.mov
After
No error. Scroll-linked animation works correctly.
Simulator.Screen.Recording.-.iPhone.15.Pro.-.2026-05-22.at.11.07.00.mov
Pre-merge author checklist
Pre-merge reviewer checklist
Note
Medium Risk
Small behavioral change on a UI-thread scroll path that Jest cannot execute; verify scroll-linked header animation on device after the Reanimated upgrade.
Overview
Fixes a Reanimated 4 runtime crash when scrolling with
HeaderStandardAnimated: the scroll handler no longer calls the plain JS helperupdateScrollYFromEventand instead assignsscrollEvent.contentOffset.ydirectly insideuseAnimatedScrollHandler, so the callback can run as a worklet on the UI thread.Removes the unused
updateScrollYFromEventexport and its unit tests, and adjustsjest.config.jscoverage thresholds and comments to reflect that theonScrollworklet body is not exercised under the Reanimated Jest mock.Reviewed by Cursor Bugbot for commit ed8796b. Bugbot is set up for automated code reviews on this repo. Configure here.