Skip to content
Merged
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
18 changes: 18 additions & 0 deletions .changeset/fix-source-map-source-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@callstack/repack": patch
"@callstack/repack-dev-server": patch
---

Fix source map source names and development stack frames for files whose path
URLs would encode.

- `babel-loader` and `babel-swc-loader` no longer set `sourceRoot`. Webpack, and
Rspack whenever Babel produced the map, prepended it to the absolute source
path, so project files were named `src/<dir>/<absolute path>` in development
and release source maps.
- The dev server now returns symbolicated file names exactly as the bundler
emitted them. Before, `source-map` percent-encoded them, so a frame outside the
project root came back as `[projectRoot%5E2]/...`, and a path with a space or
non-ASCII character came back encoded. The encoded name broke opening the file
from LogBox and reading its source when the map has no embedded content.
- Development asset requests with an encoded directory name no longer 404.
13 changes: 9 additions & 4 deletions packages/dev-server/src/plugins/symbolicate/Symbolicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { codeFrameColumns } from '@babel/code-frame';
import type { FastifyBaseLogger } from 'fastify';
import { SourceMapConsumer } from 'source-map';
import {
decodeSourceName,
escapeSourceName,
isGeneratedBundleFrame,
isSymbolicatableFrame,
normalizeInvalidWebpackSourceUrls,
prepareSourceMap,
} from '../../utils/symbolication.js';
import type {
CodeFrame,
Expand Down Expand Up @@ -101,7 +103,7 @@ export class Symbolicator {
sourceMapLength: rawSourceMap.length,
});
const sourceMapConsumer = await new SourceMapConsumer(
normalizeInvalidWebpackSourceUrls(rawSourceMap)
prepareSourceMap(rawSourceMap)
);

