Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: runtime error overlay #6274

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
79ac672
feat: poc
arbassett Dec 27, 2021
f90fdf5
fix: better sourcemap parsing
arbassett Dec 27, 2021
3a51bc1
chore: add more testing samples
arbassett Dec 27, 2021
cd55215
chore: dead code removal
arbassett Dec 27, 2021
fb6ce0d
feat: transform stack traces
arbassett Dec 27, 2021
50047e7
feat: basic unhandledrejection handler
arbassett Dec 27, 2021
b975f80
chore: formatting
arbassett Dec 27, 2021
a387e1c
fix: better regex parsing
arbassett Dec 29, 2021
678e11f
fix: consistant lineNo whitespace
arbassett Dec 29, 2021
a31eec7
feat: support unhandledRejection stack frames
arbassett Dec 29, 2021
2cf71fc
fix: bundle source-map for client
arbassett Dec 29, 2021
4a1c910
fix: remove development console.log
arbassett Dec 29, 2021
c2b086e
fix(overlay): support non-url stack traces
arbassett Dec 29, 2021
b8e11b3
feat: refactor & support non-error errors
arbassett Dec 29, 2021
8fde859
chore: formatting
arbassett Dec 29, 2021
9dee968
chore: more test cases
arbassett Dec 29, 2021
4017f98
fix: handle rejection with no stack
arbassett Dec 30, 2021
d7afc02
chore: add tests
arbassett Dec 30, 2021
4d7c0d8
chore: add build test
arbassett Dec 30, 2021
14f4428
fix: extract getStackLineInformation to error.ts
arbassett Jan 4, 2022
dbf0100
chore: getStackLineInformation tests
arbassett Jan 4, 2022
ab42107
fix: better happy path
arbassett Jan 4, 2022
1de3430
fix: extract error code to error.ts
arbassett Jan 4, 2022
be450fa
fix: respect __HMR_ENABLE_OVERLAY__
arbassett Jan 4, 2022
25c80f9
chore: formatting
arbassett Jan 4, 2022
3eefedb
chore: comment cleanup
arbassett Jan 4, 2022
61e9121
chore: code cleanup
arbassett Jan 4, 2022
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
171 changes: 171 additions & 0 deletions packages/playground/runtime-error/__tests__/runtime.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { isBuild } from '../../testUtils'

interface testErrorOverlayOptions {
message: string | RegExp
file: string | RegExp | null
frame: (string | RegExp)[] | null
stack: (string | RegExp)[]
}

const testErrorOverlay = async (
btnSelector: string,
{ message, file, frame, stack }: testErrorOverlayOptions
) => {
await (await page.$(btnSelector)).click()

const overlay = await page.waitForSelector('vite-error-overlay')
expect(overlay).toBeTruthy()

const overlayMessage = await (await overlay.$('.message-body'))?.innerHTML()
expect(overlayMessage).toMatch(message)

const overlayFile =
(await (await overlay.$('.file > .file-link'))?.innerText()) || null

if (file == null) {
expect(overlayFile).toBeNull()
} else {
expect(overlayFile).toMatch(file)
}

const overlayFrame = (await (await overlay.$('.frame')).innerText()) || null

if (frame == null) {
expect(overlayFrame).toBeNull()
} else {
frame.forEach((f) => {
expect(overlayFrame).toMatch(f)
})
}

const overlayStack = await (await overlay.$('.stack')).innerText()
stack.forEach((s) => {
expect(overlayStack).toMatch(s)
})
}

