Skip to content
6 changes: 6 additions & 0 deletions .changeset/fix-federated-source-maps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@callstack/repack": patch
"@callstack/repack-dev-server": patch
---

Fix development symbolication for Module Federation host and remote bundles. The host now follows a remote bundle's declared source map, invalid generated webpack source URLs no longer invalidate an otherwise usable map, symbolication continues when an individual frame cannot be mapped, and code frames use the matching source map's embedded source content. The dev server also logs the first useful symbolicated runtime frame as a fallback when opening the source file from the device is delayed.
10 changes: 8 additions & 2 deletions packages/dev-server/src/plugins/devtools/devtoolsPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,14 @@ async function devtoolsPlugin(
const { file, lineNumber } = parseRequestBody<OpenStackFrameRequestBody>(
request.body
);
const filepath = delegate.devTools?.resolveProjectPath(file) ?? file;
launchEditor(`${filepath}:${lineNumber}`, process.env.REACT_EDITOR);
const openedRemotely = await delegate.devTools?.openStackFrame?.(
file,
lineNumber
);
if (!openedRemotely) {
const filepath = delegate.devTools?.resolveProjectPath(file) ?? file;
launchEditor(`${filepath}:${lineNumber}`, process.env.REACT_EDITOR);
}
reply.send('OK');
},
});
Expand Down
135 changes: 80 additions & 55 deletions packages/dev-server/src/plugins/symbolicate/Symbolicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { URL } from 'node:url';
import { codeFrameColumns } from '@babel/code-frame';
import type { FastifyBaseLogger } from 'fastify';
import { SourceMapConsumer } from 'source-map';
import {
isGeneratedBundleFrame,
isSymbolicatableFrame,
normalizeInvalidWebpackSourceUrls,
} from '../../utils/symbolication.js';
import type {
CodeFrame,
InputStackFrame,
Expand Down Expand Up @@ -45,11 +50,6 @@ export class Symbolicator {
}
}

/**
* Cache with initialized `SourceMapConsumer` to improve symbolication performance.
*/
sourceMapConsumerCache: Record<string, SourceMapConsumer> = {};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does the performance compare? seems like we had this sourceMapConsumerCache for performance reasons.

is it not possible now that subsequent requests will have to recalculate? or am I misunderstanding?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

basically, we shouldn’t lose any work across calls because old cache was already destroyed & cleared after every process() call
just to be safe, I double-checked this & didn’t observe any performance regression. also, I added tests covering same-request reuse, refresh between requests & concurrent requests


