Skip to content

Commit

Permalink
Add no-anon-default-export Babel lint rule (vercel#14519)
Browse files Browse the repository at this point in the history
  • Loading branch information
Timer authored and rokinsky committed Jul 11, 2020
1 parent 2d9e451 commit 85d2c90
Show file tree
Hide file tree
Showing 9 changed files with 206 additions and 1 deletion.
83 changes: 83 additions & 0 deletions packages/next/build/babel/plugins/no-anonymous-default-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { PluginObj, types as BabelTypes } from '@babel/core'
import chalk from 'next/dist/compiled/chalk'

export default function NoAnonymousDefaultExport({
types: t,
...babel
}: {
types: typeof BabelTypes
caller: (callerCallback: (caller: any) => any) => any
}): PluginObj<any> {
let onWarning: ((reason: string | Error) => void) | null = null
babel.caller((caller) => {
onWarning = caller.onWarning
return '' // Intentionally empty to not invalidate cache
})

if (typeof onWarning !== 'function') {
return { visitor: {} }
}

const warn = onWarning!
return {
visitor: {
ExportDefaultDeclaration(path) {
const def = path.node.declaration

if (
!(
def.type === 'ArrowFunctionExpression' ||
def.type === 'FunctionDeclaration'
)
) {
return
}

switch (def.type) {
case 'ArrowFunctionExpression': {
warn(
[
chalk.yellow.bold(
'Anonymous arrow functions cause Fast Refresh to not preserve local component state.'
),
'Please add a name to your function, for example:',
'',
chalk.bold('Before'),
chalk.cyan('export default () => <div />;'),
'',
chalk.bold('After'),
chalk.cyan('const Named = () => <div />;'),
chalk.cyan('export default Named;'),
].join('\n')
)
break
}
case 'FunctionDeclaration': {
const isAnonymous = !Boolean(def.id)
if (isAnonymous) {
warn(
[
chalk.yellow.bold(
'Anonymous function declarations cause Fast Refresh to not preserve local component state.'
),
'Please add a name to your function, for example:',
'',
chalk.bold('Before'),
chalk.cyan('export default function () { /* ... */ }'),
'',
chalk.bold('After'),
chalk.cyan('export default function Named() { /* ... */ }'),
].join('\n')
)
}
break
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _: never = def
}
}
},
},
}
}
19 changes: 19 additions & 0 deletions packages/next/build/webpack/loaders/next-babel-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,18 @@ module.exports = babelLoader.custom((babel) => {
options.caller.isModern = isModern
options.caller.isDev = development

const emitWarning = this.emitWarning.bind(this)
Object.defineProperty(options.caller, 'onWarning', {
enumerable: false,
writable: false,
value: (options.caller.onWarning = function (reason) {
if (!(reason instanceof Error)) {
reason = new Error(reason)
}
emitWarning(reason)
}),
})

options.plugins = options.plugins || []

if (hasReactRefresh) {
Expand All @@ -145,6 +157,13 @@ module.exports = babelLoader.custom((babel) => {
{ type: 'plugin' }
)
options.plugins.unshift(reactRefreshPlugin)
if (!isServer) {
const noAnonymousDefaultExportPlugin = babel.createConfigItem(
[require('../../babel/plugins/no-anonymous-default-export'), {}],
{ type: 'plugin' }
)
options.plugins.unshift(noAnonymousDefaultExportPlugin)
}
}

if (!isServer && isPageFile) {
Expand Down
4 changes: 3 additions & 1 deletion test/integration/hydration/pages/_app.js
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export default ({ Component, pageProps }) => <Component {...pageProps} />
export default function CustomApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
3 changes: 3 additions & 0 deletions test/integration/no-anon-default-export/components/Child.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default () => {
return <div />
}
4 changes: 4 additions & 0 deletions test/integration/no-anon-default-export/pages/both.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import Child from '../components/Child'
export default function () {
return <Child />
}
4 changes: 4 additions & 0 deletions test/integration/no-anon-default-export/pages/child.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import Child from '../components/Child'
export default function A() {
return <Child />
}
3 changes: 3 additions & 0 deletions test/integration/no-anon-default-export/pages/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function () {
return <div />
}
86 changes: 86 additions & 0 deletions test/integration/no-anon-default-export/test/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/* eslint-env jest */

import fs from 'fs-extra'
import { check, findPort, killApp, launchApp } from 'next-test-utils'
import webdriver from 'next-webdriver'
import { join } from 'path'

jest.setTimeout(1000 * 60 * 3)

const appDir = join(__dirname, '../')

describe('no anonymous default export warning', () => {
function getRegexCount(text, regex) {
return (text.match(regex) || []).length
}

beforeEach(async () => {
await fs.remove(join(appDir, '.next'))
})

it('show correct warnings for page', async () => {
let stdout = ''

const appPort = await findPort()
const app = await launchApp(appDir, appPort, {
env: { __NEXT_TEST_WITH_DEVTOOL: true },
onStdout(msg) {
stdout += msg || ''
},
})

const browser = await webdriver(appPort, '/page')

const found = await check(() => stdout, /anonymous/i, false)
expect(found).toBeTruthy()
await browser.close()

expect(getRegexCount(stdout, /not preserve local component state/g)).toBe(1)

await killApp(app)
})

it('show correct warnings for child', async () => {
let stdout = ''

const appPort = await findPort()
const app = await launchApp(appDir, appPort, {
env: { __NEXT_TEST_WITH_DEVTOOL: true },
onStdout(msg) {
stdout += msg || ''
},
})

const browser = await webdriver(appPort, '/child')

const found = await check(() => stdout, /anonymous/i, false)
expect(found).toBeTruthy()
await browser.close()

expect(getRegexCount(stdout, /not preserve local component state/g)).toBe(1)

await killApp(app)
})

it('show correct warnings for both', async () => {
let stdout = ''

const appPort = await findPort()
const app = await launchApp(appDir, appPort, {
env: { __NEXT_TEST_WITH_DEVTOOL: true },
onStdout(msg) {
stdout += msg || ''
},
})

const browser = await webdriver(appPort, '/both')

const found = await check(() => stdout, /anonymous/i, false)
expect(found).toBeTruthy()
await browser.close()

expect(getRegexCount(stdout, /not preserve local component state/g)).toBe(2)

await killApp(app)
})
})
1 change: 1 addition & 0 deletions test/unit/next-babel-loader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const babel = async (
return callback
},
callback,
emitWarning() {},
query: {
// babel opts
babelrc: false,
Expand Down

0 comments on commit 85d2c90

Please sign in to comment.