Skip to content
Open
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
171 changes: 107 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,107 @@

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;

interface ResolvedMetroInternals {
baseJSBundle: typeof baseJSBundleType;
bundleToString: typeof bundleToStringType;
sourceMapString: SourceMapStringFunction;
}

/**
* Requires a Metro internal module, preferring the Metro used by the project being bundled
* (`projectRoot`) over the one resolvable from the SDK. In a normal install these are the same
* Metro instance, so behavior is unchanged. In this monorepo the SDK has its own Metro dev
* dependency that would otherwise shadow the app's Metro and generate source maps with a
* mismatched (older) Metro version.
*
* 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 {
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;
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* 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).
*/
// oxlint-disable-next-line typescript-eslint(no-explicit-any)
function toCallable(metroModule: any, namedExport: string): any {
if (typeof metroModule === 'function') {
return metroModule;
}
return metroModule?.[namedExport] ?? metroModule?.default;
}

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',
);

const sourceMapString: SourceMapStringFunction = toCallable(
requireMetroModule(
['metro/private/DeltaBundler/Serializers/sourceMapString', 'metro/src/DeltaBundler/Serializers/sourceMapString'],
projectRoot,
),
'sourceMapString',
);
if (typeof sourceMapString !== 'function') {
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
`);
}

return { baseJSBundle, bundleToString, sourceMapString };
}

/**
* This function ensures that modules in source maps are sorted in the same
Expand Down Expand Up @@ -69,50 +161,18 @@ 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;

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);
}
}
const { baseJSBundle, bundleToString, sourceMapString } = internals;

// 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 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 +185,8 @@ 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
const map = sourceMapStringFunction([...preModules, ...getSortedModules(graph, options)], {
const map = sourceMapString([...preModules, ...getSortedModules(graph, options)], {
processModuleFilter: options.processModuleFilter,
shouldAddToIgnoreList: options.shouldAddToIgnoreList || (() => false),
});
Expand Down
84 changes: 84 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,88 @@ 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'): 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',
`module.exports = { sourceMapString: () => '${marker}_MAP' };`,
);

return root;
}

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');
});
});
});

function mockMinSerializerArgs(options?: {
Expand Down
Loading