/**
* Constructs new `Symbolicator` instance.
*
Expand All @@ -75,86 +75,99 @@ export class Symbolicator {
): Promise<SymbolicatorResults> {
logger.debug({ msg: 'Filtering out unnecessary frames' });

const frames: InputStackFrame[] = [];
for (const frame of stack) {
const { file } = frame;
if (file?.startsWith('http')) {
frames.push(frame as InputStackFrame);
}
}
const frames = stack.filter(isSymbolicatableFrame);

// A Symbolicator instance is shared by the route. Keep consumers local to
// one request so concurrent call-stack and component-stack requests cannot
// destroy or read each other's source maps.
const sourceMapConsumers = new Map<string, SourceMapConsumer>();
try {
logger.debug({ msg: 'Processing frames', frames });

const processedFrames: StackFrame[] = [];
for (const frame of frames) {
if (!this.sourceMapConsumerCache[frame.file]) {
logger.debug({
msg: 'Loading raw source map data',
fileUrl: frame.file,
});
try {
if (!sourceMapConsumers.has(frame.file)) {
logger.debug({
msg: 'Loading raw source map data',
fileUrl: frame.file,
});

const rawSourceMap = await this.delegate.getSourceMap(frame.file);

const rawSourceMap = await this.delegate.getSourceMap(frame.file);
logger.debug({
msg: 'Creating source map instance',
fileUrl: frame.file,
sourceMapLength: rawSourceMap.length,
});
const sourceMapConsumer = await new SourceMapConsumer(
normalizeInvalidWebpackSourceUrls(rawSourceMap)
);

logger.debug({
msg: 'Saving source map instance into cache',
fileUrl: frame.file,
});
sourceMapConsumers.set(frame.file, sourceMapConsumer);
}

logger.debug({
msg: 'Creating source map instance',
fileUrl: frame.file,
sourceMapLength: rawSourceMap.length,
msg: 'Symbolicating frame',
frame,
});
const sourceMapConsumer = await new SourceMapConsumer(
rawSourceMap.toString()
);
const processedFrame = this.processFrame(frame, sourceMapConsumers);

logger.debug({
msg: 'Saving source map instance into cache',
msg: 'Finished symbolicating frame',
frame,
});
processedFrames.push(processedFrame);
} catch (error) {
// Match Metro's best-effort behavior: one unavailable or malformed
// source map must not discard frames that can still be symbolicated.
logger.debug({
msg: 'Failed to symbolicate frame',
fileUrl: frame.file,
error: (error as Error).message,
});
this.sourceMapConsumerCache[frame.file] = sourceMapConsumer;
processedFrames.push({ ...frame, collapse: false });
}

logger.debug({
msg: 'Symbolicating frame',
frame,
});
const processedFrame = this.processFrame(frame);

logger.debug({
msg: 'Finished symbolicating frame',
frame,
});
processedFrames.push(processedFrame);
}

const codeFrame =
(await this.getCodeFrame(logger, processedFrames)) ?? null;
(await this.getCodeFrame(
logger,
processedFrames,
frames,
sourceMapConsumers
)) ?? null;

logger.debug({
msg: 'Finished symbolicating frames',
processedFrames,
codeFrame,
});

return {
stack: processedFrames,
codeFrame,
};
return { stack: processedFrames, codeFrame };
} finally {
for (const key in this.sourceMapConsumerCache) {
this.sourceMapConsumerCache[key].destroy();
delete this.sourceMapConsumerCache[key];
for (const consumer of sourceMapConsumers.values()) {
consumer.destroy();
}
}
}

private processFrame(frame: InputStackFrame): StackFrame {
if (!frame.lineNumber || !frame.column) {
private processFrame(
frame: InputStackFrame,
sourceMapConsumers: Map<string, SourceMapConsumer>
): StackFrame {
if (frame.lineNumber == null || frame.column == null) {
return {
...frame,
collapse: false,
};
}

const consumer = this.sourceMapConsumerCache[frame.file];
const consumer = sourceMapConsumers.get(frame.file);
if (!consumer) {
return {
...frame,
Expand Down Expand Up @@ -186,8 +199,8 @@ export class Symbolicator {
}

return {
lineNumber: lookup.line || frame.lineNumber,
column: lookup.column || frame.column,
lineNumber: lookup.line ?? frame.lineNumber,
column: lookup.column ?? frame.column,
file: lookup.source,
methodName: lookup.name || frame.methodName,
collapse: false,
Expand All @@ -196,10 +209,16 @@ export class Symbolicator {

private async getCodeFrame(
logger: FastifyBaseLogger,
processedFrames: StackFrame[]
processedFrames: StackFrame[],
inputFrames: InputStackFrame[],
sourceMapConsumers: Map<string, SourceMapConsumer>
): Promise<CodeFrame | undefined> {
for (const frame of processedFrames) {
if (frame.collapse || !frame.lineNumber || !frame.column) {
for (const [index, frame] of processedFrames.entries()) {
if (frame.collapse || frame.lineNumber == null || frame.column == null) {
continue;
}

if (isGeneratedBundleFrame(frame)) {
continue;
}

Expand All @@ -213,9 +232,15 @@ export class Symbolicator {
});

try {
const consumer = sourceMapConsumers.get(inputFrames[index]?.file);
const embeddedSource = consumer?.sourceContentFor(frame.file, true);
const source =
embeddedSource ??
(await this.delegate.getSource(frame.file)).toString();

return {
content: codeFrameColumns(
(await this.delegate.getSource(frame.file)).toString(),
source,
{
start: { column: frame.column, line: frame.lineNumber },
},
Expand Down
Loading