if (isBuild) {
test('Should not show overlay in build', async () => {
await (await page.$('#throwBtn')).click()
const overlay = await page.$('vite-error-overlay')
expect(overlay).toBeFalsy()
})
} else {
beforeEach(async () => {
// reset the page before each run
// viteTestUrl is globally injected in scripts/jestPerTestSetup.ts
await page.goto(viteTestUrl)
})

describe('unhandled exceptions', () => {
test('should catch unhandled errors', async () => {
await testErrorOverlay('#throwBtn', {
message: 'Why did you click the button',
file: '/runtime-error/src/entry-client.ts:4:8',
frame: [
'querySelector<HTMLButtonElement>',
"new Error('Why did you click the button')"
],
stack: [
'Error: Why did you click the button',
'/runtime-error/src/entry-client.ts:4:8'
]
})
})

test('should catch runtime errors', async () => {
await testErrorOverlay('#invalidAccessor', {
message: 'Cannot set properties of undefined',
file: '/runtime-error/src/entry-client.ts:9:16',
frame: [
'querySelector<HTMLButtonElement>',
'//@ts-expect-error',
'window.doesnt.exists = 5'
],
stack: [
'TypeError: Cannot set properties of undefined',
'HTMLButtonElement.document.querySelector.onclick',
'/runtime-error/src/entry-client.ts:9:16'
]
})
})

test('should handle string errors', async () => {
await testErrorOverlay('#throwStr', {
message: 'String Error',
file: '/runtime-error/src/entry-client.ts:17:2',
frame: ['querySelector<HTMLButtonElement>', "throw 'String Error'"],
stack: [
"a non-error was thrown please check your browser's devtools for more information"
]
})
})

test('should handle number errors', async () => {
await testErrorOverlay('#throwNum', {
message: '42',
file: '/runtime-error/src/entry-client.ts:21:2',
frame: ['querySelector<HTMLButtonElement>', 'throw 42'],
stack: [
"a non-error was thrown please check your browser's devtools for more information"
]
})
})
test('should show stack trace from multiple files', async () => {
await testErrorOverlay('#throwExternal', {
message: 'Throw from externalThrow',
file: '/src/external.js:2:9',
frame: [
'export const externalThrow',
"throw new Error('Throw from externalThrow')"
],
stack: [
'Error: Throw from externalThrow',
'/src/external.js',
'2:9',
'HTMLButtonElement.document.querySelector.onclick',
'/runtime-error/src/entry-client.ts:25:2'
]
})
})
})

describe('unhandled rejections', () => {
test('should catch unhandled promises', async () => {
await testErrorOverlay('#reject', {
message: 'async failure',
file: '/runtime-error/src/entry-client.ts:13:17',
frame: ['const asyncFunc = async () => {', 'async failure'],
stack: [
'asyncFunc',
'runtime-error/src/entry-client.ts:13:17',
'HTMLButtonElement.document.querySelector.onclick',
'runtime-error/src/entry-client.ts:29:8'
]
})
})

test('should handle uncaught string reason', async () => {
await testErrorOverlay('#rejectExternal', {
message: 'Reject from externalAsync',
file: null,
frame: null,
stack: [
"a non-error was thrown please check your browser's devtools for more information"
]
})
})

test('should handle rejected module', async () => {
await testErrorOverlay('#rejectExternalModule', {
message: 'Thrown From Module',
file: '/runtime-error/src/module-thrown.ts:1:6',
frame: ["throw new Error('Thrown From Module')"],
stack: [
'Error: Thrown From Module',
'/runtime-error/src/module-thrown.ts:1:6'
]
})
})
})
}
22 changes: 22 additions & 0 deletions packages/playground/runtime-error/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Run Time ErrorL</title>
</head>
<body>
<h1>Runtime Error</h1>
<button id="throwBtn">Throw Error</button>
<button id="invalidAccessor">Invalid Accessor</button>

<button id="throwStr">Throw String</button>
<button id="throwNum">Throw Number</button>
<button id="throwExternal">Throw External</button>

<button id="reject">reject</button>
<button id="rejectExternal">Reject External Import</button>
<button id="rejectExternalModule">Reject External Module</button>
<script type="module" src="/src/entry-client.ts"></script>
</body>
</html>
11 changes: 11 additions & 0 deletions packages/playground/runtime-error/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "test-runtime-error",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"debug": "node --inspect-brk ../../vite/bin/vite",
"preview": "vite preview"
}
}
40 changes: 40 additions & 0 deletions packages/playground/runtime-error/src/entry-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { externalThrow } from './external'

document.querySelector<HTMLButtonElement>('#throwBtn').onclick = () => {
throw new Error('Why did you click the button')
}

document.querySelector<HTMLButtonElement>('#invalidAccessor').onclick = () => {
//@ts-expect-error
window.doesnt.exists = 5
}

const asyncFunc = async () => {
Promise.reject(new Error('async failure'))
}

document.querySelector<HTMLButtonElement>('#throwStr').onclick = () => {
throw 'String Error'
}

document.querySelector<HTMLButtonElement>('#throwNum').onclick = () => {
throw 42
}

document.querySelector<HTMLButtonElement>('#throwExternal').onclick = () => {
externalThrow()
}

document.querySelector<HTMLButtonElement>('#reject').onclick = async () => {
await asyncFunc()
}

document.querySelector<HTMLButtonElement>('#rejectExternalModule').onclick =
async () => {
await import('./module-thrown')
}

document.querySelector<HTMLButtonElement>('#rejectExternal').onclick =
async () => {
;(await import('./external')).externalAsync()
}
7 changes: 7 additions & 0 deletions packages/playground/runtime-error/src/external.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const externalThrow = () => {
throw new Error('Throw from externalThrow')
}

export const externalAsync = async () => {
return Promise.reject('Reject from externalAsync')
}
1 change: 1 addition & 0 deletions packages/playground/runtime-error/src/module-thrown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
throw new Error('Thrown From Module')
4 changes: 3 additions & 1 deletion packages/vite/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,16 @@ const clientConfig = {
input: path.resolve(__dirname, 'src/client/client.ts'),
external: ['./env', '@vite/env'],
plugins: [
nodeResolve(),
Copy link
Contributor

Choose a reason for hiding this comment

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

Opening the door for dependencies (bundling) in client.mjs feels a bit risky.
Simply bundling source-map makes client.mjs grow from 17k to 127k.

typescript({
target: 'es2018',
include: ['src/client/**/*.ts'],
baseUrl: path.resolve(__dirname, 'src/client'),
paths: {
'types/*': ['../../types/*']
}
})
}),
commonjs({ extensions: ['.js', '.ts'], sourceMap: true })
],
output: {
file: path.resolve(__dirname, 'dist/client', 'client.mjs'),
Expand Down
66 changes: 66 additions & 0 deletions packages/vite/src/client/__tests__/error.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { StackLineInfo, StackLineResult } from '../error'
import { getStackLineInformation } from '../error'

describe('getStackLineInformation', () => {
describe('chrome', () => {
test('should parse stack line', () => {
const input = ' at a (http://domain.com/script.js:1:11764)'
const result = getStackLineInformation(input)

expect(result).toStrictEqual({
input,
varName: 'a',
url: 'http://domain.com/script.js',
line: 1,
column: 11764,
vendor: 'chrome'
})
})

test('should parse stack line with eval', () => {
const input =
' at Object.eval (eval at <anonymous> (http://domain.com/script.js:1:11764), <anonymous>:35:3729)'
const result = getStackLineInformation(input)

expect(result).toStrictEqual({
input,
varName: 'Object.eval',
url: 'http://domain.com/script.js',
line: 1,
column: 11764,
vendor: 'chrome'
})
})
})

describe('firefox', () => {
test('should parse stack line', () => {
const input = 'a@(http://domain.com/script.js:1:11764)'
const result = getStackLineInformation(input)

expect(result).toStrictEqual({
input,
varName: 'a',
url: 'http://domain.com/script.js',
line: 1,
column: 11764,
vendor: 'firefox'
})
})

test('should parse stack line with eval', () => {
const input =
'Object.eval@http://domain.com/script.js line 1 > eval line 1 > eval:35:3729'
const result = getStackLineInformation(input)

expect(result).toStrictEqual({
input,
varName: 'Object.eval',
url: 'http://domain.com/script.js',
line: 1,
column: 0,
vendor: 'firefox'
})
})
})
})
Loading