[React Native] Reanimated workletのネイティブクラッシュで見直したこと

React Native / Expo で作っている天気・UV アプリで、UVグラフ上のバッジやラベルを Reanimated でアニメーションさせていたとき、アプリが突然落ちる問題に遭遇した。グラフ表示直後やタブ切り替え直後など、useAnimatedPropsuseAnimatedReaction が初めて動くタイミングで落ちる。以降、これらのフック群をまとめて useAnimated* と表記する。

厄介だったのは、Metro の通常ログに ReferenceError のようなわかりやすいエラーが出ないことだった。JavaScript エラーとして見えるのではなく、iOS / Android 側のネイティブクラッシュとして落ちる。

flowchart TD
  A["useAnimatedProps などが初めて動く"] --> B{"worklet 内で<br/>JS 例外が発生?"}
  B -->|いいえ| C["正常に UI を更新"]
  B -->|はい| D["Hermes が UI スレッドで abort"]
  D --> E["ネイティブクラッシュ"]
  D -. "Metro には出ない" .-> F["ReferenceError は<br/>表示されない"]

今回の件をかなり雑にまとめると、useAnimated* の初回実行時に worklet 側で参照が崩れ、ネイティブクラッシュのように見えていた。対処としては、hot path をローカル worklet に寄せ、純粋ロジックは src/features/ と Vitest 側へ分ける形に落ち着いた。

そのうえで、Reanimated の worklet から呼ぶ関数・定数・import の扱いを、次の方針に整理した。

  • useAnimated* から呼ぶ計算は、できるだけコンポーネントファイル内のローカル worklet に置く
  • worklet から呼ぶ関数には呼び出しチェーン全体で "worklet" を付ける
  • worklet 内のデフォルト引数でモジュール定数を参照しない
  • src/features/ の純粋関数を、UI スレッドの worklet hot path から直接呼ばない
  • 切り出したロジックは Vitest で検証する

何が起きていたか

症状は、useAnimated* が初めて動くタイミングでアプリが突然終了することだった。

  • UVグラフを表示した直後
  • タブを切り替えた直後
  • グラフ上のバッジやラベルのアニメーションが初めて走ったタイミング

通常の JavaScript エラーであれば、Metro 側に ReferenceErrorTypeError が表示される。しかし今回の問題では、Metro 側には決定的なエラーが出ず、アプリだけが落ちる状態になった。

クラッシュログ側を見ると、次のような worklet / Hermes 関連のスタックが含まれていた。

worklets::scheduleOnUI
WorkletRuntime::runGuarded
HermesRuntimeImpl::throwPendingError

この時点で、通常の React コンポーネントのエラーというより、Reanimated worklet が UI スレッド上で実行されたときの例外を疑った。

原因の見立て

結論は、worklet から参照しているものが UI runtime に載っておらず、worklet 内で JavaScript 例外が起きていたということだった。JS スレッド上の例外として見えるのではなく、Hermes が UI スレッド上で abort するため、ネイティブクラッシュのように見える。

Reanimated の worklet は UI スレッドで実行できる形に変換される。そのため JS スレッドでは普通に見える関数や定数でも、UI runtime では closure に載らず undefined になることがある。

具体的には次のパターンに分かれた。

  • "worklet" の付け忘れ … worklet から呼ぶ関数に "worklet" がない(例1)
  • モジュールスコープへの依存 … デフォルト引数や外部定数の参照が closure に載らない(例2)
  • import 経路の問題src/features/ の関数や barrel export を UI スレッドから直接呼ぶ(例3)
  • 定義順の問題 … 同一ファイルでも呼び出し元より後ろに定義した関数を worklet から呼ぶ
flowchart TD
  A["UI runtime に参照が載らない"] --> B["worklet ディレクティブ不足"]
  A --> C["デフォルト引数で<br/>モジュール定数を参照"]
  A --> D["features / barrel export を<br/>worklet から直接参照"]
  A --> E["helper の定義順が後ろ"]

以降、それぞれを実際のコードで見ていく。

例1:worklet から呼ぶ関数に "worklet" がない

