Skip to content

Webpack source maps name project files with a doubled path; [projectRoot^N] placeholders never resolve on the dev server #1468

Closed

Description

@dannyhw

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.

  1. Webpack only. babelSwcLoader and babelLoader pass sourceRoot: this.context together with an absolute sourceFileName. Webpack prepends sourceRoot to 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 ignores sourceRoot for absolute sources and is unaffected.
  2. Both bundlers. parseUrl runs the frame URL through new URL(), which percent-encodes the caret in [projectRoot^N]. resolveProjectPath then receives [projectRoot%5E2]/..., its regex does not match, and the placeholder is passed to compiler.getSource as 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 file to /open-stack-frame, the dev server resolves it to a path that does not exist, and launch-editor returns silently when fs.existsSync is 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)

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 sourcesContent is embedded, which hides the bug under the default source-map devtool.
  • 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-frame receives a file that does not exist.
  • The nosources-source-map fallback 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_modules files 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 SourceMapDevToolPlugin runs, 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 URL encodes, 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 platform query, 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 resolveProjectPath tests, which all assume raw carets reach the resolver.

Suggested tests

  • parseUrl: [projectRoot^2]/x.js and 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 sourceRoot and a single absolute entry in sources.
  • Tester app on webpack: symbolicate a project frame and a node_modules frame under nosources-source-map and assert a codeFrame is returned.

Environment

  • @callstack/repack 5.4.0-canary-20260913172854 and 5.3.0 (tester app dist); @callstack/repack-dev-server same
  • 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

  1. added
    area:webpackThe issue is about behaviour/problem in Webpack.
    on Sep 25, 2026
  2. dannyhw commented on Sep 25, 2026

    @dannyhw
    CollaboratorAuthor

    Follow-up: re-verified on main, one root cause in this report is wrong

    I re-ran the reproduction against a fresh build of main (0523d65e) in apps/tester-app, on both bundlers, with and without the proposed patch applied to the built dist. Bug 1 holds exactly as written. Bug 2 is real, but the caret is not encoded by parseUrl, and that changes what the fix has to cover.

    Bug 1: confirmed, including production

    Dev, devtool: 'nosources-source-map', unpatched:

    bundler [projectRoot]/ sources doubled
    webpack 5.105.4 32 27
    Rspack 1.6.0 32 0

    Release build (webpack-bundle --bundler webpack --dev=false --sourcemap-output ...), unpatched: 30 tester-app sources, 26 doubled, e.g.

    /Users/<me>/repack/apps/tester-app/src//Users/<me>/repack/apps/tester-app/src/App.tsx
    

    So the "inferred, not verified" production claim in the report is now verified.

    With sourceRoot removed from both loaders: webpack has 0 doubled sources, and the Rspack map's sources set is identical before and after (1241 entries, empty set difference both ways).

    Bug 2: the %5E comes from source-map, not from parseUrl

    The report says parseUrl runs the frame path through new URL() and that is where ^ becomes %5E. That is true of parseUrl in isolation, but it is not where the encoding first happens. source-map@0.7.6 normalises every source name through new URL inside computeSourceURL (lib/util.js), so the consumer already holds the encoded form:

    const { SourceMapConsumer } = require('source-map'); // 0.7.6
    const raw = JSON.parse(fs.readFileSync('rspack.map'));
    raw.sources.find(s => s.startsWith('[projectRoot^'));
    // '[projectRoot^2]/node_modules/.pnpm/react-native@...'
    const c = await new SourceMapConsumer(raw);
    c.sources.find(s => s.includes('projectRoot'));
    // '[projectRoot%5E2]/node_modules/.pnpm/react-native@...'

    Symbolicator.symbolicateFrame copies lookup.source straight into the response (file: lookup.source), so the encoded name is in the /symbolicate response before any dev-server URL parsing runs. That is the leak the report noticed in the response body; it is not a side effect of parseUrl.

    This matters because the response file is what LogBox posts to /open-stack-frame (Libraries/Core/Devtools/openFileInEditor.js), and that handler calls resolveProjectPath(file) directly (packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts:52). parseUrl is never on that path:

    resolveProjectPath('[projectRoot%5E2]/node_modules/x.js', '/root/apps/app')
    // => '[projectRoot%5E2]/node_modules/x.js'   (unchanged, regex does not match)
    resolveProjectPath('[projectRoot^2]/node_modules/x.js', '/root/apps/app')
    // => '/root/node_modules/x.js'

    launch-editor then gets a path that does not exist and returns silently (fs.existsSync guard).

    What the proposed patch does and does not fix

    With the three-file patch applied to dist, symbolicating a node_modules frame under nosources-source-map:

    # webpack, patched
    frame file: [projectRoot%5E2]/node_modules/.pnpm/react-native@0.86.2_.../Libraries/ActionSheetIOS/ActionSheetIOS.js @6:0
    codeFrame: YES
    
    # Rspack, patched
    frame file: [projectRoot%5E2]/node_modules/.pnpm/react-native@0.86.2_.../Libraries/ActionSheetIOS/ActionSheetIOS.js @6:0
    codeFrame: YES
    

    The code frame comes back because symbolicator.getSource happens to go through parseUrl, and decodeURIComponent turns the already-encoded %5E back into ^. But the file the device receives is still [projectRoot%5E2]/..., so:

    • open-in-editor stays a silent no-op for every [projectRoot^N] frame, on both bundlers
    • the encoded caret still reaches LogBox
    • logSymbolicatedStackFrame still prints the placeholder for local frames, because its strip regex expects a raw caret

    So "parseUrl is 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, hoisted node_modules). A standalone app with local node_modules emits [projectRoot]/node_modules/... and never triggers bug 2. "node_modules files fail on both bundlers" should read "files outside the project root".
    • Under the default source-map devtool, webpack code frames still render via embedded sourcesContent. 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, so my%20image.png is not looked up literally. webp%61ck@2x.png returns 200 unpatched. The real defect is that parseUrl re-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 temporary src/assetsTest/caret^dir/webpack.png:

    request unpatched patched
    /assets/src/assetsTest/newdir/webpack.png 200 200
    /assets/src/assetsTest/caret%5Edir/webpack.png 404 200

    Revised fix

    1. Remove sourceRoot from both loaders, as proposed.
    2. In Symbolicator.symbolicateFrame, decode lookup.source before it is returned as file. The sourceContentFor(frame.file, true) lookup in getCodeFrame must keep using the consumer's own (encoded) name, so either decode after that call or look up content with the raw lookup.source. This single change fixes the response, /open-stack-frame, and the log line together.
    3. Keep the parseUrl decode. It is still needed for the asset re-encoding path, and it makes symbolicator.getSource robust if an encoded name arrives from elsewhere.

    Suggested tests should add: a /symbolicate case for a [projectRoot^N] frame asserting the response file contains a raw ^, and an /open-stack-frame case for the same frame asserting the resolved path exists.

    Environment for this run: webpack 5.105.4, Rspack 1.6.0, source-map 0.7.6, RN 0.86.2, Node 26.3.1, macOS. Everything was measured through curl/node against the dev server, no device involved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:webpackThe issue is about behaviour/problem in Webpack.type:bugA bug report.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions