React Native Web を使うと、アプリと Web をひとつのコードベースで回せます。楽です。
ところがネイティブ専用ライブラリをひとつ足すたびに Web が静かに落ちます。それも毎回違うやり方で落ちる。ahha の開発で三度踏んでから、ようやくパターンが見えました。
事例 1 — 黒い画面と、見当違いのエラー
ランディングページの「Web で試す」を押すとアプリのプレビュー画面に飛ぶようにしていました。デプロイして押してみると真っ黒でした。
Uncaught TypeError: Failed to set an indexed property [0] on 'CSSStyleDeclaration': Indexed property setter is not supported.
CSS の話をしているのでスタイルの問題だと思いました。違いました。
原因は数セッション前の UI 改修でした。数字を円形バッジで表示するコンポーネントを、素の View から @shopify/react-native-skia の RadialGradient に変えていたのです。
Skia は Web では CanvasKit(WASM)をロードしないと動きません。それがない状態で RN Web が Canvas 描画を試みてあのエラーが出ました。そしてそのコンポーネントは画面最上部のカードから参照されていました。コンポーネントひとつが画面全体を黒くしたわけです。
なぜ発見が遅れたか
このプロジェクトには、すでに同じ問題を経て作った Web スタブが三つありました。ミニチャート 3 種(ドーナツ・バー・ライン)です。Skia を使うから .web.tsx を別に用意していました。
ところが新しく作ったコンポーネントにはスタブを作っていませんでした。規約があったのに適用しなかったのです。
さらにその間の数セッションでアイコン・スプラッシュ・オンボーディング・タブバーのアニメーションを大量に触っており、その間 Web を一度も開いていませんでした。次のデプロイでまとめて爆発しました。
処方
.web.tsx スタブを作りました。Skia なしで View + Text から同じ見た目を組む方式です。
大事なのはAPI をネイティブ版と完全に一致させることです。props・export 名・型のすべて。そうすれば使用箇所のコードを一行も直さずに済みます。実際このコンポーネントの利用箇所は四か所ありましたが、スタブを足した以外に使用箇所は一行も変えていません。
副産物 — Reanimated も同じエラーを出す
// 危険
<Animated.View style={[styles.tab, focused && { opacity: 1 }]}>
// 安全
<Animated.View style={[styles.tab, focused ? { opacity: 1 } : null]}>
focused && {...} は focused が false のときfalse という値が配列に入ります。Reanimated Web がそれをスタイルとして処理しようとして、同じ CSSStyleDeclaration エラーを出します。
? : にして null が入るようにすれば安全です。ネイティブでは両方とも問題なく動くので、Web でだけ落ちます。
事例 2 — アプリが起動途中で死ぬ
これは Web ではなく iOS で起きましたが、原因の構造が同じ系統なので一緒に書きます。Bare Workflow で設定が反映されない件と同じく、ビルドの手前で何が起きているかを見ないと原因にたどり着けないパターンです。
const Notifications = await import("expo-notifications");
動的 import です。必要なときだけロードすれば初期バンドルが軽くなると考えました。アプリは起動途中でクラッシュしました。
ERROR [Invariant Violation: new NativeEventEmitter() requires a non-null argument.] <global> (node_modules/react-native/Libraries/PushNotificationIOS/PushNotificationIOS.js:67:25) importAll (node_modules/expo/src/async-require/asyncRequireModule.ts:70:73)
PushNotificationIOS? そんなものは使っていません。
連鎖の構造
babel-preset-expoには lazy import 対象のリストがある- そのリストに
PushNotificationIOSが入っている - 動的 import で
expo-notificationsをロードすると、内部の react-native 参照をたどって lazy リストを走査する PushNotificationIOSを遅延ロードしようとする- これは deprecated なモジュールなのでネイティブ側が null
new NativeEventEmitter(null)→ クラッシュ
使ってもいないモジュールが、楽をしようとして書いた動的 import のせいでロードされ、アプリを殺したわけです。
処方
// 禁止
const Notifications = await import("expo-notifications");
// 正
import * as Notifications from "expo-notifications";
await import(...) は楽に見えますが、Reanimated・Expo 系のライブラリでは lazy のトリガを踏んで deprecated なシンボルまで走査させます。ネイティブコアを参照するライブラリは静的 import だけにします。
Metro のクラッシュスタックに importAll や asyncRequireModule が見えたら動的 import を疑えばいい。
事例 3 — import 行で死ぬ
いちばん最近の事例です。App Tracking Transparency を組み込むときにこういうモジュールを作りました。
import { Platform } from "react-native";
import {
getTrackingPermissionsAsync,
requestTrackingPermissionsAsync,
} from "expo-tracking-transparency";
export async function requestTrackingPermission(): Promise<boolean> {
if (Platform.OS !== "ios") return true; // ← Web はここで抜けるはず
}
Platform.OS で分岐したから Web でも安全だと思っていました。Web を開くとこうでした。
Uncaught Error: Cannot find native module 'ExpoTrackingTransparency'
at src/lib/ads/tracking.ts (24:1)
23 | import { Platform } from "react-native";
> 24 | import {
| ^
エラー位置が24 行目、import 文です。当然でした。import は関数呼び出しより先に評価されます。ランタイムの分岐があろうがなかろうが、モジュールを読み込む瞬間に落ちます。
処方 — また .web.ts スタブ
/**
* App Tracking Transparency (ATT) — Web Stub
*
* ATT は iOS 専用フレームワークであり、expo-tracking-transparency は
* Web 実装を提供しない。ネイティブ版を Web で読み込むと、モジュール先頭の
* import 時点で 'Cannot find native module' としてクラッシュする。
* ランタイムの Platform.OS 分岐では防げない(import が先に評価される)。
*
* Web には IDFA の概念がないため常に許可を返し、
* 後続の広告初期化が正常に進むようにする。
*/
export async function requestTrackingPermission(): Promise<boolean> {
return true;
}
シグネチャだけ合わせた空の関数です。これで解決しました。
スポンサーリンク
スポンサーリンク
パターンの整理
| 落ち方 | 原因 | 処方 |
|---|---|---|
| 描画中にクラッシュ | ネイティブのグラフィックライブラリ(Skia 等)を Web で描画 | .web.tsx スタブ(同じ見た目を View で) |
| 起動中にクラッシュ | 動的 import が lazy リストの deprecated シンボルを引き込む | 静的 import に切り替え |
| import 時点でクラッシュ | ネイティブモジュール自体が Web に存在しない | .web.ts スタブ(シグネチャのみ) |
三つ目がいちばんの罠です。Platform.OS 分岐を入れておけば安全だと錯覚しやすいのですが、import が先に評価されるので分岐に到達しません。
Metro のプラットフォーム拡張子の解決
.web.ts / .web.tsx スタブが効く仕組みは Metro のファイル解決規則です。
import { foo } from "./bar";
Web ビルド → bar.web.ts を優先し、なければ bar.ts
ネイティブ → bar.ts
ファイル名さえ合わせておけば import 文はそのままで構いません。使用側はどちらがロードされるか知らなくていい。
運用ルール三つ
1. ネイティブ専用ライブラリを足したら .web スタブを同時に作る
「あとで Web を見るときに」と先送りすると忘れます。追加するその瞬間にスタブも作ります。
2. 大量変更のあとは必ず一度 Web を開く
UI を複数セッションにわたって触ったなら、次に進む前に Web を開いてランディングと主要画面が出るかを見ます。最小限の回帰確認です。私はこれをやらずに数セッション分の変更をまとめて爆発させました。
3. コンソールのエラーを先に読む
CSSStyleDeclaration のエラーを見てスタイルの問題だと当て推量して時間を使いました。エラーメッセージとスタックを最後まで読めば原因ファイルが出ます。推測で処方を繰り返すより速い。
スポンサーリンク
スポンサーリンク
この記事について
自社アプリ ahha(React Native + Expo Bare Workflow・RevenueCat サブスク・AdMob)の開発中に踏んだ内容です。
Metro のプラットフォーム別拡張子の解決優先順位は実測ではなく、動作結果から逆算したものです。Skia の CanvasKit を Web で正しくロードする方法があるかどうかは調査しておらず、スタブで回避する側を選びました。三つの事例はそれぞれ別の時点で発生し、最後の事例のときにようやくパターンとして整理できたものです。



コメント