From 59921230d479b8d85075b2b6fb993939eaf1743b Mon Sep 17 00:00:00 2001 From: Shanavas Shaji Date: Tue, 9 Jun 2026 22:08:28 +0300 Subject: [PATCH 1/2] feat(reactotron-react-native): track Expo expo/fetch in the networking plugin Expo SDK 54+ installs expo/fetch as the default globalThis.fetch. It is backed by a native module and bypasses XMLHttpRequest, so XHRInterceptor never sees it and the networking plugin silently misses all fetch traffic on Expo. Add a FetchInterceptor that mirrors XHRInterceptor (set*Callback / enableInterception / disableInterception) and wraps the global fetch only when it is the expo/fetch builtin (detected via Symbol.for("expo.builtin")). RN's XHR-backed fetch is left to XHRInterceptor, so there is no double reporting. The networking plugin wires Reactotron into it in onConnect, gated by a new `ignoreExpoFetch` option. The wrapper returns the original response immediately and reads the body off a clone asynchronously, so callers are never blocked; image and text/event-stream bodies are skipped so streaming responses are not buffered. --- docs/plugins/networking.md | 9 +- .../src/fetch-interceptor.test.ts | 113 +++++++++++ .../src/fetch-interceptor.ts | 181 ++++++++++++++++++ .../src/plugins/networking.ts | 114 +++++++++++ 4 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 lib/reactotron-react-native/src/fetch-interceptor.test.ts create mode 100644 lib/reactotron-react-native/src/fetch-interceptor.ts diff --git a/docs/plugins/networking.md b/docs/plugins/networking.md index 742ec3988..e2620dcb9 100644 --- a/docs/plugins/networking.md +++ b/docs/plugins/networking.md @@ -6,6 +6,8 @@ title: Networking The `networking` plugin is `reactotron-react-native` which allows you to track all XMLHttpRequests in React Native. +On Expo SDK 56+, `expo/fetch` is installed as the default `globalThis.fetch`. It is backed by a native module and bypasses `XMLHttpRequest`, so it would otherwise be invisible to this plugin. The plugin detects this case and tracks `expo/fetch` requests as well (see `ignoreExpoFetch` below to opt out). Requests made through React Native's regular XHR-backed `fetch` continue to be tracked via `XMLHttpRequest`, so there is no double-reporting. + ## Usage To use the `networking` plugin, you need to add the additional plugin on the `import` line. @@ -26,10 +28,11 @@ And you're done! Now you can see your XMLHttpRequests in Reactotron. ## Advanced Usage -`networking()` also accepts an object with two options: +`networking()` also accepts an object with these options: -- `ignoreContentTypes`: a regular expression which, when matched against the `Content-Type` response header, will prevent the data from being displayed in Reactotron. You typically want to do this for images (which is the default). -- `ignoreUrls`: a regular expression which, when matched against the URL of the XHR, will prevent the request from being tracked in Reactotron. Can be useful for ignoring noisy logging requests. +- `ignoreContentTypes`: a regular expression which, when matched against the `Content-Type` response header, will prevent the data from being displayed in Reactotron. You typically want to do this for images (which is the default). `text/event-stream` response bodies are always skipped so streaming responses are not buffered. +- `ignoreUrls`: a regular expression which, when matched against the URL of the request, will prevent the request from being tracked in Reactotron. Can be useful for ignoring noisy logging requests. +- `ignoreExpoFetch`: set to `true` to skip instrumenting Expo's `expo/fetch` (the default `globalThis.fetch` on Expo SDK 56+). Has no effect on non-Expo runtimes, where the global fetch is XHR-backed and already covered by XHR tracking. ```js networking({ diff --git a/lib/reactotron-react-native/src/fetch-interceptor.test.ts b/lib/reactotron-react-native/src/fetch-interceptor.test.ts new file mode 100644 index 000000000..fbf7e9cfe --- /dev/null +++ b/lib/reactotron-react-native/src/fetch-interceptor.test.ts @@ -0,0 +1,113 @@ +import { FetchInterceptor } from "./fetch-interceptor" + +const EXPO_BUILTIN = Symbol.for("expo.builtin") + +function makeExpoFetch(impl: (...args: any[]) => Promise) { + const fn: any = (...args: any[]) => impl(...args) + fn[EXPO_BUILTIN] = true + return fn +} + +function makeResponse(status: number, headersObj: Record) { + return { + status, + headers: { + get: (k: string) => headersObj[k.toLowerCase()] ?? null, + forEach: (cb: (v: string, k: string) => void) => + Object.entries(headersObj).forEach(([k, v]) => cb(v, k)), + }, + clone() { + return this + }, + text: () => Promise.resolve(""), + } +} + +describe("FetchInterceptor", () => { + const realFetch = globalThis.fetch + + afterEach(() => { + FetchInterceptor.disableInterception() + globalThis.fetch = realFetch + }) + + it("is a no-op when the global fetch is not expo/fetch", () => { + const plain: any = jest.fn() + globalThis.fetch = plain + FetchInterceptor.enableInterception() + expect(FetchInterceptor.isInterceptorEnabled()).toBe(false) + expect(globalThis.fetch).toBe(plain) + }) + + it("wraps expo/fetch, fires callbacks, and returns the original response untouched", async () => { + const response = makeResponse(201, { "content-type": "application/json" }) + const original = makeExpoFetch(() => Promise.resolve(response)) + globalThis.fetch = original + + const open = jest.fn() + const onResponse = jest.fn() + FetchInterceptor.setOpenCallback(open) + FetchInterceptor.setResponseCallback(onResponse) + FetchInterceptor.enableInterception() + + expect(FetchInterceptor.isInterceptorEnabled()).toBe(true) + expect(globalThis.fetch).not.toBe(original) + + const result = await (globalThis.fetch as any)("https://example.com/x?a=1", { + method: "post", + headers: { Authorization: "Bearer t" }, + body: "hello", + }) + + // the caller receives the original, untouched response (non-blocking / stream-safe) + expect(result).toBe(response) + + expect(open).toHaveBeenCalledTimes(1) + const [method, url, reqHeaders, data, id] = open.mock.calls[0] + expect(method).toBe("POST") + expect(url).toBe("https://example.com/x?a=1") + expect(reqHeaders).toEqual({ Authorization: "Bearer t" }) + expect(data).toBe("hello") + + expect(onResponse).toHaveBeenCalledTimes(1) + const [rid, status, respHeaders, passedResponse, error] = onResponse.mock.calls[0] + expect(rid).toBe(id) + expect(status).toBe(201) + expect(respHeaders).toEqual({ "content-type": "application/json" }) + expect(passedResponse).toBe(response) + expect(error).toBeNull() + }) + + it("reports rejections with status -1 and a null response", async () => { + const boom = new Error("offline") + globalThis.fetch = makeExpoFetch(() => Promise.reject(boom)) + const onResponse = jest.fn() + FetchInterceptor.setResponseCallback(onResponse) + FetchInterceptor.enableInterception() + + await expect((globalThis.fetch as any)("https://x.test")).rejects.toBe(boom) + const [, status, headers, response, error] = onResponse.mock.calls[0] + expect(status).toBe(-1) + expect(headers).toBeNull() + expect(response).toBeNull() + expect(error).toBe(boom) + }) + + it("does not double-wrap an already-wrapped fetch", () => { + globalThis.fetch = makeExpoFetch(() => Promise.resolve(makeResponse(200, {}))) + FetchInterceptor.enableInterception() + const wrapped = globalThis.fetch + FetchInterceptor.enableInterception() + expect(globalThis.fetch).toBe(wrapped) + }) + + it("restores the original fetch on disable", () => { + const original = makeExpoFetch(() => Promise.resolve(makeResponse(200, {}))) + globalThis.fetch = original + FetchInterceptor.enableInterception() + expect(globalThis.fetch).not.toBe(original) + FetchInterceptor.disableInterception() + expect(globalThis.fetch).toBe(original) + expect(FetchInterceptor.isInterceptorEnabled()).toBe(false) + }) +}) diff --git a/lib/reactotron-react-native/src/fetch-interceptor.ts b/lib/reactotron-react-native/src/fetch-interceptor.ts new file mode 100644 index 000000000..3815b814e --- /dev/null +++ b/lib/reactotron-react-native/src/fetch-interceptor.ts @@ -0,0 +1,181 @@ +/** + * Intercepts the global `fetch` when it is Expo's expo/fetch implementation + * (the default `globalThis.fetch` on Expo SDK 56+). expo/fetch is backed by a + * native module and bypasses `XMLHttpRequest`, so it is invisible to + * `XHRInterceptor`. This wraps it so the networking plugin can report + * expo/fetch traffic the same way it reports XHR traffic. + * + * The shape mirrors ./xhr-interceptor (set*Callback / enableInterception / + * disableInterception) so `networking.ts` can wire Reactotron into it the same + * way. On platforms/SDKs where the global fetch is not expo/fetch (e.g. RN's + * XHR-backed fetch, which is already covered by `XHRInterceptor`), this is a + * no-op. + */ + +// Expo stamps its installed globals (see expo's `installGlobal`) with this symbol. +const EXPO_BUILTIN = Symbol.for("expo.builtin") + +export type FetchHeaders = Record | null + +type FetchInterceptorOpenCallback = ( + method: string, + url: string, + headers: FetchHeaders, + data: string | null, + id: number +) => void + +/** + * Invoked synchronously as soon as the response resolves, BEFORE the response + * is returned to the caller. To read the body, clone `response` synchronously + * inside the callback (do not `await` before cloning) so the caller's copy is + * left intact and streaming responses are not blocked. `response` is null when + * the request rejected (network error); `error` then holds the rejection. + */ +type FetchInterceptorResponseCallback = ( + id: number, + status: number, + headers: FetchHeaders, + response: Response | null, + error: unknown +) => void + +interface ReactotronFetch { + (input: any, init?: any): Promise + __reactotronWrapped?: boolean + [EXPO_BUILTIN]?: boolean +} + +let openCallback: FetchInterceptorOpenCallback | null +let responseCallback: FetchInterceptorResponseCallback | null +let originalFetch: typeof fetch | null = null +let requestId = 0 + +function isExpoFetch(fn: unknown): boolean { + return typeof fn === "function" && (fn as ReactotronFetch)[EXPO_BUILTIN] === true +} + +function isRequest(value: unknown): value is Request { + return typeof Request !== "undefined" && value instanceof Request +} + +function getUrl(input: unknown): string { + if (typeof input === "string") return input + if (isRequest(input)) return input.url + return String(input) +} + +function getMethod(input: unknown, init?: { method?: string }): string { + if (isRequest(input) && input.method) return input.method.toUpperCase() + if (init && init.method) return String(init.method).toUpperCase() + return "GET" +} + +/** + * Normalizes a fetch `HeadersInit` / `Headers` into a plain object. + */ +function headersToObject(headers: unknown): FetchHeaders { + if (!headers) return null + const anyHeaders = headers as any + if (typeof anyHeaders.forEach === "function" && typeof anyHeaders.get === "function") { + const out: Record = {} + anyHeaders.forEach((value: string, key: string) => { + out[key] = value + }) + return out + } + if (Array.isArray(headers)) { + return (headers as [string, string][]).reduce((acc: Record, pair) => { + if (pair && pair.length === 2) acc[pair[0]] = pair[1] + return acc + }, {}) + } + if (typeof headers === "object") return { ...(headers as Record) } + return null +} + +/** + * A network interceptor for Expo's expo/fetch. Mirrors `XHRInterceptor` so the + * networking plugin can register callbacks and enable/disable it identically. + */ +export const FetchInterceptor = { + /** + * Invoked synchronously before the wrapped fetch is sent. + */ + setOpenCallback(callback: FetchInterceptorOpenCallback) { + openCallback = callback + }, + + /** + * Invoked synchronously when the response resolves (or rejects). See the + * callback type for the body-cloning contract. + */ + setResponseCallback(callback: FetchInterceptorResponseCallback) { + responseCallback = callback + }, + + isInterceptorEnabled(): boolean { + return originalFetch !== null + }, + + enableInterception() { + const current = globalThis.fetch as ReactotronFetch | undefined + // Only wrap expo/fetch. RN's XHR-backed fetch is already covered by + // `XHRInterceptor`, and wrapping it here too would double-report. + if (!current || !isExpoFetch(current) || current.__reactotronWrapped) { + return + } + + originalFetch = current as typeof fetch + + const wrapped: ReactotronFetch = function (input: any, init?: any) { + const id = (requestId += 1) + const requestHeaders = headersToObject( + (init && init.headers) || (isRequest(input) ? input.headers : null) + ) + const data = + init && typeof init.body === "string" + ? init.body + : init && init.body + ? "[non-string body]" + : null + + if (openCallback) { + openCallback(getMethod(input, init), getUrl(input), requestHeaders, data, id) + } + + return (originalFetch as typeof fetch)(input, init).then( + (response) => { + // Fire synchronously and return the original response untouched, so + // the caller is never blocked and streaming bodies stay intact. + if (responseCallback) { + responseCallback(id, response.status, headersToObject(response.headers), response, null) + } + return response + }, + (error) => { + if (responseCallback) { + responseCallback(id, -1, null, null, error) + } + throw error + } + ) + } + + wrapped.__reactotronWrapped = true + // Keep it detectable as the expo builtin fetch for anything else that checks. + wrapped[EXPO_BUILTIN] = true + globalThis.fetch = wrapped + }, + + // Unpatch the global fetch and remove the callbacks. + disableInterception() { + if (!originalFetch) { + return + } + globalThis.fetch = originalFetch + originalFetch = null + openCallback = null + responseCallback = null + }, +} diff --git a/lib/reactotron-react-native/src/plugins/networking.ts b/lib/reactotron-react-native/src/plugins/networking.ts index 70c172a72..a3fbbefd4 100644 --- a/lib/reactotron-react-native/src/plugins/networking.ts +++ b/lib/reactotron-react-native/src/plugins/networking.ts @@ -1,14 +1,27 @@ import type { ReactotronCore, Plugin } from "reactotron-core-client" import { XHRInterceptor } from "../xhr-interceptor" +import { FetchInterceptor, FetchHeaders } from "../fetch-interceptor" /** * Don't include the response bodies for images by default. */ const DEFAULT_CONTENT_TYPES_RX = /^(image)\/.*$/i +/** + * Streaming response bodies must never be buffered for logging (it would defeat + * the stream and grow memory unbounded), so we always skip their bodies. + */ +const STREAMING_CONTENT_TYPES_RX = /event-stream/i + export interface NetworkingOptions { ignoreContentTypes?: RegExp ignoreUrls?: RegExp + /** + * Set to `true` to skip instrumenting Expo's expo/fetch (the default + * `globalThis.fetch` on Expo SDK 56+). Has no effect on non-Expo runtimes, + * where the global fetch is XHR-backed and already covered by XHR tracking. + */ + ignoreExpoFetch?: boolean } const DEFAULTS: NetworkingOptions = {} @@ -147,12 +160,113 @@ const networking = } } + // expo/fetch request tracker (keyed by the interceptor's request id). + // `null` marks a request we deliberately skipped (ignoreUrls). + const fetchCache: { + [id: number]: { tronRequest: any; stopTimer: () => number } | null + } = {} + + /** + * Fires (synchronously) when an expo/fetch request is sent. + */ + function onFetchOpen( + method: string, + url: string, + headers: FetchHeaders, + data: string | null, + id: number + ) { + if (options.ignoreUrls && options.ignoreUrls.test(url)) { + fetchCache[id] = null + return + } + + let params = null + const queryParamIdx = url ? url.indexOf("?") : -1 + if (queryParamIdx > -1) { + params = {} + url + .substr(queryParamIdx + 1) + .split("&") + .forEach((pair) => { + const [key, value] = pair.split("=") + if (key && value !== undefined) { + params[key] = decodeURIComponent(value.replace(/\+/g, " ")) + } + }) + } + + fetchCache[id] = { + tronRequest: { url, method, data, headers, params }, + stopTimer: reactotron.startTimer(), + } + } + + /** + * Fires (synchronously) when an expo/fetch response resolves or rejects. + * The body is read off a clone, asynchronously, so the caller's response is + * never blocked and streaming responses stay intact. + */ + function onFetchResponse( + id: number, + status: number, + headers: FetchHeaders, + response: Response | null, + error: unknown + ) { + const cached = fetchCache[id] + delete fetchCache[id] + if (!cached) { + return + } + + const { tronRequest, stopTimer } = cached + const report = (body) => + (reactotron as any).apiResponse(tronRequest, { body, status, headers }, stopTimer()) + + if (error || !response) { + report(error instanceof Error ? error.message : String(error)) + return + } + + const contentType = (headers && headers["content-type"]) || "" + if (ignoreContentTypes.test(contentType) || STREAMING_CONTENT_TYPES_RX.test(contentType)) { + // Never read (and therefore never buffer) image or streaming bodies. + report("~~~ skipped ~~~") + return + } + + // Clone synchronously (before the caller consumes the body), then read + // asynchronously so we don't block the request. + response + .clone() + .text() + .then((text) => { + let body + try { + // all i am saying, is give JSON a chance... + body = JSON.parse(text) + } catch (boom) { + body = text + } + report(body) + }) + .catch(() => report("~~~ unreadable ~~~")) + } + return { onConnect: () => { // register our monkey-patch XHRInterceptor.setSendCallback(onSend) XHRInterceptor.setResponseCallback(onResponse) XHRInterceptor.enableInterception() + + // expo/fetch (Expo SDK 56+) bypasses XHR, so instrument it too. + if (!options.ignoreExpoFetch) { + FetchInterceptor.setOpenCallback(onFetchOpen) + FetchInterceptor.setResponseCallback(onFetchResponse) + FetchInterceptor.enableInterception() + } }, } satisfies Plugin } From c64bf21f8992fcd82733d98973f4c33a9f59d0fa Mon Sep 17 00:00:00 2001 From: Joshua Yoes <37849890+joshuayoes@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:37:11 -0700 Subject: [PATCH 2/2] feat(reactotron-react-native): allow passing an explicit fetch to the networking plugin Adds a `fetch` option to networking() so apps can inject the exact fetch reference to track, skipping the expo.builtin symbol detection. Fixes the silent no-op on expo-router apps, where @expo/metro-runtime re-wraps the global fetch and drops the symbol. - enableInterception(fetchToWrap?) wraps the provided fn when given, auto-detects expo/fetch otherwise; early-returns when already enabled - the wrapper only re-stamps expo.builtin when the original carried it - verified at runtime on an expo-router SDK 57 iOS app: fetch traffic appears in the timeline with the option set, XHR reports exactly once Co-Authored-By: Claude Fable 5 --- .../src/fetch-interceptor.test.ts | 31 +++++++++++++++++++ .../src/fetch-interceptor.ts | 27 ++++++++++++---- .../src/plugins/networking.ts | 20 +++++++++++- 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/lib/reactotron-react-native/src/fetch-interceptor.test.ts b/lib/reactotron-react-native/src/fetch-interceptor.test.ts index fbf7e9cfe..3197ca226 100644 --- a/lib/reactotron-react-native/src/fetch-interceptor.test.ts +++ b/lib/reactotron-react-native/src/fetch-interceptor.test.ts @@ -101,6 +101,37 @@ describe("FetchInterceptor", () => { expect(globalThis.fetch).toBe(wrapped) }) + it("wraps an explicitly provided fetch even without the expo symbol", async () => { + const response = makeResponse(200, { "content-type": "application/json" }) + // no expo.builtin symbol — e.g. expo-router's re-wrapped global fetch + const routerWrapped: any = jest.fn(() => Promise.resolve(response)) + globalThis.fetch = routerWrapped + + const open = jest.fn() + FetchInterceptor.setOpenCallback(open) + FetchInterceptor.enableInterception(routerWrapped) + + expect(FetchInterceptor.isInterceptorEnabled()).toBe(true) + expect(globalThis.fetch).not.toBe(routerWrapped) + // the wrapper must not pretend to be the expo builtin + expect((globalThis.fetch as any)[EXPO_BUILTIN]).toBeUndefined() + + const result = await (globalThis.fetch as any)("https://example.com/y") + expect(result).toBe(response) + expect(routerWrapped).toHaveBeenCalledTimes(1) + expect(open).toHaveBeenCalledTimes(1) + expect(open.mock.calls[0][1]).toBe("https://example.com/y") + }) + + it("does not wrap an explicitly provided fetch twice", () => { + const fn: any = jest.fn() + globalThis.fetch = fn + FetchInterceptor.enableInterception(fn) + const wrapped = globalThis.fetch + FetchInterceptor.enableInterception(fn) + expect(globalThis.fetch).toBe(wrapped) + }) + it("restores the original fetch on disable", () => { const original = makeExpoFetch(() => Promise.resolve(makeResponse(200, {}))) globalThis.fetch = original diff --git a/lib/reactotron-react-native/src/fetch-interceptor.ts b/lib/reactotron-react-native/src/fetch-interceptor.ts index 3815b814e..a1ca4aa2d 100644 --- a/lib/reactotron-react-native/src/fetch-interceptor.ts +++ b/lib/reactotron-react-native/src/fetch-interceptor.ts @@ -118,11 +118,24 @@ export const FetchInterceptor = { return originalFetch !== null }, - enableInterception() { - const current = globalThis.fetch as ReactotronFetch | undefined - // Only wrap expo/fetch. RN's XHR-backed fetch is already covered by - // `XHRInterceptor`, and wrapping it here too would double-report. - if (!current || !isExpoFetch(current) || current.__reactotronWrapped) { + /** + * Wraps the global fetch and reassigns `globalThis.fetch`. + * + * With no argument, only expo/fetch is wrapped (detected by the + * `expo.builtin` symbol). RN's XHR-backed fetch is already covered by + * `XHRInterceptor`, and wrapping it here too would double-report. + * + * Pass `fetchToWrap` to skip the detection and wrap that function instead — + * for runtimes where the expo/fetch global has been re-wrapped and lost the + * symbol (e.g. expo-router's window.location polyfill). The caller is + * asserting the function does not go through XMLHttpRequest. + */ + enableInterception(fetchToWrap?: typeof fetch) { + if (originalFetch) { + return + } + const current = (fetchToWrap ?? globalThis.fetch) as ReactotronFetch | undefined + if (!current || current.__reactotronWrapped || (!fetchToWrap && !isExpoFetch(current))) { return } @@ -164,7 +177,9 @@ export const FetchInterceptor = { wrapped.__reactotronWrapped = true // Keep it detectable as the expo builtin fetch for anything else that checks. - wrapped[EXPO_BUILTIN] = true + if (isExpoFetch(current)) { + wrapped[EXPO_BUILTIN] = true + } globalThis.fetch = wrapped }, diff --git a/lib/reactotron-react-native/src/plugins/networking.ts b/lib/reactotron-react-native/src/plugins/networking.ts index a3fbbefd4..155d1ec0e 100644 --- a/lib/reactotron-react-native/src/plugins/networking.ts +++ b/lib/reactotron-react-native/src/plugins/networking.ts @@ -22,6 +22,18 @@ export interface NetworkingOptions { * where the global fetch is XHR-backed and already covered by XHR tracking. */ ignoreExpoFetch?: boolean + /** + * Explicitly pass the fetch function to track; it will be wrapped and + * installed as `globalThis.fetch` on connect, with no environment + * detection. Use this when the expo/fetch global has been re-wrapped and no + * longer carries the `expo.builtin` symbol (e.g. expo-router apps): + * + * .useReactNative({ networking: { fetch: globalThis.fetch } }) + * + * Only pass a fetch that does NOT go through XMLHttpRequest — an XHR-backed + * fetch is already tracked by the XHR interceptor and would double-report. + */ + fetch?: typeof fetch } const DEFAULTS: NetworkingOptions = {} @@ -262,7 +274,13 @@ const networking = XHRInterceptor.enableInterception() // expo/fetch (Expo SDK 56+) bypasses XHR, so instrument it too. - if (!options.ignoreExpoFetch) { + // An explicitly passed fetch skips detection; otherwise expo/fetch is + // auto-detected unless opted out. + if (options.fetch) { + FetchInterceptor.setOpenCallback(onFetchOpen) + FetchInterceptor.setResponseCallback(onFetchResponse) + FetchInterceptor.enableInterception(options.fetch) + } else if (!options.ignoreExpoFetch) { FetchInterceptor.setOpenCallback(onFetchOpen) FetchInterceptor.setResponseCallback(onFetchResponse) FetchInterceptor.enableInterception()