Repository navigation
Webpack source maps name project files with a doubled path; [projectRoot^N] placeholders never resolve on the dev server #1468
Description
Summary
Two independent bugs in the development symbolication pipeline. Together they stop the fix from #1446 ("resolve absolute stack frame paths in the webpack compiler") from working end to end, make open-in-editor a silent no-op on webpack, and make the remote source URLs introduced in #1434 point at nonexistent files on webpack.
- Webpack only.
babelSwcLoaderandbabelLoaderpasssourceRoot: this.contexttogether with an absolutesourceFileName. Webpack prependssourceRootto the absolute source, so every file inside the project ends up in the emitted map as[projectRoot]/src/<dir>/<absolute path>instead of[projectRoot]/src/<dir>/<file>. Rspack ignoressourceRootfor absolute sources and is unaffected. - Both bundlers.
parseUrlruns the frame URL throughnew URL(), which percent-encodes the caret in[projectRoot^N].resolveProjectPaththen receives[projectRoot%5E2]/..., its regex does not match, and the placeholder is passed tocompiler.getSourceas a literal filename.
Neither is a 5.4 regression. Both first shipped in 5.2.0: bug 1 comes from #1206 (own Babel loaders, August 2025) and bug 2 from #1172 (source map rework, July 2025). The affected lines are byte-identical between the 5.3.0 tag and main. What 5.4 changes is visibility: #1446 removes the double-join that used to fail first, and #1434 is the first code that deliberately puts [projectRoot^N] into a URL.
What a developer sees (webpack)
Verified on a running app, iOS simulator, 5.4.0-canary-20260913172854, host set to devtool: 'nosources-source-map', one throw new Error(...) added at the top of a host component. Same app before and after the proposed patch, no other changes.
Before
- LogBox shows the error, component stack and call stack, but no source snippet. There is no "Sources" section at all.
- Tapping a call stack frame does nothing. LogBox posts the symbolicated
fileto/open-stack-frame, the dev server resolves it to a path that does not exist, andlaunch-editorreturns silently whenfs.existsSyncis false. Posting the same request by hand with the clean path Rspack would produce launches the editor immediately, so the endpoint works and is only ever handed a bad file. - The dev server log:
Symbolicated stack frame: src/navigation/Users/<me>/super-app-showcase/packages/host/src/navigation/TabsNavigator.tsx:39:17
File /Users/<me>/.../packages/host/src/navigation/Users/<me>/.../packages/host/src/navigation/TabsNavigator.tsx not found
Note the project root appears only once in that path. The double join fixed by #1446 is gone; what remains is the bad input from the map.
After (loader lines removed, parseUrl decoding, restart host only)
- LogBox shows the source snippet with the throw line highlighted, read from disk through the fix: resolve absolute stack frame paths in the webpack compiler #1446
getSourcepath. - Tapping the frame opens
packages/host/src/navigation/TabsNavigator.tsxin the editor. - Dev server log:
Symbolicated stack frame: src/navigation/TabsNavigator.tsx:39:17.
The LogBox header never reveals the problem, because React Native prints only the basename there. The path is wrong everywhere the full string is used.
Reproduction in this repo (apps/tester-app)
Copy webpack.config.mjs to webpack.nosources.config.mjs and add devtool: 'nosources-source-map' to the returned config. Then:
cd apps/tester-app
pnpm react-native start --bundler webpack --config webpack.nosources.config.mjs
curl -s "http://localhost:8081/index.bundle?platform=ios&dev=true" -o /dev/null
curl -s "http://localhost:8081/index.bundle.map?platform=ios&dev=true" -o tester.map
python3 -c 'import json; s=json.load(open("tester.map"))["sources"]; print([x for x in s if x.startswith("[projectRoot]/")][:6])'27 of the 32 [projectRoot]/... sources contain the absolute path a second time:
[projectRoot]/src/Users/<me>/repack/apps/tester-app/src/App.tsx
[projectRoot]/src/nativewind/ui/Users/<me>/repack/apps/tester-app/src/nativewind/ui/utils.ts
The five clean entries are the SVG, CSS and PNG assets, which never pass through the Babel loaders. The same bundle built with Rspack names the file [projectRoot]/src/App.tsx.
For bug 2, symbolicate one frame from src/App.tsx and one from react-native/Libraries/ActionSheetIOS/ActionSheetIOS.js (pick any mapped generated position from the map):
curl -s -X POST http://localhost:8081/symbolicate -H 'content-type: application/json' \
--data '{"stack":[{"file":"http://localhost:8081/index.bundle?platform=ios&dev=true","lineNumber":<L>,"column":<C>,"methodName":"x"}]}'No codeFrame is returned for either. The node_modules frame comes back with file set to [projectRoot%5E2]/node_modules/..., so the encoded caret even leaks into the response the device displays. The dev server logs the same "not found" error as above with the tester app's own doubled path.
Tested against the tester app's current dist (built 2026-08-31, includes #1434). The loader lines and parseUrl are unchanged on main.
Root causes
Bug 1. packages/repack/src/loaders/babelSwcLoader/babelSwcLoader.ts lines 126 and 179, and packages/repack/src/loaders/babelLoader/babelLoader.ts line 141, set sourceRoot: this.context (the file's directory) alongside sourceFileName: this.resourcePath (absolute). Per the source map spec, sourceRoot is prepended to every entry in sources. Webpack honours that when it contextifies a loader's map, producing <dir> + /Users/.../File.tsx, then relativises it to ./src/<dir>//Users/.... SourceMapPlugin's [projectRoot] template keeps everything after the ./. Upstream babel-loader sets only sourceFileName and never sourceRoot. Rspack resolves absolute sources without consulting sourceRoot, which is why it never showed the bug. Neither Babel nor swc needs the option for anything except writing that field into the map.
Bug 2. packages/repack/src/commands/common/parseUrl.ts returns pathname from new URL(url, base) without decoding. ^ is not a valid URL path character, so it becomes %5E. resolveProjectPath matches /^\[projectRoot(?:\^(\d+))?\]$/ against the first path segment and fails. Every other reader of these placeholders added in #1434 (getRemoteSource, getPrintableFile, and the fetchSourceMapFromBundle test) already calls decodeURIComponent first. parseUrl predates that convention and is the one site left out. The same encoding means an asset requested as my%20image.png is looked up under that literal name and 404s, on both bundlers.
Impact
- Webpack stack traces carry wrong file paths for every project file, in the symbolicate response, in the dev server's "Symbolicated stack frame" log, and in anything that reads the map. Code frames still render when
sourcesContentis embedded, which hides the bug under the defaultsource-mapdevtool. - Open-in-editor from LogBox is a silent no-op on webpack for project files. Same for federated remote frames: fix: symbolicate Module Federation remote stack frames #1434 builds
http://<remote>/__repack_source__/[projectRoot]/...from the remote map's source names, so the remote's/open-stack-framereceives a file that does not exist. - The
nosources-source-mapfallback fixed in fix: resolve absolute stack frame paths in the webpack compiler #1446 cannot succeed. Project files fail on webpack because of bug 1.node_modulesfiles fail on both bundlers because of bug 2. - Production webpack maps carry the same doubled paths. The loader map is an intermediate and the rewrite happens before
SourceMapDevToolPluginruns, so release maps uploaded to crash reporters name project files incorrectly. Inferred from the shared code path, not verified with a release build. - Assets with encoded characters in the filename 404 in dev on both bundlers.
Proposed fix
Three files, 14 lines added, 4 removed. Verified by patching the installed canary and re-running the nosources case on webpack: a project frame and a node_modules frame each return a code frame read from disk, and open-in-editor opens the real file. Rspack source names were compared before and after and are identical.
diff --git a/packages/repack/src/commands/common/parseUrl.ts b/packages/repack/src/commands/common/parseUrl.ts
--- a/packages/repack/src/commands/common/parseUrl.ts
+++ b/packages/repack/src/commands/common/parseUrl.ts
@@ -1,5 +1,18 @@
+// `new URL()` percent-encodes characters such as `^` and spaces. Re.Pack's
+// `[projectRoot^N]` placeholders and asset filenames must be compared in
+// their raw form, so decode the path back. A malformed escape sequence
+// throws, in which case the raw path is the best we can do.
+function decodePath(pathname: string) {
+ try {
+ return decodeURIComponent(pathname);
+ } catch {
+ return pathname;
+ }
+}
+
export function parseUrl(url: string, platforms: string[], base = 'file:///') {
- const { pathname, searchParams } = new URL(url, base);
+ const { pathname: rawPathname, searchParams } = new URL(url, base);
+ const pathname = decodePath(rawPathname);
let path = pathname;
let platform = searchParams.get('platform');
diff --git a/packages/repack/src/loaders/babelLoader/babelLoader.ts b/packages/repack/src/loaders/babelLoader/babelLoader.ts
--- a/packages/repack/src/loaders/babelLoader/babelLoader.ts
+++ b/packages/repack/src/loaders/babelLoader/babelLoader.ts
@@ -138,7 +138,6 @@ export default async function babelLoader(
filename: this.resourcePath,
sourceMaps: withSourceMaps,
sourceFileName: this.resourcePath,
- sourceRoot: this.context,
inputSourceMap: withSourceMaps ? inputSourceMap : undefined,
...babelOverrides,
},
diff --git a/packages/repack/src/loaders/babelSwcLoader/babelSwcLoader.ts b/packages/repack/src/loaders/babelSwcLoader/babelSwcLoader.ts
--- a/packages/repack/src/loaders/babelSwcLoader/babelSwcLoader.ts
+++ b/packages/repack/src/loaders/babelSwcLoader/babelSwcLoader.ts
@@ -123,7 +123,6 @@ export default async function babelSwcLoader(
filename: this.resourcePath,
sourceMaps: withSourceMaps,
sourceFileName: this.resourcePath,
- sourceRoot: this.context,
inputSourceMap: withSourceMaps ? inputSourceMap : undefined,
...options.babelOverrides,
};
@@ -176,7 +175,6 @@ export default async function babelSwcLoader(
? JSON.stringify(babelResult?.map)
: undefined,
sourceFileName: this.resourcePath,
- sourceRoot: this.context!,
...options.swcOverrides,
});babelOverrides and swcOverrides are spread after the defaults, so anyone who set sourceRoot deliberately keeps it. The existing loader tests pass sourceRoot as an input to transform but never assert on it, and their snapshots cover code only, so they need no change.
Compatibility
- No public config, plugin option, or emitted file shape changes.
- Rspack: fix 1 produces identical source names (verified). Fix 2 only changes behaviour for paths containing characters that
URLencodes, which today never match anything. - Webpack: source names in dev and production maps become correct. Nothing consumed the doubled form.
- Metro and the RN client are not involved. The bundle URL, the
platformquery, and the symbolicate request body are untouched.[projectRoot]is a Re.Pack convention that Metro neither emits nor reads. - Consistent with fix: symbolicate Module Federation remote stack frames #1434, fix: resolve absolute stack frame paths in the webpack compiler #1446, and the existing
resolveProjectPathtests, which all assume raw carets reach the resolver.
Suggested tests
parseUrl:[projectRoot^2]/x.jsand a path containing a space round-trip to the raw form; a path with a bare%returns unchanged.- Loader: the emitted map for a project file has no
sourceRootand a single absolute entry insources. - Tester app on webpack: symbolicate a project frame and a
node_modulesframe undernosources-source-mapand assert acodeFrameis returned.
Environment
@callstack/repack5.4.0-canary-20260913172854 and 5.3.0 (tester app dist);@callstack/repack-dev-serversame- webpack 5.109.2 and 5.105.4; Rspack 2.2.6 for comparison
- React Native 0.87.1 and 0.86.2, Node 26, macOS, iOS simulator
- Also reproduced on callstack/super-app-showcase branch
test/repack-5.4-canary-webpack
Activity
dannyhw commentedon Sep 25, 2026
Follow-up: re-verified on
main, one root cause in this report is wrongI re-ran the reproduction against a fresh build of
main(0523d65e) inapps/tester-app, on both bundlers, with and without the proposed patch applied to the builtdist. Bug 1 holds exactly as written. Bug 2 is real, but the caret is not encoded byparseUrl, and that changes what the fix has to cover.Bug 1: confirmed, including production
Dev,
devtool: 'nosources-source-map', unpatched:[projectRoot]/sourcesRelease build (
webpack-bundle --bundler webpack --dev=false --sourcemap-output ...), unpatched: 30 tester-app sources, 26 doubled, e.g.So the "inferred, not verified" production claim in the report is now verified.
With
sourceRootremoved from both loaders: webpack has 0 doubled sources, and the Rspack map'ssourcesset is identical before and after (1241 entries, empty set difference both ways).Bug 2: the
%5Ecomes fromsource-map, not fromparseUrlThe report says
parseUrlruns the frame path throughnew URL()and that is where^becomes%5E. That is true ofparseUrlin isolation, but it is not where the encoding first happens.source-map@0.7.6normalises every source name throughnew URLinsidecomputeSourceURL(lib/util.js), so the consumer already holds the encoded form:Symbolicator.symbolicateFramecopieslookup.sourcestraight into the response (file: lookup.source), so the encoded name is in the/symbolicateresponse before any dev-server URL parsing runs. That is the leak the report noticed in the response body; it is not a side effect ofparseUrl.This matters because the response
fileis what LogBox posts to/open-stack-frame(Libraries/Core/Devtools/openFileInEditor.js), and that handler callsresolveProjectPath(file)directly (packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts:52).parseUrlis never on that path:launch-editorthen gets a path that does not exist and returns silently (fs.existsSyncguard).What the proposed patch does and does not fix
With the three-file patch applied to
dist, symbolicating anode_modulesframe undernosources-source-map:The code frame comes back because
symbolicator.getSourcehappens to go throughparseUrl, anddecodeURIComponentturns the already-encoded%5Eback into^. But thefilethe device receives is still[projectRoot%5E2]/..., so:[projectRoot^N]frame, on both bundlerslogSymbolicatedStackFramestill prints the placeholder for local frames, because its strip regex expects a raw caretSo "
parseUrlis the one site left out" is not right. The devtools plugin's local-frame path and the Symbolicator's own output are also left out, and they are the ones users actually hit when tapping a frame.Scope corrections
[projectRoot^N]only appears for files resolved outside the project root (monorepos, hoistednode_modules). A standalone app with localnode_modulesemits[projectRoot]/node_modules/...and never triggers bug 2. "node_modules files fail on both bundlers" should read "files outside the project root".source-mapdevtool, webpack code frames still render via embeddedsourcesContent. What is broken there is open-in-editor and the printed path, not the snippet.Asset claim: outcome holds, mechanism is different
Fastify decodes the wildcard route param before it reaches
getAsset, somy%20image.pngis not looked up literally.webp%61ck@2x.pngreturns 200 unpatched. The real defect is thatparseUrlre-encodes the already-decoded name. Asset filenames are sanitised to[a-z0-9_]by the assets loader, so only a directory segment can carry an encodable character. Verified with a temporarysrc/assetsTest/caret^dir/webpack.png:/assets/src/assetsTest/newdir/webpack.png/assets/src/assetsTest/caret%5Edir/webpack.pngRevised fix
sourceRootfrom both loaders, as proposed.Symbolicator.symbolicateFrame, decodelookup.sourcebefore it is returned asfile. ThesourceContentFor(frame.file, true)lookup ingetCodeFramemust keep using the consumer's own (encoded) name, so either decode after that call or look up content with the rawlookup.source. This single change fixes the response,/open-stack-frame, and the log line together.parseUrldecode. It is still needed for the asset re-encoding path, and it makessymbolicator.getSourcerobust if an encoded name arrives from elsewhere.Suggested tests should add: a
/symbolicatecase for a[projectRoot^N]frame asserting the responsefilecontains a raw^, and an/open-stack-framecase for the same frame asserting the resolved path exists.Environment for this run: webpack 5.105.4, Rspack 1.6.0,
source-map0.7.6, RN 0.86.2, Node 26.3.1, macOS. Everything was measured throughcurl/nodeagainst the dev server, no device involved.