ブラウザでは、new URL('../x', 'https://a.example/b/c/d') は https://a.example/b/x になります。React Native 0.87.1 の組み込みの URL クラスでは https://a.example/b/c/d/../x です。ウェブサイトと React Native アプリで共有しているクライアントライブラリは、ウェブ側のテストをすべて通しても、スマートフォンでは違うパスにリクエストを送ってしまうことがあります。

URL は、共有コードが違うプラットフォームに触れる3か所のうちの1つです。あとの2つは、Expo が fetch を置き換えない限り undefined の response.body と、React Native のセットアップのファイルが定義しない TextDecoder です。これは、React Native と Expo が実際に何を提供しているかの短い監査で、文書にはあまり書かれていないのでソースから書きました。必要なものを確認し、信頼できないものを避ける小さなクライアントも作りました。

TL;DR

  • 共有クライアントから見えるのは、最大3層です。JavaScript エンジン、React Native 自身のポリフィル、そして Expo のアプリでは、その上にある Expo の「winter」ランタイムです。手に入る Web API は、どの層があるかで変わります。
  • React Native 0.87.1 の組み込み URL は、正規表現ベースの小さなクラスです。Node の WHATWG URL と並べて動かすと、new URL('../x', 'https://a.example/b/c/d') に対して、ウェブなら https://a.example/b/x のところ、https://a.example/b/c/d/../x を返しました。//c.example/x も解決されず、ホストの大文字小文字と明示した :443 はそのままで、スペースはパーセントエンコードされませんでした。Expo のランタイムを読み込むプロジェクトでは、グローバルの URL が whatwg-url-minimum の実装に置き換わり、これらのケースはウェブと同じに動きます。
  • React Native 0.87.1 のソースでは、fetch は whatwg-fetch 3.6.20 から来ており、その中にレスポンスの body ストリームを提供するものは見つかりませんでした。Expo が置き換える fetch はストリームを作ります。ストリーミングのコードには、フォールバックか、要件の明記が必要です。
  • AbortSignal.timeout と AbortSignal.any は 0.87.1 に存在します。古い助言は逆のことを言うので、覚えるのではなく確認する点です。
  • 実用的な規則は3つです。起動時に1回、足りないものを列挙する機能確認を走らせます。URL は自分で制御できる文字列結合で作ります。共有コードは、出荷するランタイムの組み合わせでテストします。
  • Hermes も実機も動かしていません。テストの「ランタイム」は模擬です。

文書が述べていること、述べていないこと

React Native のネットワークのページは、React Native が Fetch API、XMLHttpRequest API、WebSocket のサポートを提供することと、「ネイティブアプリには CORS という概念がない」ことを述べています。そのほかにどのウェブのグローバルがあるか、標準にどれだけ従っているかは書かれていません。その情報はソースにあるので、react-native 0.87.1 と expo 57.0.26 をインストールして、グローバルがどう定義されるかを読みました。

React Native の setUpXHR.js は、次のものを遅延評価でグローバルに定義します。XMLHttpRequest、FormData、Headers・Request・Response つきの fetch、WebSocket、Blob、File、FileReader、URL と URLSearchParams、AbortController と AbortSignal です。TextDecoder は定義しません。

Expo のランタイムのファイル(src/winter/runtime.native.ts)が、さらに次を入れます。TextDecoder(UTF-8 のフォールバックで、提供しないランタイム向けとのコメントがあります)、TextDecoderStream、TextEncoderStream、whatwg-url-minimum パッケージの URL と URLSearchParams、DOMException、structuredClone、FormData と AbortSignal へのパッチ、そして EXPO_PUBLIC_USE_RN_FETCH が設定されていなければ置き換えの fetch です。ReadableStream は、Metro が注入するものとされています。

グローバルReact Native 0.87.1Expo のランタイムによる追加・置き換え
fetchwhatwg-fetch 3.6.20。レスポンスの body ストリームは見つかりませんでしたExpo の fetch(ストリームあり。EXPO_PUBLIC_USE_RN_FETCH がなければ)
URL、URLSearchParamsRN 自身の Libraries/Blob/URL.jswhatwg-url-minimum
AbortController、AbortSignalRN 自身のもの。abort、timeout、any がありますtimeout/any がなければパッチで追加
TextDecodersetUpXHR.js は定義しません(エンジンが追加するかは確認していません)UTF-8 のフォールバック
ReadableStreamここでは調べていませんMetro が注入(コメントによる)

そのランタイムを読み込まない Expo アプリや、素の React Native アプリには、中央の列しか見えません。共有ライブラリは自分がどちらにいるかを知り得ないので、尋ねるべきなのです。

URL の違いを測る

React Native の URL.js は、Flow の型つきの素の JavaScript です。flow-remove-types で型を取り除き、Node.js 22.23.3 上で、Node 自身の URL と whatwg-url-minimum と並べて動かしました。React Native のパッケージのコードを Node で動かしたもので、Hermes ではありません。Hermes で違う可能性は、僕のテストの範囲外です。

