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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
- Copy `app.vitals.start.screen` and `app.vitals.start.type` onto standalone `app.start` children, including user spans under `app.start.extended` ([#6631](https://github.com/getsentry/sentry-react-native/pull/6631))
- `featureFlagsIntegration` now forwards flag evaluations to the native SDKs, so flags are attached to native crashes too ([#6613](https://github.com/getsentry/sentry-react-native/pull/6613))

### Internal

- Resolve Metro from the app's project root when generating source maps ([#6625](https://github.com/getsentry/sentry-react-native/pull/6625))

### Dependencies

- Bump Android SDK from v8.53.0 to v8.54.0 ([#6624](https://github.com/getsentry/sentry-react-native/pull/6624))
Expand Down
192 changes: 128 additions & 64 deletions packages/core/src/js/tools/vendor/metro/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,121 @@

import type { MixedOutput, Module, ReadOnlyGraph } from 'metro';
import type * as baseJSBundleType from 'metro/private/DeltaBundler/Serializers/baseJSBundle';
import type * as sourceMapStringType from 'metro/private/DeltaBundler/Serializers/sourceMapString';
import type * as bundleToStringType from 'metro/private/lib/bundleToString';

import type { MetroSerializer } from '../../utils';

type NewSourceMapStringExport = {
// Since Metro v0.80.10 https://github.com/facebook/metro/compare/v0.80.9...v0.80.10#diff-1b836d1729e527a725305eef0cec22e44605af2700fa413f4c2489ea1a03aebcL28
sourceMapString: typeof sourceMapStringType;
};
type SourceMapStringFunction = (
modules: readonly Module[],
options: {
processModuleFilter?: (module: Module<MixedOutput>) => boolean;
shouldAddToIgnoreList?: (module: Module<MixedOutput>) => boolean;
},
) => string;

// baseJSBundle and bundleToString are needed on every build (hot/dev and production). sourceMapString
// is resolved separately and lazily โ€” see resolveSourceMapString and createDefaultMetroSerializer.
interface ResolvedMetroInternals {
baseJSBundle: typeof baseJSBundleType;
bundleToString: typeof bundleToStringType;
}

/**
* Requires a Metro internal module, preferring the Metro used by the project being bundled
* (`projectRoot`) over the SDK's own Metro dev dependency, which would otherwise generate source
* maps with a mismatched (older) Metro version. In a normal install both resolve to the same Metro
* instance, so behavior is unchanged.
*
* Resolution is location-first: every candidate path shape is tried against the app
* (`projectRoot`) before falling back to the SDK's own location. Within a single location the
* newer `metro/private/*` path is preferred over the legacy `metro/src/*` path, since Metro moved
* its internals behind the `private` export. Ordering locations outside the path shapes is what
* guarantees the app's Metro wins even when the two copies expose their internals via different
* subpaths (e.g. app on `metro/src/*`, SDK on `metro/private/*`).
*/
// oxlint-disable-next-line typescript-eslint(no-explicit-any)
function requireMetroModule(candidates: string[], projectRoot: string | undefined): any {
const roots = projectRoot ? [projectRoot, __dirname] : [__dirname];
let lastError: unknown;
for (const root of roots) {
for (const candidate of candidates) {
try {
// the line below resolves `candidate` as Node would if
// required from `root`, without actually requiring it from there. That's what lets us
// pick up the app's node_modules Metro instead of the SDK's own, even though this code
// itself lives inside the SDK.
return require(require.resolve(candidate, { paths: [root] }));
} catch (e) {
lastError = e;
}
}
}
// Last resort: a bare require from the SDK's own module context. Preserves the previous
// fallback behavior for environments where `require.resolve` with an explicit `paths` cannot
// resolve a subpath that a plain `require` can. Runs only after every located attempt failed,
// so it can never shadow the app's Metro.
for (const candidate of candidates) {
try {
return require(candidate);
} catch (e) {
lastError = e;
}
}
throw lastError;
}

/**
* Normalizes a Metro internal module to its callable export, tolerating the different export
* shapes Metro has used over versions (bare function, named export, or default export). Throws a
* descriptive error when no callable can be found, so an unsupported Metro version fails loudly
* with an actionable message instead of a later opaque "x is not a function".
*/
// oxlint-disable-next-line typescript-eslint(no-explicit-any)
function toCallable(metroModule: any, namedExport: string): any {
const callable =
typeof metroModule === 'function' ? metroModule : (metroModule?.[namedExport] ?? metroModule?.default);
if (typeof callable !== 'function') {
throw new Error(
`[@sentry/react-native/metro] Could not resolve the '${namedExport}' function from Metro's internals. ` +
`Please check the version of Metro you are using and report the issue at ` +
`http://www.github.com/getsentry/sentry-react-native/issues`,
);
}
return callable;
}

function resolveMetroInternals(projectRoot: string | undefined): ResolvedMetroInternals {
const baseJSBundle: typeof baseJSBundleType = toCallable(
requireMetroModule(
['metro/private/DeltaBundler/Serializers/baseJSBundle', 'metro/src/DeltaBundler/Serializers/baseJSBundle'],
projectRoot,
),
'baseJSBundle',
);

const bundleToString: typeof bundleToStringType = toCallable(
requireMetroModule(['metro/private/lib/bundleToString', 'metro/src/lib/bundleToString'], projectRoot),
'bundleToString',
);

return { baseJSBundle, bundleToString };
}

/**
* Resolves Metro's `sourceMapString` internal. Kept separate from resolveMetroInternals and resolved
* lazily on the first non-hot build: source maps are only generated for production bundles, so a Metro
* whose `sourceMapString` shape/path we can't resolve must not break the dev server (`yarn start`),
* where this function is never called.
*/
function resolveSourceMapString(projectRoot: string | undefined): SourceMapStringFunction {
return toCallable(
requireMetroModule(
['metro/private/DeltaBundler/Serializers/sourceMapString', 'metro/src/DeltaBundler/Serializers/sourceMapString'],
projectRoot,
),
'sourceMapString',
);
}

/**
* This function ensures that modules in source maps are sorted in the same
Expand Down Expand Up @@ -69,50 +175,20 @@ export const getSortedModules = (
* https://github.com/facebook/metro/blob/9b85f83c9cc837d8cd897aa7723be7da5b296067/packages/metro/src/Server.js#L244-L277
*/
export const createDefaultMetroSerializer = (): MetroSerializer => {
// Lazy-load Metro internals only when serializer is created
// This defers requiring Metro modules until they're actually needed (during build),
// avoiding import-time failures when Metro is only a transitive dependency

// oxlint-disable-next-line typescript-eslint(no-explicit-any)
let baseJSBundleModule: any;
try {
baseJSBundleModule = require('metro/private/DeltaBundler/Serializers/baseJSBundle');
} catch {
baseJSBundleModule = require('metro/src/DeltaBundler/Serializers/baseJSBundle');
}
// Lazy-load Metro internals on the first serialization rather than at import or serializer
// creation time. This defers requiring Metro until it's actually needed (during build) and,
// crucially, until `options.projectRoot` is available so we can resolve the Metro used by the
// app being bundled. Resolved once and memoized for subsequent bundles.
let internals: ResolvedMetroInternals | undefined;
// Resolved lazily on the first non-hot build (see resolveSourceMapString) and memoized after.
let sourceMapString: SourceMapStringFunction | undefined;

const baseJSBundle: typeof baseJSBundleType =
typeof baseJSBundleModule === 'function'
? baseJSBundleModule
: (baseJSBundleModule?.baseJSBundle ?? baseJSBundleModule?.default);

let sourceMapString: typeof sourceMapStringType;
try {
const sourceMapStringModule = require('metro/private/DeltaBundler/Serializers/sourceMapString');
sourceMapString = (sourceMapStringModule as { sourceMapString: typeof sourceMapStringType }).sourceMapString;
} catch (e) {
sourceMapString = require('metro/src/DeltaBundler/Serializers/sourceMapString');
if ('sourceMapString' in sourceMapString) {
// Changed to named export in https://github.com/facebook/metro/commit/34148e61200a508923315fbe387b26d1da27bf4b
// Metro 0.81.0 and 0.80.10 patch
sourceMapString = (sourceMapString as { sourceMapString: typeof sourceMapStringType }).sourceMapString;
return (entryPoint, preModules, graph, options) => {
if (!internals) {
internals = resolveMetroInternals(options.projectRoot);
}
}

// oxlint-disable-next-line typescript-eslint(no-explicit-any)
let bundleToStringModule: any;
try {
bundleToStringModule = require('metro/private/lib/bundleToString');
} catch {
bundleToStringModule = require('metro/src/lib/bundleToString');
}
const { baseJSBundle, bundleToString } = internals;

const bundleToString: typeof bundleToStringType =
typeof bundleToStringModule === 'function'
? bundleToStringModule
: (bundleToStringModule?.bundleToString ?? bundleToStringModule?.default);

return (entryPoint, preModules, graph, options) => {
// baseJSBundle assigns IDs to modules in a consistent order
let bundle = baseJSBundle(entryPoint, preModules, graph, options);
const isHot = 'hot' in graph.transformOptions ? graph.transformOptions.hot : graph.transformOptions.dev;
Expand All @@ -125,25 +201,13 @@ export const createDefaultMetroSerializer = (): MetroSerializer => {
return code;
}

let sourceMapStringFunction: typeof sourceMapString | undefined;
if (typeof sourceMapString === 'function') {
sourceMapStringFunction = sourceMapString;
} else if (
typeof sourceMapString === 'object' &&
sourceMapString != null &&
'sourceMapString' in sourceMapString &&
typeof sourceMapString['sourceMapString'] === 'function'
) {
sourceMapStringFunction = (sourceMapString as NewSourceMapStringExport).sourceMapString;
} else {
throw new Error(`
[@sentry/react-native/metro] Cannot find sourceMapString function in 'metro/src/DeltaBundler/Serializers/sourceMapString'.
Please check the version of Metro you are using and report the issue at http://www.github.com/getsentry/sentry-react-native/issues
`);
// Always generate source maps, can't use Sentry without source maps. sourceMapString is resolved
// here rather than with the other internals so that an unresolvable sourceMapString can't break
// the dev server (`yarn start`), where this non-hot path never runs.
if (!sourceMapString) {
sourceMapString = resolveSourceMapString(options.projectRoot);
}

// Always generate source maps, can't use Sentry without source maps
const map = sourceMapStringFunction([...preModules, ...getSortedModules(graph, options)], {
const map = sourceMapString([...preModules, ...getSortedModules(graph, options)], {
processModuleFilter: options.processModuleFilter,
shouldAddToIgnoreList: options.shouldAddToIgnoreList || (() => false),
});
Expand Down
118 changes: 118 additions & 0 deletions packages/core/test/tools/sentryMetroSerializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { MixedOutput, Module } from 'metro';
import * as fs from 'fs';
import CountingSet from 'metro/private/lib/CountingSet';
import countLines from 'metro/private/lib/countLines';
import * as os from 'os';
import * as path from 'path';
import { minify } from 'uglify-js';

import { createSentryMetroSerializer } from '../../src/js/tools/sentryMetroSerializer';
Expand Down Expand Up @@ -258,6 +260,122 @@ describe('Sentry Metro Serializer', () => {
expect(result.code).toBeDefined();
expect(result.map).toBeDefined();
});

describe('resolves Metro internals from the project root', () => {
// See: https://github.com/getsentry/sentry-react-native/pull/6625
// The default serializer must load Metro internals from the app being bundled (`options.projectRoot`)
// rather than the Metro resolvable from the SDK's own location. Otherwise, when a different Metro
// version is nested under the SDK (monorepo / from-source install), source maps are generated with
// the wrong Metro.
const createdFixtures: string[] = [];

afterEach(() => {
while (createdFixtures.length) {
fs.rmSync(createdFixtures.pop() as string, { recursive: true, force: true });
}
});

// Writes a fake `metro` package to a temp project root, exposing the three internals the default
// serializer needs. `layout` selects whether they are exposed via the newer `metro/private/*` path
// or the legacy `metro/src/*` path. Each internal is a sentinel so we can assert which Metro ran.
function writeFakeMetro(marker: string, layout: 'private' | 'src', brokenSourceMap = false): string {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-metro-fixture-')));
createdFixtures.push(root);

const write = (rel: string, contents: string): void => {
const abs = path.join(root, 'node_modules', 'metro', layout, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, contents);
};

// No `exports` map: subpaths resolve directly to real files under the chosen layout.
fs.mkdirSync(path.join(root, 'node_modules', 'metro'), { recursive: true });
fs.writeFileSync(
path.join(root, 'node_modules', 'metro', 'package.json'),
JSON.stringify({ name: 'metro', version: `0.0.0-${marker}` }),
);
write('DeltaBundler/Serializers/baseJSBundle.js', 'module.exports = { baseJSBundle: () => ({}) };');
write('lib/bundleToString.js', `module.exports = { bundleToString: () => ({ code: '${marker}_CODE' }) };`);
write(
'DeltaBundler/Serializers/sourceMapString.js',
// `brokenSourceMap` exposes a non-callable sourceMapString to simulate a Metro whose shape/path
// we can't resolve, used to assert the hot path doesn't touch it.
brokenSourceMap
? 'module.exports = { notSourceMapString: 1 };'
: `module.exports = { sourceMapString: () => '${marker}_MAP' };`,
);

return root;
}

function serializeWithProjectRootHot(projectRoot: string): unknown {
const { createDefaultMetroSerializer } = require('../../src/js/tools/vendor/metro/utils');
const serializer = createDefaultMetroSerializer();
const [entryPoint, preModules, graph, options] = mockMinSerializerArgs();
return serializer(
entryPoint,
preModules,
{ ...graph, transformOptions: { ...graph.transformOptions, hot: true } },
{ ...options, projectRoot, sentryBundleCallback: undefined },
);
}

function serializeWithProjectRoot(projectRoot: string): { code: unknown; map: unknown } {
const { createDefaultMetroSerializer } = require('../../src/js/tools/vendor/metro/utils');
const serializer = createDefaultMetroSerializer();
const [entryPoint, preModules, graph, options] = mockMinSerializerArgs();
return serializer(
entryPoint,
preModules,
{ ...graph, transformOptions: { ...graph.transformOptions, hot: false } },
{
...options,
projectRoot,
sentryBundleCallback: undefined,
},
);
}

test("prefers the app's Metro at projectRoot over the SDK's Metro", () => {
const appRoot = writeFakeMetro('APP', 'private');

const result = serializeWithProjectRoot(appRoot);

// Sentinel output proves the fake Metro at projectRoot ran, not the real Metro resolvable from the SDK.
expect(result.code).toBe('APP_CODE');
expect(result.map).toBe('APP_MAP');
});

test("uses the app's Metro even when it only exposes internals via metro/src/* and the SDK exposes metro/private/*", () => {
// Regression guard for the resolution-order bug: the app's Metro must win by location, even though
// the SDK's real Metro exposes the newer `metro/private/*` path shape and the app's only exposes
// the legacy `metro/src/*` path shape. Ordering path shape above location would pick the SDK's Metro.
const appRoot = writeFakeMetro('APPSRC', 'src');

const result = serializeWithProjectRoot(appRoot);

expect(result.code).toBe('APPSRC_CODE');
expect(result.map).toBe('APPSRC_MAP');
});

test('resolves sourceMapString lazily, so the hot/dev path works even if sourceMapString is unresolvable', () => {
// Regression guard for the dev-server break: sourceMapString is only used for non-hot (production)
// builds, so an unresolvable sourceMapString must not throw during `yarn start`.
const appRoot = writeFakeMetro('HOT', 'private', /* brokenSourceMap */ true);

const result = serializeWithProjectRootHot(appRoot);

// Hot path returns code only and must not have thrown resolving the broken sourceMapString.
expect(result).toBe('HOT_CODE');
});

test('still throws for an unresolvable sourceMapString on the non-hot path', () => {
// The guard must still fire where sourceMapString is actually needed.
const appRoot = writeFakeMetro('COLD', 'private', /* brokenSourceMap */ true);

expect(() => serializeWithProjectRoot(appRoot)).toThrow(/sourceMapString/);
});
});
});

function mockMinSerializerArgs(options?: {
Expand Down
2 changes: 1 addition & 1 deletion samples/react-native/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ android {
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
testProguardFiles "proguard-rules.pro"
proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro"
}
Expand Down
6 changes: 3 additions & 3 deletions samples/react-native/android/build.gradle
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@

buildscript {
ext {
buildToolsVersion = "36.0.0"
buildToolsVersion = "37.0.0"
minSdkVersion = 24
compileSdkVersion = 36
compileSdkVersion = 37
targetSdkVersion = 36
ndkVersion = "27.1.12297006"
kotlinVersion = "2.1.20"
kotlinVersion = "2.2.0"
}
repositories {
google()
Expand Down
5 changes: 5 additions & 0 deletions samples/react-native/android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,8 @@ hermesEnabled=true
# It's used for testing the native SDK auto-start feature.
# true means manual native start is disabled and JS auto initializes native SDK.
sentryDisableNativeStart=false

# Opt out of built-in kotlin and new DSL behavior that ships with AGP 9.
# Starting from AGP 10.x these opt outs will be removed.
android.builtInKotlin=false
android.newDsl=false
Loading
Loading