Skip to content

Commit

Permalink
Make build error urls clickable (#46251)
Browse files Browse the repository at this point in the history
Makes urls in build errors clickable by turning them into `<a>`. Reuses the `<HotlinkedText />` component in build errors that previously only was used in runtime errors.  Also fixes an error that made the links break if they had `\n` before or after the url.

Fixes NEXT-586

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md)

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [ ] Related issues linked using `fixes #number`
- [ ] [e2e](https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs) tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md)

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm build && pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
  • Loading branch information
hanneslund authored Feb 22, 2023
1 parent ab123c1 commit d38035c
Show file tree
Hide file tree
Showing 7 changed files with 162 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Anser from 'next/dist/compiled/anser'
import * as React from 'react'
import { HotlinkedText } from '../hot-linked-text'
import { EditorLink } from './EditorLink'

export type TerminalProps = { content: string }
Expand Down Expand Up @@ -59,7 +60,7 @@ export const Terminal: React.FC<TerminalProps> = function Terminal({
: undefined),
}}
>
{entry.content}
<HotlinkedText text={entry.content} />
</span>
))}
{editorLinks.map((file) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ const styles = css`
[data-with-open-in-editor-link] {
margin-left: var(--size-gap-double);
}
[data-nextjs-terminal] a {
color: inherit;
}
`

export { styles }
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getWordsAndWhitespaces } from './get-words-and-whitespaces'

describe('getWordsAndWhitespaces', () => {
it('should return sequences of words and whitespaces', () => {
const text = ' \n\nhello world https://nextjs.org/\nhttps://nextjs.org/'
expect(getWordsAndWhitespaces(text)).toEqual([
' \n\n',
'hello',
' ',
'world',
' ',
'https://nextjs.org/',
'\n',
'https://nextjs.org/',
])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
function isWhitespace(char: string) {
return char === ' ' || char === '\n'
}

/**
* Get sequences of words and whitespaces from a string.
*
* e.g. "Hello world \n\n" -> ["Hello", " ", "world", " \n\n"]
*/
export function getWordsAndWhitespaces(text: string) {
const wordsAndWhitespaces: string[] = []

let current = ''
let currentIsWhitespace = false
for (const char of text) {
if (current.length === 0) {
current += char
currentIsWhitespace = isWhitespace(char)
continue
}

const nextIsWhitespace = isWhitespace(char)
if (currentIsWhitespace === nextIsWhitespace) {
current += char
} else {
wordsAndWhitespaces.push(current)
current = char
currentIsWhitespace = nextIsWhitespace
}
}

if (current.length > 0) {
wordsAndWhitespaces.push(current)
}

return wordsAndWhitespaces
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react'
import { getWordsAndWhitespaces } from './get-words-and-whitespaces'

const linkRegex = /https?:\/\/[^\s/$.?#].[^\s"]*/i

export const HotlinkedText: React.FC<{
text: string
}> = function HotlinkedText(props) {
const { text } = props

const wordsAndWhitespaces = getWordsAndWhitespaces(text)

return (
<>
{linkRegex.test(text)
? wordsAndWhitespaces.map((word, index) => {
if (linkRegex.test(word)) {
return (
<React.Fragment key={`link-${index}`}>
<a href={word}>{word}</a>
</React.Fragment>
)
}
return <React.Fragment key={`text-${index}`}>{word}</React.Fragment>
})
: text}
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { CloseIcon } from '../icons/CloseIcon'
import { RuntimeError } from './RuntimeError'
import { VersionStalenessInfo } from '../components/VersionStalenessInfo'
import type { VersionInfo } from '../../../../../server/dev/parse-version-info'
import { HotlinkedText } from '../components/hot-linked-text'

export type SupportedErrorEvent = {
id: number
Expand Down Expand Up @@ -52,35 +53,6 @@ function getErrorSignature(ev: SupportedErrorEvent): string {
return ''
}

const HotlinkedText: React.FC<{
text: string
}> = function HotlinkedText(props) {
const { text } = props

const linkRegex = /https?:\/\/[^\s/$.?#].[^\s"]*/i
return (
<>
{linkRegex.test(text)
? text.split(' ').map((word, index, array) => {
if (linkRegex.test(word)) {
return (
<React.Fragment key={`link-${index}`}>
<a href={word}>{word}</a>
{index === array.length - 1 ? '' : ' '}
</React.Fragment>
)
}
return index === array.length - 1 ? (
<React.Fragment key={`text-${index}`}>{word}</React.Fragment>
) : (
<React.Fragment key={`text-${index}`}>{word} </React.Fragment>
)
})
: text}
</>
)
}

export const Errors: React.FC<ErrorsProps> = function Errors({
errors,
initialDisplayState,
Expand Down
73 changes: 73 additions & 0 deletions test/development/acceptance-app/error-message-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { createNextDescribe, FileRef } from 'e2e-utils'
import path from 'path'
import { sandbox } from './helpers'

createNextDescribe(
'Error overlay - error message urls',
{
files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')),
dependencies: {
react: 'latest',
'react-dom': 'latest',
},
skipStart: true,
},
({ next }) => {
it('should be possible to click url in build error', async () => {
const { session, browser, cleanup } = await sandbox(next)

const content = await next.readFile('app/page.js')

await session.patch(
'app/page.js',
content + '\nexport function getServerSideProps() {}'
)

expect(await session.hasRedbox(true)).toBe(true)

const link = await browser.elementByCss('[data-nextjs-terminal] a')
const text = await link.text()
const href = await link.getAttribute('href')
expect(text).toEqual(
'https://beta.nextjs.org/docs/data-fetching/fundamentals'
)
expect(href).toEqual(
'https://beta.nextjs.org/docs/data-fetching/fundamentals'
)

await cleanup()
})

it('should be possible to click url in runtime error', async () => {
const { session, browser, cleanup } = await sandbox(
next,
new Map([
[
'app/page.js',
`'use client'
export default function Page() {
return typeof window === 'undefined' ? 'HELLO' : 'WORLD'
}
`,
],
])
)

await session.waitForAndOpenRuntimeError()

const link = await browser.elementByCss(
'#nextjs__container_errors_desc a'
)
const text = await link.text()
const href = await link.getAttribute('href')
expect(text).toEqual(
'https://nextjs.org/docs/messages/react-hydration-error'
)
expect(href).toEqual(
'https://nextjs.org/docs/messages/react-hydration-error'
)

await cleanup()
})
}
)

0 comments on commit d38035c

Please sign in to comment.