ケースNode の URLReact Native 0.87.1 の URL.jswhatwg-url-minimum
new URL('../x', 'https://a.example/b/c/d')https://a.example/b/xhttps://a.example/b/c/d/../xhttps://a.example/b/x
new URL('//c.example/x', 'https://a.example')https://c.example/x//c.example/xhttps://c.example/x
HTTPS://A.EXAMPLE/x の href小文字化そのままhttps://a.example/x
https://a.example:443/x の port"""443"""
https://a.example/a b の pathname/a%20b/a b/a%20b
u.pathname = '/y'動く効果なし動く
URL.canParse関数undefined関数
searchParams.append のあとの href?k=v?k=v未テスト
search のセッター動く動く未テスト

Node と React Native の列はすべての行で、whatwg-url-minimum の列は最後の2行を除いて動かしました。僕のスクリプトは非 strict モードで動いたので、pathname への代入は黙って無視されました。strict モードのコードなら TypeError になると思いますが、試していません。

これらの動作は URL 仕様に定められています(ベースに対するドットセグメントの解決を含みます)。ウェブ向けに書かれたコードは、それを前提にします。

共有クライアントに入れるもの

3つの判断になります。

必要なものを宣言し、1回確認する。

export const REQUIRED = ["fetch", "AbortController", "TextDecoder"];

export function missingCapabilities(g = globalThis) {
  return REQUIRED.filter((name) => typeof g[name] !== "function");
}
export function assertRuntime(g = globalThis) {
  const missing = missingCapabilities(g);
  if (missing.length) throw new Error(`shared client needs globals this runtime lacks: ${missing.join(", ")}`);
}

起動時に、足りないものすべてを挙げる1つのエラーの方が、あとで出る3つの別々の失敗より対処しやすくなります。

URL は文字列で作る。 クライアントがするのは、設定されたベースとパスとクエリの結合だけです。解決を要するパスは、ランタイムごとに違う形で解決せず、拒否します。

const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());

