diff --git a/packages/gatsby-cli/src/reporter/errors.ts b/packages/gatsby-cli/src/reporter/errors.ts index 095e271884354..cc72e9015b18f 100644 --- a/packages/gatsby-cli/src/reporter/errors.ts +++ b/packages/gatsby-cli/src/reporter/errors.ts @@ -3,6 +3,8 @@ import stackTrace from "stack-trace" import { prepareStackTrace, ErrorWithCodeFrame } from "./prepare-stack-trace" import { isNodeInternalModulePath } from "gatsby-core-utils" import { IStructuredStackFrame } from "../structured-errors/types" +import { readFileSync } from "fs-extra" +import { codeFrameColumns } from "@babel/code-frame" const packagesToSkip = [`core-js`, `bluebird`, `regenerator-runtime`, `graphql`] @@ -99,31 +101,91 @@ export function getErrorFormatter(): PrettyError { return prettyError } +type ErrorWithPotentialForcedLocation = Error & { + forcedLocation?: { + fileName: string + lineNumber?: number + columnNumber?: number + endLineNumber?: number + endColumnNumber?: number + functionName?: string + } +} + /** * Convert a stringified webpack compilation error back into * an Error instance so it can be formatted properly */ export function createErrorFromString( - errorStr: string = ``, + errorOrErrorStack: string | ErrorWithPotentialForcedLocation = ``, sourceMapFile: string ): ErrorWithCodeFrame { - let [message, ...rest] = errorStr.split(/\r\n|[\n\r]/g) - // pull the message from the first line then remove the `Error:` prefix - // FIXME: when https://github.com/AriaMinaei/pretty-error/pull/49 is merged + if (typeof errorOrErrorStack === `string`) { + const errorStr = errorOrErrorStack + let [message, ...rest] = errorStr.split(/\r\n|[\n\r]/g) + // pull the message from the first line then remove the `Error:` prefix + // FIXME: when https://github.com/AriaMinaei/pretty-error/pull/49 is merged - message = message.replace(/^(Error:)/, ``) + message = message.replace(/^(Error:)/, ``) - const error = new Error(message) + const error = new Error(message) - error.stack = [message, rest.join(`\n`)].join(`\n`) + error.stack = [message, rest.join(`\n`)].join(`\n`) - error.name = `WebpackError` - try { - if (sourceMapFile) { - return prepareStackTrace(error, sourceMapFile) + error.name = `WebpackError` + try { + if (sourceMapFile) { + return prepareStackTrace(error, sourceMapFile) + } + } catch (err) { + // don't shadow a real error because of a parsing issue + } + return error + } else { + if (errorOrErrorStack.forcedLocation) { + const forcedLocation = errorOrErrorStack.forcedLocation + const error = new Error(errorOrErrorStack.message) as ErrorWithCodeFrame + error.stack = `${errorOrErrorStack.message} + at ${forcedLocation.functionName ?? ``} (${ + forcedLocation.fileName + }${ + forcedLocation.lineNumber + ? `:${forcedLocation.lineNumber}${ + forcedLocation.columnNumber + ? `:${forcedLocation.columnNumber}` + : `` + }` + : `` + })` + + try { + const source = readFileSync(forcedLocation.fileName, `utf8`) + + error.codeFrame = codeFrameColumns( + source, + { + start: { + line: forcedLocation.lineNumber ?? 0, + column: forcedLocation.columnNumber ?? 0, + }, + end: forcedLocation.endColumnNumber + ? { + line: forcedLocation.endLineNumber ?? 0, + column: forcedLocation.endColumnNumber ?? 0, + } + : undefined, + }, + { + highlightCode: true, + } + ) + } catch (e) { + // failed to generate codeframe, we still should show an error so we keep going + } + + return error + } else { + return createErrorFromString(errorOrErrorStack.stack, sourceMapFile) } - } catch (err) { - // don't shadow a real error because of a parsing issue } - return error } diff --git a/packages/gatsby/cache-dir/fast-refresh-overlay/components/hooks.js b/packages/gatsby/cache-dir/fast-refresh-overlay/components/hooks.js index 07690fe196594..a01a5b460e7cf 100644 --- a/packages/gatsby/cache-dir/fast-refresh-overlay/components/hooks.js +++ b/packages/gatsby/cache-dir/fast-refresh-overlay/components/hooks.js @@ -10,8 +10,15 @@ const initialResponse = { sourceContent: null, } -export function useStackFrame({ moduleId, lineNumber, columnNumber }) { - const url = +export function useStackFrame({ + moduleId, + lineNumber, + columnNumber, + skipSourceMap, + endLineNumber, + endColumnNumber, +}) { + let url = `/__original-stack-frame?moduleId=` + window.encodeURIComponent(moduleId) + `&lineNumber=` + @@ -19,6 +26,18 @@ export function useStackFrame({ moduleId, lineNumber, columnNumber }) { `&columnNumber=` + window.encodeURIComponent(columnNumber) + if (skipSourceMap) { + url += `&skipSourceMap=true` + } + + if (endLineNumber) { + url += `&endLineNumber=` + window.encodeURIComponent(endLineNumber) + + if (endColumnNumber) { + url += `&endColumnNumber=` + window.encodeURIComponent(endColumnNumber) + } + } + const [response, setResponse] = React.useState(initialResponse) React.useEffect(() => { diff --git a/packages/gatsby/cache-dir/fast-refresh-overlay/components/runtime-errors.js b/packages/gatsby/cache-dir/fast-refresh-overlay/components/runtime-errors.js index 195ab09492a6a..e4acd4b56e2a9 100644 --- a/packages/gatsby/cache-dir/fast-refresh-overlay/components/runtime-errors.js +++ b/packages/gatsby/cache-dir/fast-refresh-overlay/components/runtime-errors.js @@ -3,21 +3,35 @@ import ErrorStackParser from "error-stack-parser" import { Overlay, Header, HeaderOpenClose, Body } from "./overlay" import { useStackFrame } from "./hooks" import { CodeFrame } from "./code-frame" -import { getCodeFrameInformation, openInEditor } from "../utils" +import { getCodeFrameInformationFromStackTrace, openInEditor } from "../utils" import { Accordion, AccordionItem } from "./accordion" -function WrappedAccordionItem({ error, open }) { +function getCodeFrameInformationFromError(error) { + if (error.forcedLocation) { + return { + skipSourceMap: true, + moduleId: error.forcedLocation.fileName, + functionName: error.forcedLocation.functionName, + lineNumber: error.forcedLocation.lineNumber, + columnNumber: error.forcedLocation.columnNumber, + endLineNumber: error.forcedLocation.endLineNumber, + endColumnNumber: error.forcedLocation.endColumnNumber, + } + } + const stacktrace = ErrorStackParser.parse(error) - const codeFrameInformation = getCodeFrameInformation(stacktrace) + return getCodeFrameInformationFromStackTrace(stacktrace) +} + +function WrappedAccordionItem({ error, open }) { + const codeFrameInformation = getCodeFrameInformationFromError(error) const modulePath = codeFrameInformation?.moduleId - const lineNumber = codeFrameInformation?.lineNumber - const columnNumber = codeFrameInformation?.columnNumber const name = codeFrameInformation?.functionName // With the introduction of Metadata management the modulePath can have a resourceQuery that needs to be removed first const filePath = modulePath.replace(/(\?|&)export=(default|head)$/, ``) - const res = useStackFrame({ moduleId: modulePath, lineNumber, columnNumber }) + const res = useStackFrame(codeFrameInformation) const line = res.sourcePosition?.line const Title = () => { diff --git a/packages/gatsby/cache-dir/fast-refresh-overlay/utils.js b/packages/gatsby/cache-dir/fast-refresh-overlay/utils.js index e11e7a17040f2..d75a1a00e846b 100644 --- a/packages/gatsby/cache-dir/fast-refresh-overlay/utils.js +++ b/packages/gatsby/cache-dir/fast-refresh-overlay/utils.js @@ -35,7 +35,7 @@ export function skipSSR() { } } -export function getCodeFrameInformation(stackTrace) { +export function getCodeFrameInformationFromStackTrace(stackTrace) { const stackFrame = stackTrace.find(stackFrame => { const fileName = stackFrame.getFileName() return fileName && fileName !== `[native code]` // Quirk of Safari error stack frames diff --git a/packages/gatsby/cache-dir/slice.js b/packages/gatsby/cache-dir/slice.js index d1562e4bbbcd8..952fc2f9c2ff5 100644 --- a/packages/gatsby/cache-dir/slice.js +++ b/packages/gatsby/cache-dir/slice.js @@ -20,7 +20,8 @@ export function Slice(props) { throw new SlicePropsError( slicesContext.renderEnvironment === `browser`, internalProps.sliceName, - propErrors + propErrors, + props.__renderedByLocation ) } @@ -61,9 +62,12 @@ export function Slice(props) { } class SlicePropsError extends Error { - constructor(inBrowser, sliceName, propErrors) { + constructor(inBrowser, sliceName, propErrors, renderedByLocation) { const errors = Object.entries(propErrors) - .map(([key, value]) => `${key}: "${value}"`) + .map( + ([key, value]) => + `not serializable "${value}" type passed to "${key}" prop` + ) .join(`, `) const name = `SlicePropsError` @@ -81,22 +85,25 @@ class SlicePropsError extends Error { stackLines[0] = stackLines[0].trim() stack = `\n` + stackLines.join(`\n`) - // look for any hints for the component name in the stack trace - const componentRe = /^at\s+([a-zA-Z0-9]+)/ - const componentMatch = stackLines[0].match(componentRe) - const componentHint = componentMatch ? `in ${componentMatch[1]} ` : `` - - message = `Slice "${sliceName}" was passed props ${componentHint}that are not serializable (${errors}).` + message = `Slice "${sliceName}" was passed props that are not serializable (${errors}).` } else { // we can't really grab any extra info outside of the browser, so just print what we can - message = `${name}: Slice "${sliceName}" was passed props that are not serializable (${errors}). Use \`gatsby develop\` to see more information.` + message = `${name}: Slice "${sliceName}" was passed props that are not serializable (${errors}).` const stackLines = new Error().stack.trim().split(`\n`).slice(2) stack = `${message}\n${stackLines.join(`\n`)}` } super(message) this.name = name - this.stack = stack + if (stack) { + this.stack = stack + } else { + Error.captureStackTrace(this, SlicePropsError) + } + + if (renderedByLocation) { + this.forcedLocation = { ...renderedByLocation, functionName: `Slice` } + } } } diff --git a/packages/gatsby/src/commands/build-html.ts b/packages/gatsby/src/commands/build-html.ts index 0add33012555a..a6bf84fe243b0 100644 --- a/packages/gatsby/src/commands/build-html.ts +++ b/packages/gatsby/src/commands/build-html.ts @@ -556,10 +556,7 @@ export const doBuildPages = async ( try { await renderHTMLQueue(workerPool, activity, rendererPath, pagePaths, stage) } catch (error) { - const prettyError = createErrorFromString( - error.stack, - `${rendererPath}.map` - ) + const prettyError = createErrorFromString(error, `${rendererPath}.map`) const buildError = new BuildHTMLError(prettyError) buildError.context = error.context diff --git a/packages/gatsby/src/query/__tests__/__snapshots__/file-parser.js.snap b/packages/gatsby/src/query/__tests__/__snapshots__/file-parser.js.snap index 8b6accca1fecd..50fa500daa633 100644 --- a/packages/gatsby/src/query/__tests__/__snapshots__/file-parser.js.snap +++ b/packages/gatsby/src/query/__tests__/__snapshots__/file-parser.js.snap @@ -2584,7 +2584,7 @@ Object { "pageSlices": null, "warnCalls": Array [ Array [ - "[Gatsby Slice API] Could not find values in \\"slice-function.js\\" for the following props at build time: alias", + "[Gatsby Slice API] Could not find values in \\"slice-function.js:5:10\\" for the following props at build time: alias", ], ], } diff --git a/packages/gatsby/src/utils/babel-loader-helpers.ts b/packages/gatsby/src/utils/babel-loader-helpers.ts index bd71b8bd9de28..bcea4034fdd92 100644 --- a/packages/gatsby/src/utils/babel-loader-helpers.ts +++ b/packages/gatsby/src/utils/babel-loader-helpers.ts @@ -115,6 +115,21 @@ export const prepareOptions = ( ) } + if ( + stage === `develop` || + stage === `build-html` || + stage === `develop-html` + ) { + requiredPlugins.push( + babel.createConfigItem( + [resolve(`./babel/babel-plugin-add-slice-placeholder-location`)], + { + type: `plugin`, + } + ) + ) + } + const requiredPresets: Array = [] if (stage === `develop`) { diff --git a/packages/gatsby/src/utils/babel/babel-plugin-add-slice-placeholder-location.ts b/packages/gatsby/src/utils/babel/babel-plugin-add-slice-placeholder-location.ts new file mode 100644 index 0000000000000..53bd6539836f9 --- /dev/null +++ b/packages/gatsby/src/utils/babel/babel-plugin-add-slice-placeholder-location.ts @@ -0,0 +1,90 @@ +import { relative } from "path" +import type { PluginObj, types as BabelTypes, PluginPass } from "@babel/core" +import { ObjectProperty } from "@babel/types" +import { store } from "../../redux" + +/** + * This is a plugin that finds placeholder components and injects the __renderedByLocation prop + * with filename and location in the file where the placeholder was found. This is later used to provide + * more useful error messages when the user props are invalid showing codeframe where user tries to render it + * instead of codeframe of the Slice component itself (internals of gatsby) that is not useful for the user. + */ + +export default function addSlicePlaceholderLocation( + this: PluginPass, + { + types: t, + }: { + types: typeof BabelTypes + } +): PluginObj { + return { + name: `babel-plugin-add-slice-placeholder-location`, + visitor: { + JSXOpeningElement(nodePath): void { + if (!nodePath.get(`name`).referencesImport(`gatsby`, `Slice`)) { + return + } + + if (this.file.opts.filename) { + const __renderedByLocationProperties: Array = [ + t.objectProperty( + t.identifier(`fileName`), + t.stringLiteral( + relative( + store.getState().program.directory, + this.file.opts.filename + ) + ) + ), + ] + + if (nodePath.node.loc?.start.line) { + __renderedByLocationProperties.push( + t.objectProperty( + t.identifier(`lineNumber`), + t.numericLiteral(nodePath.node.loc.start.line) + ) + ) + + if (nodePath.node.loc?.start.column) { + __renderedByLocationProperties.push( + t.objectProperty( + t.identifier(`columnNumber`), + t.numericLiteral(nodePath.node.loc.start.column + 1) + ) + ) + } + + if (nodePath.node.loc?.end.line) { + __renderedByLocationProperties.push( + t.objectProperty( + t.identifier(`endLineNumber`), + t.numericLiteral(nodePath.node.loc.end.line) + ) + ) + + if (nodePath.node.loc?.end.column) { + __renderedByLocationProperties.push( + t.objectProperty( + t.identifier(`endColumnNumber`), + t.numericLiteral(nodePath.node.loc.end.column + 1) + ) + ) + } + } + } + + const newProp = t.jsxAttribute( + t.jsxIdentifier(`__renderedByLocation`), + t.jsxExpressionContainer( + t.objectExpression(__renderedByLocationProperties) + ) + ) + + nodePath.node.attributes.push(newProp) + } + }, + }, + } +} diff --git a/packages/gatsby/src/utils/babel/find-slices.ts b/packages/gatsby/src/utils/babel/find-slices.ts index 539101ad6e5bb..3f3cf6c105981 100644 --- a/packages/gatsby/src/utils/babel/find-slices.ts +++ b/packages/gatsby/src/utils/babel/find-slices.ts @@ -66,7 +66,15 @@ export function collectSlices( const { alias: name, allowEmpty = false } = props if (unresolvedProps.length) { - const error = `[Gatsby Slice API] Could not find values in "${filename}" for the following props at build time: ${unresolvedProps.join( + let locationInFile = `` + if (nodePath.node.loc?.start?.line) { + locationInFile = `:${nodePath.node.loc.start.line}` + if (nodePath.node.loc?.start?.column) { + locationInFile += `:${nodePath.node.loc.start.column + 1}` + } + } + + const error = `[Gatsby Slice API] Could not find values in "${filename}${locationInFile}" for the following props at build time: ${unresolvedProps.join( `, ` )}` diff --git a/packages/gatsby/src/utils/dev-ssr/parse-error.ts b/packages/gatsby/src/utils/dev-ssr/parse-error.ts index d21b870498fa6..e3d3512fda27d 100644 --- a/packages/gatsby/src/utils/dev-ssr/parse-error.ts +++ b/packages/gatsby/src/utils/dev-ssr/parse-error.ts @@ -1,8 +1,7 @@ import { createErrorFromString } from "gatsby-cli/lib/reporter/errors" - -const sysPath = require(`path`) -const fs = require(`fs-extra`) -const { slash } = require(`gatsby-core-utils`) +import * as sysPath from "path" +import * as fs from "fs-extra" +import { slash } from "gatsby-core-utils/path" const getPosition = function (stackObject: Array): { filename: string @@ -96,21 +95,40 @@ export const parseError = function ({ htmlComponentRendererPath: string }): IParsedError { // convert stack trace to use source file locations and not compiled ones - err = createErrorFromString(err.stack, `${htmlComponentRendererPath}.map`) + err = createErrorFromString(err, `${htmlComponentRendererPath}.map`) const stack = err.stack ? err.stack : `` const stackObject = stack.split(`\n`) const position = getPosition(stackObject) - // Remove the `/lib/` added by webpack - const filename = sysPath.join( - directory, - // Don't need to use path.sep as webpack always uses a single forward slash - // as a path separator. - ...position.filename - .split(sysPath.sep) - .slice(position.filename.startsWith(`/`) ? 2 : 1) - ) + let relativeFileName = position.filename + while (relativeFileName.startsWith(`/`)) { + relativeFileName = relativeFileName.substring(1) + } + + let filename = sysPath.join(directory, relativeFileName) + + // webpack tends to inject project name as first segment in stack traces + // so the filename / relativeFileName might not be correct - so we are checking + // if it points to existing file and try to remove project name if it's first segment + if (!fs.existsSync(filename)) { + try { + const projectName = fs.readJsonSync( + sysPath.join(directory, `package.json`), + `utf8` + ).name + + if (relativeFileName.startsWith(projectName + sysPath.sep)) { + relativeFileName = relativeFileName.substring( + (projectName + sysPath.sep).length + ) + } + + filename = sysPath.join(directory, relativeFileName) + } catch (e) { + // nothing more we can do here + } + } let sourceContent try { diff --git a/packages/gatsby/src/utils/start-server.ts b/packages/gatsby/src/utils/start-server.ts index 1806c7796fd46..71a075e94ef79 100644 --- a/packages/gatsby/src/utils/start-server.ts +++ b/packages/gatsby/src/utils/start-server.ts @@ -389,72 +389,128 @@ export async function startServer( ) app.get(`/__original-stack-frame`, (req, res) => { - const compilation = res.locals?.webpack?.devMiddleware?.stats?.compilation const emptyResponse = { codeFrame: `No codeFrame could be generated`, sourcePosition: null, sourceContent: null, } - if (!compilation) { - res.json(emptyResponse) - return - } + let sourceContent: string | null + let sourceLine: number | undefined + let sourceColumn: number | undefined + let sourceEndLine: number | undefined + let sourceEndColumn: number | undefined + let sourcePosition: { line?: number; column?: number } | null - const moduleId = req.query?.moduleId - const lineNumber = parseInt((req.query?.lineNumber as string) ?? 1, 10) - const columnNumber = parseInt((req.query?.columnNumber as string) ?? 1, 10) + if (req.query?.skipSourceMap) { + if (!req.query?.moduleId) { + res.json(emptyResponse) + return + } - let fileModule - for (const module of compilation.modules) { - const moduleIdentifier = compilation.chunkGraph.getModuleId(module) - if (moduleIdentifier === moduleId) { - fileModule = module - break + const absolutePath = path.resolve( + store.getState().program.directory, + req.query.moduleId as string + ) + try { + sourceContent = fs.readFileSync(absolutePath, `utf-8`) + } catch (e) { + res.json(emptyResponse) + return } - } - if (!fileModule) { - res.json(emptyResponse) - return - } + if (req.query?.lineNumber) { + try { + sourceLine = parseInt(req.query.lineNumber as string, 10) - // We need the internal webpack file that is used in the bundle, not the module source. - // It doesn't have the correct sourceMap. - const webpackSource = compilation?.codeGenerationResults - ?.get(fileModule) - ?.sources.get(`javascript`) + if (req.query?.endLineNumber) { + sourceEndLine = parseInt(req.query.endLineNumber as string, 10) + } + if (req.query?.columnNumber) { + sourceColumn = parseInt(req.query.columnNumber as string, 10) + } + if (req.query?.endColumnNumber) { + sourceEndColumn = parseInt(req.query.endColumnNumber as string, 10) + } + } catch { + // failed to get line/column, we should still try to show the code frame + } + } + sourcePosition = { + line: sourceLine, + column: sourceColumn, + } + } else { + const compilation = res.locals?.webpack?.devMiddleware?.stats?.compilation + if (!compilation) { + res.json(emptyResponse) + return + } - const sourceMap = webpackSource?.map() + const moduleId = req.query?.moduleId + const lineNumber = parseInt((req.query?.lineNumber as string) ?? 1, 10) + const columnNumber = parseInt( + (req.query?.columnNumber as string) ?? 1, + 10 + ) - if (!sourceMap) { - res.json(emptyResponse) - return - } + let fileModule + for (const module of compilation.modules) { + const moduleIdentifier = compilation.chunkGraph.getModuleId(module) + if (moduleIdentifier === moduleId) { + fileModule = module + break + } + } - const position = { - line: lineNumber, - column: columnNumber, - } - const result = findOriginalSourcePositionAndContent(sourceMap, position) + if (!fileModule) { + res.json(emptyResponse) + return + } - const sourcePosition = result?.sourcePosition - const sourceLine = sourcePosition?.line - const sourceColumn = sourcePosition?.column - const sourceContent = result?.sourceContent + // We need the internal webpack file that is used in the bundle, not the module source. + // It doesn't have the correct sourceMap. + const webpackSource = compilation?.codeGenerationResults + ?.get(fileModule) + ?.sources.get(`javascript`) - if (!sourceContent || !sourceLine) { - res.json(emptyResponse) - return + const sourceMap = webpackSource?.map() + + if (!sourceMap) { + res.json(emptyResponse) + return + } + + const position = { + line: lineNumber, + column: columnNumber, + } + const result = findOriginalSourcePositionAndContent(sourceMap, position) + + sourcePosition = result?.sourcePosition + sourceLine = sourcePosition?.line + sourceColumn = sourcePosition?.column + sourceContent = result?.sourceContent + + if (!sourceContent || !sourceLine) { + res.json(emptyResponse) + return + } } const codeFrame = codeFrameColumns( sourceContent, { start: { - line: sourceLine, + line: sourceLine ?? 0, column: sourceColumn ?? 0, }, + end: sourceEndLine + ? { + line: sourceEndLine, + column: sourceEndColumn, + } + : undefined, }, { highlightCode: true,