diff --git a/CHANGELOG.md b/CHANGELOG.md index 078d6bb6a0..6905c54abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/packages/core/src/js/tools/vendor/metro/utils.ts b/packages/core/src/js/tools/vendor/metro/utils.ts index ed2ca0a43c..a2e4f3b033 100644 --- a/packages/core/src/js/tools/vendor/metro/utils.ts +++ b/packages/core/src/js/tools/vendor/metro/utils.ts @@ -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) => boolean; + shouldAddToIgnoreList?: (module: Module) => 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 @@ -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; @@ -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), }); diff --git a/packages/core/test/tools/sentryMetroSerializer.test.ts b/packages/core/test/tools/sentryMetroSerializer.test.ts index e8bd5019c1..3c0bd6d529 100644 --- a/packages/core/test/tools/sentryMetroSerializer.test.ts +++ b/packages/core/test/tools/sentryMetroSerializer.test.ts @@ -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'; @@ -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?: {