メインコンテンツまでスキップ

Expo SDK 50アップグレード

参考

以下の記事を参考にして、このアプリのExpo SDKを50にアップグレードしました。 主な変更点とこのアプリで実施したアップグレード手順を紹介します。

なお、使用される可能性の低いEASとReact Native Webに関する内容は記載しません。

Expo SDK 50の主な変更

React Native v0.73.6

詳細は、以下のリンク先を参照してください。

Reactのバージョンは変更されておらず、依然としてv18.2.0です。

非推奨となっていた以下のコンポーネントは、React Native v0.73.0で削除されました。以下に挙げるコミュニティライブラリなどへの移行が必要です。

プラットフォームに関連する破壊的変更

Android、iOSのプラットフォーム関連で以下のような変更がされています。

  • Androidの最低サポートバージョンが、Android 6 (API 23)に変更されました
  • iOSの最低サポートバージョンが、13.4に変更されました
  • Android Gradle Pluginのバージョンが8になり、Java 17が必須となりました
    • Androidアプリをビルドする場合は、Java 17をインストールする必要があります
  • Node.jsのバージョンは18以上が必須となりました
  • React Nativeのテンプレートに含まれるAndroid用のMain*クラスがJavaからKotlinに変更されました
    • MainApplication.javaMainApplication.kt
    • MainActivity.javaMainActivity.kt

Dev toolsの改善

デバッグに利用できるDev tools pluginが導入されました。以下のようなライブラリでの開発を効率化するためのプラグインが公開されているようです。

サンプルアプリではまだ利用していませんが、これらのライブラリを利用している場合はDev toolsの利用を検討してみると良さそうです。

@react-native-picker/pickerのiOSでの振る舞い変更

@react-native-picker/pickerが、iOSではvalueを必ず文字列化するように変更されました。そのため、以下のようなコードでもiOSではsetCountに文字列が渡されます

const CountPicker = () => {
const [count, setCount] = useState<number>();
return (
<Picker selectedValue={count} onValueChange={setCount}>
<Picker.Item label="Count 1" value={1} />
<Picker.Item label="Count 2" value={2} />
</Picker>
);
};

この変更の影響に関して、以下のようなIssueが報告されています。valueとして文字列以外を渡している場合は注意してください。

例えば、以下のようにPickerを実装している場合、この変更によって項目を選択できなくなってしまうので注意してください。

import {useCallback,useState} from "react";
import {Picker} from "@react-native-picker/picker";

type ItemType = {value: number};
const ITEMS = [{value: 1}, {value: 2}, {value: 3}, {value: 4}];
const DEFAULT = ITEMS[0];
const CountPicker = () => {
const [selected, setSelected] = useState<ItemType>(DEFAULT);
const onValueChange = useCallback(
// iOSでは、選択された値(sel)として文字列化されたvalueが渡されてしまう。
// そのため、find内での比較で一致するものが見つからず、結果としてselectedが変更されない
(sel: number) => setSelected(ITEMS.find(item => item.value === sel) ?? DEFAULT),
[],
);
return (
<Picker selectedValue={selected?.value} onValueChange={onValueChange}>
{ITEMS.map(item => (
<Picker.Item label={String(item.value)} value={item.value} />
))}
</Picker>
);
};

なお、@react-native-picker/pikerをラップしているreact-native-picker-selectでは、数値をvalueとして渡していると項目が選択できなくなります。

ネイティブプロジェクトの更新ヘルパー

ネイティブプロジェクトのアップグレードの参考になるように、Native project upgrade helperがExpoから提供されるようになりました。

React Nativeコミュニティのプロジェクトテンプレート向けには、以前からReact Native Upgrade Helperが提供されていましたが、そのExpoテンプレート版になります。

たとえば、SDK 49からSDK 50への修正にあたって更新されたExpoテンプレートのネイティブプロジェクトファイルは、以下のURLで確認できます。

https://docs.expo.dev/bare/upgrade/?fromSdk=49&toSdk=50

自分たちで管理しているネイティブプロジェクトファイルに対してこれらの修正を加えることで、テンプレートの状態をSDK 50のデフォルト状態に合わせることができます。

このアプリのようにExpo prebuildを利用したContinuous Native Generation (CNG)を採用している場合、これらの変更はネイティブプロジェクトの生成時に自動的に反映されます。そういった場合でも、具体的にどのような変更が加わったのかを把握するために活用できます。

URLおよびURLSearchParamsの組み込み

React NativeでURLURLSearchParamの全機能を利用するためには、react-native-url-polyfillなどのポリフィルを利用する必要がありました。

Expo SDK 50ではこれらの標準APIがExpoのランタイムに組み込まれたので、ポリフィルは不要になりました。

詳細はURL - Expo Documentationを参照してください。

Xcodeからアプリを起動した場合、Metro Bundlerが起動しないように変更

Xcodeからアプリを起動したり、ビルドした場合にMetro Bundlerが自動的には起動しなくなりました。ビルドの際は特に問題ありませんが、アプリを起動する際は別途Metro Bundlerを立ち上げる必要があります。

Expoがサポートするライブラリや機能

以下に挙げる変更点の詳細やその他の変更については、ExpoのChangelogを参照してください。

ライブラリの更新

  • @expo/vector-icons
    • react-native-vector-icons@10.0.0を使うように更新されました
    • Ioniconsをアイコンとして利用する場合のアイコン名が変更され、md-およびios-というプレフィックスが削除されました
  • expo-router v3
  • expo-font
    • Config Pluginを利用して、フォントをネイティブリソースとして追加できるようになりました。詳細については、ExpoのFontについてのガイドも参考にしてください。
  • expo-secure-store
    • 同期的に処理するgetItemsetItemが追加されました。
    • キーが存在しない場合の振る舞いがiOSとAndroidで統一され、nullを返すようになりました。修正前はAndroidでは例外が送出されていました。
    • iOSで顔認証を利用する場合に必要となるNSFaceIDUsageDescriptionを、app.config.jsで設定できるようになりました。詳細はExpo SecureStoreを参照してください。
  • @expo/metro-config
    • tsconfig.jsonで設定したpathsが自動で読み込まれるようになり、設定なしでもpath aliasが正しく解決されるようになりました。
    • ただし、Jestの実行時などには有効にはならないようです。
  • babel-preset-expo
    • React Native ReanimatedのBabelプラグインが自動的に有効化されるようになりました。
    • react-native-vector-icons@expo/vector-iconsにエイリアスする設定がMetro Resolverに移動されました。 それに伴って、babel-plugin-module-resolverが依存関係から取り除かれました。
      • このアプリではJestなどでもpath aliasを扱えるようにbabel-preset-module-resolverを利用する必要があります。そのため、devDependenciesに明示的に追加して対応しました。

ライブラリの新機能

  • expo-sqlite/next
    • 全面的に書き直され、同期APIやPrepared Statement、Blobデータ型などが利用できるようになりました。
    • SDK 50ではexpo-sqlite/nextとして公開されていますが、SDK 51ではexpo-sqliteがこの新SDKに置き換えられています。
  • expo-camera/next
    • Androidでは、推奨されているCameraXを利用するように変更されています。
    • iOS向けには、DataScannerViewControllerのサポートが追加されました。
    • 顔検出機能など利用される頻度が低い機能は削除されました。frame processorを利用するなどの高度な用途向けにはreact-native-vision-cameraの利用が推奨されています。
    • SDK 50ではexpo-camera/nextとして公開されていますが、SDK 51ではexpo-cameraがこの新SDKに置き換えられています。
    • 詳細はexpo-camera/next is ready for a close upを参照してください。
  • @expo/fingerprint
    • npx @expo/fingerprint .で、ネイティブモジュールなども含めたアプリのフィンガープリントを取得できるようになりました。
    • この機能を利用することで、ビルドされたアプリとJavaScriptバンドルが整合性のあるバージョンとなっているかを検証できるようになります。

ライブラリの廃止

このアプリで実施したアップグレードの手順

このアプリでは、Expo 50へのアップグレード方法を参考に、以下の作業を実施してExpo SDK 50にアップグレードしました。

アップグレードを実施したプルリクエストはPull Request #1321 · ws-4020/mobile-app-crib-notesです。

  1. Expoのアップグレード
  2. Expoが管理するライブラリのアップグレード
  3. バージョンの整合性が取れなくなったライブラリのアップグレード
  4. 依存ライブラリから除外されたライブラリのインストール
  5. 不要となったライブラリのアンインストール
  6. React Navigationのバージョンを更新
  7. npx expo-doctorでバージョン整合性を確認
  8. 既存パッチファイルの更新
  9. ビルドなどに利用するツールのバージョンを更新
  10. Config Pluginの修正
  11. 静的解析エラーの修正
  12. 自動テストの失敗を修正
  13. @react-native-picker/pickerの変更に伴う修正
  14. Expoの更新履歴確認と対応
  15. React Nativeの更新履歴確認と対応
  16. expo-template-blank-typescriptの更新履歴確認と対応
  17. expo-template-bare-minimumの更新履歴確認と対応
  18. ライセンス情報を更新

