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

Improve TypeScript plugin for server boundary #63667

Merged
merged 1 commit into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion packages/next/src/server/typescript/constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ export const NEXT_TS_ERRORS = {
INVALID_SERVER_API: 71001,
INVALID_ENTRY_EXPORT: 71002,
INVALID_OPTION_VALUE: 71003,
MISPLACED_CLIENT_ENTRY: 71004,
MISPLACED_ENTRY_DIRECTIVE: 71004,
INVALID_PAGE_PROP: 71005,
INVALID_CONFIG_OPTION: 71006,
INVALID_CLIENT_ENTRY_PROP: 71007,
INVALID_METADATA_EXPORT: 71008,
INVALID_ERROR_COMPONENT: 71009,
INVALID_ENTRY_DIRECTIVE: 71010,
INVALID_SERVER_ENTRY_RETURN: 71011,
}

export const ALLOWED_EXPORTS = [
Expand Down Expand Up @@ -40,5 +42,10 @@ export const DISALLOWED_SERVER_REACT_APIS: string[] = [
'useOptimistic',
]

export const DISALLOWED_SERVER_REACT_DOM_APIS: string[] = [
'useFormStatus',
'useFormState',
]

export const ALLOWED_PAGE_PROPS = ['params', 'searchParams']
export const ALLOWED_LAYOUT_PROPS = ['params', 'children']
58 changes: 51 additions & 7 deletions packages/next/src/server/typescript/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import {
init,
getIsClientEntry,
getEntryInfo,
isAppEntryFile,
isDefaultFunctionExport,
isPositionInsideNode,
Expand All @@ -23,6 +23,7 @@ import entryConfig from './rules/config'
import serverLayer from './rules/server'
import entryDefault from './rules/entry'
import clientBoundary from './rules/client-boundary'
import serverBoundary from './rules/server-boundary'
import metadata from './rules/metadata'
import errorEntry from './rules/error'
import type tsModule from 'typescript/lib/tsserverlibrary'
Expand Down Expand Up @@ -62,7 +63,8 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
if (!isAppEntryFile(fileName)) return prior

// If it's a server entry.
if (!getIsClientEntry(fileName)) {
const entryInfo = getEntryInfo(fileName)
if (!entryInfo.client) {
// Remove specified entries from completion list
prior.entries = serverLayer.filterCompletionsAtPosition(prior.entries)

Expand Down Expand Up @@ -147,7 +149,8 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
if (!isAppEntryFile(fileName)) return prior

// Remove type suggestions for disallowed APIs in server components.
if (!getIsClientEntry(fileName)) {
const entryInfo = getEntryInfo(fileName)
if (!entryInfo.client) {
const definitions = info.languageService.getDefinitionAtPosition(
fileName,
position
Expand Down Expand Up @@ -176,18 +179,22 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
if (!source) return prior

let isClientEntry = false
let isServerEntry = false
const isAppEntry = isAppEntryFile(fileName)

try {
isClientEntry = getIsClientEntry(fileName, true)
const entryInfo = getEntryInfo(fileName, true)
isClientEntry = entryInfo.client
isServerEntry = entryInfo.server
} catch (e: any) {
prior.push({
file: source,
category: ts.DiagnosticCategory.Error,
code: NEXT_TS_ERRORS.MISPLACED_CLIENT_ENTRY,
code: NEXT_TS_ERRORS.MISPLACED_ENTRY_DIRECTIVE,
...e,
})
isClientEntry = false
isServerEntry = false
}

if (isInsideApp(fileName)) {
Expand All @@ -202,7 +209,7 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
if (ts.isImportDeclaration(node)) {
// import ...
if (isAppEntry) {
if (!isClientEntry) {
if (!isClientEntry || isServerEntry) {
// Check if it has valid imports in the server layer
const diagnostics =
serverLayer.getSemanticDiagnosticsForImportDeclaration(
Expand Down Expand Up @@ -244,6 +251,15 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
)
)
}

if (isServerEntry) {
prior.push(
...serverBoundary.getSemanticDiagnosticsForExportVariableStatement(
source,
node
)
)
}
} else if (isDefaultFunctionExport(node)) {
// export default function ...
if (isAppEntry) {
Expand All @@ -263,6 +279,15 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
)
)
}

if (isServerEntry) {
prior.push(
...serverBoundary.getSemanticDiagnosticsForFunctionExport(
source,
node
)
)
}
} else if (
ts.isFunctionDeclaration(node) &&
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
Expand All @@ -289,6 +314,15 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
)
)
}

if (isServerEntry) {
prior.push(
...serverBoundary.getSemanticDiagnosticsForFunctionExport(
source,
node
)
)
}
} else if (ts.isExportDeclaration(node)) {
// export { ... }
if (isAppEntry) {
Expand All @@ -303,6 +337,15 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
)
prior.push(...metadataDiagnostics)
}

if (isServerEntry) {
prior.push(
...serverBoundary.getSemanticDiagnosticsForExportDeclaration(
source,
node
)
)
}
}
})

Expand All @@ -311,7 +354,8 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({

// Get definition and link for specific node
proxy.getDefinitionAndBoundSpan = (fileName: string, position: number) => {
if (isAppEntryFile(fileName) && !getIsClientEntry(fileName)) {
const entryInfo = getEntryInfo(fileName)
if (isAppEntryFile(fileName) && !entryInfo.client) {
const metadataDefinition = metadata.getDefinitionAndBoundSpan(
fileName,
position
Expand Down
152 changes: 152 additions & 0 deletions packages/next/src/server/typescript/rules/server-boundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// This module provides intellisense for all exports from `"use server"` directive.

import { NEXT_TS_ERRORS } from '../constant'
import { getTs, getTypeChecker } from '../utils'
import type tsModule from 'typescript/lib/tsserverlibrary'

// Check if the type is `Promise<T>`.
function isPromiseType(type: tsModule.Type, typeChecker: tsModule.TypeChecker) {
const typeReferenceType = type as tsModule.TypeReference
if (!typeReferenceType.target) return false

// target should be Promise or Promise<...>
if (
!/^Promise(<.+>)?$/.test(typeChecker.typeToString(typeReferenceType.target))
) {
return false
}

return true
}

function isFunctionReturningPromise(
node: tsModule.Node,
typeChecker: tsModule.TypeChecker,
ts: typeof tsModule
) {
const type = typeChecker.getTypeAtLocation(node)
const signatures = typeChecker.getSignaturesOfType(
type,
ts.SignatureKind.Call
)

let isPromise = true
if (signatures.length) {
for (const signature of signatures) {
const returnType = signature.getReturnType()
if (returnType.isUnion()) {
for (const t of returnType.types) {
if (!isPromiseType(t, typeChecker)) {
isPromise = false
break
}
}
} else {
isPromise = isPromiseType(returnType, typeChecker)
}
}
} else {
isPromise = false
}

return isPromise
}

const serverBoundary = {
getSemanticDiagnosticsForExportDeclaration(
source: tsModule.SourceFile,
node: tsModule.ExportDeclaration
) {
const ts = getTs()
const typeChecker = getTypeChecker()
if (!typeChecker) return []

const diagnostics: tsModule.Diagnostic[] = []

const exportClause = node.exportClause
if (exportClause && ts.isNamedExports(exportClause)) {
for (const e of exportClause.elements) {
if (!isFunctionReturningPromise(e, typeChecker, ts)) {
diagnostics.push({
file: source,
category: ts.DiagnosticCategory.Error,
code: NEXT_TS_ERRORS.INVALID_SERVER_ENTRY_RETURN,
messageText: `The "use server" file can only export async functions.`,
start: e.getStart(),
length: e.getWidth(),
})
}
}
}

return diagnostics
},

getSemanticDiagnosticsForExportVariableStatement(
source: tsModule.SourceFile,
node: tsModule.VariableStatement
) {
const ts = getTs()

const diagnostics: tsModule.Diagnostic[] = []

if (ts.isVariableDeclarationList(node.declarationList)) {
for (const declaration of node.declarationList.declarations) {
const initializer = declaration.initializer
if (
initializer &&
(ts.isArrowFunction(initializer) ||
ts.isFunctionDeclaration(initializer) ||
ts.isFunctionExpression(initializer))
) {
diagnostics.push(
...serverBoundary.getSemanticDiagnosticsForFunctionExport(
source,
initializer
)
)
} else {
diagnostics.push({
file: source,
category: ts.DiagnosticCategory.Error,
code: NEXT_TS_ERRORS.INVALID_SERVER_ENTRY_RETURN,
messageText: `The "use server" file can only export async functions.`,
start: declaration.getStart(),
length: declaration.getWidth(),
})
}
}
}

return diagnostics
},

getSemanticDiagnosticsForFunctionExport(
source: tsModule.SourceFile,
node:
| tsModule.FunctionDeclaration
| tsModule.ArrowFunction
| tsModule.FunctionExpression
) {
const ts = getTs()
const typeChecker = getTypeChecker()
if (!typeChecker) return []

const diagnostics: tsModule.Diagnostic[] = []

if (!isFunctionReturningPromise(node, typeChecker, ts)) {
diagnostics.push({
file: source,
category: ts.DiagnosticCategory.Error,
code: NEXT_TS_ERRORS.INVALID_SERVER_ENTRY_RETURN,
messageText: `The "use server" file can only export async functions. Add "async" to the function declaration or return a Promise.`,
start: node.getStart(),
length: node.getWidth(),
})
}

return diagnostics
},
}

export default serverBoundary
35 changes: 29 additions & 6 deletions packages/next/src/server/typescript/rules/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { DISALLOWED_SERVER_REACT_APIS, NEXT_TS_ERRORS } from '../constant'
import {
DISALLOWED_SERVER_REACT_APIS,
DISALLOWED_SERVER_REACT_DOM_APIS,
NEXT_TS_ERRORS,
} from '../constant'
import { getTs } from '../utils'
import type tsModule from 'typescript/lib/tsserverlibrary'

Expand Down Expand Up @@ -38,11 +42,12 @@ const serverLayer = {
const diagnostics: tsModule.Diagnostic[] = []

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
const importClause = node.importClause
const namedBindings = importClause?.namedBindings

if (importClause) {
if (/^['"]react['"]$/.test(importPath)) {
// Check if it imports "useState"
if (namedBindings && ts.isNamedImports(namedBindings)) {
const elements = namedBindings.elements
for (const element of elements) {
Expand All @@ -59,6 +64,24 @@ const serverLayer = {
}
}
}
} else if (/^['"]react-dom['"]$/.test(importPath)) {
// Check if it imports "useFormState"
if (namedBindings && ts.isNamedImports(namedBindings)) {
const elements = namedBindings.elements
for (const element of elements) {
const name = element.name.getText(source!)
if (DISALLOWED_SERVER_REACT_DOM_APIS.includes(name)) {
diagnostics.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(),
})
}
}
}
}
}

Expand Down
Loading