export function joinUrl(base, path, query) {
  if (/(^|\/)\.\.?(\/|$)/.test(path) || /^\/\//.test(path)) throw new Error(`refusing a path that needs URL resolution: ${path}`);
  const qs = query ? Object.entries(query).filter(([, v]) => v !== undefined)
    .map(([k, v]) => `${enc(k)}=${enc(String(v))}`).join("&") : "";
  return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}${qs ? "?" + qs : ""}`;
}

AbortSignal.timeout や .any に依存しない。 タイマーと AbortController で、テストしたどのプロファイルでも用が足ります。AbortSignal.timeout のないプロファイルも含みます。

export function timeoutSignal(ms, outer) {
  const ctl = new AbortController();
  const timer = setTimeout(() => ctl.abort(new Error(`timeout after ${ms}ms`)), ms);
  const onOuter = () => ctl.abort(outer.reason);
  if (outer) outer.aborted ? onOuter() : outer.addEventListener("abort", onOuter, { once: true });
  return { signal: ctl.signal, cancel() { clearTimeout(timer); outer?.removeEventListener("abort", onOuter); } };
}

できるときはストリームで読み、できないときはそう伝える。 行区切りのレスポンスでは、リーダーはストリームがあればそれを使い、なければ本文全体を読みます。後者は行を早く渡さないので、気にする呼び出し元のために canStream を公開しています。

export async function* readLines(response) {
  if (!canStream(response)) { for (const line of (await response.text()).split("\n")) if (line) yield line; return; }
  const decoder = new TextDecoder();
  const reader = response.body.getReader();
  let buf = "";
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });   // 受信途中のマルチバイト文字を保持する
    let i; while ((i = buf.indexOf("\n")) >= 0) { yield buf.slice(0, i); buf = buf.slice(i + 1); }
  }
  buf += decoder.decode();
  if (buf) yield buf;
}

{ stream: true } は重要です。これがあれば、2つのネットワークチャンクにまたがる日本語の文字が正しくデコードされます。ラボでは、同じバイト列をこれなしでもデコードしていて、結果は文字化けします。

出荷するランタイムでテストする

テストは、クライアントのソースを、組み合わせごとに合わせてグローバルを用意した別々の node:vm の実行環境で動かします。層の模擬であり、エンジンそのものではありません。

profile     missing for the shared client      naive new URL('../x', 'https://a.example/b/c/d')     joinUrl('https://a.example/b/c/d','x')
web-like    -                                  https://a.example/b/x                                https://a.example/b/c/d/x
rn-0.87.1   TextDecoder                        https://a.example/b/c/d/../x                         https://a.example/b/c/d/x
rn+expo     -                                  https://a.example/b/x                                https://a.example/b/c/d/x
bare        fetch, TextDecoder                 throws: URL is not defined                           https://a.example/b/c/d/x

rn-0.87.1 のプロファイルは、React Native 自身の URL.js を使い、TextDecoder はありません。rn+expo は whatwg-url-minimum を使い、TextDecoder を足します。bare には URL、fetch、TextDecoder がありません。契約テスト(13件、すべて成功)は次を確かめます。joinUrl が4つすべてで同じ文字列を返すこと、timeoutSignal が4つすべてで動くこと、assertRuntime が足りないグローバルだけを挙げること、readLines がストリームの有無にかかわらず動くこと、素朴な new URL の結果が上のようにランタイム間で違うことです。

共有したい Web API のチェックリスト

  1. 対象とするランタイムごとに、そのグローバルがどこで定義されるかを調べます(エンジン、フレームワークのポリフィル、自分たちのランタイム層)。見つからなければ、ないものとして扱います。
  2. 名前ではなく、コードが依存する動作を確認します。解決、エンコード、大文字小文字、セッターです。
  3. ライブラリの境界では、素の文字列や配列を優先します。URL、Request、ストリームのような豊かなオブジェクトは、プラットフォームのアダプターの中に置きます。
  4. 起動時に機能確認を入れ、ランタイムのプロファイルごとに1つテストを置きます。
  5. React Native や Expo を上げたら、確認し直します。ランタイムのファイルはバージョン間で変わります。

プロファイルを動かす

mkdir shared-client-lab && cd shared-client-lab
# ラボの client.js、profiles.mjs、rn-url.cjs、contract.test.mjs、run-lab.mjs をここに置く
npm init -y && npm i -D [email protected] [email protected] [email protected] --ignore-scripts --legacy-peer-deps
node run-lab.mjs
node --test contract.test.mjs

共有コードがスマートフォンで壊れる場面

  • 共有コードで new URL(relative, base) を使うこと。 症状: ../ やプロトコル相対の参照が、スマートフォンで違う URL に解決され、意図しないパスにリクエストが飛びます。対処: 文字列を結合し、解決が必要な入力は拒否します。
  • フォールバックなしで response.body を読むこと。 症状: 1つのランタイムでだけ Cannot read property 'getReader' of undefined になります。対処: ストリームの有無を確認し、フォールバックは本文全体をバッファすることを書いておきます。
  • 確認せずに TextDecoder を使うこと。 症状: ないエンジンや構成で ReferenceError になります。対処: 起動時に assertRuntime() を呼びます。
  • チャンクを1つずつデコードすること。 症状: チャンクの境界で文字化けします。マルチバイトのテキストのときだけです。対処: ストリームごとに1つのデコーダーを使い、{ stream: true } を付けます。
  • 古いバージョンの豆知識を覚えていること。 症状: もう要らない AbortSignal.timeout 欠如の回避策を残しているか、存在しない機能に頼っています。対処: 出荷するバージョンのソースを読みます。0.87.1 ではどちらも存在すると分かりましたが、どのリリースで初めて入ったかはたどっていません。
  • Node かブラウザでしかテストしないこと。 症状: CI ではすべて通り、アプリで失敗します。対処: プロファイルごとに契約テストを走らせ、リリース前に少なくとも実際のランタイムでスモークテストをします。

クライアントを共有する場面

ロジックが主役のときに、クライアントを共有します。リクエストの署名、リトライの方針、ページネーション、パースなどです。プラットフォームとの境界は薄く、取り替えられる形に保ちます。

ランタイムを固定できないなら、豊かな Web オブジェクトに寄りかかるコード(Request の clone、URL の変更、ReadableStream のパイプ)は共有しないでください。アプリとサイトが別々に進化するなら、小さなアダプターを2つ持つ方が、凝った抽象を1つ持つより安く済みます。

Node で確かめたこと、Hermes では確かめていないこと

確認したことです。インストールされた react-native 0.87.1 と expo 57.0.26 の中身(セットアップのファイルがどのグローバルを定義するか、whatwg-fetch 3.6.20 にレスポンスの body がないこと、AbortSignal の実装に timeout と any があること)。URL の表は、型を取り除いた React Native の URL.js を、Node 22.23.3 の URL および whatwg-url-minimum 0.1.2 と並べて動かして作りました。4つの模擬プロファイル上の13件の契約テストも確認しました。

確認していないことです。Hermes。その中ではコードを動かしていないので、エンジンが TextDecoder を提供するか、URL.js の動き方が違うかは言えません。スマートフォンでアプリは動かしておらず、Expo のランタイム自体も動かしていません。動かしたのは、それが入れる whatwg-url-minimum パッケージだけです。各グローバルがどのバージョンで React Native に入ったかは調べていません。node:vm のプロファイルは、上のソースから作った近似です。

ランタイムに尋ねる

共有クライアントがうまく動くのは、自分が動いているランタイムを知っていて、起動時にそれを伝えるときです。Web プラットフォームは、各ランタイムが異なる程度に実装している標準の集まりです。そして最初に表に出るのは、地味すぎてテストしないと思っていた部分です。