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:
parent
57515a767a
commit
1930a950a1
3 changed files with 206 additions and 179 deletions
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue