Skip to content

Commit

Permalink
Improve TypeScript plugin for server boundary (vercel#63667)
Browse files Browse the repository at this point in the history
For problems like vercel#62821, vercel#62860 and other, the only way to improve the
DX would be relying on the type checker to ensure that Server Actions
are async functions. Inlined definitions will always be checked by SWC
(as they're always syntactically defined as functions already), but
export values are sometimes determined at the runtime.

Also added `react-dom` related methods to the disallow list for the
server layer.

Examples:


https://github.com/vercel/next.js/assets/3676859/ac0b12fa-829b-42a4-a4c6-e1c321b68a8e


https://github.com/vercel/next.js/assets/3676859/2e2e3ab8-6743-4281-9783-30bd2a82fb5c


https://github.com/vercel/next.js/assets/3676859/b61a4c0a-1ad4-4ad6-9d50-311ef3450e13



Closes NEXT-2913
  • Loading branch information
shuding authored Mar 25, 2024
1 parent 3ba3eeb commit 9677c87
Show file tree
Hide file tree
Showing 5 changed files with 270 additions and 18 deletions.
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

0 comments on commit 9677c87

Please sign in to comment.