Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/olive-cobras-listen.md
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 76 additions & 0 deletions src/react-native/__tests__/dev-server.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
72 changes: 72 additions & 0 deletions src/react-native/__tests__/symbolicate.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
2 changes: 1 addition & 1 deletion src/react-native/copy.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
58 changes: 58 additions & 0 deletions src/react-native/dev-server.ts
Original file line number Diff line number Diff line change
@@ -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;
};
9 changes: 0 additions & 9 deletions src/react-native/get-dev-server.d.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/react-native/get-rendered-by.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import symbolicateStackTrace from "react-native/Libraries/Core/Devtools/symbolicateStackTrace";
import { symbolicateStackTrace } from "./symbolicate";
import { ReactNativeFiberNode } from "./types";

export type RenderedByFrame = {
Expand Down
2 changes: 1 addition & 1 deletion src/react-native/open.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import getDevServer from "react-native/Libraries/Core/Devtools/getDevServer";
import { getDevServer } from "./dev-server";

type OpenFramePayload = {
file: string;
Expand Down
47 changes: 47 additions & 0 deletions src/react-native/symbolicate.ts
Original file line number Diff line number Diff line change
@@ -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<SymbolicatedStackTrace> => {
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;
};
Loading