logger.debug({
Expand Down Expand Up @@ -201,7 +203,7 @@ export class Symbolicator {
return {
lineNumber: lookup.line ?? frame.lineNumber,
column: lookup.column ?? frame.column,
file: lookup.source,
file: decodeSourceName(lookup.source),
methodName: lookup.name || frame.methodName,
collapse: false,
};
Expand Down Expand Up @@ -233,7 +235,10 @@ export class Symbolicator {

try {
const consumer = sourceMapConsumers.get(inputFrames[index]?.file);
const embeddedSource = consumer?.sourceContentFor(frame.file, true);
const embeddedSource = consumer?.sourceContentFor(
escapeSourceName(frame.file),
true
);
const source =
embeddedSource ??
(await this.delegate.getSource(frame.file)).toString();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { stripVTControlCharacters } from 'node:util';
import type { FastifyBaseLogger } from 'fastify';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { logSymbolicatedStackFrame } from '../logSymbolicatedStackFrame.js';
Expand Down Expand Up @@ -50,6 +51,20 @@ function createSourceMapWithoutContent(source: string) {
});
}

// Maps generated line N to the start of `sources[N - 1]`.
function createSourceMapWithSources(
sources: string[],
sourcesContent?: string[]
) {
return JSON.stringify({
version: 3,
sources,
sourcesContent,
names: [],
mappings: ['AAAA', ...sources.slice(1).map(() => 'ACAA')].join(';'),
});
}

function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((resolvePromise) => {
Expand Down Expand Up @@ -188,6 +203,87 @@ describe('Symbolicator', () => {
});
});

describe('source names that URLs encode', () => {
const bundleUrl = 'http://localhost:8081/index.bundle?platform=ios';
const sources = [
'[projectRoot^2]/node_modules/react-native/Libraries/Core/ExceptionsManager.js',
'[projectRoot]/src/screens/Home Screen.tsx',
'[projectRoot]/src/Écran.tsx',
'[projectRoot]/src/100%25 done.tsx',
'/Users/me/My Projects/app/src/App.tsx',
];
const frameFor = (source: string) => ({
file: bundleUrl,
lineNumber: sources.indexOf(source) + 1,
column: 0,
methodName: 'frame',
});

it.each(sources)('returns %s as emitted by the bundler', async (source) => {
const symbolicator = new Symbolicator(
createDelegate(async () =>
createSourceMapWithSources(
sources,
sources.map((name) => `// content of ${name}`)
)
)
);

const result = await symbolicator.process(logger, [frameFor(source)]);

expect(result.stack[0]?.file).toBe(source);
expect(result.codeFrame?.fileName).toBe(source);
expect(
stripVTControlCharacters(result.codeFrame?.content ?? '')
).toContain(`content of ${source}`);
});

it.each(sources)(
'reads %s from the compiler when the map has no content',
async (source) => {
const getSource = vi.fn(async () => 'const fromDisk = true;');
const symbolicator = new Symbolicator(
createDelegate(
async () => createSourceMapWithSources(sources),
getSource
)
);

const result = await symbolicator.process(logger, [frameFor(source)]);

expect(getSource).toHaveBeenCalledWith(source);
expect(
stripVTControlCharacters(result.codeFrame?.content ?? '')
).toContain('const fromDisk = true;');
}
);

it('keeps remote source URLs encoded', async () => {
// `fetchSourceMapFromBundle` rewrites remote source names to URLs.
const remoteSource =
'http://localhost:8082/__repack_source__/[projectRoot%5E2]/packages/remote/src/App.tsx';
const symbolicator = new Symbolicator(
createDelegate(async () =>
createSourceMapWithSources([remoteSource], ['const remote = 1;'])
)
);

const result = await symbolicator.process(logger, [
{
file: 'http://localhost:8082/remote.chunk.bundle',
lineNumber: 1,
column: 0,
methodName: 'App',
},
]);

expect(result.stack[0]?.file).toBe(remoteSource);
expect(
stripVTControlCharacters(result.codeFrame?.content ?? '')
).toContain('const remote = 1;');
});
});

it('loads a source map once for repeated frames in one request', async () => {
const bundleUrl = 'http://localhost:8082/repeated.chunk.bundle';
const getSourceMap = vi.fn(async () =>
Expand Down Expand Up @@ -338,6 +434,41 @@ describe('logSymbolicatedStackFrame', () => {
});
});

it('strips the placeholder from a frame outside of the project root', async () => {
const info = vi.fn();
const runtimeLogger = { ...logger, info } as unknown as FastifyBaseLogger;
const stack = [
{
file: 'http://localhost:8081/index.bundle?platform=ios',
lineNumber: 1,
column: 0,
methodName: 'Shared',
},
{
file: 'http://localhost:8081/index.bundle?platform=ios',
lineNumber: 200,
column: 30,
methodName: 'renderWithHooks',
},
];
const symbolicator = new Symbolicator(
createDelegate(async () =>
createSourceMapWithSources(
['[projectRoot^2]/packages/shared/src/Shared.tsx'],
['export const Shared = () => null;']
)
)
);

const results = await symbolicator.process(runtimeLogger, stack);
logSymbolicatedStackFrame(runtimeLogger, stack, results);

expect(info).toHaveBeenCalledWith({
msg: 'Symbolicated stack frame: packages/shared/src/Shared.tsx:1:0',
methodName: 'Shared',
});
});

it('does not log component-stack-only symbolication', () => {
const info = vi.fn();
const runtimeLogger = { info } as unknown as FastifyBaseLogger;
Expand Down
33 changes: 23 additions & 10 deletions packages/dev-server/src/utils/symbolication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,28 @@ interface StackFrameLike {
file: string | null;
}

export function normalizeInvalidWebpackSourceUrls(
rawSourceMap: string | Buffer
): string | RawSourceMap | RawIndexMap {
const sourceMapText = rawSourceMap.toString();
if (!sourceMapText.includes('webpack://')) {
return sourceMapText;
}
// `source-map` normalises every source name through `new URL()`, which
// percent-encodes characters such as spaces, non-ASCII letters and the caret
// in `[projectRoot^N]`. Escaping `%` before the consumer reads the map makes
// `decodeSourceName` an exact inverse, so a file name that contains `%` keeps
// it instead of being decoded a second time.
export function escapeSourceName(source: string) {
return source.replaceAll('%', '%25');
}

const sourceMap = JSON.parse(sourceMapText) as {
export function decodeSourceName(source: string) {
return decodeURIComponent(source);
}

/**
* Prepare a raw source map for `SourceMapConsumer`: replace webpack source
* URLs that would make it reject the map and escape every source name, so
* names returned by the consumer can be restored with `decodeSourceName`.
*/
export function prepareSourceMap(
rawSourceMap: string | Buffer
): RawSourceMap | RawIndexMap {
const sourceMap = JSON.parse(rawSourceMap.toString()) as {
sources?: unknown[];
sections?: Array<{ map?: unknown }>;
};
Expand All @@ -39,12 +52,12 @@ export function normalizeInvalidWebpackSourceUrls(
'webpack://$1/'
);
if (!normalizedSource.startsWith('webpack://')) {
return normalizedSource;
return escapeSourceName(normalizedSource);
}

try {
new URL(normalizedSource);
return normalizedSource;
return escapeSourceName(normalizedSource);
} catch {
// Some generated Module Federation runtime modules use their source
// text as a webpack URL. A single invalid URL makes source-map reject
Expand Down
28 changes: 28 additions & 0 deletions packages/repack/src/commands/common/__tests__/parseUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,34 @@ describe('parseUrl', () => {
});
});

it('should return the decoded path', () => {
// Symbolicated stack frames are passed as file names, not URLs.
expectParsed('[projectRoot^2]/node_modules/pkg/index.js', {
resourcePath: '[projectRoot^2]/node_modules/pkg/index.js',
platform: undefined,
});
expectParsed('[projectRoot]/src/Home Screen.tsx', {
resourcePath: '[projectRoot]/src/Home Screen.tsx',
platform: undefined,
});
expectParsed('[projectRoot]/src/Écran.ios.tsx', {
resourcePath: '[projectRoot]/src/Écran.ios.tsx',
platform: 'ios',
});
// Request URLs arrive percent-encoded.
expectParsed('/assets/src/caret%5Edir/image.png?platform=ios', {
resourcePath: 'assets/src/caret^dir/image.png',
platform: 'ios',
});
});

it('should keep a path that is not a valid escape sequence', () => {
expectParsed('assets/100%.png?platform=ios', {
resourcePath: 'assets/100%.png',
platform: 'ios',
});
});

it('should work with different platform lists', () => {
expectParsed(
'/react-native/src/index.js',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from 'node:path';
import { parseUrl } from '../parseUrl.js';
import { resolveProjectPath } from '../resolveProjectPath.js';

describe('resolveProjectPath', () => {
Expand Down Expand Up @@ -47,4 +48,21 @@ describe('resolveProjectPath', () => {
'/a/b/c/d/e/f'
);
});

it('should resolve paths returned by parseUrl', () => {
// The dev server passes symbolicated file names through parseUrl before
// resolving them, so encoded characters must not survive that step.
const resolveParsed = (input: string) =>
resolveProjectPath(
parseUrl(input, ['ios', 'android']).resourcePath,
path.resolve('/workspace/apps/app')
);

expect(resolveParsed('[projectRoot^2]/node_modules/pkg/index.js')).toBe(
path.resolve('/workspace/node_modules/pkg/index.js')
);
expect(resolveParsed('[projectRoot]/src/Home Screen.tsx')).toBe(
path.resolve('/workspace/apps/app/src/Home Screen.tsx')
);
});
});
14 changes: 13 additions & 1 deletion packages/repack/src/commands/common/parseUrl.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
// `new URL()` percent-encodes characters such as spaces, non-ASCII letters
// and the caret in `[projectRoot^N]`, but callers look the path up as a file.
function decodePathname(pathname: string) {
try {
return decodeURIComponent(pathname);
} catch {
// a malformed escape sequence, e.g. a literal `%` in a file name
return pathname;
}
}

export function parseUrl(url: string, platforms: string[], base = 'file:///') {
const { pathname, searchParams } = new URL(url, base);
const { pathname: encodedPathname, searchParams } = new URL(url, base);
const pathname = decodePathname(encodedPathname);

let path = pathname;
let platform = searchParams.get('platform');
Expand Down
1 change: 0 additions & 1 deletion packages/repack/src/loaders/babelLoader/babelLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
2 changes: 0 additions & 2 deletions packages/repack/src/loaders/babelSwcLoader/babelSwcLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -176,7 +175,6 @@ export default async function babelSwcLoader(
? JSON.stringify(babelResult?.map)
: undefined,
sourceFileName: this.resourcePath,
sourceRoot: this.context!,
...options.swcOverrides,
});

Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions tests/integration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@module-federation/enhanced-v21": "npm:@module-federation/enhanced@0.21.0",
"@rspack/core": "catalog:",
"@types/node": "catalog:",
"hermes-parser": "0.36.0",
"memfs": "^4.11.1",
"typescript": "catalog:",
"vitest": "catalog:",
Expand Down
Loading
Loading