Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(gatsby): Add eslint rules to warn against bad patterns in pageTemplates (for Fast Refresh) #28689

Merged
merged 25 commits into from
Jan 15, 2021
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
94449a6
tmp: add preliminary babel plugin for page templates to warn when tho…
pieh Dec 18, 2020
ff80b61
slight reorder of warning message to be bit more readable (filename i…
pieh Dec 18, 2020
82964c7
fix lint?
pieh Dec 18, 2020
f98c030
Merge remote-tracking branch 'upstream/master' into fast-refresh/ensu…
Jan 5, 2021
b12b7d6
update messages
LekoArts Jan 6, 2021
2ea5561
Improve warning messages and also add warning to other default export…
LekoArts Jan 6, 2021
69db5a3
add onWarning
LekoArts Jan 6, 2021
342866f
Merge remote-tracking branch 'upstream/master' into fast-refresh/ensu…
Jan 6, 2021
40a4ee1
revert previous changes
LekoArts Jan 6, 2021
b120c37
revert previous changes
LekoArts Jan 7, 2021
aeb8040
use two local rules
LekoArts Jan 7, 2021
a6b7c5e
return correct empty object
LekoArts Jan 7, 2021
72c0e5a
exlude template queries
LekoArts Jan 7, 2021
fd895b2
add e2e tests
LekoArts Jan 8, 2021
a5b8305
add first unit test
LekoArts Jan 8, 2021
a356876
unit test for page exports
LekoArts Jan 8, 2021
4ba90f7
review comments
LekoArts Jan 11, 2021
908077c
handle multiple declarations
LekoArts Jan 12, 2021
24468c1
feat(gatsby): add required eslint rules even if user has custom eslin…
pieh Jan 13, 2021
8cf7475
Apply suggestions from code review
LekoArts Jan 14, 2021
df460b2
fix consistent return
LekoArts Jan 14, 2021
2a814b9
add describe block so that IDEs can run them :D
LekoArts Jan 15, 2021
765986f
handle more complicated cases
LekoArts Jan 15, 2021
0fd9deb
add one more case
LekoArts Jan 15, 2021
2252bf7
handle commonjs syntax
LekoArts Jan 15, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
if (Cypress.env("HOT_LOADER") === `fast-refresh`) {
LekoArts marked this conversation as resolved.
Show resolved Hide resolved
describe(`limited-exports-page-templates`, () => {
it(`should log warning to console for invalid export`, () => {
cy.visit(`/eslint-rules/limited-exports-page-templates`, {
onBeforeLoad(win) {
cy.stub(win.console, 'log').as(`consoleLog`)
}
}).waitForRouteChange()

cy.get(`@consoleLog`).should(`be.calledWithMatch`, /15:1 warning In page templates only a default export of a valid React component and the named export of a page query is allowed./i)
cy.get(`@consoleLog`).should(`not.be.calledWithMatch`, /17:1 warning In page templates only a default export of a valid React component and the named export of a page query is allowed./i)
})
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
if (Cypress.env("HOT_LOADER") === `fast-refresh`) {
describe(`no-anonymous-exports-page-templates`, () => {
it(`should log warning to console for arrow functions`, () => {
cy.visit(`/eslint-rules/no-anonymous-exports-page-templates`, {
onBeforeLoad(win) {
cy.stub(win.console, 'log').as(`consoleLog`)
}
}).waitForRouteChange()

cy.get(`@consoleLog`).should(`be.calledWithMatch`, /Anonymous arrow functions cause Fast Refresh to not preserve local component state./i)
})
it(`should log warning to console for function declarations`, () => {
cy.visit(`/eslint-rules/no-anonymous-exports-page-templates-function`, {
onBeforeLoad(win) {
cy.stub(win.console, 'log').as(`consoleLog`)
}
}).waitForRouteChange()

cy.get(`@consoleLog`).should(`be.calledWithMatch`, /Anonymous function declarations cause Fast Refresh to not preserve local component state./i)
})
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from "react"
import { graphql } from "gatsby"

function PageQuery({ data }) {
return (
<div>
<h1 data-testid="title">Limited Exports Page Templates. ESLint Rule</h1>
<p data-testid="hot">
{data.site.siteMetadata.title}
</p>
</div>
)
}

export function notAllowed() {}

export const query = graphql`
{
site {
siteMetadata {
title
}
}
}
`

export default PageQuery
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from "react"

import Layout from "../../components/layout"

export default function () {
return (
<Layout>
<h1 data-testid="title">Anonymous Arrow Function. ESLint Rule</h1>
</Layout>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from "react"

import Layout from "../../components/layout"

export default () => (
<Layout>
<h1 data-testid="title">Anonymous Arrow Function. ESLint Rule</h1>
</Layout>
)
130 changes: 130 additions & 0 deletions packages/gatsby/src/utils/__tests__/eslint-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { mergeRequiredConfigIn } from "../eslint-config"
import { CLIEngine } from "eslint"
import * as path from "path"

describe(`eslint-config`, () => {
describe(`mergeRequiredConfigIn`, () => {
it(`adds rulePaths and extends if those don't exist`, () => {
const conf: CLIEngine.Options = {}

mergeRequiredConfigIn(conf)

expect(conf?.baseConfig).toMatchInlineSnapshot(`
Object {
"extends": Array [
"<PROJECT_ROOT>/packages/gatsby/src/utils/eslint/required.js",
],
}
`)

expect(conf.rulePaths).toMatchInlineSnapshot(`
Array [
"<PROJECT_ROOT>/packages/gatsby/src/utils/eslint-rules",
]
`)
})

it(`adds rulePath if rulePaths exist but don't contain required rules`, () => {
const conf: CLIEngine.Options = {
rulePaths: [`test`],
}

mergeRequiredConfigIn(conf)

expect(conf.rulePaths).toMatchInlineSnapshot(`
Array [
"test",
"<PROJECT_ROOT>/packages/gatsby/src/utils/eslint-rules",
]
`)
})

it(`doesn't add rulePath multiple times`, () => {
const conf: CLIEngine.Options = {
rulePaths: [path.resolve(__dirname, `../eslint-rules`), `test`],
}

mergeRequiredConfigIn(conf)

expect(conf.rulePaths).toMatchInlineSnapshot(`
Array [
"<PROJECT_ROOT>/packages/gatsby/src/utils/eslint-rules",
"test",
]
`)
})

it(`adds extend if extends exist (array) but don't contain required preset`, () => {
const conf: CLIEngine.Options = {
baseConfig: {
extends: [`ext1`],
},
}

mergeRequiredConfigIn(conf)

expect(conf.baseConfig).toMatchInlineSnapshot(`
Object {
"extends": Array [
"ext1",
"<PROJECT_ROOT>/packages/gatsby/src/utils/eslint/required.js",
],
}
`)
})

it(`adds extend if extends exist (string) but don't contain required preset`, () => {
const conf: CLIEngine.Options = {
baseConfig: {
extends: `ext1`,
},
}

mergeRequiredConfigIn(conf)

expect(conf.baseConfig).toMatchInlineSnapshot(`
Object {
"extends": Array [
"ext1",
"<PROJECT_ROOT>/packages/gatsby/src/utils/eslint/required.js",
],
}
`)
})

it(`doesn't add extend multiple times (extends is array)`, () => {
const conf: CLIEngine.Options = {
baseConfig: {
extends: [require.resolve(`../eslint/required`), `ext1`],
},
}

mergeRequiredConfigIn(conf)

expect(conf.baseConfig).toMatchInlineSnapshot(`
Object {
"extends": Array [
"<PROJECT_ROOT>/packages/gatsby/src/utils/eslint/required.js",
"ext1",
],
}
`)
})

it(`doesn't add extend multiple times (extends is string)`, () => {
const conf: CLIEngine.Options = {
baseConfig: {
extends: require.resolve(`../eslint/required`),
},
}

mergeRequiredConfigIn(conf)

expect(conf.baseConfig).toMatchInlineSnapshot(`
Object {
"extends": "<PROJECT_ROOT>/packages/gatsby/src/utils/eslint/required.js",
}
`)
})
})
})
66 changes: 63 additions & 3 deletions packages/gatsby/src/utils/eslint-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,61 @@
import { printSchema, GraphQLSchema } from "graphql"
import { CLIEngine } from "eslint"
import path from "path"

const eslintRulePaths = path.resolve(`${__dirname}/eslint-rules`)
const eslintRequirePreset = require.resolve(`./eslint/required`)

export const eslintRequiredConfig: CLIEngine.Options = {
rulePaths: [eslintRulePaths],
baseConfig: {
globals: {
graphql: true,
__PATH_PREFIX__: true,
__BASE_PATH__: true, // this will rarely, if ever, be used by consumers
},
extends: [eslintRequirePreset],
},
}

export function mergeRequiredConfigIn(
existingOptions: CLIEngine.Options
): void {
// make sure rulePaths include our custom rules
if (existingOptions.rulePaths) {
if (
Array.isArray(existingOptions.rulePaths) &&
!existingOptions.rulePaths.includes(eslintRulePaths)
) {
existingOptions.rulePaths.push(eslintRulePaths)
}
} else {
existingOptions.rulePaths = [eslintRulePaths]
}

// make sure we extend required preset
if (!existingOptions.baseConfig) {
existingOptions.baseConfig = {}
}

if (existingOptions.baseConfig.extends) {
if (
Array.isArray(existingOptions.baseConfig.extends) &&
!existingOptions.baseConfig.extends.includes(eslintRequirePreset)
) {
existingOptions.baseConfig.extends.push(eslintRequirePreset)
} else if (
typeof existingOptions.baseConfig.extends === `string` &&
existingOptions.baseConfig.extends !== eslintRequirePreset
) {
existingOptions.baseConfig.extends = [
existingOptions.baseConfig.extends,
eslintRequirePreset,
]
}
} else {
existingOptions.baseConfig.extends = [eslintRequirePreset]
}
}

export const eslintConfig = (
schema: GraphQLSchema,
Expand All @@ -8,13 +64,17 @@ export const eslintConfig = (
return {
useEslintrc: false,
resolvePluginsRelativeTo: __dirname,
rulePaths: [eslintRulePaths],
baseConfig: {
globals: {
graphql: true,
__PATH_PREFIX__: true,
__BASE_PATH__: true, // this will rarely, if ever, be used by consumers
},
extends: [require.resolve(`eslint-config-react-app`)],
extends: [
require.resolve(`eslint-config-react-app`),
eslintRequirePreset,
],
plugins: [`graphql`],
rules: {
// New versions of react use a special jsx runtime that remove the requirement
Expand All @@ -32,7 +92,7 @@ export const eslintConfig = (
tagName: `graphql`,
},
],
"react/jsx-pascal-case": `off`, // Prevents errors with Theme-UI and Styled component
"react/jsx-pascal-case": `warn`,
// https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/master/docs/rules
"jsx-a11y/accessible-emoji": `warn`,
"jsx-a11y/alt-text": `warn`,
Expand Down Expand Up @@ -98,7 +158,7 @@ export const eslintConfig = (
],
},
],
//"jsx-a11y/label-has-for": `warn`, was deprecated and replaced with jsx-a11y/has-associated-control in v6.1.0
// "jsx-a11y/label-has-for": `warn`, was deprecated and replaced with jsx-a11y/has-associated-control in v6.1.0
"jsx-a11y/label-has-associated-control": `warn`,
"jsx-a11y/lang": `warn`,
"jsx-a11y/media-has-caption": `warn`,
Expand Down
22 changes: 22 additions & 0 deletions packages/gatsby/src/utils/eslint-rules-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Rule } from "eslint"
import { GatsbyReduxStore } from "../redux"

export function isPageTemplate(
s: GatsbyReduxStore,
c: Rule.RuleContext
): boolean {
const filename = c.getFilename()
if (!filename) {
return false
}
return s.getState().components.has(filename)
}

export function test(t): any {
return Object.assign(t, {
parserOptions: {
sourceType: `module`,
ecmaVersion: 9,
},
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { RuleTester } from "eslint"
import { test } from "../../eslint-rules-helpers"
const rule = require(`../limited-exports-page-templates`)

const parserOptions = {
ecmaVersion: 2018,
sourceType: `module`,
ecmaFeatures: {
jsx: true,
},
}

const ruleTester = new RuleTester({ parserOptions })

jest.mock(`../../eslint-rules-helpers`, () => {
return {
...jest.requireActual(`../../eslint-rules-helpers`),
isPageTemplate: jest.fn().mockReturnValue(true),
}
})

ruleTester.run(`no-anonymous-exports-page-templates`, rule, {
valid: [
test({
code: `const Template = () => {}\nexport const query = graphql\`test\`\nexport default Template`,
}),
test({
code: `const Template = () => {}\nexport default Template`,
}),
test({
code: `const Template = () => {}\nconst query = graphql\`test\`\nexport { query }\nexport default Template`,
}),
],
invalid: [
test({
code: `const Template = () => {}\nexport const query = graphql\`test\`\nexport function Test() {}\nexport default Template`,
errors: [{ messageId: `limitedExportsPageTemplates` }],
}),
test({
code: `const Template = () => {}\nconst query = graphql\`test\`\nfunction Test() {}\nexport { query, Test }\nexport default Template`,
errors: [{ messageId: `limitedExportsPageTemplates` }],
}),
test({
code: `const Template = () => {}\nexport const query = graphql\`test\`, hello = 10\nexport default Template`,
errors: [{ messageId: `limitedExportsPageTemplates` }],
}),
test({
code: `const Template = () => {}\nexport const hello = 10, query = graphql\`test\`\nexport default Template`,
errors: [{ messageId: `limitedExportsPageTemplates` }],
}),
],
})
Loading