Expoのアップグレード

npm install expo@^50.0.0を実行して、Expo SDK 50のexpoパッケージをインストールします。

Expoが管理するライブラリのアップグレード

npx expo install --fixを実行して、Expoが管理するライブラリをアップグレードします。ただ、今回は一部のパッケージが更新されず、後で実行するnpx expo-doctorで検知されました。

それらのパッケージは手動で後ほどアップグレードします。

バージョンの整合性が取れなくなったライブラリのアップグレード

Expoが管理するライブラリに依存しているライブラリの一部でバージョンの整合性が取れなくなってしまったため、整合性の取れるバージョンに更新しました。

  • react-native-qrcode-svg
    • このライブラリが依存しているreact-native-svgが14系に更新されたため、対応するバージョンに更新しました。
    • npm i react-native-qrcode-svg@~6.3.0
  • @expo/config-plugins
    • npm i -D @expo/config-plugins@~7.9.2

依存ライブラリから除外されたライブラリのインストール

リリースノートには記載がなかったのですが、SDK 49ではexpoの依存関係に含まれていたexpo-applicationが、SDK 50では含まれなくなっていました。そのため、このアプリの依存関係に含めるように修正しました。

  • npx expo install expo-application

またライブラリの更新でも触れていますが、babel-preset-expoの更新でbabel-plugin-module-resolverが依存関係に含まれなくなりました。このアプリではJest実行時などにもpath aliasを解決する必要がありbabel.config.jsでの設定を削除できなかったため、依存関係に含めるように修正しました。

  • npm i -D babel-plugin-module-resolver

不要となったライブラリのアンインストール

URLURLSearchParamがExpoに組み込まれ、react-native-url-polyfillは不要となったためアンインストールしました。

  • npm uninstall react-native-url-polyfill
  • SantokuAppルートディレクトリのindex.jsからimport 'react-native-url-polyfill/auto';を削除

React Navigationのバージョンを更新

今回のExpo SDKバージョンアップに必須ではありませんでしたが、リグレッションテストの効率などを考慮してあわせてアップグレードしました。

npm i @react-navigation/bottom-tabs@~6.6.0 @react-navigation/drawer@~6.7.0 @react-navigation/native-stack@~6.11.0 @react-navigation/stack@~6.4.0

npx expo-doctorでバージョン整合性を確認

npx expo-doctorで確認したところ、いくつかのライブラリのバージョンについて警告が出力されました。

 ❯❯❯ npx expo-doctor
...略...
The following packages should be updated for best compatibility with the installed expo version:
@types/react@18.2.31 - expected version: ~18.2.45
babel-preset-expo@9.5.2 - expected version: ^10.0.0
jest-expo@49.0.0 - expected version: ~50.0.4
typescript@5.1.6 - expected version: ^5.3.0
Your project may not work correctly until you install the correct versions of the packages.
Found outdated dependencies
Advice: Use 'npx expo install --check' to review and upgrade your dependencies.

通常は、アドバイス通りnpx expo install --checkを実行して正しいバージョンに修正するのが望ましいです。しかし、このアプリではTypeScriptが他ライブラリ(msw@1.3.2)と競合してしまったため~5.1.3のままとする必要がありました。 そのため、以下のコマンドでバージョンを更新しました。

  • npm i -D @types/react@~18.2.45 babel-preset-expo@^10.0.0 jest-expo@^50.0.4

既存パッチファイルの更新

このアプリでは、patch-packageを使用して、以下のライブラリにパッチファイルを適用していました。 パッチ内容の詳細は、こちらを参照してください。

  • @expo/config-plugins
  • expo-splash-screen
  • react-native-elements

expo-splash-screenは、Expo SDKのアップグレードに伴いバージョンが上がりました。しかし、適用していたパッチファイルはまだ必要な対応だったため、パッチファイルは削除せずに各ライブラリのバージョンに合わせてファイル名をリネームしました。

@expo/config-pluginsはまだ必要な対応だったのですが、バージョンの更新に起因してパッチが失敗するようになりました。修正内容には変更はなかったため、利用しているバージョンでのパッチが成功するようにパッチファイルを作成し直して対応しました。

react-native-elementsに関しては、バージョンが変わらなかったため変更はありません。

ビルドなどに利用するツールのバージョンを更新

Expo SDK 50から、Javaなどの必要バージョンが変更されています。このアプリの使っているビルド環境にインストールされているRubyのバージョンも変更されていたため、Rubyについてもバージョンを変更しました。 また、npm run pod-installを実行したところFirebase SDKのインストールにCocoaPods 1.12以上が必要というエラーが表示されました。この対応として、CocoaPodsのバージョンも更新しました。

  • Java: JDK 17
  • Ruby: 3.1.6
  • CocoaPods: 1.15.2

Config Pluginの修正

このアプリでは、以下の変更のためにExpo config pluginでAndroidのMainApplication.javaMainActivity.javaに修正を加えていました。

  • スプラッシュスクリーン表示用のアクティビティ追加
  • テスト用のネイティブモジュール追加

今回のアップグレードでJavaファイルではなくKotlinファイルが利用されるようになったため、Config Pluginに修正を加えて対応しました。

  • テンプレートファイルの修正
    • スプラッシュスクリーン表示用のアクティビティとして用意していたテンプレートファイル(MainActivity.java)をKotlin(MainActivity.kt)に変更
    • テンプレートファイルのコピー処理の対象拡張子をjavaからktに変更
    • 関連ファイル
      • config/plugin/src/android/withAndroidAppActivity.ts
      • config/plugin/src/android/withAndroidCopyMainActivity.ts
  • ネイティブモジュール追加のコード片を修正
    • MainApplication.javaではなくMainApplication.ktを対象に処理を実施するように修正
    • 置換用のコード片がJava用になっていたので、Kotlin用のものを追加
      • 関連ファイル
        • config/plugin/src/android/withAndroidAddNativeModulePakcages.ts

静的解析エラーの修正

削除されたAPIをモックから削除

今回のアップグレードで、expo-splash-screenからhidepreventAutoHideというAPIが廃止されていました。

Jestでのテスト用にexpo-splash-screenのモックを用意しているのですが、そこで型エラーとなっていたのでモックしていた部分を削除して対応しました。

 import SplashScreen from 'expo-splash-screen';

export const preventAutoHideAsync = jest.fn(() => Promise.resolve(true));
export const hideAsync = jest.fn(() => Promise.resolve(true));
-export const hide = jest.fn();
-export const preventAutoHide = jest.fn();

const mock: jest.Mocked<typeof SplashScreen> = {
preventAutoHideAsync,
hideAsync,
- hide,
- preventAutoHide,
};

Object.defineProperty(__mocks, 'expoSplashScreen', {value: mock});

export default mock;

React Native Reanimatedで廃止予定となった型の修正

React Native Reanimatedのv3.5.0で、Reanimated.AnimatePropsが廃止予定とされました。

デフォルトエクスポートからAnimatePropsを利用するのではなく、名前付きエクスポートされたAnimatedProps(微妙に型名が変わっていることに注意)を利用するように修正しました。

AnimatedPropsを利用するように修正
 import {composePressableStyles} from 'bases/core/utils/composePressableStyles';
import React, {useMemo} from 'react';
import {Modal as RNModal, ModalProps, Pressable, PressableProps, StyleSheet, ViewProps} from 'react-native';
-import Reanimated, {WithTimingConfig} from 'react-native-reanimated';
+import {AnimatedProps, WithTimingConfig} from 'react-native-reanimated';

import {useModalBackdrop} from './useModalBackdrop';

// 〜〜中略〜〜

