From addbd1daae81bb6b85ac51288be6a041317d73c0 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 27 Aug 2026 12:02:36 +0200 Subject: [PATCH 1/6] fix(core): Resolve Metro from project root for source map generation The vendored default serializer required Metro internals resolvable from the SDK's own location. In this monorepo the core package has its own Metro dev dependency (0.84.4) that shadows the app's Metro, so bundling the RN 0.87 sample generated source maps with the mismatched older Metro and threw `Unexpected module with full source map found` on the metro-runtime require.js polyfill. Resolve Metro internals from `options.projectRoot` (the app being bundled) with a fallback to the previous SDK-local resolution. In a normal install both resolve the same Metro instance, so behavior is unchanged; in the monorepo the sample now bundles with its own 0.87 Metro. Resolution is deferred to the first serialization so `options.projectRoot` is available, and memoized thereafter. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + .../core/src/js/tools/vendor/metro/utils.ts | 161 +++++++++++------- 2 files changed, 98 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e11cbebc16..66f57c4b01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixes +- Resolve Metro from the app's project root when generating source maps, fixing production bundling when a different Metro version is nested under the SDK (e.g. Metro 0.87) ([#6625](https://github.com/getsentry/sentry-react-native/pull/6625)) - Warn when replay sample rates are set but the Replay integration is missing ([#6612](https://github.com/getsentry/sentry-react-native/pull/6612)) - Fix time to display spans causing transactions to be dropped by Relay ([#6608](https://github.com/getsentry/sentry-react-native/pull/6608)) - Fix Expo iOS build failing when the project path contains spaces ([#6604](https://github.com/getsentry/sentry-react-native/pull/6604)) diff --git a/packages/core/src/js/tools/vendor/metro/utils.ts b/packages/core/src/js/tools/vendor/metro/utils.ts index ed2ca0a43c..9cd7a52e49 100644 --- a/packages/core/src/js/tools/vendor/metro/utils.ts +++ b/packages/core/src/js/tools/vendor/metro/utils.ts @@ -27,15 +27,97 @@ 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; + +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. + * + * Each candidate is tried in order (`metro/private/*` first, `metro/src/*` as fallback) since + * Metro moved its internals behind the `private` export path. + */ +// oxlint-disable-next-line typescript-eslint(no-explicit-any) +function requireMetroModule(candidates: string[], projectRoot: string | undefined): any { + const paths = projectRoot ? [projectRoot, __dirname] : undefined; + let lastError: unknown; + for (const candidate of candidates) { + if (paths) { + try { + return require(require.resolve(candidate, { paths })); + } catch (e) { + lastError = e; + } + } + 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). + */ +// 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 @@ -69,50 +151,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; @@ -125,25 +175,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), }); From 06451544eb46c13f879b3139cfebc2cbf9fe6c27 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 27 Aug 2026 14:54:53 +0200 Subject: [PATCH 2/6] fix(core): Resolve Metro internals location-first, preferring the app over a nested SDK copy requireMetroModule now tries each candidate path shape against the app (projectRoot) before the SDK's own location, so the app's Metro wins even when the two copies expose internals via different subpaths (app on metro/src/*, SDK on metro/private/*). Adds projectRoot-preference and ordering-regression tests using on-disk fake-Metro fixtures. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 7 +- .../core/src/js/tools/vendor/metro/utils.ts | 22 +++-- .../test/tools/sentryMetroSerializer.test.ts | 84 +++++++++++++++++++ 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfcab414c6..fef97dfd07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Internal + +- Resolve Metro from the app's project root when generating source maps ([#6625](https://github.com/getsentry/sentry-react-native/pull/6625)) + ## 8.24.0 ### Features @@ -14,7 +20,6 @@ ### Fixes -- Resolve Metro from the app's project root when generating source maps, fixing production bundling when a different Metro version is nested under the SDK (e.g. Metro 0.87) ([#6625](https://github.com/getsentry/sentry-react-native/pull/6625)) - Warn when replay sample rates are set but the Replay integration is missing ([#6612](https://github.com/getsentry/sentry-react-native/pull/6612)) - Fix time to display spans causing transactions to be dropped by Relay ([#6608](https://github.com/getsentry/sentry-react-native/pull/6608)) - Fix Expo iOS build failing when the project path contains spaces ([#6604](https://github.com/getsentry/sentry-react-native/pull/6604)) diff --git a/packages/core/src/js/tools/vendor/metro/utils.ts b/packages/core/src/js/tools/vendor/metro/utils.ts index 9cd7a52e49..b3f252f7f8 100644 --- a/packages/core/src/js/tools/vendor/metro/utils.ts +++ b/packages/core/src/js/tools/vendor/metro/utils.ts @@ -52,21 +52,31 @@ interface ResolvedMetroInternals { * dependency that would otherwise shadow the app's Metro and generate source maps with a * mismatched (older) Metro version. * - * Each candidate is tried in order (`metro/private/*` first, `metro/src/*` as fallback) since - * Metro moved its internals behind the `private` export path. + * 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 paths = projectRoot ? [projectRoot, __dirname] : undefined; + const roots = projectRoot ? [projectRoot, __dirname] : [__dirname]; let lastError: unknown; - for (const candidate of candidates) { - if (paths) { + for (const root of roots) { + for (const candidate of candidates) { try { - return require(require.resolve(candidate, { paths })); + 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) { diff --git a/packages/core/test/tools/sentryMetroSerializer.test.ts b/packages/core/test/tools/sentryMetroSerializer.test.ts index e8bd5019c1..7ac7bb92ab 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,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?: { From c47fda4bc7630fa9d556de352965b47fce73dd3e Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 1 Sep 2026 14:22:27 +0200 Subject: [PATCH 3/6] Add inline explanation Co-authored-by: LucasZF --- packages/core/src/js/tools/vendor/metro/utils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/src/js/tools/vendor/metro/utils.ts b/packages/core/src/js/tools/vendor/metro/utils.ts index b3f252f7f8..38afbf575f 100644 --- a/packages/core/src/js/tools/vendor/metro/utils.ts +++ b/packages/core/src/js/tools/vendor/metro/utils.ts @@ -66,6 +66,10 @@ function requireMetroModule(candidates: string[], projectRoot: string | undefine 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; From 5dbc011caa8d4c3d51b85e990ecbfa94cb113d0f Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 1 Sep 2026 14:25:06 +0200 Subject: [PATCH 4/6] Simplify function description --- packages/core/src/js/tools/vendor/metro/utils.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/core/src/js/tools/vendor/metro/utils.ts b/packages/core/src/js/tools/vendor/metro/utils.ts index 38afbf575f..bda5ffe2c3 100644 --- a/packages/core/src/js/tools/vendor/metro/utils.ts +++ b/packages/core/src/js/tools/vendor/metro/utils.ts @@ -47,10 +47,9 @@ interface ResolvedMetroInternals { /** * 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. + * (`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 From 79ea6094b3862c62d96f5af5e4214de662847315 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 1 Sep 2026 14:33:36 +0200 Subject: [PATCH 5/6] Guard all Metro internals with a descriptive error --- .../core/src/js/tools/vendor/metro/utils.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/core/src/js/tools/vendor/metro/utils.ts b/packages/core/src/js/tools/vendor/metro/utils.ts index bda5ffe2c3..405a610826 100644 --- a/packages/core/src/js/tools/vendor/metro/utils.ts +++ b/packages/core/src/js/tools/vendor/metro/utils.ts @@ -91,14 +91,22 @@ function requireMetroModule(candidates: string[], projectRoot: string | undefine /** * 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). + * 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 { - if (typeof metroModule === 'function') { - return metroModule; + 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 metroModule?.[namedExport] ?? metroModule?.default; + return callable; } function resolveMetroInternals(projectRoot: string | undefined): ResolvedMetroInternals { @@ -122,12 +130,6 @@ function resolveMetroInternals(projectRoot: string | undefined): ResolvedMetroIn ), '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 }; } From b585061a2e0f85ff5428775017c7014a780cebcf Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 1 Sep 2026 14:44:24 +0200 Subject: [PATCH 6/6] Resolve Metro sourceMapString lazily on the non-hot path --- .../core/src/js/tools/vendor/metro/utils.ts | 28 +++++++++++--- .../test/tools/sentryMetroSerializer.test.ts | 38 ++++++++++++++++++- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/core/src/js/tools/vendor/metro/utils.ts b/packages/core/src/js/tools/vendor/metro/utils.ts index 405a610826..a2e4f3b033 100644 --- a/packages/core/src/js/tools/vendor/metro/utils.ts +++ b/packages/core/src/js/tools/vendor/metro/utils.ts @@ -39,10 +39,11 @@ type SourceMapStringFunction = ( }, ) => 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; - sourceMapString: SourceMapStringFunction; } /** @@ -123,15 +124,23 @@ function resolveMetroInternals(projectRoot: string | undefined): ResolvedMetroIn 'bundleToString', ); - const sourceMapString: SourceMapStringFunction = toCallable( + 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', ); - - return { baseJSBundle, bundleToString, sourceMapString }; } /** @@ -171,12 +180,14 @@ export const createDefaultMetroSerializer = (): MetroSerializer => { // 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; return (entryPoint, preModules, graph, options) => { if (!internals) { internals = resolveMetroInternals(options.projectRoot); } - const { baseJSBundle, bundleToString, sourceMapString } = internals; + const { baseJSBundle, bundleToString } = internals; // baseJSBundle assigns IDs to modules in a consistent order let bundle = baseJSBundle(entryPoint, preModules, graph, options); @@ -190,7 +201,12 @@ export const createDefaultMetroSerializer = (): MetroSerializer => { return code; } - // Always generate source maps, can't use Sentry without source maps + // 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); + } 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 7ac7bb92ab..3c0bd6d529 100644 --- a/packages/core/test/tools/sentryMetroSerializer.test.ts +++ b/packages/core/test/tools/sentryMetroSerializer.test.ts @@ -278,7 +278,7 @@ describe('Sentry Metro Serializer', () => { // 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 { + 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); @@ -298,12 +298,28 @@ describe('Sentry Metro Serializer', () => { write('lib/bundleToString.js', `module.exports = { bundleToString: () => ({ code: '${marker}_CODE' }) };`); write( 'DeltaBundler/Serializers/sourceMapString.js', - `module.exports = { sourceMapString: () => '${marker}_MAP' };`, + // `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(); @@ -341,6 +357,24 @@ describe('Sentry Metro Serializer', () => { 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/); + }); }); });