From d38035ccf1c9b8ad6e842ff81c57fad4306a5cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Born=C3=B6?= Date: Wed, 22 Feb 2023 19:35:38 +0100 Subject: [PATCH] Make build error urls clickable (#46251) Makes urls in build errors clickable by turning them into ``. Reuses the `` 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) --- .../internal/components/Terminal/Terminal.tsx | 3 +- .../internal/components/Terminal/styles.tsx | 3 + .../get-words-and-whitespaces.test.ts | 17 +++++ .../get-words-and-whitespaces.ts | 37 ++++++++++ .../components/hot-linked-text/index.tsx | 29 ++++++++ .../internal/container/Errors.tsx | 30 +------- .../acceptance-app/error-message-url.test.ts | 73 +++++++++++++++++++ 7 files changed, 162 insertions(+), 30 deletions(-) create mode 100644 packages/next/src/client/components/react-dev-overlay/internal/components/hot-linked-text/get-words-and-whitespaces.test.ts create mode 100644 packages/next/src/client/components/react-dev-overlay/internal/components/hot-linked-text/get-words-and-whitespaces.ts create mode 100644 packages/next/src/client/components/react-dev-overlay/internal/components/hot-linked-text/index.tsx create mode 100644 test/development/acceptance-app/error-message-url.test.ts diff --git a/packages/next/src/client/components/react-dev-overlay/internal/components/Terminal/Terminal.tsx b/packages/next/src/client/components/react-dev-overlay/internal/components/Terminal/Terminal.tsx index ed61d5fd13e3a..e563d69b26c6c 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/components/Terminal/Terminal.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/components/Terminal/Terminal.tsx @@ -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 } @@ -59,7 +60,7 @@ export const Terminal: React.FC = function Terminal({ : undefined), }} > - {entry.content} + ))} {editorLinks.map((file) => ( diff --git a/packages/next/src/client/components/react-dev-overlay/internal/components/Terminal/styles.tsx b/packages/next/src/client/components/react-dev-overlay/internal/components/Terminal/styles.tsx index f9bee2f8c5763..bf96e64248fa6 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/components/Terminal/styles.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/components/Terminal/styles.tsx @@ -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 } diff --git a/packages/next/src/client/components/react-dev-overlay/internal/components/hot-linked-text/get-words-and-whitespaces.test.ts b/packages/next/src/client/components/react-dev-overlay/internal/components/hot-linked-text/get-words-and-whitespaces.test.ts new file mode 100644 index 0000000000000..9105ff91c8fc8 --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/internal/components/hot-linked-text/get-words-and-whitespaces.test.ts @@ -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/', + ]) + }) +}) diff --git a/packages/next/src/client/components/react-dev-overlay/internal/components/hot-linked-text/get-words-and-whitespaces.ts b/packages/next/src/client/components/react-dev-overlay/internal/components/hot-linked-text/get-words-and-whitespaces.ts new file mode 100644 index 0000000000000..a1632bdb12dbe --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/internal/components/hot-linked-text/get-words-and-whitespaces.ts @@ -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 +} diff --git a/packages/next/src/client/components/react-dev-overlay/internal/components/hot-linked-text/index.tsx b/packages/next/src/client/components/react-dev-overlay/internal/components/hot-linked-text/index.tsx new file mode 100644 index 0000000000000..2dde5aba96952 --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/internal/components/hot-linked-text/index.tsx @@ -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 ( + + {word} + + ) + } + return {word} + }) + : text} + + ) +} diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx index 5a2cc24c5137c..0714bdd55b5ba 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/Errors.tsx @@ -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 @@ -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 ( - - {word} - {index === array.length - 1 ? '' : ' '} - - ) - } - return index === array.length - 1 ? ( - {word} - ) : ( - {word} - ) - }) - : text} - - ) -} - export const Errors: React.FC = function Errors({ errors, initialDisplayState, diff --git a/test/development/acceptance-app/error-message-url.test.ts b/test/development/acceptance-app/error-message-url.test.ts new file mode 100644 index 0000000000000..138c102746c40 --- /dev/null +++ b/test/development/acceptance-app/error-message-url.test.ts @@ -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() + }) + } +)