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
14 changes: 14 additions & 0 deletions e2e/cases/browser-logs/stack-trace-full-react-error/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { rspackTest } from '@e2e/helper';

// Omitted some parts of the stack trace as they are not static
const EXPECTED_LOG = `error [browser] Uncaught ReferenceError: undefinedValue is not defined
at App (src/App.jsx:4:0)
at Object.react_stack_bottom_frame (../../../../node_modules/.pnpm/react-dom`;

rspackTest(
'should display formatted full stack trace in React component',
async ({ dev }) => {
const rsbuild = await dev();
await rsbuild.expectLog(EXPECTED_LOG, { posix: true });
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';

export default defineConfig({
plugins: [pluginReact()],
dev: {
browserLogs: {
stackTrace: 'full',
},
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const App = () => {
return (
<button id="button" type="button">
count: {undefinedValue}
</button>
);
};

export default App;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';

const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(React.createElement(App));
}
12 changes: 12 additions & 0 deletions e2e/cases/browser-logs/stack-trace-full/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { rspackTest } from '@e2e/helper';

// Omitted some parts of the stack trace as they are not static
const EXPECTED_LOG = `error [browser] Uncaught Error: foo
at foo (src/foo.js:2:0)
at ./src/index.js (src/index.js:3:0)
at __webpack_require__ (http://localhost`;

rspackTest('should display formatted full stack trace', async ({ dev }) => {
const rsbuild = await dev();
await rsbuild.expectLog(EXPECTED_LOG, { posix: true });
});
9 changes: 9 additions & 0 deletions e2e/cases/browser-logs/stack-trace-full/rsbuild.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from '@rsbuild/core';

export default defineConfig({
dev: {
browserLogs: {
stackTrace: 'full',
},
},
});
3 changes: 3 additions & 0 deletions e2e/cases/browser-logs/stack-trace-full/src/foo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function foo() {
throw new Error('foo');
}
3 changes: 3 additions & 0 deletions e2e/cases/browser-logs/stack-trace-full/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { foo } from './foo';

foo();
163 changes: 129 additions & 34 deletions packages/core/src/server/browserLogs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import path from 'node:path';
import { promisify } from 'node:util';
import { parse as parseStack, type StackFrame } from 'stacktrace-parser';
import type {
InvalidOriginalMapping,
OriginalMapping,
} from '../../compiled/@jridgewell/trace-mapping';
import { SCRIPT_REGEX } from '../constants';
import { color } from '../helpers';
import { requireCompiledPackage } from '../helpers/vendors';
Expand All @@ -14,7 +18,7 @@ import type { ClientMessageError } from './socketServer';
* Maps a position in compiled code to its original source position using
* source maps.
*/
function mapSourceMapPosition(
function getOriginalPosition(
rawSourceMap: string,
line: number,
column: number,
Expand All @@ -30,7 +34,7 @@ function mapSourceMapPosition(
/**
* Returns the first stack frame that looks like user code
*/
const findSourceFrame = (parsed: StackFrame[]) => {
const findFirstUserFrame = (parsed: StackFrame[]) => {
return parsed.find(
(frame) =>
frame.file !== null &&
Expand All @@ -40,25 +44,11 @@ const findSourceFrame = (parsed: StackFrame[]) => {
) as { file: string; column: number; lineNumber: number } | undefined;
};

/**
* Resolve source filename and original position from runtime stack trace
*/
const resolveSourceLocation = async (
stack: string,
const getOriginalPositionForFrame = async (
frame: Pick<StackFrame, 'file' | 'column' | 'lineNumber'>,
fs: Rspack.OutputFileSystem,
context: InternalContext,
) => {
const parsed = parseStack(stack);
if (!parsed.length) {
return;
}

// only parse JS files
const frame = findSourceFrame(parsed);
if (!frame) {
return;
}

const { file, column, lineNumber } = frame;
const sourceMapInfo = await getFileFromUrl(
`${file}.map`,
Expand All @@ -74,7 +64,11 @@ const resolveSourceLocation = async (
try {
const sourceMap = await readFile(sourceMapInfo.filename);
if (sourceMap) {
return mapSourceMapPosition(sourceMap.toString(), lineNumber, column);
return getOriginalPosition(
sourceMap.toString(),
lineNumber ?? 0,
column ?? 0,
);
}
} catch (error) {
if (error instanceof Error) {
Expand All @@ -84,32 +78,63 @@ const resolveSourceLocation = async (
};

/**
* Formats error location information into a readable relative path string.
* Resolve source filename and original position from runtime stack trace,
* return formatted string like `src/App.tsx:10:20`
*/
const formatErrorLocation = async (
const resolveOriginalLocation = async (
stack: string,
context: InternalContext,
fs: Rspack.OutputFileSystem,
context: InternalContext,
) => {
const parsed = await resolveSourceLocation(stack, fs, context);
const parsed = parseStack(stack);
if (!parsed.length) {
return;
}

// only parse JS files
const frame = findFirstUserFrame(parsed);
if (!frame) {
return;
}

if (!parsed) {
const originalMapping = await getOriginalPositionForFrame(frame, fs, context);
if (!originalMapping) {
return;
}

const { source, line, column } = parsed;
return formatOriginalLocation(originalMapping, context);
};

const formatOriginalLocation = (
originalMapping: OriginalMapping | InvalidOriginalMapping,
context: InternalContext,
) => {
const { source, line, column } = originalMapping;
if (!source) {
return;
}

let rawLocation = path.relative(context.rootPath, source);
let result = path.relative(context.rootPath, source);
if (line !== null) {
rawLocation += column === null ? `:${line}` : `:${line}:${column}`;
result += column === null ? `:${line}` : `:${line}:${column}`;
}
return rawLocation;
return result;
};

const enhanceBrowserErrorLog = (log: string) => {
const formatFrameLocation = (frame: StackFrame) => {
const { file, lineNumber, column } = frame;
if (!file) {
return;
}
if (lineNumber !== null) {
return column !== null
? `${file}:${lineNumber}:${column}`
: `${file}:${lineNumber}`;
}
return file;
};

const enhanceErrorLogWithHints = (log: string) => {
const isProcessUndefined = log.includes(
'ReferenceError: process is not defined',
);
Expand All @@ -123,6 +148,60 @@ const enhanceBrowserErrorLog = (log: string) => {
return log;
};

const formatFullStack = async (
stack: string,
context: InternalContext,
fs: Rspack.OutputFileSystem,
) => {
const parsed = parseStack(stack);

if (!parsed.length) {
return;
}

let result = '';

for (const frame of parsed) {
const originalMapping = await getOriginalPositionForFrame(
frame,
fs,
context,
);
const { methodName } = frame;
const parts: (string | undefined)[] = [];

if (methodName !== '<unknown>') {
parts.push(methodName);
}

if (originalMapping) {
const originalLocation = formatOriginalLocation(originalMapping, context);
if (originalLocation) {
parts.push(originalLocation);
} else {
const frameString = formatFrameLocation(frame);
if (frameString) {
parts.push(frameString);
}
}
} else {
const frameString = formatFrameLocation(frame);
if (frameString) {
parts.push(frameString);
}
}

if (parts[0]) {
result += `\n at ${parts[0]}`;
}
if (parts[1]) {
result += ` (${parts[1]})`;
}
}

return result;
};

/**
* Formats error messages received from the browser into a log string with
* source location information.
Expand All @@ -135,12 +214,28 @@ export const formatBrowserErrorLog = async (
): Promise<string> => {
let log = `${color.cyan('[browser]')} ${color.red(message.message)}`;

if (message.stack && stackTrace !== 'none') {
const rawLocation = await formatErrorLocation(message.stack, context, fs);
if (rawLocation) {
log += color.dim(` (${rawLocation})`);
if (message.stack) {
switch (stackTrace) {
case 'summary': {
const location = await resolveOriginalLocation(
message.stack,
fs,
context,
);
log += location ? color.dim(` (${location})`) : '';
break;
}
case 'full': {
const fullStack = await formatFullStack(message.stack, context, fs);
if (fullStack) {
log += fullStack;
}
break;
}
case 'none':
break;
}
}

return enhanceBrowserErrorLog(log);
return enhanceErrorLogWithHints(log);
};
3 changes: 2 additions & 1 deletion packages/core/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1712,7 +1712,7 @@ export type CliShortcut = {

export type WriteToDisk = boolean | ((filename: string) => boolean);

export type BrowserLogsStackTrace = 'summary' | 'none';
export type BrowserLogsStackTrace = 'summary' | 'full' | 'none';

export interface DevConfig {
/**
Expand All @@ -1728,6 +1728,7 @@ export interface DevConfig {
* Controls how the error stack trace is displayed in the terminal when forwarding
* browser errors.
* - `'summary'` – Show only the first frame (e.g. `(src/App.jsx:3:0)`).
* - `'full'` – Print the full stack trace with all frames.
* - `'none'` – Hide stack traces.
* @default 'summary'
*/
Expand Down
Loading