feat(kit,nuxt,schema): support experimental decorators syntax (#27672)

This commit is contained in:
Daniel Roe 2025-02-10 19:16:36 +00:00
parent 3d0d831463
commit 3af848f9a6
No known key found for this signature in database
GPG Key ID: CBC814C393D93268
19 changed files with 291 additions and 40 deletions

View File

@ -524,3 +524,41 @@ Alternatively, you can render the template alongside the Nuxt app root by settin
```
This avoids a white flash when hydrating a client-only page.
## decorators
This option enables enabling decorator syntax across your entire Nuxt/Nitro app, powered by [esbuild](https://github.com/evanw/esbuild/releases/tag/v0.21.3).
For a long time, TypeScript has had support for decorators via `compilerOptions.experimentalDecorators`. This implementation predated the TC39 standardization process. Now, decorators are a [Stage 3 Proposal](https://github.com/tc39/proposal-decorators), and supported without special configuration in TS 5.0+ (see https://github.com/microsoft/TypeScript/pull/52582 and https://devblogs.microsoft.com/typescript/announcing-typescript-5-0-beta/#decorators).
Enabling `experimental.decorators` enables support for the TC39 proposal, **NOT** for TypeScript's previous `compilerOptions.experimentalDecorators` implementation.
::warning
Note that there may be changes before this finally lands in the JS standard.
::
### Usage
```ts twoslash [nuxt.config.ts]
export default defineNuxtConfig({
experimental: {
decorators: true,
},
})
```
```ts [app.vue]
function something (_method: () => unknown) {
return () => 'decorated'
}
class SomeClass {
@something
public someMethod () {
return 'initial'
}
}
const value = new SomeClass().someMethod()
// this will return 'decorated'
```

View File

@ -180,6 +180,8 @@ export async function _generateTypes (nuxt: Nuxt) {
.then(r => r?.version && gte(r.version, '5.4.0'))
.catch(() => isV4)
const useDecorators = Boolean(nuxt.options.experimental?.decorators)
// https://www.totaltypescript.com/tsconfig-cheat-sheet
const tsConfig: TSConfig = defu(nuxt.options.typescript?.tsConfig, {
compilerOptions: {
@ -197,12 +199,20 @@ export async function _generateTypes (nuxt: Nuxt) {
noUncheckedIndexedAccess: isV4,
forceConsistentCasingInFileNames: true,
noImplicitOverride: true,
/* Decorator support */
...useDecorators
? {
useDefineForClassFields: false,
experimentalDecorators: false,
}
: {},
/* If NOT transpiling with TypeScript: */
module: hasTypescriptVersionWithModulePreserve ? 'preserve' : 'ESNext',
noEmit: true,
/* If your code runs in the DOM: */
lib: [
'ESNext',
...useDecorators ? ['esnext.decorators'] : [],
'dom',
'dom.iterable',
'webworker',

View File

@ -1,5 +1,4 @@
import type { Literal, Property, SpreadElement } from 'estree'
import { transform } from 'esbuild'
import { defu } from 'defu'
import { findExports } from 'mlly'
import type { Nuxt } from '@nuxt/schema'
@ -8,7 +7,7 @@ import MagicString from 'magic-string'
import { normalize } from 'pathe'
import type { ObjectPlugin, PluginMeta } from 'nuxt/app'
import { parseAndWalk, withLocations } from '../../core/utils/parse'
import { parseAndWalk, transform, withLocations } from '../../core/utils/parse'
import { logger } from '../../utils'
const internalOrderMap = {

View File

@ -1,9 +1,8 @@
import { transform } from 'esbuild'
import { createUnplugin } from 'unplugin'
import MagicString from 'magic-string'
import { hash } from 'ohash'
import { parseAndWalk, withLocations } from '../../core/utils/parse'
import { parseAndWalk, transform, withLocations } from '../../core/utils/parse'
import { isJS, isVue } from '../utils'
export function PrehydrateTransformPlugin (options: { sourcemap?: boolean } = {}) {

View File

@ -1,22 +1,17 @@
import { walk as _walk } from 'estree-walker'
import type { Node, SyncHandler } from 'estree-walker'
import type {
ArrowFunctionExpression,
CatchClause,
Program as ESTreeProgram,
FunctionDeclaration,
FunctionExpression,
Identifier,
ImportDefaultSpecifier,
ImportNamespaceSpecifier,
ImportSpecifier,
VariableDeclaration,
} from 'estree'
import type { ArrowFunctionExpression, CatchClause, Program as ESTreeProgram, FunctionDeclaration, FunctionExpression, Identifier, ImportDefaultSpecifier, ImportNamespaceSpecifier, ImportSpecifier, VariableDeclaration } from 'estree'
import { parse } from 'acorn'
import type { Program } from 'acorn'
import { type SameShape, type TransformOptions, type TransformResult, transform as esbuildTransform } from 'esbuild'
import { tryUseNuxt } from '@nuxt/kit'
export type { Node }
export async function transform<T extends TransformOptions> (input: string | Uint8Array, options?: SameShape<TransformOptions, T>): Promise<TransformResult<T>> {
return await esbuildTransform(input, { ...tryUseNuxt()?.options.esbuild.options, ...options })
}
type WithLocations<T> = T & { start: number, end: number }
type WalkerCallback = (this: ThisParameterType<SyncHandler>, node: WithLocations<Node>, parent: WithLocations<Node> | null, ctx: { key: string | number | symbol | null | undefined, index: number | null | undefined, ast: Program | Node }) => void

View File

@ -1,11 +1,10 @@
import { runInNewContext } from 'node:vm'
import { transform } from 'esbuild'
import type { NuxtPage } from '@nuxt/schema'
import type { NitroRouteConfig } from 'nitropack'
import { normalize } from 'pathe'
import { getLoader } from '../core/utils'
import { parseAndWalk } from '../core/utils/parse'
import { parseAndWalk, transform } from '../core/utils/parse'
import { extractScriptContent, pathToNitroGlob } from './utils'
const ROUTE_RULE_RE = /\bdefineRouteRules\(/

View File

@ -7,12 +7,11 @@ import { genArrayFromRaw, genDynamicImport, genImport, genSafeVariableName } fro
import escapeRE from 'escape-string-regexp'
import { filename } from 'pathe/utils'
import { hash } from 'ohash'
import { transform } from 'esbuild'
import type { Property } from 'estree'
import type { NuxtPage } from 'nuxt/schema'
import { klona } from 'klona'
import { parseAndWalk, withLocations } from '../core/utils/parse'
import { parseAndWalk, transform, withLocations } from '../core/utils/parse'
import { getLoader, uniqueBy } from '../core/utils'
import { logger, toArray } from '../utils'

View File

@ -39,6 +39,7 @@ export default defineBuildConfig({
'consola',
'css-minimizer-webpack-plugin',
'cssnano',
'esbuild',
'esbuild-loader',
'file-loader',
'h3',

View File

@ -50,6 +50,7 @@
"chokidar": "4.0.3",
"compatx": "0.1.8",
"css-minimizer-webpack-plugin": "7.0.0",
"esbuild": "0.25.0",
"esbuild-loader": "4.3.0",
"file-loader": "6.2.0",
"h3": "1.15.0",

View File

@ -0,0 +1,32 @@
import { defu } from 'defu'
import type { TransformOptions } from 'esbuild'
import { defineResolvers } from '../utils/definition'
export default defineResolvers({
esbuild: {
/**
* Configure shared esbuild options used within Nuxt and passed to other builders, such as Vite or Webpack.
* @type {import('esbuild').TransformOptions}
*/
options: {
jsxFactory: 'h',
jsxFragment: 'Fragment',
tsconfigRaw: {
$resolve: async (_val, get) => {
const val: NonNullable<Exclude<TransformOptions['tsconfigRaw'], string>> = typeof _val === 'string' ? JSON.parse(_val) : (_val && typeof _val === 'object' ? _val : {})
const useDecorators = await get('experimental').then(r => r?.decorators === true)
if (!useDecorators) {
return val
}
return defu(val, {
compilerOptions: {
useDefineForClassFields: false,
experimentalDecorators: false,
},
} satisfies TransformOptions['tsconfigRaw'])
},
},
},
},
})

View File

@ -136,6 +136,12 @@ export default defineResolvers({
},
},
experimental: {
/**
* Enable to use experimental decorators in Nuxt and Nitro.
*
* @see https://github.com/tc39/proposal-decorators
*/
decorators: false,
/**
* Set to true to generate an async entry point for the Vue bundle (for module federation support).
*/

View File

@ -5,6 +5,7 @@ import app from './app'
import build from './build'
import common from './common'
import dev from './dev'
import esbuild from './esbuild'
import experimental from './experimental'
import generate from './generate'
import internal from './internal'
@ -28,6 +29,7 @@ export default {
...postcss,
...router,
...typescript,
...esbuild,
...vite,
...webpack,
} satisfies ResolvableConfigSchema

View File

@ -1,4 +1,5 @@
import { consola } from 'consola'
import defu from 'defu'
import { resolve } from 'pathe'
import { isTest } from 'std-env'
import { defineResolvers } from '../utils/definition'
@ -86,6 +87,9 @@ export default defineResolvers({
},
},
optimizeDeps: {
esbuildOptions: {
$resolve: async (val, get) => defu(val && typeof val === 'object' ? val : {}, await get('esbuild.options')),
},
exclude: {
$resolve: async (val, get) => [
...Array.isArray(val) ? val : [],
@ -95,9 +99,9 @@ export default defineResolvers({
},
},
esbuild: {
jsxFactory: 'h',
jsxFragment: 'Fragment',
tsconfigRaw: '{}',
$resolve: async (val, get) => {
return defu(val && typeof val === 'object' ? val : {}, await get('esbuild.options'))
},
},
clearScreen: true,
build: {

View File

@ -160,9 +160,9 @@ export default defineResolvers({
* @type {Omit<typeof import('esbuild-loader')['LoaderOptions'], 'loader'>}
*/
esbuild: {
jsxFactory: 'h',
jsxFragment: 'Fragment',
tsconfigRaw: '{}',
$resolve: async (val, get) => {
return defu(val && typeof val === 'object' ? val : {}, await get('esbuild.options'))
},
},
/**

View File

@ -713,10 +713,10 @@ importers:
version: 4.2.4
'@types/webpack-bundle-analyzer':
specifier: 4.7.0
version: 4.7.0
version: 4.7.0(esbuild@0.25.0)
'@types/webpack-hot-middleware':
specifier: 2.25.9
version: 2.25.9
version: 2.25.9(esbuild@0.25.0)
'@unhead/schema':
specifier: 1.11.18
version: 1.11.18
@ -746,13 +746,16 @@ importers:
version: 0.1.8
css-minimizer-webpack-plugin:
specifier: 7.0.0
version: 7.0.0(webpack@5.96.1)
version: 7.0.0(esbuild@0.25.0)(webpack@5.96.1(esbuild@0.25.0))
esbuild:
specifier: 0.25.0
version: 0.25.0
esbuild-loader:
specifier: 4.3.0
version: 4.3.0(webpack@5.96.1)
version: 4.3.0(webpack@5.96.1(esbuild@0.25.0))
file-loader:
specifier: 6.2.0
version: 6.2.0(webpack@5.96.1)
version: 6.2.0(webpack@5.96.1(esbuild@0.25.0))
h3:
specifier: 1.15.0
version: 1.15.0
@ -764,7 +767,7 @@ importers:
version: 7.0.3
mini-css-extract-plugin:
specifier: 2.9.2
version: 2.9.2(webpack@5.96.1)
version: 2.9.2(webpack@5.96.1(esbuild@0.25.0))
nitropack:
specifier: 2.10.4
version: 2.10.4(typescript@5.7.3)
@ -779,7 +782,7 @@ importers:
version: 8.5.2
sass-loader:
specifier: 16.0.4
version: 16.0.4(@rspack/core@1.2.3)(webpack@5.96.1)
version: 16.0.4(@rspack/core@1.2.3)(webpack@5.96.1(esbuild@0.25.0))
scule:
specifier: 1.3.0
version: 1.3.0
@ -806,16 +809,16 @@ importers:
version: 2.1.1
vue-loader:
specifier: 17.4.2
version: 17.4.2(@vue/compiler-sfc@3.5.13)(vue@3.5.13(typescript@5.7.3))(webpack@5.96.1)
version: 17.4.2(@vue/compiler-sfc@3.5.13)(vue@3.5.13(typescript@5.7.3))(webpack@5.96.1(esbuild@0.25.0))
vue-router:
specifier: 4.5.0
version: 4.5.0(vue@3.5.13(typescript@5.7.3))
webpack:
specifier: 5.96.1
version: 5.96.1
version: 5.96.1(esbuild@0.25.0)
webpack-dev-middleware:
specifier: 7.4.2
version: 7.4.2(webpack@5.96.1)
version: 7.4.2(webpack@5.96.1(esbuild@0.25.0))
packages/ui-templates:
devDependencies:
@ -9742,6 +9745,17 @@ snapshots:
- uglify-js
- webpack-cli
'@types/webpack-bundle-analyzer@4.7.0(esbuild@0.25.0)':
dependencies:
'@types/node': 22.13.1
tapable: 2.2.1
webpack: 5.96.1(esbuild@0.25.0)
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
- webpack-cli
'@types/webpack-hot-middleware@2.25.9':
dependencies:
'@types/connect': 3.4.38
@ -9753,6 +9767,17 @@ snapshots:
- uglify-js
- webpack-cli
'@types/webpack-hot-middleware@2.25.9(esbuild@0.25.0)':
dependencies:
'@types/connect': 3.4.38
tapable: 2.2.1
webpack: 5.96.1(esbuild@0.25.0)
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
- webpack-cli
'@types/yargs-parser@21.0.3': {}
'@types/yargs@17.0.33':
@ -11037,6 +11062,18 @@ snapshots:
'@rspack/core': 1.2.3
webpack: 5.97.1
css-minimizer-webpack-plugin@7.0.0(esbuild@0.25.0)(webpack@5.96.1(esbuild@0.25.0)):
dependencies:
'@jridgewell/trace-mapping': 0.3.25
cssnano: 7.0.6(postcss@8.5.2)
jest-worker: 29.7.0
postcss: 8.5.2
schema-utils: 4.3.0
serialize-javascript: 6.0.2
webpack: 5.96.1(esbuild@0.25.0)
optionalDependencies:
esbuild: 0.25.0
css-minimizer-webpack-plugin@7.0.0(webpack@5.96.1):
dependencies:
'@jridgewell/trace-mapping': 0.3.25
@ -11393,6 +11430,14 @@ snapshots:
dependencies:
es-errors: 1.3.0
esbuild-loader@4.3.0(webpack@5.96.1(esbuild@0.25.0)):
dependencies:
esbuild: 0.25.0
get-tsconfig: 4.8.0
loader-utils: 2.0.4
webpack: 5.96.1(esbuild@0.25.0)
webpack-sources: 1.4.3
esbuild-loader@4.3.0(webpack@5.96.1):
dependencies:
esbuild: 0.25.0
@ -11804,6 +11849,12 @@ snapshots:
dependencies:
flat-cache: 4.0.1
file-loader@6.2.0(webpack@5.96.1(esbuild@0.25.0)):
dependencies:
loader-utils: 2.0.4
schema-utils: 3.3.0
webpack: 5.96.1(esbuild@0.25.0)
file-loader@6.2.0(webpack@5.96.1):
dependencies:
loader-utils: 2.0.4
@ -13248,6 +13299,12 @@ snapshots:
min-indent@1.0.1: {}
mini-css-extract-plugin@2.9.2(webpack@5.96.1(esbuild@0.25.0)):
dependencies:
schema-utils: 4.3.0
tapable: 2.2.1
webpack: 5.96.1(esbuild@0.25.0)
mini-css-extract-plugin@2.9.2(webpack@5.96.1):
dependencies:
schema-utils: 4.3.0
@ -14538,12 +14595,12 @@ snapshots:
safe-buffer@5.2.1: {}
sass-loader@16.0.4(@rspack/core@1.2.3)(webpack@5.96.1):
sass-loader@16.0.4(@rspack/core@1.2.3)(webpack@5.96.1(esbuild@0.25.0)):
dependencies:
neo-async: 2.6.2
optionalDependencies:
'@rspack/core': 1.2.3
webpack: 5.96.1
webpack: 5.96.1(esbuild@0.25.0)
sass@1.78.0:
dependencies:
@ -14926,6 +14983,17 @@ snapshots:
mkdirp: 3.0.1
yallist: 5.0.0
terser-webpack-plugin@5.3.11(esbuild@0.25.0)(webpack@5.96.1(esbuild@0.25.0)):
dependencies:
'@jridgewell/trace-mapping': 0.3.25
jest-worker: 27.5.1
schema-utils: 4.3.0
serialize-javascript: 6.0.2
terser: 5.37.0
webpack: 5.96.1(esbuild@0.25.0)
optionalDependencies:
esbuild: 0.25.0
terser-webpack-plugin@5.3.11(webpack@5.96.1):
dependencies:
'@jridgewell/trace-mapping': 0.3.25
@ -15631,6 +15699,16 @@ snapshots:
dependencies:
vue: 3.5.13(typescript@5.7.3)
vue-loader@17.4.2(@vue/compiler-sfc@3.5.13)(vue@3.5.13(typescript@5.7.3))(webpack@5.96.1(esbuild@0.25.0)):
dependencies:
chalk: 4.1.2
hash-sum: 2.0.0
watchpack: 2.4.2
webpack: 5.96.1(esbuild@0.25.0)
optionalDependencies:
'@vue/compiler-sfc': 3.5.13
vue: 3.5.13(typescript@5.7.3)
vue-loader@17.4.2(@vue/compiler-sfc@3.5.13)(vue@3.5.13(typescript@5.7.3))(webpack@5.96.1):
dependencies:
chalk: 4.1.2
@ -15718,6 +15796,17 @@ snapshots:
- bufferutil
- utf-8-validate
webpack-dev-middleware@7.4.2(webpack@5.96.1(esbuild@0.25.0)):
dependencies:
colorette: 2.0.20
memfs: 4.14.1
mime-types: 2.1.35
on-finished: 2.4.1
range-parser: 1.2.1
schema-utils: 4.3.0
optionalDependencies:
webpack: 5.96.1(esbuild@0.25.0)
webpack-dev-middleware@7.4.2(webpack@5.96.1):
dependencies:
colorette: 2.0.20
@ -15785,6 +15874,36 @@ snapshots:
- esbuild
- uglify-js
webpack@5.96.1(esbuild@0.25.0):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.6
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.14.0
browserslist: 4.24.3
chrome-trace-event: 1.0.4
enhanced-resolve: 5.18.0
es-module-lexer: 1.6.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
loader-runner: 4.3.0
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 3.3.0
tapable: 2.2.1
terser-webpack-plugin: 5.3.11(esbuild@0.25.0)(webpack@5.96.1(esbuild@0.25.0))
watchpack: 2.4.2
webpack-sources: 3.2.3
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
webpack@5.97.1:
dependencies:
'@types/eslint-scope': 3.7.7

View File

@ -2784,8 +2784,14 @@ describe('teleports', () => {
})
})
describe('Node.js compatibility for client-side', () => {
it('should work', async () => {
describe('experimental', () => {
it('decorators support works', async () => {
const html = await $fetch('/experimental/decorators')
expect(html).toContain('decorated-decorated')
expectNoClientErrors('/experimental/decorators')
})
it('Node.js compatibility for client-side', async () => {
const { page } = await renderPage('/experimental/node-compat')
await page.locator('body').getByText('Nuxt is Awesome!').waitFor()
expect(await page.innerHTML('body')).toContain('CWD: [available]')

View File

@ -161,6 +161,7 @@ export default defineNuxtConfig({
inlineStyles: id => !!id && !id.includes('assets.vue'),
},
experimental: {
decorators: true,
typedPages: true,
polyfillVueUseHead: true,
respectNoSSRHeader: true,

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
function something (_method: () => unknown) {
return () => 'decorated'
}
class SomeClass {
@something
public someMethod () {
return 'initial'
}
}
const value = new SomeClass().someMethod()
const { data } = await useFetch('/api/experimental/decorators')
</script>
<template>
<div>
{{ value }}-{{ data }}
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,14 @@
export default eventHandler((_event) => {
function something (_method: () => unknown) {
return () => 'decorated'
}
class SomeClass {
@something
public someMethod () {
return 'initial'
}
}
return new SomeClass().someMethod()
})