// React Native ReanimatedのLayout Animationsを使用すると↓の不具合が発生するため、'exiting'・'entering'の指定ができないようにしています。
// https://github.com/software-mansion/react-native-reanimated/issues/2906
-export type ModalBackdropProps = Omit<Reanimated.AnimateProps<ViewProps>, 'exiting' | 'entering'> & {
+export type ModalBackdropProps = Omit<AnimatedProps<ViewProps>, 'exiting' | 'entering'> & {
isVisible: boolean;
onPress?: () => unknown;
afterFadeIn?: (finished?: boolean) => unknown;

React Native Reanimatedで公開されている型の変更に対応

React Native ReanimatedからはもともとKeyframeという型が公開されていたのですが、クラスが公開されるように変更されました。

ただ、単純にtypeof Keyframeと変更してもエラーが解消せず、公開されていないReanimatedKeyframeを利用する必要がありました。

公開されていない型の利用は避けておきたかったので、以下のような型を独自に定義して利用するように修正しました。

ReanimatedKeyframe.ts
import {ReduceMotion} from 'react-native-reanimated';

// WORKAROUND: React Native ReanimatedのKeyframeを引数として受ける際に利用できる適切な型がなかったので、独自に定義して利用しています。

export interface ReanimatedKeyframe {
duration(durationMs: number): this;
delay(delayMs: number): this;
reduceMotion(reduceMotionV: ReduceMotion): this;
withCallback(callback: (finished: boolean) => void): this;
}

Keyframeの利用箇所は、以下のように修正しています。

OverlayContainer.tsx
 import {useSafeAreaInsets} from 'react-native-safe-area-context';
+
+import {ReanimatedKeyframe} from './ReanimatedKeyframe';

// 〜〜〜中略〜〜〜

/**
* enteringに指定したAnimationBuilderなどでwithCallbackを指定しても、本コンポーネント内で上書きしているため実行できません。
* withCallbackで実行する関数は、enteringCallbackで指定してください。
*/
- entering?: BaseAnimationBuilder | Keyframe;
+ entering?: BaseAnimationBuilder | ReanimatedKeyframe;
/**
* exitingに指定したAnimationBuilderなどでwithCallbackを指定しても、本コンポーネント内で上書きしているため実行できません。
* withCallbackで実行する関数は、exitingCallbackで指定してください。
*/
- exiting?: BaseAnimationBuilder | Keyframe;
+ exiting?: BaseAnimationBuilder | ReanimatedKeyframe;
};

不要になった型定義ファイルを削除

React Native Reanimatedの以前のバージョンではJest拡張などの型定義が提供されていなかったのですが、現在は提供されるようになっていたため、該当するファイルを削除しました。

削除したファイル(src/@types/react-native-reanimated-jest-util.d.ts)
-declare namespace jest {
- import Reanimated from 'react-native-reanimated';
- import {ImageStyle, TextStyle, ViewStyle} from 'react-native';
-
- interface Matchers<R> {
- toHaveAnimatedStyle(
- style:
- | Reanimated.AnimateStyle<ViewStyle | ImageStyle | TextStyle>[]
- | Reanimated.AnimateStyle<ViewStyle | ImageStyle | TextStyle>,
- ): R;
- }
-}
-
-declare module 'react-native-reanimated/lib/module/reanimated2/jestUtils' {
- import {ReactTestInstance} from 'react-test-renderer';
- import {ImageStyle, TextStyle, ViewStyle} from 'react-native';
- export const withReanimatedTimer: (test: () => unknown) => void;
- export const advanceAnimationByTime: (time: number) => void;
- export const advanceAnimationByFrame: (count: number) => void;
- export const getAnimatedStyle: <T = ViewStyle | ImageStyle | TextStyle>(instance: ReactTestInstance) => T;
-}

自動テストの失敗を修正

React Native WebViewのモックを追加

テストを実行したところ、WebView利用箇所で以下のようなエラーが発生してしまいテストが失敗するようになっていました。

mobile-app-crib-notes/example-app/SantokuApp/node_modules/react-native-webview/lib/RNCWebViewNativeComponent.js: Could not find component config for native component

18 | import React, {useCallback, useEffect, useState} from 'react';
19 | import {ActivityIndicator, StyleSheet} from 'react-native';
> 20 | import {WebView as RNWebView, WebViewProps} from 'react-native-webview';
| ^
21 | import {
22 | WebViewErrorEvent,
23 | WebViewNavigationEvent,

at throwIfConfigNotfound (node_modules/@react-native/codegen/lib/parsers/error-utils.js:285:11)
at findComponentConfig (node_modules/@react-native/codegen/lib/parsers/parsers-commons.js:896:3)
at buildComponentSchema (node_modules/@react-native/codegen/lib/parsers/flow/components/index.js:24:32)
at buildSchemaFromConfigType (node_modules/@react-native/codegen/lib/parsers/parsers-commons.js:462:34)
at buildSchema (node_modules/@react-native/codegen/lib/parsers/parsers-commons.js:526:10)
at FlowParser.parseString (node_modules/@react-native/codegen/lib/parsers/flow/parser.js:134:12)
at parseFile (node_modules/babel-preset-expo/node_modules/@react-native/babel-plugin-codegen/index.js:36:23)
at generateViewConfig (node_modules/babel-preset-expo/node_modules/@react-native/babel-plugin-codegen/index.js:49:18)

以下のIssueなどを参考に、WebViewのモックを追加して対応しました。

jest/__mocks__/react-native-webview.tsx
import {forwardRef, useImperativeHandle} from 'react';
import {View} from 'react-native';
import {type WebView as RNWebView, WebViewProps} from 'react-native-webview';

const refOverride = {
goBack: jest.fn(),
goForward: jest.fn(),
reload: jest.fn(),
stopLoading: jest.fn(),
injectJavaScript: jest.fn(),
requestFocus: jest.fn(),
postMessage: jest.fn(),
context: jest.fn(),
setState: jest.fn(),
forceUpdate: jest.fn(),
render: jest.fn(),
state: jest.fn(),
};
const MockWebView = forwardRef<RNWebView, WebViewProps>((props, ref) => {
useImperativeHandle(ref, () => ({...refOverride, props, refs: {}}), [props]);
return <View {...props} />;
});

export const WebView = MockWebView;
export default MockWebView;

React Native ReanimatedのrunOnJS実行タイミングの変更

React Native ReanimatedのrunOnJSを使っているコールバック関数が、正しく呼び出されることのテストが失敗するようになっていました。

コールバック関数をマイクロタスクキューに追加するよう変更されていたので、以下のように修正してコールバック関数が実行されるのを待つように修正しました。

 import {useWorkletCallback} from './useWorkletCallback';

describe('useWorkletCallback', () => {
- it('should be called callback if callback exits', () => {
+ it('should be called callback if callback exits', async () => {
const callback = jest.fn();
const {result} = renderHook(props => useWorkletCallback(props), {initialProps: callback});
const hook = result.current;
hook(true);
+ // runOnJSで追加されたコールバックはマイクロタスクとして追加されるので、キューが消化されるのを待たないといけない。
+ await Promise.resolve();
expect(callback).toHaveBeenCalledWith(true);
});
});

act外での状態更新という警告の修正

React Native Reanimatedのアニメーションを伴う操作をテストしている箇所で、以下のような警告が出力されるようになっていました。

Warning: An update to PickerBackdrop inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):

React Native Testing LibraryのfireEventを利用している箇所だったので、同じくライブラリから提供されているwaitForを利用するように変更しました。(テスト関数自体をasyncに変更する必要があります)

-    fireEvent.press(deleteLink);
+ await waitFor(() => {
+ fireEvent.press(deleteLink);
+ });

@react-native-picker/pickerの変更に伴う修正

@react-native-picker/pickerのiOSでの振る舞い変更に記載した通り、iOSではPicker.Itemに渡したvalueが強制的に文字列化されてしまいます。また、PickeronValueChangeにも文字列化された値が渡されるようになっています。

このアプリで独自に実装しているPicker部品にも影響があったため、SelectPickerYearMonthPickerに以下のような修正を加えました。(修正内容が大きくなってしまうので、主要部分のみ抜き出しています)

値にstringもしくはnumber以外を持つ場合は、要素を特定するためのkeyを必須とするように変更

@react-native-picker/pickerPickerselectedValueとして、stringもしくはnubmerが渡されることを期待しています。これは、Picker内のPicker.Item#valueと一致する必要があります。

node_modules/@react-native-picker/picker/typings/Picker.d.ts
  /**
* Value matching value of one of the items. Can be a string or an integer.
*/
selectedValue?: T;

そこで、SelectPickerからPickerPicker.Itemに渡す値がstringもしくはnumberとなるように、SelectPickerに渡す要素の型を変更しました。

SelectPicker.ts
+export type ItemSelectionKey = string | number;
export type Item<T> = {
label: string;
value: T;
inputLabel?: string;
- key?: React.Key;
color?: string;
fontFamily?: string;
-};
+} & (T extends ItemSelectionKey
+ ? {
+ // valueがstringもしくはnumberの場合は、value自体をkeyの代わりに利用できるので任意としています。
+ key?: ItemSelectionKey;
+ }
+ : {
+ // valueがstringでもnumberでもない場合は、valueをkeyとしては利用できないので、必須としています。
+ key: ItemSelectionKey;
+ });

上記の差分に含まれてしまっていますが、もともとkeyReact.Keyとしていましたがstringnumberのみ設定できるように変更しています。

iOSの場合のみ、選択中要素を取得する際に文字列化した値が一致することを条件とするように変更

SelectPickerに渡されたitemsから選択中の要素を抽出する処理において、iOSでのみ抽出に利用する値を文字列化して比較した値が一致することを条件とするよう修正しました。

useSelectPicker.ts
   const getSelectedItem = useCallback(
- (key?: React.Key | ItemT) => {
- return items.find(item => item.key === key || item.value === key);
+ (key?: ItemSelectionKey | ItemT) => {
+ return items.find(item => {
+ if (Platform.OS === 'ios') {
+ // @react-native-picker/pickerは、iOSではpickerに渡されたvalueを強制的に文字列化してしまいます。
+ // そのため、onValueChangeに渡されてくる値は文字列となり、valueが文字列でなかった場合は直接比較しても一致しません。
+ // ここでは、@react-native-picker/pickerの内部処理と同様に、双方を文字列化して比較することで、選択中の要素を取得します。
+ return String(item.key) === String(key) || String(item.value) === String(key);
+ }
+ return item.key === key || item.value === key;
+ });
},
[items],
);

YearMonthPickeronSelectedItemChangeを呼びだす際に強制的に数値型に変換するように修正

YearMonthPickerでは選択中の年月をコールバック関数に渡す処理があるのですが、iOSではこのとき渡される値が文字列化されてしまっていました。

もともとのコードではnumberに型アサーションしていたのですが、強制的に数値に戻すように修正しました。

useYearMonthPicker
   const onValueChangeYear = useCallback(
(value: React.Key) => {
- const year = value as number;
- onSelectedItemChange?.(getSelectedYearMonth({year, month: selectedMonth}));
+ // iOSではvalueが強制的にstringに変換されてしまうので、number型に変換する。
+ onSelectedItemChange?.(getSelectedYearMonth({year: Number(value), month: selectedMonth}));
},
[getSelectedYearMonth, onSelectedItemChange, selectedMonth],
);
const onValueChangeMonth = useCallback(
(value: React.Key) => {
- const month = value as number;
- onSelectedItemChange?.(getSelectedYearMonth({year: selectedYear, month}));
+ // iOSではvalueが強制的にstringに変換されてしまうので、number型に変換する。
+ onSelectedItemChange?.(getSelectedYearMonth({year: selectedYear, month: Number(value)}));
},
[getSelectedYearMonth, onSelectedItemChange, selectedYear],
);

テストの期待値を修正

iOS環境として実行された場合には文字列化されてしまうことの影響で、@react-native-picker/pickerに渡されている値のテストが失敗するようになっていました。

以下のように期待値を修正して対応しました。

     const yearPicker = screen.getByTestId('yearPicker');
- const yearPickerProps = yearPicker.props as SelectPickerItemsProps<string>;
- expect(yearPickerProps.items).toEqual([{value: 2022, label: '2022'}]);
+ const yearPickerProps = yearPicker.props as SelectPickerItemsProps<number>;
+ // iOS版のPicker(PickerIOS.ios.js)では、PickerItemのvalueをString関数で文字列に変換してネイティブコンポーネントに渡している。
+ // そのため、期待値を文字列とする必要がある。
+ expect(yearPickerProps.items).toEqual([{value: '2022', label: '2022'}]);
const monthPicker = screen.getByTestId('monthPicker');
- const monthPickerProps = monthPicker.props as SelectPickerItemsProps<string>;
- expect(monthPickerProps.items).toEqual([{value: 4, label: '4'}]);
+ const monthPickerProps = monthPicker.props as SelectPickerItemsProps<number>;
+ expect(monthPickerProps.items).toEqual([{value: '4', label: '4'}]);
});

Expoの更新履歴確認と対応

ExpoのCHANGELOGを参照して、Expo SDKとExpoが管理するライブラリの更新内容を確認しました。

ここまでの対応以外に追加で必要なものはありませんでした。

React Nativeの更新履歴確認と対応

React NativeのCHANGELOGを参照して、React Nativeの更新内容を確認しました。

AndroidでのScrollView#onMomentumEndの不具合修正

今回のアップグレード前は、AndroidでScrollViewのスクロール終了時処理(onMomentumEnd)が複数回呼び出されてしまうという不具合がありました。

React Native 0.73.0でこの不具合が修正されました。

このアプリで実装しているPickerでは、Android向け部品の中でこの問題を回避するための処理を追加していました。今回のアップグレードで不要になっていることが確認できたので、対策していた部分を削除しました。

useSelectPickerItems.ts
   const scrollHandler = useAnimatedScrollHandler(e => {
offset.value = e.contentOffset.y;
});
- // // 1回のスクロールで、onMomentumScrollEndが複数回実行されてしまう事象に対応
- // https://github.com/facebook/react-native/issues/32696
- const canMomentum = useRef(false);
- const onMomentumScrollBegin = useCallback(() => (canMomentum.current = true), []);
-
const middleIndex = useListMiddleIndex({itemHeight, listSize: items.length});

const selectedIndex = useMemo(() => {

// 〜〜〜中略〜〜〜

const handleValueChange = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
- if (canMomentum.current) {
- canMomentum.current = false;
- const {index, value} = getRowItemAtOffset(event.nativeEvent.contentOffset.y);
- _onChange(value, index);
- }
+ const {index, value} = getRowItemAtOffset(event.nativeEvent.contentOffset.y);
+ _onChange(value, index);
},
[_onChange, getRowItemAtOffset],
);

// 〜〜〜中略〜〜〜

return {
offset,
flatListRef,
handleValueChange,
scrollToPassedIndex,
scrollHandler,
selectedIndex,
height,
selectItem,
getItemLayout,
- onMomentumScrollBegin,
};
};
SelectPickerItems.android.tsx
   const {
offset,
height,
handleValueChange,
scrollToPassedIndex,
selectedIndex,
selectItem,
getItemLayout,
flatListRef,
scrollHandler,
- onMomentumScrollBegin,
} = useSelectPickerItems<ItemT>({
selectedValue,
items,

// 〜〜〜中略〜〜〜

<Reanimated.FlatList
data={items}
keyExtractor={keyExtractor ?? defaultKeyExtractor}
style={StyleSheet.flatten([itemsHeightStyle, styles.items])}
onScroll={scrollHandler}
onMomentumScrollEnd={handleValueChange}
- onMomentumScrollBegin={onMomentumScrollBegin}
showsVerticalScrollIndicator={false}
onLayout={scrollToPassedIndex}
ref={flatListRef}

CocoaPodsのバージョン

React Native v0.73.3時点ではCocoaPodsの最新版が1.15.0だったようなのですが、このバージョンに不具合があったようでv1.15系は利用しないように制限されていました。

しかし、このアプリでのアップデート時点ではこの不具合を修正した1.15.2が公開されており、React Nativeで利用するバージョンも修正されています。

ビルドエラーも発生していないことから、今回のアップグレードではCocoaPods 1.15.2を利用するよう修正しました。

expo-template-blank-typescriptの更新履歴確認と対応

expo-template-blank-typescriptの更新履歴を確認しました。

npm install expo@^50.0.0npx expo install --fixで更新される依存ライブラリのアップグレードが主な変更でした。そのため、このアプリで特別な対応は必要ありませんでした。

expo-template-bare-minimumの更新履歴確認と対応

このアプリではConfig Pluginsに対応しているので、expo-template-bare-minimumの更新に伴う個別の対応は基本的に必要ありません。 ただし、以下の場合は個別に対応する必要があるため、この観点に絞ってexpo-template-bare-minimumの更新履歴を確認しました。

  • このアプリで作成しているConfig Pluginsによる変更と、expo-template-bare-minimumの更新に伴う変更が競合した場合
  • Prebuild時に生成・更新されないファイル
  • android/, ios/以外のファイル(.gitignoreなど)

ここまでの対応以外に追加で必要なものはありませんでした。

ライセンス情報を更新

このアプリでは、使用しているライブラリのライセンス一覧を出力するスクリプトを用意しています。詳細は、こちらを参照してください。

managed-license.jsの更新

ライセンス情報が不足しており補完したい、あるいは、開発時のみ使用するため除外したいライブラリとバージョンをmanaged-license.jsで管理しています。

ライセンス情報が不足しているライブラリなどは、以下のコマンドを実行することで確認できます。

  • node .script/check-licenses.js

実行した結果、いくつかのライブラリのライセンス情報を更新する必要があったので以下を実施しました。

  • 使用ライブラリの名前更新
  • 使用ライブラリのバージョン更新
  • 使用ライブラリのライセンスファイルURL更新
  • 新規ライブラリ情報の追加
  • 使用しなくなったライブラリ情報の削除

新規ライセンスへの対応

このアプリでは、アプリに使用しても問題ないライセンスを管理してチェックできるようにしています。

今回のExpo SDKアップグレードでは、Blue Oak Model License 1.0.0BlueOak-1.0.0)のライブラリが増えました。 BlueOak-1.0.0はApacheライセンスやMITライセンスと似たパーミッシブ・ライセンスであり、ライセンス明記以外の対応は必要ないため使用可としました。 そのライセンス名(BlueOak-1.0.0)をcheck-licenses.jslicenseWhitelistに追加しています。

また、以下のライセンスについてSPDX表記ではなく正式名で記載しているライブラリがあったため、list-dependencies.jsaliasListに追加しました。

  • Boost Software License
    • BSL-1.0のエイリアスとして追加
  • Blue Oak Model License
    • BlueOak-1.0.0のエイリアスとして追加