Provide error hints for invalid layout props via the TS plugin (#43835)

This PR adds named slot detection in the TS plugin, so we can hint
layout props too. Note that `test` is valid but `invalid` shows an
inline error here:

![CleanShot 2022-12-08 at 00 30
22@2x](https://user-images.githubusercontent.com/3676859/206319749-a9e51318-e9d2-434a-8cfb-abb91c853959.png)

Since page props can only be `params` or `searchParams`, that part was
already supported.

## 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)
This commit is contained in:
Shu Ding 2022-12-08 01:18:39 +01:00 committed by GitHub
parent 57515a767a
commit 1930a950a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 206 additions and 179 deletions

View file

@ -9,6 +9,7 @@
*/
import path from 'path'
import fs from 'fs'
const DISALLOWED_SERVER_REACT_APIS: string[] = [
'useState',
@ -30,6 +31,7 @@ const DISALLOWED_SERVER_REACT_APIS: string[] = [
const ALLOWED_EXPORTS = ['config', 'generateStaticParams']
const ALLOWED_PAGE_PROPS = ['params', 'searchParams']
const ALLOWED_LAYOUT_PROPS = ['params', 'children']
const NEXT_TS_ERRORS = {
INVALID_SERVER_API: 71001,
@ -213,6 +215,28 @@ export function createTSPlugin(modules: {
/^page\.(mjs|js|jsx|ts|tsx)$/.test(path.basename(filePath))
)
}
const isDefaultFunctionExport = (node: ts.Node) => {
if (ts.isFunctionDeclaration(node)) {
let hasExportKeyword = false
let hasDefaultKeyword = false
if (node.modifiers) {
for (const modifier of node.modifiers) {
if (modifier.kind === ts.SyntaxKind.ExportKeyword) {
hasExportKeyword = true
} else if (modifier.kind === ts.SyntaxKind.DefaultKeyword) {
hasDefaultKeyword = true
}
}
}
// `export default function`
if (hasExportKeyword && hasDefaultKeyword) {
return true
}
}
return false
}
function getIsClientEntry(
fileName: string,
@ -532,185 +556,45 @@ export function createTSPlugin(modules: {
const prior = info.languageService.getSemanticDiagnostics(fileName)
if (!isAppEntryFile(fileName)) return prior
const source = info.languageService.getProgram()?.getSourceFile(fileName)
if (source) {
let isClientEntry = false
const program = info.languageService.getProgram()
const source = program?.getSourceFile(fileName)
if (!source || !program) return prior
try {
isClientEntry = getIsClientEntry(fileName, true)
} catch (e: any) {
prior.push({
file: source,
category: ts.DiagnosticCategory.Error,
code: NEXT_TS_ERRORS.MISPLACED_CLIENT_ENTRY,
...e,
})
isClientEntry = false
}
let isClientEntry = false
ts.forEachChild(source!, (node) => {
if (ts.isImportDeclaration(node)) {
if (!isClientEntry) {
const importPath = node.moduleSpecifier.getText(source!)
if (importPath === "'react'" || importPath === '"react"') {
// Check if it imports "useState"
const importClause = node.importClause
if (importClause) {
const namedBindings = importClause.namedBindings
if (namedBindings && ts.isNamedImports(namedBindings)) {
const elements = namedBindings.elements
for (const element of elements) {
const name = element.name.getText(source!)
if (DISALLOWED_SERVER_REACT_APIS.includes(name)) {
prior.push({
file: source,
category: ts.DiagnosticCategory.Error,
code: NEXT_TS_ERRORS.INVALID_SERVER_API,
messageText: `"${name}" is not allowed in Server Components.`,
start: element.name.getStart(),
length: element.name.getWidth(),
})
}
}
}
}
}
}
} else if (
ts.isVariableStatement(node) &&
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
) {
if (ts.isVariableDeclarationList(node.declarationList)) {
for (const declarartion of node.declarationList.declarations) {
const name = declarartion.name
if (ts.isIdentifier(name)) {
if (
!ALLOWED_EXPORTS.includes(name.text) &&
!API_DOCS[name.text]
) {
prior.push({
file: source,
category: ts.DiagnosticCategory.Error,
code: NEXT_TS_ERRORS.INVALID_ENTRY_EXPORT,
messageText: `"${name.text}" is not a valid Next.js entry export value.`,
start: name.getStart(),
length: name.getWidth(),
})
} else if (API_DOCS[name.text]) {
// Check if the value is valid
const value = declarartion.initializer
try {
isClientEntry = getIsClientEntry(fileName, true)
} catch (e: any) {
prior.push({
file: source,
category: ts.DiagnosticCategory.Error,
code: NEXT_TS_ERRORS.MISPLACED_CLIENT_ENTRY,
...e,
})
isClientEntry = false
}
if (value) {
let displayedValue = ''
let errorMessage = ''
let isInvalid = false
if (
ts.isStringLiteral(value) ||
ts.isNoSubstitutionTemplateLiteral(value)
) {
const text = removeStringQuotes(value.getText())
const allowedValues = Object.keys(
API_DOCS[name.text].options
)
.filter((v) => /^['"]/.test(v))
.map(removeStringQuotes)
if (!allowedValues.includes(text)) {
isInvalid = true
displayedValue = `'${text}'`
}
} else if (
ts.isNumericLiteral(value) ||
(ts.isPrefixUnaryExpression(value) &&
ts.isMinusToken((value as any).operator) &&
(ts.isNumericLiteral((value as any).operand.kind) ||
(ts.isIdentifier((value as any).operand.kind) &&
(value as any).operand.kind.getText() ===
'Infinity'))) ||
(ts.isIdentifier(value) &&
value.getText() === 'Infinity')
) {
const v = value.getText()
if (API_DOCS[name.text].isValid?.(v) === false) {
isInvalid = true
displayedValue = v
}
} else if (
value.kind === ts.SyntaxKind.TrueKeyword ||
value.kind === ts.SyntaxKind.FalseKeyword
) {
const v = value.getText()
if (API_DOCS[name.text].isValid?.(v) === false) {
isInvalid = true
displayedValue = v
}
} else if (
// Other literals
ts.isBigIntLiteral(value) ||
ts.isArrayLiteralExpression(value) ||
ts.isObjectLiteralExpression(value) ||
ts.isRegularExpressionLiteral(value) ||
ts.isPrefixUnaryExpression(value)
) {
isInvalid = true
displayedValue = value.getText()
} else {
// Not a literal, error because it's not statically analyzable
isInvalid = true
displayedValue = value.getText()
errorMessage = `"${displayedValue}" is not a valid value for the "${name.text}" option. The configuration must be statically analyzable.`
}
if (isInvalid) {
prior.push({
file: source,
category: ts.DiagnosticCategory.Error,
code: NEXT_TS_ERRORS.INVALID_OPTION_VALUE,
messageText:
errorMessage ||
`"${displayedValue}" is not a valid value for the "${name.text}" option.`,
start: value.getStart(),
length: value.getWidth(),
})
}
}
}
}
}
}
} else if (ts.isFunctionDeclaration(node)) {
let hasExportKeyword = false
let hasDefaultKeyword = false
if (node.modifiers) {
for (const modifier of node.modifiers) {
if (modifier.kind === ts.SyntaxKind.ExportKeyword) {
hasExportKeyword = true
} else if (modifier.kind === ts.SyntaxKind.DefaultKeyword) {
hasDefaultKeyword = true
}
}
}
// `export default function`
if (hasExportKeyword && hasDefaultKeyword) {
if (isPageFile(fileName)) {
const props = node.parameters?.[0]?.name
// For page entries (page.js), it can only have `params` and `searchParams`
// as the prop names.
if (props && ts.isObjectBindingPattern(props)) {
for (const prop of (props as ts.ObjectBindingPattern)
.elements) {
const propName = prop.name.getText()
if (!ALLOWED_PAGE_PROPS.includes(propName)) {
ts.forEachChild(source!, (node) => {
if (ts.isImportDeclaration(node)) {
if (!isClientEntry) {
const importPath = node.moduleSpecifier.getText(source!)
if (importPath === "'react'" || importPath === '"react"') {
// Check if it imports "useState"
const importClause = node.importClause
if (importClause) {
const namedBindings = importClause.namedBindings
if (namedBindings && ts.isNamedImports(namedBindings)) {
const elements = namedBindings.elements
for (const element of elements) {
const name = element.name.getText(source!)
if (DISALLOWED_SERVER_REACT_APIS.includes(name)) {
prior.push({
file: source,
category: ts.DiagnosticCategory.Error,
code: NEXT_TS_ERRORS.INVALID_PAGE_PROP,
messageText: `"${propName}" is not a valid page property.`,
start: prop.getStart(),
length: prop.getWidth(),
code: NEXT_TS_ERRORS.INVALID_SERVER_API,
messageText: `"${name}" is not allowed in Server Components.`,
start: element.name.getStart(),
length: element.name.getWidth(),
})
}
}
@ -718,8 +602,151 @@ export function createTSPlugin(modules: {
}
}
}
})
}
} else if (
ts.isVariableStatement(node) &&
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
) {
// Check if it has correct option exports
if (ts.isVariableDeclarationList(node.declarationList)) {
for (const declarartion of node.declarationList.declarations) {
const name = declarartion.name
if (ts.isIdentifier(name)) {
if (
!ALLOWED_EXPORTS.includes(name.text) &&
!API_DOCS[name.text]
) {
prior.push({
file: source,
category: ts.DiagnosticCategory.Error,
code: NEXT_TS_ERRORS.INVALID_ENTRY_EXPORT,
messageText: `"${name.text}" is not a valid Next.js entry export value.`,
start: name.getStart(),
length: name.getWidth(),
})
} else if (API_DOCS[name.text]) {
// Check if the value is valid
const value = declarartion.initializer
if (value) {
let displayedValue = ''
let errorMessage = ''
let isInvalid = false
if (
ts.isStringLiteral(value) ||
ts.isNoSubstitutionTemplateLiteral(value)
) {
const text = removeStringQuotes(value.getText())
const allowedValues = Object.keys(
API_DOCS[name.text].options
)
.filter((v) => /^['"]/.test(v))
.map(removeStringQuotes)
if (!allowedValues.includes(text)) {
isInvalid = true
displayedValue = `'${text}'`
}
} else if (
ts.isNumericLiteral(value) ||
(ts.isPrefixUnaryExpression(value) &&
ts.isMinusToken((value as any).operator) &&
(ts.isNumericLiteral((value as any).operand.kind) ||
(ts.isIdentifier((value as any).operand.kind) &&
(value as any).operand.kind.getText() ===
'Infinity'))) ||
(ts.isIdentifier(value) && value.getText() === 'Infinity')
) {
const v = value.getText()
if (API_DOCS[name.text].isValid?.(v) === false) {
isInvalid = true
displayedValue = v
}
} else if (
value.kind === ts.SyntaxKind.TrueKeyword ||
value.kind === ts.SyntaxKind.FalseKeyword
) {
const v = value.getText()
if (API_DOCS[name.text].isValid?.(v) === false) {
isInvalid = true
displayedValue = v
}
} else if (
// Other literals
ts.isBigIntLiteral(value) ||
ts.isArrayLiteralExpression(value) ||
ts.isObjectLiteralExpression(value) ||
ts.isRegularExpressionLiteral(value) ||
ts.isPrefixUnaryExpression(value)
) {
isInvalid = true
displayedValue = value.getText()
} else {
// Not a literal, error because it's not statically analyzable
isInvalid = true
displayedValue = value.getText()
errorMessage = `"${displayedValue}" is not a valid value for the "${name.text}" option. The configuration must be statically analyzable.`
}
if (isInvalid) {
prior.push({
file: source,
category: ts.DiagnosticCategory.Error,
code: NEXT_TS_ERRORS.INVALID_OPTION_VALUE,
messageText:
errorMessage ||
`"${displayedValue}" is not a valid value for the "${name.text}" option.`,
start: value.getStart(),
length: value.getWidth(),
})
}
}
}
}
}
}
} else if (isDefaultFunctionExport(node)) {
// `export default function`
let validProps = []
let type: string
if (isPageFile(fileName)) {
// For page entries (page.js), it can only have `params` and `searchParams`
// as the prop names.
validProps = ALLOWED_PAGE_PROPS
type = 'page'
} else {
// For layout entires, check if it has any named slots.
const currentDir = path.dirname(fileName)
const items = fs.readdirSync(currentDir, { withFileTypes: true })
const slots = []
for (const item of items) {
if (item.isDirectory() && item.name.startsWith('@')) {
slots.push(item.name.slice(1))
}
}
validProps = ALLOWED_LAYOUT_PROPS.concat(slots)
type = 'layout'
}
const props = (node as ts.FunctionDeclaration).parameters?.[0]?.name
if (props && ts.isObjectBindingPattern(props)) {
for (const prop of (props as ts.ObjectBindingPattern).elements) {
const propName = prop.name.getText()
if (!validProps.includes(propName)) {
prior.push({
file: source,
category: ts.DiagnosticCategory.Error,
code: NEXT_TS_ERRORS.INVALID_PAGE_PROP,
messageText: `"${propName}" is not a valid ${type} prop.`,
start: prop.getStart(),
length: prop.getWidth(),
})
}
}
}
}
})
return prior
}

View file

@ -5,13 +5,13 @@
import { useCallback, useState } from 'react'
export default function Page({ whatIsThis }) {
return <div>hello</div>
return <div>hello app</div>
}
export const runtime = 'nodejs'
export const dynamic = 'auto',
revalidate = 100,
revalidate = -100,
fetchCache = 'force-no-store',
preferredRegion = 'auto'

View file

@ -2,13 +2,13 @@
export const revalidate = -1
export default function Root({ children }) {
export default function Root({ test, invalid }) {
return (
<html className="this-is-the-document-html">
<head>
<title>Hello</title>
</head>
<body className="this-is-the-document-body">{children}</body>
<body className="this-is-the-document-body">{test}</body>
</html>
)
}