diff --git a/package.json b/package.json index 83712d54..3bef3a04 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "express": "4.18.2", "fs-extra": "11.1.0", "lightningcss": "1.21.5", + "launch-editor": "2.6.0", "mini-css-extract-plugin": "2.7.2", "node-fetch": "2.6.7", "nodemon-webpack-plugin": "4.8.1", diff --git a/server/devRoutes.js b/server/devRoutes.js new file mode 100644 index 00000000..43248fe0 --- /dev/null +++ b/server/devRoutes.js @@ -0,0 +1,49 @@ +import path from 'path' +import fs from 'fs' + +/** + * @param {import("express").Application} server + */ +export default function addDevRoutes(server) { + server.get("/nullstack-dev-server/open-editor", (req, res) => { + const { fileName } = req.query + + if (fileName) { + const launchEditor = require("launch-editor") + launchEditor(fileName, process.env.NULLSTACK_EDITOR || 'code') + } + + res.end() + }) + + server.get("/nullstack-dev-server/get-file", (req, res) => { + /** @type {{ fileName: string, lineNumber: string, columnNumber: string }} */ + const { fileName, lineNumber, columnNumber } = req.query + + const originalFile = fs.readFileSync(fileName, 'utf8') + const lineBelowError = ( + '|'.padStart(lineNumber.length + 4, ' ') + + '^'.padStart(parseInt(columnNumber) + 1, ' ') + ) + const file = originalFile + .split('\n') + .map((line, idx) => { + const currentLineNumber = idx + 1 + const formattedLine = ` ${currentLineNumber} | ${line}` + if (currentLineNumber === parseInt(lineNumber)) { + return ['>' + formattedLine, lineBelowError] + } + return ' ' + formattedLine + }) + .flat() + .slice(parseInt(lineNumber) - 3, parseInt(lineNumber) + 3) + .join('\n') + + const relativePath = path + .relative(process.cwd(), fileName) + .split(path.sep) + .join('/') + + res.send({ file, relativePath }) + }) +} diff --git a/server/server.js b/server/server.js index 26721640..bb4c8822 100644 --- a/server/server.js +++ b/server/server.js @@ -18,6 +18,7 @@ import generateRobots from './robots' import template from './template' import { generateServiceWorker } from './worker' import { load } from './lazy' +import addDevRoutes from './devRoutes' const server = express() @@ -102,6 +103,8 @@ server.start = function () { response.contentType('application/json') response.send(generateFile(`${request.params.number}.client.css.map`, server)) }) + + addDevRoutes(server) } server.get(`/manifest.webmanifest`, (request, response) => { diff --git a/shared/element.js b/shared/element.js index 87db2113..5d7d4639 100644 --- a/shared/element.js +++ b/shared/element.js @@ -7,6 +7,9 @@ function normalize(child) { } export default function element(type, props, ...children) { + if (module.hot && type === undefined) { + require('./runtimeError').add(props?.__source) + } children = seed.concat(...children).map(normalize) if (type === 'textarea') { children = [children.join('')] diff --git a/shared/generateTree.js b/shared/generateTree.js index fae3986d..179f9516 100644 --- a/shared/generateTree.js +++ b/shared/generateTree.js @@ -3,18 +3,23 @@ import { isClass, isFalse, isFunction, isUndefined } from '../shared/nodes' import fragment from './fragment' import { transformNodes } from './plugins' -async function generateBranch(siblings, node, depth, scope) { +export function throwUndefinedNodeProd() { + throw new Error(` + 🚨 An undefined node exist on your application! + 🚨 Access this route on development mode to get the location!`) +} + +async function generateBranch(siblings, node, depth, scope, parent) { transformNodes(scope, node, depth) if (isUndefined(node)) { - let message = 'Attempting to render an undefined node. \n' - if (node === undefined) { - message += - 'This error usually happens because of a missing return statement around JSX or returning undefined from a renderable function.' - } else { - message += 'This error usually happens because of a missing import statement or a typo on a component tag' + if (module.hot) { + scope.skipHotReplacement = true + return require('./runtimeError').add(parent.attributes?.__source, { + node + }) } - throw new Error(message) + return throwUndefinedNodeProd() } if (isFalse(node)) { @@ -100,7 +105,7 @@ async function generateBranch(siblings, node, depth, scope) { } node.children = [].concat(children) for (let i = 0; i < node.children.length; i++) { - await generateBranch(siblings, node.children[i], `${depth}-${i}`, scope) + await generateBranch(siblings, node.children[i], `${depth}-${i}`, scope, node) } return } @@ -144,7 +149,7 @@ async function generateBranch(siblings, node, depth, scope) { const children = node.type(context) node.children = [].concat(children) for (let i = 0; i < node.children.length; i++) { - await generateBranch(siblings, node.children[i], `${depth}-${i}`, scope) + await generateBranch(siblings, node.children[i], `${depth}-${i}`, scope, node) } return } @@ -157,7 +162,7 @@ async function generateBranch(siblings, node, depth, scope) { }) for (let i = 0; i < node.children.length; i++) { const id = `${depth}-${i}` - await generateBranch(scope.nextHead, node.children[i], id, scope) + await generateBranch(scope.nextHead, node.children[i], id, scope, node) scope.nextHead[scope.nextHead.length - 1].attributes.id ??= id } } else if (node.children) { @@ -167,7 +172,7 @@ async function generateBranch(siblings, node, depth, scope) { children: [], } for (let i = 0; i < node.children.length; i++) { - await generateBranch(branch.children, node.children[i], `${depth}-${i}`, scope) + await generateBranch(branch.children, node.children[i], `${depth}-${i}`, scope, node) } siblings.push(branch) } diff --git a/shared/runtimeError.js b/shared/runtimeError.js new file mode 100644 index 00000000..b689cc01 --- /dev/null +++ b/shared/runtimeError.js @@ -0,0 +1,246 @@ +// Modified from: webpack-dev-server/client/overlay +// The error overlay is inspired (and mostly copied) by Create React App (https://github.com/facebookincubator/create-react-app) +// They, in turn, got inspired by webpack-hot-middleware (https://github.com/glenjamin/webpack-hot-middleware). +// styles are inspired by `react-error-overlay` +const msgStyles = { + backgroundColor: 'rgba(206, 17, 38, 0.05)', + color: '#fccfcf', + padding: '1rem 1rem 1.5rem 1rem', + marginBottom: '1rem' +} +const iframeStyle = { + 'z-index': 9999999999 +} +const containerStyle = { + position: 'fixed', + inset: 0, + fontSize: '1rem', + padding: '2rem 2rem 1rem', + whiteSpace: 'pre-wrap', + overflow: 'auto', + backgroundColor: '#111827', + fontFamily: 'Roboto, Consolas, monospace', + fontWeight: '600' +} +const headerStyle = { + color: '#EF5350', + fontSize: '2em', + marginRight: '1rem' +} +const dismissButtonStyle = { + color: '#ffffff', + width: '2.5rem', + fontSize: '1.7rem', + margin: '1rem', + cursor: 'pointer', + position: 'absolute', + right: 0, + top: 0, + backgroundColor: 'transparent', + border: 'none', + fontFamily: 'Menlo, Consolas, monospace' +} +const explanationStyle = { + fontSize: '12px', + fontWeight: 'lighter', + color: '#d19aac', + margin: '1rem 0' +} +const msgTypeStyle = { + color: '#EF5350', + fontSize: '1.1rem', + marginBottom: '1rem', + cursor: 'pointer' +} +const msgTextStyle = { + lineHeight: '1.5', + fontSize: '1rem', + fontFamily: 'Menlo, Consolas, monospace', + fontWeight: 'initial' +} + +import { throwUndefinedNodeProd } from './generateTree' + +/** + * @param {{ source: { fileName: string, lineNumber: string, columnNumber: string }, filenameWithLOC: string }} item + * @returns {Promise<{ header: string, body: string}>} + */ +async function formatProblem(item) { + const { file, relativePath } = await getFile(item.source) + const linkStyle = 'all:unset;margin-left:.3em;color:#F48FB1;font-size:14px;' + const openEditor = `Open in Editor >` + const { lineNumber, columnNumber } = item.source + const relativeLOC = `${relativePath}:${lineNumber}:${columnNumber}` + console.error( + `Error: Attempting to render an undefined node at\n%O`, + relativeLOC + ) + return { + header: `${relativeLOC} ${openEditor}`, + body: file || '' + } +} + +function createOverlay() { + /** @type {HTMLIFrameElement | null | undefined} */ + let overlayElement + /** @type {HTMLDivElement | null | undefined} */ + let containerElement + + /** + * + * @param {HTMLElement} element + * @param {CSSStyleDeclaration} style + */ + function applyStyle(element, style) { + Object.keys(style).forEach(function (prop) { + element.style[prop] = style[prop] + }) + } + + function createEl(elName, styles = {}, attributes = {}) { + const el = document.createElement(elName) + applyStyle(el, styles) + Object.keys(attributes).forEach(key => { + el[key] = attributes[key] + }) + return el + } + + function createContainer(onLoad) { + overlayElement = createEl('div', iframeStyle, { + id: 'nullstack-dev-server-client-overlay' + }) + const contentElement = createEl('div', containerStyle, { + id: 'nullstack-dev-server-client-overlay-div' + }) + const headerElement = createEl('div', headerStyle, { + innerText: 'Undefined nodes found:' + }) + const closeButtonElement = createEl('button', dismissButtonStyle, { + innerText: 'x', + ariaLabel: 'Dismiss' + }) + const explanationElement = createEl('p', explanationStyle, { + innerText: 'Tip: This error means a missing return statement around JSX, returning undefined from a renderable function, a missing component import or a typo on it\'s tag name.' + }) + closeButtonElement.addEventListener('click', () => clear(true)) + containerElement = createEl('div') + contentElement.append( + headerElement, + closeButtonElement, + explanationElement, + containerElement + ) + + overlayElement.appendChild(contentElement) + onLoad(containerElement) + document.body.appendChild(overlayElement) + } + + /** + * @param {(element: HTMLDivElement) => void} callback + */ + function ensureOverlayExists(callback) { + if (containerElement) { + // Everything is ready, call the callback right away. + callback(containerElement) + return + } + if (overlayElement) { + return + } + createContainer(callback) + } + + // Successful compilation. + /** + * @param {boolean} force + */ + function clear(force) { + const initializedClient = isClient() && initialized() + if ((!initializedClient || !overlayElement) && !force) { + return + } + + // Clean up and reset internal state. + document.body.removeChild(overlayElement) + overlayElement = null + containerElement = null + storedErrors = [] + } + + // Compilation with errors (e.g. syntax error or missing modules). + /** + * @param {{ source: object, filenameWithLOC: string }} messageData + */ + async function show(messageData) { + const { header, body } = await formatProblem(messageData) + ensureOverlayExists(function () { + const typeElement = createEl('div', msgTypeStyle, { + innerHTML: header + }) + typeElement.addEventListener('click', function () { + const query = new URLSearchParams({ + fileName: messageData.filenameWithLOC + }) + fetch(`/nullstack-dev-server/open-editor?${query}`) + }) + + const entryElement = createEl('div', msgStyles) + const messageTextNode = createEl('div', msgTextStyle, { + innerText: body + }) + entryElement.append(typeElement, messageTextNode) + + containerElement.appendChild(entryElement) + }) + } + return { show, clear } +} + +/** + * @param {{ fileName: string, lineNumber: string, columnNumber: string }} source + */ +async function getFile(source) { + const query = new URLSearchParams(source) + return (await fetch(`/nullstack-dev-server/get-file?${query}`)).json() +} + +function isClient() { + return typeof window !== 'undefined' && window?.document +} + +function initialized() { + return initialRenders > 2 +} + +function throwUndefinedMain() { + throw new Error('Your main component is trying to render an undefined node!') +} + +const overlay = createOverlay() +let storedErrors = [] +let initialRenders = 0 + +/** + * @param {{ fileName: string, lineNumber: string, columnNumber: string }} source + * @param {{ node?: object }} options + */ +export async function add(source, options) { + ++initialRenders + if (options?.node !== undefined) return + if (!isClient()) return throwUndefinedNodeProd(options) + if (!source) return throwUndefinedMain() + + const { fileName, lineNumber, columnNumber } = source + const filenameWithLOC = `${fileName}:${lineNumber}:${columnNumber}` + if (storedErrors.includes(filenameWithLOC)) return + storedErrors.push(filenameWithLOC) + await overlay.show({ + source, + filenameWithLOC + }) +} + +export const clear = overlay.clear diff --git a/tests/src/UndefinedNodes.njs b/tests/src/UndefinedNodes.njs index ac78cbfc..47c39633 100644 --- a/tests/src/UndefinedNodes.njs +++ b/tests/src/UndefinedNodes.njs @@ -16,8 +16,7 @@ class UndefinedNodes extends Nullstack { return (
{params.withoutReturn && } - {params.withoutUndefinedReturn && } - {params.withoutRetunr && } + {params.withUndefinedReturn && } {params.forgotToImport && } {params.undeclaredVariable &&
{this.undeclaredVariable}
}
diff --git a/tests/src/UndefinedNodes.test.js b/tests/src/UndefinedNodes.test.js index 1fdca547..f09126d6 100644 --- a/tests/src/UndefinedNodes.test.js +++ b/tests/src/UndefinedNodes.test.js @@ -1,22 +1,15 @@ describe('UndefinedNodes WithoutReturn', () => { test('renderable functions without return should raise an error', async () => { - const response = await page.goto('http://localhost:6969/undefined-nodes?withoutReturn=true') - expect(response.status()).toEqual(500) - }) -}) - -describe('UndefinedNodes WithoutUndefinedReturn', () => { - test('renderable functions with undefined return should raise an error', async () => { - const response = await page.goto('http://localhost:6969/undefined-nodes?withoutUndefinedReturn=true', { + const response = await page.goto('http://localhost:6969/undefined-nodes?withoutReturn=true', { waitUntil: 'networkidle0', }) expect(response.status()).toEqual(500) }) }) -describe('UndefinedNodes WithoutRetunr', () => { - test('tagging a renderable function that does not exist should raise an error', async () => { - const response = await page.goto('http://localhost:6969/undefined-nodes?withoutRetunr=true', { +describe('UndefinedNodes WithUndefinedReturn', () => { + test('renderable functions with undefined return should raise an error', async () => { + const response = await page.goto('http://localhost:6969/undefined-nodes?withUndefinedReturn=true', { waitUntil: 'networkidle0', }) expect(response.status()).toEqual(500)