最初に疑ったのは、worklet から呼ぶ helper 側に "worklet" が付いていないケースだった。

例えば、次のようなコードは危ない。

function helper(x: number): number {
  return x + 1;
}
 
function compute(x: number): number {
  "worklet";
  return helper(x);
}

compute には "worklet" が付いているが、そこから呼ばれる helper には付いていない。JS スレッドでは普通に見えても、UI スレッド側ではここで崩れる。

修正後は、呼び出しチェーン全体に "worklet" を付ける。

function helper(x: number): number {
  "worklet";
  return x + 1;
}
 
function compute(x: number): number {
  "worklet";
  return helper(x);
}

入口だけでなく、そこから呼ばれる関数まで含めて worklet としてそろえる。

例2:デフォルト引数でモジュール定数を参照しない

このパターンは見た目が普通なので、あとから振り返ると厄介だった。

次のようなコードも避けるようにした。

const LABEL_HALF_WIDTH: number = 24;
 
function clampLabelX(x: number, halfWidth: number = LABEL_HALF_WIDTH): number {
  "worklet";
  return Math.max(halfWidth, x);
}

一見問題なさそうに見えるが、デフォルト引数で参照した LABEL_HALF_WIDTH が UI runtime 側の closure に載らず、未定義になることがある。

そこで、デフォルト引数ではなく関数本体で処理する形に変えた。

const LABEL_HALF_WIDTH: number = 24;
 
function clampLabelX(x: number, halfWidth?: number): number {
  "worklet";
 
  const resolvedHalfWidth: number = halfWidth ?? LABEL_HALF_WIDTH;
 
  return Math.max(resolvedHalfWidth, x);
}

モジュール定数を使うなら、この形の方がまだ事故りにくい。

例3:src/features/ の関数を useAnimated* から直接呼ばない

ここは設計としていちばん迷ったところだった。きれいに共通化したくなるが、実機で落ちるならその時点で負けだった。

プロジェクトではビジネスロジックを src/features/ に置いていたが、useAnimated* からその関数を直接 import して呼ぶと、UI runtime 側で関数が undefined になることがあった。

例えば、次のような構成。

import { resolveAnimatedBadgeLayoutFromTarget } from "../../features/uv/uvChartMarkerValueBadge";
 
const animatedProps = useAnimatedProps(() => {
  "worklet";
 
  const layout = resolveAnimatedBadgeLayoutFromTarget(
    target.value,
    offset.value,
    progress.value,
  );
 
  return {
    x: layout.rectX,
    y: layout.rectY,
  };
});

JS スレッドでは動いても、UI runtime では import した関数がそのまま使えないケースがあった。

ロジックの置き場所を分ける

そこで、ロジックは置き場所ごとに割り切ることにした。

置き場所は次のように分けた。

用途 置き場所
useAnimated* から毎フレーム呼ぶ座標計算 コンポーネントファイル内のローカル worklet
JS スレッドで使う純粋ロジック src/features/
テストしたいロジック src/features/ + Vitest
features と UI の両方で必要な最小計算 features に本体を置き、worklet hot path では最小限だけ複製

要するに、UI スレッドで毎フレーム動くものはコンポーネント側、純粋ロジックは features 側という分け方である。

flowchart TD
  A["ロジックを追加・移動する"] --> B{"useAnimated* から<br/>毎フレーム呼ぶ?"}
  B -->|はい| C["コンポーネント内の<br/>ローカル worklet に置く"]
  B -->|いいえ| D["src/features/ に置いて<br/>Vitest でテストする"]
  C --> E["実機・シミュレータで<br/>該当 UI を必ず確認"]

修正後のイメージ

useAnimatedProps から直接呼ぶ計算は、同じコンポーネントファイル内に置く。

type BadgeLayout = {
  rectX: number;
  rectY: number;
};
 
function computeAnimatedBadgeLayout(
  target: BadgeLayout,
  offsetStart: BadgeLayout,
  progress: number,
): BadgeLayout {
  "worklet";
 
  const factor: number = 1 - progress;
 
  return {
    rectX: target.rectX + offsetStart.rectX * factor,
    rectY: target.rectY + offsetStart.rectY * factor,
  };
}
 
