Skip to content
Open
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
98 changes: 97 additions & 1 deletion packages/next/src/server/typescript/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,97 @@ import metadata from './rules/metadata'
import errorEntry from './rules/error'
import type tsModule from 'typescript/lib/tsserverlibrary'

const UNUSED_TS_EXPECT_ERROR_CODE = 2578
const TYPESCRIPT_SINGLE_LINE_DIRECTIVE =
/^\/\/\/?\s*@(ts-expect-error|ts-ignore)/
const TYPESCRIPT_MULTI_LINE_DIRECTIVE =
/^(?:\/|\*)*\s*@(ts-expect-error|ts-ignore)/

type TypeScriptDirective = {
line: number
type: 'ts-expect-error' | 'ts-ignore'
}

function getTypeScriptDirectiveForDiagnostic(
source: tsModule.SourceFile,
diagnostic: tsModule.Diagnostic
): TypeScriptDirective | undefined {
if (diagnostic.file !== source || diagnostic.start === undefined) {
return
}

const lineStarts = source.getLineStarts()
let line = source.getLineAndCharacterOfPosition(diagnostic.start).line - 1

while (line >= 0) {
const lineText = source.text
.slice(lineStarts[line], lineStarts[line + 1])
.trim()
const match = (
lineText.startsWith('//')
? TYPESCRIPT_SINGLE_LINE_DIRECTIVE
: TYPESCRIPT_MULTI_LINE_DIRECTIVE
).exec(lineText)

if (match) {
return {
line,
type: match[1] as TypeScriptDirective['type'],
}
}

// Match TypeScript's behavior: skip blank and single-line comment lines
// while looking for a directive that precedes the diagnostic.
if (lineText !== '' && !/^\/\/.*$/.test(lineText)) {
return
}

line--
}
}

function applyTypeScriptCommentDirectives(
source: tsModule.SourceFile,
diagnostics: tsModule.Diagnostic[],
priorDiagnosticCount: number
): tsModule.Diagnostic[] {
const usedExpectErrorLines = new Set<number>()
const filteredDiagnostics: tsModule.Diagnostic[] = []

for (let i = 0; i < diagnostics.length; i++) {
const diagnostic = diagnostics[i]

if (i >= priorDiagnosticCount) {
const directive = getTypeScriptDirectiveForDiagnostic(source, diagnostic)
if (directive) {
if (directive.type === 'ts-expect-error') {
usedExpectErrorLines.add(directive.line)
}
continue
}
}

filteredDiagnostics.push(diagnostic)
}

if (usedExpectErrorLines.size === 0) {
return filteredDiagnostics
}

return filteredDiagnostics.filter((diagnostic) => {
if (
diagnostic.code !== UNUSED_TS_EXPECT_ERROR_CODE ||
diagnostic.file !== source ||
diagnostic.start === undefined
) {
return true
}

const line = source.getLineAndCharacterOfPosition(diagnostic.start).line
return !usedExpectErrorLines.has(line)
})
}

export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
typescript: ts,
}) => {
Expand Down Expand Up @@ -177,6 +268,7 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
// Show errors for disallowed imports
proxy.getSemanticDiagnostics = (fileName: string) => {
const prior = info.languageService.getSemanticDiagnostics(fileName)
const priorDiagnosticCount = prior.length
const source = getSource(fileName)
if (!source) return prior

Expand Down Expand Up @@ -351,7 +443,11 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
}
})

return prior
return applyTypeScriptCommentDirectives(
source,
prior,
priorDiagnosticCount
)
}

return proxy
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client'

export default function ClientComponent(props: {
// @ts-ignore -- This non-serializable prop is intentionally exposed.
_ignoredFunction: () => void
// @ts-expect-error -- This non-serializable prop is intentionally exposed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think people will be able to use this since any tsc or standard TS langugage server will fail here about unused @ts-expect-error.

And then there's the the general argument against overwriting language semantics. Ecosystem interop will be a pain: any existing linters will be confused by these directives because they don't know this is a Next.js LSP specific suppression not a standard TS suppression.

_expectedFunction: () => void
// @ts-expect-error -- This directive should remain unused.
_serializable: string
_unsuppressedFunction: () => void
}) {
return <p>hello world</p>
}
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,26 @@ describe('typescript-plugin - client-boundary', () => {
`)
})

it('should respect TypeScript suppression directives', () => {
const tsFile = resolve(__dirname, 'app/suppressed-props.tsx')

const diagnostics = languageService
.getSemanticDiagnostics(tsFile)
.map(({ code, messageText }) => ({ code, messageText }))

expect(diagnostics).toEqual([
{
code: 2578,
messageText: "Unused '@ts-expect-error' directive.",
},
{
code: NEXT_TS_ERRORS.INVALID_CLIENT_ENTRY_PROP,
messageText:
'Props must be serializable for components in the "use client" entry file. "_unsuppressedFunction" is a function that\'s not a Server Action. Rename "_unsuppressedFunction" either to "action" or have its name end with "Action" e.g. "_unsuppressedFunctionAction" to indicate it is a Server Action.',
},
])
})

it('should not flag framework-injected function props in error files', () => {
const tsFile = resolve(__dirname, 'app/error.tsx')

Expand Down
Loading