From 7c71ad4af91414ca5aaa611de9edec584794d072 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 7 Aug 2026 15:28:21 -0700 Subject: [PATCH] Allow suppressing TypeScript plugin diagnostics --- packages/next/src/server/typescript/index.ts | 98 ++++++++++++++++++- .../client-boundary/app/suppressed-props.tsx | 13 +++ .../client-boundary/client-boundary.test.ts | 20 ++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 test/development/typescript-plugin/client-boundary/app/suppressed-props.tsx diff --git a/packages/next/src/server/typescript/index.ts b/packages/next/src/server/typescript/index.ts index 816bea396c05..212e2e1c0567 100644 --- a/packages/next/src/server/typescript/index.ts +++ b/packages/next/src/server/typescript/index.ts @@ -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() + 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, }) => { @@ -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 @@ -351,7 +443,11 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({ } }) - return prior + return applyTypeScriptCommentDirectives( + source, + prior, + priorDiagnosticCount + ) } return proxy diff --git a/test/development/typescript-plugin/client-boundary/app/suppressed-props.tsx b/test/development/typescript-plugin/client-boundary/app/suppressed-props.tsx new file mode 100644 index 000000000000..64f1bf21e90a --- /dev/null +++ b/test/development/typescript-plugin/client-boundary/app/suppressed-props.tsx @@ -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. + _expectedFunction: () => void + // @ts-expect-error -- This directive should remain unused. + _serializable: string + _unsuppressedFunction: () => void +}) { + return

hello world

+} diff --git a/test/development/typescript-plugin/client-boundary/client-boundary.test.ts b/test/development/typescript-plugin/client-boundary/client-boundary.test.ts index 93f1fc887e57..b4896f4a4958 100644 --- a/test/development/typescript-plugin/client-boundary/client-boundary.test.ts +++ b/test/development/typescript-plugin/client-boundary/client-boundary.test.ts @@ -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')