Skip to content

fix(HeaderStandardAnimated): inline scroll update to fix Reanimated 4 UI-thread error#1185

Merged
georgewrmarshall merged 4 commits into
mainfrom
fix/reanimated-worklet-ui-thread-error
May 26, 2026
Merged

fix(HeaderStandardAnimated): inline scroll update to fix Reanimated 4 UI-thread error#1185
georgewrmarshall merged 4 commits into
mainfrom
fix/reanimated-worklet-ui-thread-error

Conversation

@georgewrmarshall

@georgewrmarshall georgewrmarshall commented May 22, 2026

Copy link
Copy Markdown
Contributor

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 a useAnimatedScrollHandler callback run on the UI thread and must be compiled as worklets. Previously, updateScrollYFromEvent was 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:

[Reanimated] Tried to synchronously call a non-worklet function
'updateScrollYFromEvent' on the UI thread.

The fix inlines the single-line update directly in the handler callback. Inline arrow functions inside useAnimatedScrollHandler are automatically compiled as worklets by the Reanimated Babel plugin — no 'worklet' directive needed, no Metro cache dependency.

The dead updateScrollYFromEvent function and its unit test have also been removed (see below).

Why unit tests didn't catch this

The test suite mocks Reanimated entirely:

jest.mock('react-native-reanimated', () =>
  jest.requireActual('react-native-reanimated/mock'),
);

This replaces useAnimatedScrollHandler with a stub that never executes the callback passed to it. The worklet body — including the call to updateScrollYFromEvent — 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 useAnimatedScrollHandler or other Reanimated worklet APIs should be verified manually on device/simulator, as unit tests cannot exercise this code path.

Dead code cleanup

After inlining, updateScrollYFromEvent had 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 in jest.config.js have been updated to reflect the true testable surface, with a comment explaining that the onScroll worklet callback is untestable in Jest.

Related issues

Fixes: (regression from #1165)

Manual testing steps

  1. Run yarn storybook:ios on a device/simulator
  2. Navigate to a story that uses HeaderStandardAnimated (e.g. Components/HeaderStandardAnimated/Default)
  3. Scroll the content — confirm no crash and smooth scroll-linked header animation

Screenshots/Recordings

Before

[Reanimated] Tried to synchronously call a non-worklet function
'updateScrollYFromEvent' on the UI thread.

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

  • I've followed MetaMask Contributor Docs
  • I've completed the PR template to the best of my ability
  • I've included tests if applicable
  • I've documented my code using JSDoc format if applicable
  • I've applied the right labels on the PR (see labeling guidelines). Not required for external contributors.

Pre-merge reviewer checklist

  • I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed).
  • I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.

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 helper updateScrollYFromEvent and instead assigns scrollEvent.contentOffset.y directly inside useAnimatedScrollHandler, so the callback can run as a worklet on the UI thread.

Removes the unused updateScrollYFromEvent export and its unit tests, and adjusts jest.config.js coverage thresholds and comments to reflect that the onScroll worklet 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.

@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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.

Create PR

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.

@georgewrmarshall georgewrmarshall self-assigned this May 22, 2026
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.
@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@@ -62,8 +48,9 @@ export function useHeaderStandardAnimated(): UseHeaderStandardAnimatedReturn {
);

const onScroll = useAnimatedScrollHandler({

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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', () =>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@georgewrmarshall georgewrmarshall marked this pull request as ready for review May 25, 2026 18:55
@georgewrmarshall georgewrmarshall requested a review from a team as a code owner May 25, 2026 18:55
@georgewrmarshall georgewrmarshall enabled auto-merge (squash) May 26, 2026 16:42
@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@georgewrmarshall georgewrmarshall merged commit 5cb5ec2 into main May 26, 2026
44 checks passed
@georgewrmarshall georgewrmarshall deleted the fix/reanimated-worklet-ui-thread-error branch May 26, 2026 19:17
@georgewrmarshall georgewrmarshall mentioned this pull request May 29, 2026
18 tasks
georgewrmarshall added a commit that referenced this pull request May 29, 2026
## 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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants