diff --git a/packages/next/src/build/swc/options.ts b/packages/next/src/build/swc/options.ts index 32f3b354fb5b..e288d6401f3d 100644 --- a/packages/next/src/build/swc/options.ts +++ b/packages/next/src/build/swc/options.ts @@ -39,14 +39,19 @@ function shouldOutputCommonJs(filename: string) { export function getParserOptions({ filename, jsConfig, ...rest }: any) { const isTSFile = filename.endsWith('.ts') const hasTsSyntax = isTypeScriptFile(filename) - const enableDecorators = Boolean( + const enableLegacyDecorators = Boolean( jsConfig?.compilerOptions?.experimentalDecorators ) return { ...rest, syntax: hasTsSyntax ? 'typescript' : 'ecmascript', dynamicImport: true, - decorators: enableDecorators, + // Always enable decorator parsing for TypeScript files so that both + // legacy (@experimentalDecorators) and stage-3/TS5 decorators are parsed. + // Without this, SWC rejects @decorator syntax at the parser level before + // any transform runs — making TS5 decorators impossible even when the + // transform is correctly configured (fixes #48360). + decorators: hasTsSyntax || enableLegacyDecorators, // Exclude regular TypeScript files from React transformation to prevent e.g. generic parameters and angle-bracket type assertion from being interpreted as JSX tags. [hasTsSyntax ? 'tsx' : 'jsx']: !isTSFile, importAssertions: true, @@ -106,7 +111,7 @@ function getBaseSWCOptions({ const isAppRouterPagesLayer = isWebpackAppPagesLayer(bundleLayer) const parserConfig = getParserOptions({ filename, jsConfig }) const paths = jsConfig?.compilerOptions?.paths - const enableDecorators = Boolean( + const enableLegacyDecorators = Boolean( jsConfig?.compilerOptions?.experimentalDecorators ) const emitDecoratorMetadata = Boolean( @@ -147,8 +152,16 @@ function getBaseSWCOptions({ }, } : {}), - legacyDecorator: enableDecorators, - decoratorMetadata: emitDecoratorMetadata, + // When experimentalDecorators is set, use the legacy (stage 1) transform. + // Otherwise, use the stage-3/TS5 Ecma decorator proposal (decoratorVersion "2022-03"). + // This matches Turbopack's behavior in crates/next-core/src/transform_options.rs + // which selects DecoratorsKind::Legacy vs DecoratorsKind::Ecma (fixes #48360). + ...(enableLegacyDecorators + ? { legacyDecorator: true } + : { decoratorVersion: '2022-03' as const }), + decoratorMetadata: enableLegacyDecorators + ? emitDecoratorMetadata + : false, useDefineForClassFields: useDefineForClassFields, react: { importSource: diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 59be2705f54e..7a6ef5a8d203 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -543,12 +543,15 @@ export default async function getBaseWebpackConfig( pnp: Boolean(process.versions.pnp), optimizeServerReact: Boolean(config.experimental.optimizeServerReact), modularizeImports: config.modularizeImports, - decorators: Boolean( - jsConfig?.compilerOptions?.experimentalDecorators - ), + // Always parse decorators in TypeScript; the transform layer handles + // legacy vs stage-3 selection. Without this, TS5 decorators fail at + // parse time (fixes #48360). + decorators: true, emitDecoratorMetadata: Boolean( - jsConfig?.compilerOptions?.emitDecoratorMetadata - ), + jsConfig?.compilerOptions?.experimentalDecorators + ) + ? Boolean(jsConfig?.compilerOptions?.emitDecoratorMetadata) + : false, regeneratorRuntimePath: require.resolve( 'next/dist/compiled/regenerator-runtime' ), diff --git a/test/production/ts5-decorators/app/pages/index.tsx b/test/production/ts5-decorators/app/pages/index.tsx new file mode 100644 index 000000000000..343b47ae1c8b --- /dev/null +++ b/test/production/ts5-decorators/app/pages/index.tsx @@ -0,0 +1,22 @@ +// Stage-3/TS5 decorator — no experimentalDecorators flag required +function logged any>( + target: T, + context: ClassMethodDecoratorContext +) { + return function (this: any, ...args: Parameters): ReturnType { + console.log(`Calling ${String(context.name)}`) + return target.apply(this, args) + } +} + +class Greeter { + @logged + greet(name: string) { + return `Hello, ${name}!` + } +} + +export default function Home() { + const g = new Greeter() + return

{g.greet('world')}

+} diff --git a/test/production/ts5-decorators/index.test.ts b/test/production/ts5-decorators/index.test.ts new file mode 100644 index 000000000000..26622171e9e5 --- /dev/null +++ b/test/production/ts5-decorators/index.test.ts @@ -0,0 +1,14 @@ +import { join } from 'path' +import { nextTestSetup } from 'e2e-utils' + +describe('TypeScript 5 stage-3 decorators', () => { + const { next } = nextTestSetup({ + files: join(__dirname, 'app'), + }) + + it('should build and render a page using TS5 decorators', async () => { + const browser = await next.browser('/') + const text = await browser.elementByCss('#result').text() + expect(text).toBe('Hello, world!') + }) +})