\d+)/;
+ let sourceInfo = {} as any;
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const match = line.match(regex);
+ if (match) {
+ const { filename, line, column } = match.groups as any;
+ sourceInfo = { filename, line, column };
+ break;
+ }
+ }
+ if (!sourceInfo.filename) return;
+
+ try {
+ const content = await findSourceCode(sourceInfo);
+ return content;
+ } catch (e) {}
+}
diff --git a/packages/core/src/client/hmr.ts b/packages/core/src/client/hmr.ts
index 18bf0458e3..7fc5431182 100644
--- a/packages/core/src/client/hmr.ts
+++ b/packages/core/src/client/hmr.ts
@@ -6,7 +6,8 @@
*/
import type { StatsError } from '@rsbuild/shared';
import type { ClientConfig } from '@rsbuild/shared';
-import { formatStatsMessages } from './format';
+import type { OverlayError } from '../types';
+import { formatBuildErrors, formatStatsMessages } from './format';
/**
* hmr socket connect path
@@ -62,11 +63,11 @@ function clearOutdatedErrors() {
}
}
-let createOverlay: undefined | ((err: string[]) => void);
+let createOverlay: undefined | ((err: OverlayError) => void);
let clearOverlay: undefined | (() => void);
export const registerOverlay = (
- createFn: (err: string[]) => void,
+ createFn: (err: OverlayError) => void,
clearFn: () => void,
) => {
createOverlay = createFn;
@@ -124,18 +125,13 @@ function handleErrors(errors: StatsError[]) {
hasCompileErrors = true;
// "Massage" webpack messages.
- const formatted = formatStatsMessages({
- errors,
- warnings: [],
- });
+ const overlayError = formatBuildErrors(errors);
// Also log them to the console.
- for (const error of formatted.errors) {
- console.error(error);
- }
+ console.error(overlayError.content);
if (createOverlay) {
- createOverlay(formatted.errors);
+ createOverlay(overlayError);
}
// Do not attempt to reload now.
diff --git a/packages/core/src/client/overlay.ts b/packages/core/src/client/overlay.ts
index dae07a106d..5451768d1b 100644
--- a/packages/core/src/client/overlay.ts
+++ b/packages/core/src/client/overlay.ts
@@ -1,3 +1,4 @@
+import type { OverlayError } from '../types';
import { registerOverlay } from './hmr';
function stripAnsi(content: string) {
@@ -11,8 +12,13 @@ function stripAnsi(content: string) {
return content.replace(regex, '');
}
+function displayElement(el: Element) {
+ (el as HTMLElement).style.display = 'block';
+}
+
function linkedText(root: ShadowRoot, selector: string, text: string): void {
const el = root.querySelector(selector)!;
+ displayElement(el);
const fileRegex = /(?:[a-zA-Z]:\\|\/).*?:\d+:\d+/g;
let curIndex = 0;
@@ -39,6 +45,24 @@ function linkedText(root: ShadowRoot, selector: string, text: string): void {
el.appendChild(document.createTextNode(frag));
}
+function updateLink(root: ShadowRoot, selector: string, file: string): void {
+ const el = root.querySelector(selector)!;
+ el.addEventListener('click', () => {
+ fetch(`/__open-in-editor?file=${encodeURIComponent(file)}`);
+ });
+ el.classList.add('cursor-pointer');
+}
+
+function updateElement(
+ root: ShadowRoot,
+ selector: string,
+ innerHTML: string,
+): void {
+ const el = root.querySelector(selector)!;
+ el.innerHTML = innerHTML;
+ displayElement(el);
+}
+
const overlayTemplate = `
-
Compilation failed
-
-
+
+
+
+
+
`;
@@ -166,7 +214,7 @@ const {
} = typeof window !== 'undefined' ? window : globalThis;
class ErrorOverlay extends HTMLElement {
- constructor(message: string[]) {
+ constructor(error: OverlayError) {
super();
if (!this.attachShadow) {
@@ -176,28 +224,55 @@ class ErrorOverlay extends HTMLElement {
return;
}
+ const { type, content, title, stack, sourceFile } = error;
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = overlayTemplate;
- linkedText(root, '.content', stripAnsi(message.join('/n')).trim());
+ updateElement(root, '.title', title);
+
+ if (type === 'build') {
+ linkedText(root, '.build-content', stripAnsi(content).trim());
+ } else {
+ updateElement(root, '.runtime-content', content);
+ if (sourceFile) {
+ updateLink(root, '.runtime-content', sourceFile);
+ }
+ }
- root.querySelector('.close')?.addEventListener('click', this.close);
+ if (stack?.length) {
+ updateElement(
+ root,
+ '.stack',
+ `
+
+ ${stack.length} stack frames
+ ${stack.join('\n')}
+
+ `,
+ );
+ }
- // close overlay when click outside
- this.addEventListener('click', this.close);
+ updateElement(
+ root,
+ '.footer',
+ type === 'build'
+ ? ' This error occurred during the build time and cannot be dismissed.
'
+ : "Fix error, click outside, or press Esc to close the overlay.
Disable overlay by setting Rsbuild's dev.client.runtimeErrors config to false.
",
+ );
- root.querySelector('.container')!.addEventListener('click', (e) => {
- e.stopPropagation();
- });
+ if (type === 'runtime') {
+ displayElement(root.querySelector('.close') as Element);
+ root.querySelector('.close')?.addEventListener('click', this.close);
- const onEscKeydown = (e: KeyboardEvent) => {
- if (e.key === 'Escape' || e.code === 'Escape') {
- this.close();
- }
- document.removeEventListener('keydown', onEscKeydown);
- };
+ const onEscKeydown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' || e.code === 'Escape') {
+ this.close();
+ }
+ document.removeEventListener('keydown', onEscKeydown);
+ };
- document.addEventListener('keydown', onEscKeydown);
+ document.addEventListener('keydown', onEscKeydown);
+ }
}
close = () => {
@@ -220,17 +295,42 @@ if (customElements && !customElements.get(overlayId)) {
customElements.define(overlayId, ErrorOverlay);
}
-function createOverlay(err: string[]) {
+const documentAvailable = typeof document !== 'undefined';
+
+export function createOverlay(err: OverlayError) {
+ if (!documentAvailable) {
+ console.info(
+ '[Rsbuild] Failed to display error overlay as document is not available, you can disable the `dev.client.overlay` option.',
+ );
+ return;
+ }
+
+ if (!err) return;
+
+ if (hasOverlay()) return;
+
clearOverlay();
document.body.appendChild(new ErrorOverlay(err));
}
-function clearOverlay() {
+export function clearOverlay() {
+ if (!documentAvailable) {
+ return;
+ }
+
// use NodeList's forEach api instead of dom.iterable
// biome-ignore lint/complexity/noForEach:
document.querySelectorAll(overlayId).forEach((n) => n.close());
}
+export function hasOverlay() {
+ if (!documentAvailable) {
+ return false;
+ }
+
+ return document.querySelector(overlayId) !== null;
+}
+
if (typeof document !== 'undefined') {
registerOverlay(createOverlay, clearOverlay);
} else {
diff --git a/packages/core/src/client/runtimeErrors.ts b/packages/core/src/client/runtimeErrors.ts
new file mode 100644
index 0000000000..0aa6f0fdf6
--- /dev/null
+++ b/packages/core/src/client/runtimeErrors.ts
@@ -0,0 +1,14 @@
+import { formatRuntimeErrors } from './format';
+import { createOverlay } from './overlay';
+
+window.addEventListener('error', async (event) => {
+ const formatted = await formatRuntimeErrors(event, false);
+ createOverlay(formatted);
+});
+
+window.addEventListener('unhandledrejection', async (event) => {
+ if (event.reason?.stack) {
+ const formatted = await formatRuntimeErrors(event, true);
+ createOverlay(formatted);
+ }
+});
diff --git a/packages/core/src/server/compilerDevMiddleware.ts b/packages/core/src/server/compilerDevMiddleware.ts
index c1c5333113..af6b75616f 100644
--- a/packages/core/src/server/compilerDevMiddleware.ts
+++ b/packages/core/src/server/compilerDevMiddleware.ts
@@ -31,6 +31,15 @@ function getClientPaths(devConfig: DevConfig) {
clientPaths.push(`${require.resolve('@rsbuild/core/client/overlay')}`);
}
+ if (
+ typeof devConfig?.client?.overlay === 'object' &&
+ devConfig.client.overlay.runtimeErrors
+ ) {
+ clientPaths.push(
+ `${require.resolve('@rsbuild/core/client/runtimeErrors')}`,
+ );
+ }
+
return clientPaths;
}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index d5e7765077..b48a7e53fd 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -63,3 +63,15 @@ export type {
NormalizedPerformanceConfig,
NormalizedModuleFederationConfig,
} from '@rsbuild/shared';
+
+export interface OverlayError {
+ content: string;
+
+ title: string;
+
+ type: 'build' | 'runtime';
+
+ stack?: string[];
+
+ sourceFile?: string;
+}
diff --git a/packages/shared/src/types/config/dev.ts b/packages/shared/src/types/config/dev.ts
index 314dddcba2..862eea358f 100644
--- a/packages/shared/src/types/config/dev.ts
+++ b/packages/shared/src/types/config/dev.ts
@@ -27,7 +27,12 @@ export type ClientConfig = {
host?: string;
protocol?: 'ws' | 'wss';
/** Shows an overlay in the browser when there are compiler errors. */
- overlay?: boolean;
+ overlay?:
+ | boolean
+ | {
+ // unhandled runtime errors
+ runtimeErrors?: boolean;
+ };
};
export type ChokidarWatchOptions = WatchOptions;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 768e8ab3d4..e06ec7ef79 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -704,6 +704,9 @@ importers:
postcss:
specifier: ^8.4.38
version: 8.4.38
+ source-map-js:
+ specifier: ^1.2.0
+ version: 1.2.0
devDependencies:
'@types/fs-extra':
specifier: ^11.0.4