const animatedProps = useAnimatedProps(() => {
  "worklet";
 
  const layout: BadgeLayout = computeAnimatedBadgeLayout(
    badgeTarget.value,
    badgeOffsetStart.value,
    badgeProgress.value,
  );
 
  return {
    x: layout.rectX,
    y: layout.rectY,
  };
});

ここでは 定義順 も気にするようにした。worklet helper は呼び出し元より前に置く。

実装ルール

barrel export 経由の import は避ける

barrel export は便利だが、この用途では切り分けを難しくしやすかった。

// 避ける
import { resolveLayout } from "../../features/uv";

worklet としてどうしても別ファイルから呼ぶ必要がある場合は、実装ファイルから直接 import する。

// まだこちらの方が安全
import { resolveLayout } from "../../features/uv/uvChartMarkerValueLabels";

ただ、useAnimated* の hot path から呼ぶなら、最後はコンポーネントファイル内にローカル worklet として置く方が安心だった。

worklet に載せる処理と載せない処理を分ける

全部を worklet に載せようとしない、というのも今回決めたことの一つだった。

すべてを worklet に載せる必要はない。今回の整理では、次のように分けた。

処理 方針
座標計算 worklet 向き
補間 worklet 向き
opacity / translate などの軽い計算 worklet 向き
React state 更新 runOnJS 経由
複雑な文字列整形 JS スレッド側
RegExp を使ったパース JS スレッド側
オブジェクト SharedValue の各プロパティへ withTiming 避ける
スカラーの progress を withTiming 採用

オブジェクト全体をアニメーションさせるより、progress のようなスカラー値を withTiming し、描画時に補間する方が扱いやすかった。

Vitest で検証する範囲を分ける

テストの分け方も、きれいさより安定性を優先した。

worklet に寄せる処理は UI スレッドで動くため、すべてを通常の単体テストで見切るのは難しい。なので次のように分けた。

  • 複雑な判定・座標計算の本体は src/features/ に置いて Vitest でテストする
  • Reanimated の hot path では、テスト済みロジックを参考にした最小限の worklet をローカルに置く
  • worklet 側に置いた計算は、実機またはシミュレータで該当UIを必ず表示して確認する

最終的なチェックリスト

今回の件以降、Reanimated worklet を触るときは次を確認するようにした。

  • useAnimatedProps / useAnimatedStyle / useAnimatedReaction / useAnimatedScrollHandler の callback に "worklet" がある
  • callback から呼ぶ関数にも "worklet" がある
  • worklet helper は呼び出し元より前に定義している
  • useAnimated* から src/features/ の関数を直接呼んでいない
  • barrel export 経由で worklet helper を import していない
  • worklet 内のデフォルト引数でモジュール定数を参照していない
  • worklet 内で RegExp やオブジェクト SharedValue への withTiming を使っていない
  • 切り出したロジックには Vitest がある
  • 実機またはシミュレータで、該当UIを一度表示している

まとめ

今回の問題は、Metro の通常ログに ReferenceError が出ないため切り分けにくいタイプのクラッシュだった。手がかりになったのはクラッシュログの worklets::scheduleOnUIHermesRuntimeImpl::throwPendingError だった。

最終的な対処は、個別のクラッシュ箇所を直すだけではなく、次の設計ルールに落とし込むことだった。

  • UI スレッド の worklet hot path は、React コンポーネント側にローカル worklet として置く
  • src/features/ は JS スレッドの純粋ロジックとテスト対象として扱う
  • worklet から呼ぶ関数・定数・import 経路を明示的に制限する
  • Metro ログだけでなく、ネイティブクラッシュログも見る

Reanimated worklet は、通常の JavaScript と同じ感覚で関数を切り出すと UI runtime 側でだけ壊れることがある。「共通化できるか」よりも、「UI スレッドに安全に載るか」を先に考える方が安定しやすい。

[MySQL] macOSで久しぶりに触るときの起動確認、設定の見方、ログイン、パスワード再設定まとめ

[MySQL] sakila サンプルデータベースを macOS に導入する