Skip to content

Commit 710de56

Browse files
committed
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
1 parent 502b064 commit 710de56

9 files changed

Lines changed: 261 additions & 12 deletions

File tree

.changeset/olive-cobras-listen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-native-grab": patch
3+
---
4+
5+
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.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const sourceCode: {
4+
getConstants?: () => { scriptURL?: string | null };
5+
scriptURL?: string | null;
6+
} = {};
7+
8+
vi.mock("react-native", () => ({
9+
NativeModules: {
10+
get SourceCode() {
11+
return sourceCode;
12+
},
13+
},
14+
}));
15+
16+
import { getDevServer, resetDevServerCache } from "../dev-server";
17+
18+
const setScriptUrl = (scriptURL: string | null) => {
19+
sourceCode.getConstants = () => ({ scriptURL });
20+
};
21+
22+
beforeEach(() => {
23+
resetDevServerCache();
24+
delete sourceCode.getConstants;
25+
delete sourceCode.scriptURL;
26+
});
27+
28+
afterEach(() => {
29+
resetDevServerCache();
30+
});
31+
32+
describe("getDevServer", () => {
33+
it("derives the dev server origin from the bundle scriptURL", () => {
34+
setScriptUrl("http://192.168.0.10:8081/index.bundle?platform=ios&dev=true");
35+
36+
expect(getDevServer()).toEqual({
37+
url: "http://192.168.0.10:8081/",
38+
fullBundleUrl: "http://192.168.0.10:8081/index.bundle?platform=ios&dev=true",
39+
bundleLoadedFromServer: true,
40+
});
41+
});
42+
43+
it("falls back to localhost when the bundle was not loaded from Metro", () => {
44+
setScriptUrl("file:///var/containers/Bundle/Application/main.jsbundle");
45+
46+
expect(getDevServer()).toEqual({
47+
url: "http://localhost:8081/",
48+
fullBundleUrl: null,
49+
bundleLoadedFromServer: false,
50+
});
51+
});
52+
53+
it("reads scriptURL as a plain constant when getConstants is unavailable", () => {
54+
sourceCode.scriptURL = "https://localhost:8082/index.bundle";
55+
56+
expect(getDevServer().url).toBe("https://localhost:8082/");
57+
});
58+
59+
it("falls back when the SourceCode module throws", () => {
60+
sourceCode.getConstants = () => {
61+
throw new Error("bridge unavailable");
62+
};
63+
64+
expect(getDevServer().bundleLoadedFromServer).toBe(false);
65+
});
66+
67+
it("caches the resolved URL across calls", () => {
68+
const getConstants = vi.fn(() => ({ scriptURL: "http://localhost:8081/index.bundle" }));
69+
sourceCode.getConstants = getConstants;
70+
71+
getDevServer();
72+
getDevServer();
73+
74+
expect(getConstants).toHaveBeenCalledTimes(1);
75+
});
76+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const devServer = {
4+
url: "http://localhost:8081/",
5+
fullBundleUrl: "http://localhost:8081/index.bundle",
6+
bundleLoadedFromServer: true,
7+
};
8+
9+
vi.mock("../dev-server", () => ({
10+
getDevServer: () => devServer,
11+
}));
12+
13+
import { symbolicateStackTrace } from "../symbolicate";
14+
15+
const frame = {
16+
methodName: "Counter",
17+
file: "http://localhost:8081/index.bundle",
18+
lineNumber: 12,
19+
column: 3,
20+
};
21+
22+
beforeEach(() => {
23+
devServer.bundleLoadedFromServer = true;
24+
});
25+
26+
afterEach(() => {
27+
vi.unstubAllGlobals();
28+
});
29+
30+
describe("symbolicateStackTrace", () => {
31+
it("posts the stack to Metro and returns the symbolicated result", async () => {
32+
const symbolicated = { stack: [{ ...frame, file: "src/Counter.tsx" }], codeFrame: null };
33+
const fetchMock = vi.fn(async () => ({
34+
ok: true,
35+
status: 200,
36+
json: async () => symbolicated,
37+
}));
38+
vi.stubGlobal("fetch", fetchMock);
39+
40+
await expect(symbolicateStackTrace([frame], { extra: true })).resolves.toEqual(symbolicated);
41+
42+
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
43+
expect(url).toBe("http://localhost:8081/symbolicate");
44+
expect(init.method).toBe("POST");
45+
expect(JSON.parse(init.body as string)).toEqual({
46+
stack: [frame],
47+
extraData: { extra: true },
48+
});
49+
});
50+
51+
it("throws when the bundle was not loaded from Metro", async () => {
52+
devServer.bundleLoadedFromServer = false;
53+
const fetchMock = vi.fn();
54+
vi.stubGlobal("fetch", fetchMock);
55+
56+
await expect(symbolicateStackTrace([frame])).rejects.toThrow(
57+
"Bundle was not loaded from Metro",
58+
);
59+
expect(fetchMock).not.toHaveBeenCalled();
60+
});
61+
62+
it("throws when Metro responds with an error status", async () => {
63+
vi.stubGlobal(
64+
"fetch",
65+
vi.fn(async () => ({ ok: false, status: 500, json: async () => ({}) })),
66+
);
67+
68+
await expect(symbolicateStackTrace([frame])).rejects.toThrow(
69+
"Symbolicate request failed with status 500",
70+
);
71+
});
72+
});

src/react-native/copy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import getDevServer from "react-native/Libraries/Core/Devtools/getDevServer";
1+
import { getDevServer } from "./dev-server";
22

33
const DEFAULT_COPY_ENDPOINT = "/__react-native-grab/copy";
44

src/react-native/dev-server.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { NativeModules } from "react-native";
2+
3+
export type DevServerInfo = {
4+
url: string;
5+
fullBundleUrl: string | null;
6+
bundleLoadedFromServer: boolean;
7+
};
8+
9+
const FALLBACK_URL = "http://localhost:8081/";
10+
11+
type SourceCodeModule = {
12+
getConstants?: () => { scriptURL?: string | null };
13+
scriptURL?: string | null;
14+
};
15+
16+
let cachedDevServerUrl: string | null | undefined;
17+
let cachedFullBundleUrl: string | null;
18+
19+
/**
20+
* `scriptURL` is exposed through `getConstants()` on the New Architecture, but
21+
* older React Native versions only expose it as a plain constant on the module.
22+
*/
23+
const getScriptUrl = (): string | null => {
24+
const sourceCode = (NativeModules as { SourceCode?: SourceCodeModule }).SourceCode;
25+
if (!sourceCode) return null;
26+
27+
try {
28+
return sourceCode.getConstants?.().scriptURL ?? sourceCode.scriptURL ?? null;
29+
} catch {
30+
return null;
31+
}
32+
};
33+
34+
/**
35+
* Resolves the Metro dev server URL without deep-importing
36+
* `react-native/Libraries/Core/Devtools/getDevServer`, which is no longer part
37+
* of React Native's public API. `NativeModules` is a public root export, so
38+
* this keeps working across the Strict API cutover in 0.87.
39+
*/
40+
export const getDevServer = (): DevServerInfo => {
41+
if (cachedDevServerUrl === undefined) {
42+
const scriptUrl = getScriptUrl();
43+
const match = scriptUrl?.match(/^https?:\/\/.*?\//);
44+
cachedDevServerUrl = match ? match[0] : null;
45+
cachedFullBundleUrl = match ? (scriptUrl as string) : null;
46+
}
47+
48+
return {
49+
url: cachedDevServerUrl ?? FALLBACK_URL,
50+
fullBundleUrl: cachedFullBundleUrl,
51+
bundleLoadedFromServer: cachedDevServerUrl !== null,
52+
};
53+
};
54+
55+
export const resetDevServerCache = (): void => {
56+
cachedDevServerUrl = undefined;
57+
cachedFullBundleUrl = null;
58+
};

src/react-native/get-dev-server.d.ts

Lines changed: 0 additions & 9 deletions
This file was deleted.

src/react-native/get-rendered-by.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import symbolicateStackTrace from "react-native/Libraries/Core/Devtools/symbolicateStackTrace";
1+
import { symbolicateStackTrace } from "./symbolicate";
22
import { ReactNativeFiberNode } from "./types";
33

44
export type RenderedByFrame = {

src/react-native/open.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import getDevServer from "react-native/Libraries/Core/Devtools/getDevServer";
1+
import { getDevServer } from "./dev-server";
22

33
type OpenFramePayload = {
44
file: string;

src/react-native/symbolicate.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { getDevServer } from "./dev-server";
2+
3+
export type StackFrame = {
4+
methodName: string;
5+
file: string | null | undefined;
6+
lineNumber: number | null | undefined;
7+
column: number | null | undefined;
8+
collapse?: boolean;
9+
};
10+
11+
export type CodeFrame = {
12+
content: string;
13+
location: { row: number; column: number } | null;
14+
fileName: string;
15+
};
16+
17+
export type SymbolicatedStackTrace = {
18+
stack: StackFrame[];
19+
codeFrame: CodeFrame | null;
20+
};
21+
22+
/**
23+
* Posts a stack to Metro's `symbolicate` endpoint, replacing the deep import of
24+
* `react-native/Libraries/Core/Devtools/symbolicateStackTrace`. That module has
25+
* no public replacement, but it is a thin wrapper over this request.
26+
*/
27+
export const symbolicateStackTrace = async (
28+
stack: StackFrame[],
29+
extraData?: unknown,
30+
): Promise<SymbolicatedStackTrace> => {
31+
const devServer = getDevServer();
32+
if (!devServer.bundleLoadedFromServer) {
33+
throw new Error("Bundle was not loaded from Metro.");
34+
}
35+
36+
const response = await fetch(`${devServer.url}symbolicate`, {
37+
method: "POST",
38+
headers: { "Content-Type": "application/json" },
39+
body: JSON.stringify({ stack, extraData }),
40+
});
41+
42+
if (!response.ok) {
43+
throw new Error(`Symbolicate request failed with status ${response.status}`);
44+
}
45+
46+
return (await response.json()) as SymbolicatedStackTrace;
47+
};

0 commit comments

Comments
 (0)