From 710de565f69eb525bed48cc4f03ff354b12a6934 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 31 Aug 2026 14:31:44 +0200 Subject: [PATCH] fix: remove deep imports from 'react-native' React Native 0.80 deprecated deep imports from `react-native/Libraries/*`, and 0.87 enables the Strict TypeScript API by default, which blocks them at the type level. `./Libraries/*` still resolves at runtime, but it is now explicitly outside the public API contract. Replace both internal modules with local implementations built on the public API: - `dev-server.ts` derives the Metro origin from `NativeModules.SourceCode.getConstants().scriptURL`, matching upstream's cache and localhost fallback semantics. `NativeModules` is a public root export of the Strict API. - `symbolicate.ts` posts directly to Metro's `symbolicate` endpoint, which is all the upstream module did. The alternative, `react-native/unstable-internals-do-not-use`, exports `getDevServer` but not `symbolicateStackTrace`, requires consumers to set a `customConditions` entry in their tsconfig, and does not exist before 0.87. Also drops the now-unneeded ambient `declare module` shim. Closes #17 Claude-Session: https://claude.ai/code/session_01KDiabfZtYE2vxCeybZnA4D --- .changeset/olive-cobras-listen.md | 5 ++ src/react-native/__tests__/dev-server.test.ts | 76 +++++++++++++++++++ .../__tests__/symbolicate.test.ts | 72 ++++++++++++++++++ src/react-native/copy.ts | 2 +- src/react-native/dev-server.ts | 58 ++++++++++++++ src/react-native/get-dev-server.d.ts | 9 --- src/react-native/get-rendered-by.ts | 2 +- src/react-native/open.ts | 2 +- src/react-native/symbolicate.ts | 47 ++++++++++++ 9 files changed, 261 insertions(+), 12 deletions(-) create mode 100644 .changeset/olive-cobras-listen.md create mode 100644 src/react-native/__tests__/dev-server.test.ts create mode 100644 src/react-native/__tests__/symbolicate.test.ts create mode 100644 src/react-native/dev-server.ts delete mode 100644 src/react-native/get-dev-server.d.ts create mode 100644 src/react-native/symbolicate.ts diff --git a/.changeset/olive-cobras-listen.md b/.changeset/olive-cobras-listen.md new file mode 100644 index 0000000..426b7ef --- /dev/null +++ b/.changeset/olive-cobras-listen.md @@ -0,0 +1,5 @@ +--- +"react-native-grab": patch +--- + +Remove deep imports from `react-native`. `getDevServer` and `symbolicateStackTrace` are now implemented locally on top of the public `NativeModules` export and Metro's `symbolicate` endpoint, so the deprecation warnings are gone and the library keeps working under the Strict API that React Native 0.87 enables by default. diff --git a/src/react-native/__tests__/dev-server.test.ts b/src/react-native/__tests__/dev-server.test.ts new file mode 100644 index 0000000..0619ab7 --- /dev/null +++ b/src/react-native/__tests__/dev-server.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const sourceCode: { + getConstants?: () => { scriptURL?: string | null }; + scriptURL?: string | null; +} = {}; + +vi.mock("react-native", () => ({ + NativeModules: { + get SourceCode() { + return sourceCode; + }, + }, +})); + +import { getDevServer, resetDevServerCache } from "../dev-server"; + +const setScriptUrl = (scriptURL: string | null) => { + sourceCode.getConstants = () => ({ scriptURL }); +}; + +beforeEach(() => { + resetDevServerCache(); + delete sourceCode.getConstants; + delete sourceCode.scriptURL; +}); + +afterEach(() => { + resetDevServerCache(); +}); + +describe("getDevServer", () => { + it("derives the dev server origin from the bundle scriptURL", () => { + setScriptUrl("http://192.168.0.10:8081/index.bundle?platform=ios&dev=true"); + + expect(getDevServer()).toEqual({ + url: "http://192.168.0.10:8081/", + fullBundleUrl: "http://192.168.0.10:8081/index.bundle?platform=ios&dev=true", + bundleLoadedFromServer: true, + }); + }); + + it("falls back to localhost when the bundle was not loaded from Metro", () => { + setScriptUrl("file:///var/containers/Bundle/Application/main.jsbundle"); + + expect(getDevServer()).toEqual({ + url: "http://localhost:8081/", + fullBundleUrl: null, + bundleLoadedFromServer: false, + }); + }); + + it("reads scriptURL as a plain constant when getConstants is unavailable", () => { + sourceCode.scriptURL = "https://localhost:8082/index.bundle"; + + expect(getDevServer().url).toBe("https://localhost:8082/"); + }); + + it("falls back when the SourceCode module throws", () => { + sourceCode.getConstants = () => { + throw new Error("bridge unavailable"); + }; + + expect(getDevServer().bundleLoadedFromServer).toBe(false); + }); + + it("caches the resolved URL across calls", () => { + const getConstants = vi.fn(() => ({ scriptURL: "http://localhost:8081/index.bundle" })); + sourceCode.getConstants = getConstants; + + getDevServer(); + getDevServer(); + + expect(getConstants).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/react-native/__tests__/symbolicate.test.ts b/src/react-native/__tests__/symbolicate.test.ts new file mode 100644 index 0000000..8197965 --- /dev/null +++ b/src/react-native/__tests__/symbolicate.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const devServer = { + url: "http://localhost:8081/", + fullBundleUrl: "http://localhost:8081/index.bundle", + bundleLoadedFromServer: true, +}; + +vi.mock("../dev-server", () => ({ + getDevServer: () => devServer, +})); + +import { symbolicateStackTrace } from "../symbolicate"; + +const frame = { + methodName: "Counter", + file: "http://localhost:8081/index.bundle", + lineNumber: 12, + column: 3, +}; + +beforeEach(() => { + devServer.bundleLoadedFromServer = true; +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("symbolicateStackTrace", () => { + it("posts the stack to Metro and returns the symbolicated result", async () => { + const symbolicated = { stack: [{ ...frame, file: "src/Counter.tsx" }], codeFrame: null }; + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => symbolicated, + })); + vi.stubGlobal("fetch", fetchMock); + + await expect(symbolicateStackTrace([frame], { extra: true })).resolves.toEqual(symbolicated); + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("http://localhost:8081/symbolicate"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + stack: [frame], + extraData: { extra: true }, + }); + }); + + it("throws when the bundle was not loaded from Metro", async () => { + devServer.bundleLoadedFromServer = false; + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(symbolicateStackTrace([frame])).rejects.toThrow( + "Bundle was not loaded from Metro", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("throws when Metro responds with an error status", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 500, json: async () => ({}) })), + ); + + await expect(symbolicateStackTrace([frame])).rejects.toThrow( + "Symbolicate request failed with status 500", + ); + }); +}); diff --git a/src/react-native/copy.ts b/src/react-native/copy.ts index 8cd12c6..4cba6fd 100644 --- a/src/react-native/copy.ts +++ b/src/react-native/copy.ts @@ -1,4 +1,4 @@ -import getDevServer from "react-native/Libraries/Core/Devtools/getDevServer"; +import { getDevServer } from "./dev-server"; const DEFAULT_COPY_ENDPOINT = "/__react-native-grab/copy"; diff --git a/src/react-native/dev-server.ts b/src/react-native/dev-server.ts new file mode 100644 index 0000000..c81d3d9 --- /dev/null +++ b/src/react-native/dev-server.ts @@ -0,0 +1,58 @@ +import { NativeModules } from "react-native"; + +export type DevServerInfo = { + url: string; + fullBundleUrl: string | null; + bundleLoadedFromServer: boolean; +}; + +const FALLBACK_URL = "http://localhost:8081/"; + +type SourceCodeModule = { + getConstants?: () => { scriptURL?: string | null }; + scriptURL?: string | null; +}; + +let cachedDevServerUrl: string | null | undefined; +let cachedFullBundleUrl: string | null; + +/** + * `scriptURL` is exposed through `getConstants()` on the New Architecture, but + * older React Native versions only expose it as a plain constant on the module. + */ +const getScriptUrl = (): string | null => { + const sourceCode = (NativeModules as { SourceCode?: SourceCodeModule }).SourceCode; + if (!sourceCode) return null; + + try { + return sourceCode.getConstants?.().scriptURL ?? sourceCode.scriptURL ?? null; + } catch { + return null; + } +}; + +/** + * Resolves the Metro dev server URL without deep-importing + * `react-native/Libraries/Core/Devtools/getDevServer`, which is no longer part + * of React Native's public API. `NativeModules` is a public root export, so + * this keeps working across the Strict API cutover in 0.87. + */ +export const getDevServer = (): DevServerInfo => { + if (cachedDevServerUrl === undefined) { + const scriptUrl = getScriptUrl(); + const match = scriptUrl?.match(/^https?:\/\/.*?\//); + cachedDevServerUrl = match ? match[0] : null; + cachedFullBundleUrl = match ? (scriptUrl as string) : null; + } + + return { + url: cachedDevServerUrl ?? FALLBACK_URL, + fullBundleUrl: cachedFullBundleUrl, + bundleLoadedFromServer: cachedDevServerUrl !== null, + }; +}; + +export const resetDevServerCache = (): void => { + cachedDevServerUrl = undefined; + cachedFullBundleUrl = null; +}; diff --git a/src/react-native/get-dev-server.d.ts b/src/react-native/get-dev-server.d.ts deleted file mode 100644 index 91c1af5..0000000 --- a/src/react-native/get-dev-server.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -declare module "react-native/Libraries/Core/Devtools/getDevServer" { - type DevServerInfo = { - url: string; - fullBundleUrl: string | null; - bundleLoadedFromServer: boolean; - }; - - export default function getDevServer(): DevServerInfo; -} diff --git a/src/react-native/get-rendered-by.ts b/src/react-native/get-rendered-by.ts index 1feffed..2c6aaf9 100644 --- a/src/react-native/get-rendered-by.ts +++ b/src/react-native/get-rendered-by.ts @@ -1,4 +1,4 @@ -import symbolicateStackTrace from "react-native/Libraries/Core/Devtools/symbolicateStackTrace"; +import { symbolicateStackTrace } from "./symbolicate"; import { ReactNativeFiberNode } from "./types"; export type RenderedByFrame = { diff --git a/src/react-native/open.ts b/src/react-native/open.ts index 29afa6a..6e2cc2e 100644 --- a/src/react-native/open.ts +++ b/src/react-native/open.ts @@ -1,4 +1,4 @@ -import getDevServer from "react-native/Libraries/Core/Devtools/getDevServer"; +import { getDevServer } from "./dev-server"; type OpenFramePayload = { file: string; diff --git a/src/react-native/symbolicate.ts b/src/react-native/symbolicate.ts new file mode 100644 index 0000000..a39a466 --- /dev/null +++ b/src/react-native/symbolicate.ts @@ -0,0 +1,47 @@ +import { getDevServer } from "./dev-server"; + +export type StackFrame = { + methodName: string; + file: string | null | undefined; + lineNumber: number | null | undefined; + column: number | null | undefined; + collapse?: boolean; +}; + +export type CodeFrame = { + content: string; + location: { row: number; column: number } | null; + fileName: string; +}; + +export type SymbolicatedStackTrace = { + stack: StackFrame[]; + codeFrame: CodeFrame | null; +}; + +/** + * Posts a stack to Metro's `symbolicate` endpoint, replacing the deep import of + * `react-native/Libraries/Core/Devtools/symbolicateStackTrace`. That module has + * no public replacement, but it is a thin wrapper over this request. + */ +export const symbolicateStackTrace = async ( + stack: StackFrame[], + extraData?: unknown, +): Promise => { + const devServer = getDevServer(); + if (!devServer.bundleLoadedFromServer) { + throw new Error("Bundle was not loaded from Metro."); + } + + const response = await fetch(`${devServer.url}symbolicate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stack, extraData }), + }); + + if (!response.ok) { + throw new Error(`Symbolicate request failed with status ${response.status}`); + } + + return (await response.json()) as SymbolicatedStackTrace; +};