diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dfe1e05f3c..9e955565192 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,18 +100,27 @@ jobs: - name: "Production build, with optional features" BUILD: "production" ENABLE_OPTIONAL_FEATURES: "true" + - name: "Stable decorators" + VITE_STABLE_DECORATORS: "true" steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - name: build run: pnpm vite build --mode=${{ matrix.BUILD || 'development' }} + env: + ALL_DEPRECATIONS_ENABLED: ${{ matrix.ALL_DEPRECATIONS_ENABLED }} + OVERRIDE_DEPRECATION_VERSION: ${{ matrix.OVERRIDE_DEPRECATION_VERSION }} + ENABLE_OPTIONAL_FEATURES: ${{ matrix.ENABLE_OPTIONAL_FEATURES }} + RAISE_ON_DEPRECATION: ${{ matrix.RAISE_ON_DEPRECATION }} + VITE_STABLE_DECORATORS: ${{ matrix.VITE_STABLE_DECORATORS }} - name: test env: ALL_DEPRECATIONS_ENABLED: ${{ matrix.ALL_DEPRECATIONS_ENABLED }} OVERRIDE_DEPRECATION_VERSION: ${{ matrix.OVERRIDE_DEPRECATION_VERSION }} ENABLE_OPTIONAL_FEATURES: ${{ matrix.ENABLE_OPTIONAL_FEATURES }} RAISE_ON_DEPRECATION: ${{ matrix.RAISE_ON_DEPRECATION }} + VITE_STABLE_DECORATORS: ${{ matrix.VITE_STABLE_DECORATORS }} run: pnpm test diff --git a/babel.config.mjs b/babel.config.mjs index 34b6aa42dee..bca99f4cec6 100644 --- a/babel.config.mjs +++ b/babel.config.mjs @@ -17,13 +17,17 @@ export default { allowDeclareFields: true, }, ], - [ - 'module:decorator-transforms', - { - runEarly: true, - runtime: { import: 'decorator-transforms/runtime' }, - }, - ], + ...(process.env.VITE_STABLE_DECORATORS + ? [['@babel/plugin-proposal-decorators', { version: '2023-11' }]] + : [ + [ + 'module:decorator-transforms', + { + runEarly: true, + runtime: { import: 'decorator-transforms/runtime' }, + }, + ], + ]), [ 'babel-plugin-ember-template-compilation', { diff --git a/package.json b/package.json index 8eb3a3f75d8..d6c49ab2cbc 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,8 @@ }, "devDependencies": { "@aws-sdk/client-s3": "^3.731.0", - "@babel/plugin-transform-typescript": "^7.22.9", + "@babel/plugin-proposal-decorators": "^7.25.9", + "@babel/plugin-transform-typescript": "^7.26.8", "@babel/preset-env": "^7.16.11", "@babel/types": "^7.22.5", "@embroider/shared-internals": "^2.5.0", @@ -401,4 +402,4 @@ } }, "packageManager": "pnpm@10.5.0" -} +} \ No newline at end of file diff --git a/packages/@ember/-internals/metal/index.ts b/packages/@ember/-internals/metal/index.ts index 608bfa75037..9895f85b473 100644 --- a/packages/@ember/-internals/metal/index.ts +++ b/packages/@ember/-internals/metal/index.ts @@ -47,6 +47,7 @@ export { ComputedDescriptor, type ElementDescriptor, isElementDescriptor, + isDecoratorCall, nativeDescDecorator, descriptorForDecorator, descriptorForProperty, diff --git a/packages/@ember/-internals/metal/lib/alias.ts b/packages/@ember/-internals/metal/lib/alias.ts index af955f3cb12..aa217d2ff76 100644 --- a/packages/@ember/-internals/metal/lib/alias.ts +++ b/packages/@ember/-internals/metal/lib/alias.ts @@ -16,7 +16,7 @@ import type { ExtendedMethodDecorator } from './decorator'; import { ComputedDescriptor, descriptorForDecorator, - isElementDescriptor, + isDecoratorCall, makeComputedDecorator, } from './decorator'; import { defineProperty } from './properties'; @@ -28,7 +28,7 @@ export type AliasDecorator = ExtendedMethodDecorator & PropertyDecorator & Alias export default function alias(altKey: string): AliasDecorator { assert( 'You attempted to use @alias as a decorator directly, but it requires a `altKey` parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); // SAFETY: We passed in the impl for this class diff --git a/packages/@ember/-internals/metal/lib/cached.ts b/packages/@ember/-internals/metal/lib/cached.ts index b40ccf4a2de..1503d6ab449 100644 --- a/packages/@ember/-internals/metal/lib/cached.ts +++ b/packages/@ember/-internals/metal/lib/cached.ts @@ -3,6 +3,11 @@ // of @cached, so any changes made to one should also be made to the other import { DEBUG } from '@glimmer/env'; import { createCache, getValue } from '@glimmer/validator'; +import { + type Decorator, + identifyModernDecoratorArgs, + isModernDecoratorArgs, +} from './decorator-util'; /** * @decorator @@ -84,7 +89,7 @@ import { createCache, getValue } from '@glimmer/validator'; the subsequent cache invalidations of the `@cached` properties who were using this `trackedProp`. - Remember that setting tracked data should only be done during initialization, + Remember that setting tracked data should only be done during initialization, or as the result of a user action. Setting tracked data during render (such as in a getter), is not supported. @@ -94,6 +99,10 @@ import { createCache, getValue } from '@glimmer/validator'; @public */ export const cached: MethodDecorator = (...args: any[]) => { + if (isModernDecoratorArgs(args)) { + return cached2023(args) as any; + } + const [target, key, descriptor] = args; // Error on `@cached()`, `@cached(...args)`, and `@cached propName = value;` @@ -123,6 +132,23 @@ export const cached: MethodDecorator = (...args: any[]) => { }; }; +function cached2023(args: Parameters) { + const dec = identifyModernDecoratorArgs(args); + switch (dec.kind) { + case 'getter': { + const caches = new WeakMap(); + return function (this: any) { + if (!caches.has(this)) { + caches.set(this, createCache(dec.value.bind(this))); + } + return getValue(caches.get(this)); + }; + } + default: + throw new Error(`unsupported use of @cached on ${dec.kind} ${dec.context.name?.toString()}`); + } +} + function throwCachedExtraneousParens(): never { throw new Error( 'You attempted to use @cached(), which is not necessary nor supported. Remove the parentheses and you will be good to go!' diff --git a/packages/@ember/-internals/metal/lib/computed.ts b/packages/@ember/-internals/metal/lib/computed.ts index aac84147b84..922cffb933d 100644 --- a/packages/@ember/-internals/metal/lib/computed.ts +++ b/packages/@ember/-internals/metal/lib/computed.ts @@ -40,6 +40,7 @@ import { notifyPropertyChange, PROPERTY_DID_CHANGE, } from './property_events'; +import { isModernDecoratorArgs } from './decorator-util'; export type ComputedPropertyGetterFunction = (this: any, key: string) => unknown; export type ComputedPropertySetterFunction = ( @@ -884,6 +885,14 @@ export function computed(callback: ComputedPropertyCallback): ComputedDecorator; export function computed( ...args: ElementDescriptor | string[] | ComputedDecoratorKeysAndConfig ): ComputedDecorator | DecoratorPropertyDescriptor | void { + if (isModernDecoratorArgs(args)) { + let decorator = makeComputedDecorator( + new ComputedProperty([]), + ComputedDecoratorImpl + ) as ComputedDecorator; + return decorator(...(args as [any, any])); + } + assert( `@computed can only be used directly as a native decorator. If you're using tracked in classic classes, add parenthesis to call it like a function: computed()`, !(isElementDescriptor(args.slice(0, 3)) && args.length === 5 && (args[4] as unknown) === true) diff --git a/packages/@ember/-internals/metal/lib/decorator-util.ts b/packages/@ember/-internals/metal/lib/decorator-util.ts new file mode 100644 index 00000000000..68815852676 --- /dev/null +++ b/packages/@ember/-internals/metal/lib/decorator-util.ts @@ -0,0 +1,86 @@ +/* + Types and utilities for working with 2023-11 decorators -- the ones that are + currently (as of 2025-05-05) in Stage 3. + + TypeScript provides built-in types for all the Context objects, but not a way + to do type discrimination against the `value` argument. +*/ + +export type ClassMethodDecorator = ( + value: Function, + context: ClassMethodDecoratorContext +) => Function | void; + +export type ClassGetterDecorator = ( + value: Function, + context: ClassGetterDecoratorContext +) => Function | void; + +export type ClassSetterDecorator = ( + value: Function, + context: ClassSetterDecoratorContext +) => Function | void; + +export type ClassFieldDecorator = ( + value: undefined, + context: ClassFieldDecoratorContext +) => (initialValue: unknown) => unknown | void; + +export type ClassDecorator = (value: Function, context: ClassDecoratorContext) => Function | void; + +export type ClassAutoAccessorDecorator = ( + value: ClassAccessorDecoratorTarget, + context: ClassAccessorDecoratorContext +) => ClassAccessorDecoratorResult; + +export type Decorator = + | ClassMethodDecorator + | ClassGetterDecorator + | ClassSetterDecorator + | ClassFieldDecorator + | ClassDecorator + | ClassAutoAccessorDecorator; + +export function isModernDecoratorArgs(args: unknown[]): args is Parameters { + return args.length === 2 && typeof args[1] === 'object' && args[1] != null && 'kind' in args[1]; +} + +// this is designed to turn the arguments into a discriminated union so you can +// check the kind once and then have the right types for them. +export function identifyModernDecoratorArgs(args: Parameters): + | { + kind: 'method'; + value: Parameters[0]; + context: Parameters[1]; + } + | { + kind: 'getter'; + value: Parameters[0]; + context: Parameters[1]; + } + | { + kind: 'setter'; + value: Parameters[0]; + context: Parameters[1]; + } + | { + kind: 'field'; + value: Parameters[0]; + context: Parameters[1]; + } + | { + kind: 'class'; + value: Parameters[0]; + context: Parameters[1]; + } + | { + kind: 'accessor'; + value: Parameters[0]; + context: Parameters[1]; + } { + return { + kind: args[1].kind, + value: args[0], + context: args[1], + } as ReturnType; +} diff --git a/packages/@ember/-internals/metal/lib/decorator.ts b/packages/@ember/-internals/metal/lib/decorator.ts index 8e749b87a70..54f32ed4706 100644 --- a/packages/@ember/-internals/metal/lib/decorator.ts +++ b/packages/@ember/-internals/metal/lib/decorator.ts @@ -2,6 +2,12 @@ import type { Meta } from '@ember/-internals/meta'; import { meta as metaFor, peekMeta } from '@ember/-internals/meta'; import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; +import { + type Decorator, + identifyModernDecoratorArgs, + isModernDecoratorArgs, +} from './decorator-util'; +import { findDescriptor } from '@ember/-internals/utils/lib/lookup-descriptor'; export type DecoratorPropertyDescriptor = (PropertyDescriptor & { initializer?: any }) | undefined; @@ -36,6 +42,12 @@ export function isElementDescriptor(args: unknown[]): args is ElementDescriptor ); } +export function isDecoratorCall( + args: unknown[] +): args is ElementDescriptor | Parameters { + return isElementDescriptor(args) || isModernDecoratorArgs(args); +} + export function nativeDescDecorator(propertyDesc: PropertyDescriptor) { let decorator = function () { return propertyDesc; @@ -113,32 +125,23 @@ export function makeComputedDecorator( desc: ComputedDescriptor, DecoratorClass: { prototype: object } ): ExtendedMethodDecorator { - let decorator = function COMPUTED_DECORATOR( - target: object, - key: string, - propertyDesc?: DecoratorPropertyDescriptor, - maybeMeta?: Meta, - isClassicDecorator?: boolean - ): DecoratorPropertyDescriptor { - assert( - `Only one computed property decorator can be applied to a class field or accessor, but '${key}' was decorated twice. You may have added the decorator to both a getter and setter, which is unnecessary.`, - isClassicDecorator || - !propertyDesc || - !propertyDesc.get || - !COMPUTED_GETTERS.has(propertyDesc.get) - ); - - let meta = arguments.length === 3 ? metaFor(target) : maybeMeta; + let decorator = function COMPUTED_DECORATOR(...args: unknown[]): DecoratorPropertyDescriptor { + if (isModernDecoratorArgs(args)) { + return computedDecorator2023(args, desc) as unknown as DecoratorPropertyDescriptor; + } + + let [target, key, propertyDesc, maybeMeta, isClassicDecorator] = args as [ + object, + string, + DecoratorPropertyDescriptor | undefined, + Meta | undefined, + boolean | undefined, + ]; + + let meta = args.length < 4 ? metaFor(target) : maybeMeta; desc.setup(target, key, propertyDesc, meta!); - let computedDesc: PropertyDescriptor = { - enumerable: desc.enumerable, - configurable: desc.configurable, - get: DESCRIPTOR_GETTER_FUNCTION(key, desc), - set: DESCRIPTOR_SETTER_FUNCTION(key, desc), - }; - - return computedDesc; + return makeDescriptor(desc, key, propertyDesc, isClassicDecorator); }; setClassicDecorator(decorator, desc); @@ -148,6 +151,99 @@ export function makeComputedDecorator( return decorator; } +function makeDescriptor( + desc: ComputedDescriptor, + key: string, + propertyDesc?: DecoratorPropertyDescriptor, + isClassicDecorator?: boolean +): PropertyDescriptor { + assert( + `Only one computed property decorator can be applied to a class field or accessor, but '${key}' was decorated twice. You may have added the decorator to both a getter and setter, which is unnecessary.`, + isClassicDecorator || + !propertyDesc || + !propertyDesc.get || + !COMPUTED_GETTERS.has(propertyDesc.get) + ); + + let computedDesc: PropertyDescriptor = { + enumerable: desc.enumerable, + configurable: desc.configurable, + get: DESCRIPTOR_GETTER_FUNCTION(key, desc), + set: DESCRIPTOR_SETTER_FUNCTION(key, desc), + }; + return computedDesc; +} + +function once() { + let needsToRun = true; + return function (fn: () => void): void { + if (needsToRun) { + fn(); + needsToRun = false; + } + }; +} + +function computedDecorator2023(args: Parameters, desc: ComputedDescriptor) { + const dec = identifyModernDecoratorArgs(args); + let setup = once(); + + switch (dec.kind) { + case 'field': + dec.context.addInitializer(function (this: any) { + setup(() => { + desc.setup( + this.constructor.prototype, + dec.context.name as string, + undefined, + metaFor(this.constructor.prototype) + ); + }); + Object.defineProperty( + this, + dec.context.name, + makeDescriptor(desc, dec.context.name as string) + ); + }); + return undefined; + case 'setter': + case 'getter': { + dec.context.addInitializer(function (this: any) { + setup(() => { + let found = findDescriptor(this, dec.context.name); + if (!found) { + return; + } + desc.setup( + found.object, + dec.context.name as string, + found.descriptor, + metaFor(found.object) + ); + Object.defineProperty( + found.object, + dec.context.name, + makeDescriptor(desc, dec.context.name as string, found.descriptor) + ); + }); + }); + return undefined; + } + case 'method': + assert( + `@computed can only be used on accessors or fields, attempted to use it with ${dec.context.name.toString()} but that was a method. Try converting it to a getter (e.g. \`get ${dec.context.name.toString()}() {}\`)`, + false + ); + // TS knows "assert()" is terminal and will complain about unreachable code if + // I use a break here. ESLint complains if I *don't* use a break here. + // eslint-disable-next-line no-fallthrough + default: + throw new Error( + `unimplemented: computedDecorator on ${dec.kind} ${dec.context.name?.toString()}` + ); + } +} + ///////////// const DECORATOR_DESCRIPTOR_MAP: WeakMap = diff --git a/packages/@ember/-internals/metal/lib/injected_property.ts b/packages/@ember/-internals/metal/lib/injected_property.ts index 1228bf97454..8382901df49 100644 --- a/packages/@ember/-internals/metal/lib/injected_property.ts +++ b/packages/@ember/-internals/metal/lib/injected_property.ts @@ -5,6 +5,11 @@ import { computed } from './computed'; import type { DecoratorPropertyDescriptor, ElementDescriptor } from './decorator'; import { isElementDescriptor } from './decorator'; import { defineProperty } from './properties'; +import { + type Decorator, + identifyModernDecoratorArgs, + isModernDecoratorArgs, +} from './decorator-util'; export let DEBUG_INJECTION_FUNCTIONS: WeakMap; @@ -49,6 +54,10 @@ function inject( let elementDescriptor; let name: string | undefined; + if (isModernDecoratorArgs(args)) { + return inject2023(type, undefined, args); + } + if (isElementDescriptor(args)) { elementDescriptor = args; } else if (typeof args[0] === 'string') { @@ -88,4 +97,50 @@ function inject( } } +function inject2023(type: string, name: string | undefined, args: Parameters) { + const dec = identifyModernDecoratorArgs(args); + + function getInjection(this: any) { + let owner = getOwner(this) || this.container; // fallback to `container` for backwards compat + + assert( + `Attempting to lookup an injected property on an object without a container, ensure that the object was instantiated via a container.`, + Boolean(owner) + ); + + return owner.lookup(`${type}:${name || (dec.context.name as string)}`); + } + + if (DEBUG) { + DEBUG_INJECTION_FUNCTIONS.set(getInjection, { + type, + name, + }); + } + + switch (dec.kind) { + case 'field': + dec.context.addInitializer(function (this: any) { + Object.defineProperty(this, dec.context.name, { + get: getInjection, + set(this: object, value: unknown) { + Object.defineProperty(this, dec.context.name, { value }); + }, + }); + }); + return; + case 'accessor': + return { + get: getInjection, + set(this: object, value: unknown) { + Object.defineProperty(this, dec.context.name, { value }); + }, + }; + default: + throw new Error( + `The @service decorator does not support ${dec.kind} ${dec.context.name?.toString()}` + ); + } +} + export default inject; diff --git a/packages/@ember/-internals/metal/lib/tracked.ts b/packages/@ember/-internals/metal/lib/tracked.ts index 226b632c2e3..ce9097b4c7d 100644 --- a/packages/@ember/-internals/metal/lib/tracked.ts +++ b/packages/@ember/-internals/metal/lib/tracked.ts @@ -8,6 +8,11 @@ import { CHAIN_PASS_THROUGH } from './chain-tags'; import type { ExtendedMethodDecorator, DecoratorPropertyDescriptor } from './decorator'; import { COMPUTED_SETTERS, isElementDescriptor, setClassicDecorator } from './decorator'; import { SELF_TAG } from './tags'; +import { + type Decorator, + identifyModernDecoratorArgs, + isModernDecoratorArgs, +} from './decorator-util'; /** @decorator @@ -82,6 +87,11 @@ export function tracked( desc: DecoratorPropertyDescriptor ): DecoratorPropertyDescriptor; export function tracked(...args: any[]): ExtendedMethodDecorator | DecoratorPropertyDescriptor { + if (isModernDecoratorArgs(args)) { + // TODO: cast is a lie, keeping the public types unchanged for now + return tracked2023(args) as unknown as DecoratorPropertyDescriptor; + } + assert( `@tracked can only be used directly as a native decorator. If you're using tracked in classic classes, add parenthesis to call it like a function: tracked()`, !(isElementDescriptor(args.slice(0, 3)) && args.length === 5 && args[4] === true) @@ -120,10 +130,18 @@ export function tracked(...args: any[]): ExtendedMethodDecorator | DecoratorProp _meta?: any, isClassicDecorator?: boolean ): DecoratorPropertyDescriptor { - assert( - `You attempted to set a default value for ${key} with the @tracked({ value: 'default' }) syntax. You can only use this syntax with classic classes. For native classes, you can use class initializers: @tracked field = 'default';`, - isClassicDecorator - ); + let args = Array.from(arguments); + if (isModernDecoratorArgs(args)) { + assert( + `You attempted to set a default value for ${args[1].name?.toString()} with the @tracked({ value: 'default' }) syntax. You can only use this syntax with classic classes. For native classes, you can use class initializers: @tracked field = 'default';`, + isClassicDecorator + ); + } else { + assert( + `You attempted to set a default value for ${key} with the @tracked({ value: 'default' }) syntax. You can only use this syntax with classic classes. For native classes, you can use class initializers: @tracked field = 'default';`, + isClassicDecorator + ); + } let fieldDesc = { initializer: initializer || (() => value), @@ -203,3 +221,36 @@ export class TrackedDescriptor { this._set.call(obj, value); } } + +function tracked2023(args: Parameters) { + const dec = identifyModernDecoratorArgs(args); + switch (dec.kind) { + case 'field': + dec.context.addInitializer(function (this: any) { + let initial = this[dec.context.name]; + Object.defineProperty( + this, + dec.context.name, + descriptorForField([ + this, + dec.context.name as string, + { initializer: () => initial }, + ]) as any + ); + }); + return; + case 'accessor': + return { + get(this: object) { + consumeTag(tagFor(this, dec.context.name)); + return dec.value.get.call(this); + }, + set(this: object, value: unknown) { + dirtyTagFor(this, dec.context.name); + return dec.value.set.call(this, value); + }, + }; + default: + throw new Error(`unimplemented: tracked on ${dec.kind} ${dec.context.name?.toString()}`); + } +} diff --git a/packages/@ember/-internals/metal/tests/tracked/validation_test.js b/packages/@ember/-internals/metal/tests/tracked/validation_test.js index 307c35dcc4e..e2eef6ed310 100644 --- a/packages/@ember/-internals/metal/tests/tracked/validation_test.js +++ b/packages/@ember/-internals/metal/tests/tracked/validation_test.js @@ -43,7 +43,9 @@ moduleFor( assert.equal(validateTag(tag, snapshot), true); } - [`@test autotracking should work with initializers`](assert) { + [`@test autotracking should work with initializers (${import.meta.env.VITE_STABLE_DECORATORS ? 'stable' : 'legacy'} decorators)`]( + assert + ) { class Tracked { @tracked first = `first: ${this.second}`; @tracked second = 'second'; @@ -54,7 +56,11 @@ moduleFor( let tag = track(() => obj.first); let snapshot = valueForTag(tag); - assert.equal(obj.first, 'first: second', 'The value initializes correctly'); + let expectedInitialValue = import.meta.env.VITE_STABLE_DECORATORS + ? 'first: undefined' + : 'first: second'; + + assert.equal(obj.first, expectedInitialValue, 'The value initializes correctly'); assert.equal(validateTag(tag, snapshot), true); snapshot = valueForTag(tag); @@ -65,7 +71,7 @@ moduleFor( // See: https://github.com/glimmerjs/glimmer-vm/pull/1018 // assert.equal(validate(tag, snapshot), true); - assert.equal(obj.first, 'first: second', 'The value stays the same once initialized'); + assert.equal(obj.first, expectedInitialValue, 'The value stays the same once initialized'); snapshot = valueForTag(tag); assert.equal(validateTag(tag, snapshot), true); diff --git a/packages/@ember/-internals/utils/lib/lookup-descriptor.ts b/packages/@ember/-internals/utils/lib/lookup-descriptor.ts index 395dea6ebde..66e67db9d77 100644 --- a/packages/@ember/-internals/utils/lib/lookup-descriptor.ts +++ b/packages/@ember/-internals/utils/lib/lookup-descriptor.ts @@ -1,11 +1,21 @@ -export default function lookupDescriptor(obj: object, keyName: string | symbol) { +export function findDescriptor( + obj: object, + keyName: string | symbol +): { object: object; descriptor: PropertyDescriptor } | null { let current: object | null = obj; do { let descriptor = Object.getOwnPropertyDescriptor(current, keyName); if (descriptor !== undefined) { - return descriptor; + return { descriptor, object: current }; } current = Object.getPrototypeOf(current); } while (current !== null); return null; } + +export default function lookupDescriptor( + obj: object, + keyName: string | symbol +): PropertyDescriptor | null { + return findDescriptor(obj, keyName)?.descriptor ?? null; +} diff --git a/packages/@ember/object/compat.ts b/packages/@ember/object/compat.ts index 39265d27b66..dece38a0eaf 100644 --- a/packages/@ember/object/compat.ts +++ b/packages/@ember/object/compat.ts @@ -6,6 +6,10 @@ import { setClassicDecorator, } from '@ember/-internals/metal'; import type { ElementDescriptor } from '@ember/-internals/metal'; +import { + identifyModernDecoratorArgs, + isModernDecoratorArgs, +} from '@ember/-internals/metal/lib/decorator-util'; import { assert } from '@ember/debug'; import type { UpdatableTag } from '@glimmer/validator'; import { consumeTag, tagFor, track, updateTag } from '@glimmer/validator'; @@ -139,6 +143,25 @@ export function dependentKeyCompat( ); return wrapGetterSetter(target, key, desc); + } else if (isModernDecoratorArgs(args)) { + const dec = identifyModernDecoratorArgs(args); + assert( + 'The @dependentKeyCompat decorator must be applied to getters/setters when used in native classes', + dec.kind === 'getter' + ); + return function (this: any) { + let propertyTag = tagFor(this, dec.context.name as string) as UpdatableTag; + let ret; + + let tag = track(() => { + ret = dec.value.call(this); + }); + + updateTag(propertyTag, tag); + consumeTag(tag); + + return ret; + }; } else { const desc = args[0]; diff --git a/packages/@ember/object/index.ts b/packages/@ember/object/index.ts index 58325bcf278..25f5b6a58fa 100644 --- a/packages/@ember/object/index.ts +++ b/packages/@ember/object/index.ts @@ -11,6 +11,12 @@ import { setObservers } from '@ember/-internals/utils'; import type { AnyFn } from '@ember/-internals/utility-types'; import CoreObject from '@ember/object/core'; import Observable from '@ember/object/observable'; +import { + type Decorator, + identifyModernDecoratorArgs, + isModernDecoratorArgs, +} from '@ember/-internals/metal/lib/decorator-util'; +import { findDescriptor } from '@ember/-internals/utils/lib/lookup-descriptor'; export { notifyPropertyChange, @@ -181,6 +187,10 @@ export function action(desc: PropertyDescriptor): ExtendedMethodDecorator; export function action( ...args: ElementDescriptor | [PropertyDescriptor] ): PropertyDescriptor | ExtendedMethodDecorator { + if (isModernDecoratorArgs(args)) { + return action2023(args) as unknown as PropertyDescriptor; + } + let actionFn: object | Function; if (!isElementDescriptor(args)) { @@ -309,3 +319,25 @@ export function observer( }); return func; } + +function action2023(args: Parameters) { + const dec = identifyModernDecoratorArgs(args); + assert( + 'The @action decorator must be applied to methods when used in native classes', + dec.kind === 'method' + ); + let needsSetup = true; + dec.context.addInitializer(function (this: any) { + if (needsSetup) { + let found = findDescriptor(this, dec.context.name); + if (found?.object) { + Object.defineProperty( + found.object, + dec.context.name, + setupAction(found.object, dec.context.name, dec.value) + ); + } + needsSetup = false; + } + }); +} diff --git a/packages/@ember/object/lib/computed/computed_macros.ts b/packages/@ember/object/lib/computed/computed_macros.ts index 0c71a2967e0..bdb76bdc6c9 100644 --- a/packages/@ember/object/lib/computed/computed_macros.ts +++ b/packages/@ember/object/lib/computed/computed_macros.ts @@ -1,4 +1,4 @@ -import { computed, isElementDescriptor, alias, expandProperties } from '@ember/-internals/metal'; +import { computed, isDecoratorCall, alias, expandProperties } from '@ember/-internals/metal'; import { get, set } from '@ember/object'; import type { DeprecationOptions } from '@ember/debug'; import { assert, deprecate } from '@ember/debug'; @@ -33,7 +33,7 @@ function generateComputedWithPredicate(name: string, predicate: (value: unknown) assert( `You attempted to use @${name} as a decorator directly, but it requires at least one dependent key parameter`, - !isElementDescriptor(properties) + !isDecoratorCall(properties) ); let dependentKeys = expandPropertiesToArray(name, properties); @@ -98,7 +98,7 @@ function generateComputedWithPredicate(name: string, predicate: (value: unknown) export function empty(dependentKey: string) { assert( 'You attempted to use @empty as a decorator directly, but it requires a `dependentKey` parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return computed(`${dependentKey}.length`, function () { @@ -144,7 +144,7 @@ export function empty(dependentKey: string) { export function notEmpty(dependentKey: string) { assert( 'You attempted to use @notEmpty as a decorator directly, but it requires a `dependentKey` parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return computed(`${dependentKey}.length`, function () { @@ -187,7 +187,7 @@ export function notEmpty(dependentKey: string) { export function none(dependentKey: string) { assert( 'You attempted to use @none as a decorator directly, but it requires a `dependentKey` parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return computed(dependentKey, function () { @@ -229,7 +229,7 @@ export function none(dependentKey: string) { export function not(dependentKey: string) { assert( 'You attempted to use @not as a decorator directly, but it requires a `dependentKey` parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return computed(dependentKey, function () { @@ -277,7 +277,7 @@ export function not(dependentKey: string) { export function bool(dependentKey: string) { assert( 'You attempted to use @bool as a decorator directly, but it requires a `dependentKey` parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return computed(dependentKey, function () { @@ -323,7 +323,7 @@ export function bool(dependentKey: string) { export function match(dependentKey: string, regexp: RegExp) { assert( 'You attempted to use @match as a decorator directly, but it requires `dependentKey` and `regexp` parameters', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return computed(dependentKey, function () { @@ -369,7 +369,7 @@ export function match(dependentKey: string, regexp: RegExp) { export function equal(dependentKey: string, value: unknown) { assert( 'You attempted to use @equal as a decorator directly, but it requires `dependentKey` and `value` parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return computed(dependentKey, function () { @@ -414,7 +414,7 @@ export function equal(dependentKey: string, value: unknown) { export function gt(dependentKey: string, value: number) { assert( 'You attempted to use @gt as a decorator directly, but it requires `dependentKey` and `value` parameters', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return computed(dependentKey, function () { @@ -459,7 +459,7 @@ export function gt(dependentKey: string, value: number) { export function gte(dependentKey: string, value: number) { assert( 'You attempted to use @gte as a decorator directly, but it requires `dependentKey` and `value` parameters', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return computed(dependentKey, function () { @@ -504,7 +504,7 @@ export function gte(dependentKey: string, value: number) { export function lt(dependentKey: string, value: number) { assert( 'You attempted to use @lt as a decorator directly, but it requires `dependentKey` and `value` parameters', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return computed(dependentKey, function () { @@ -549,7 +549,7 @@ export function lt(dependentKey: string, value: number) { export function lte(dependentKey: string, value: number) { assert( 'You attempted to use @lte as a decorator directly, but it requires `dependentKey` and `value` parameters', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return computed(dependentKey, function () { @@ -723,7 +723,7 @@ export const or = generateComputedWithPredicate('or', (value) => !value); export function oneWay(dependentKey: string) { assert( 'You attempted to use @oneWay as a decorator directly, but it requires a `dependentKey` parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return alias(dependentKey).oneWay() as PropertyDecorator; @@ -787,7 +787,7 @@ export function oneWay(dependentKey: string) { export function readOnly(dependentKey: string) { assert( 'You attempted to use @readOnly as a decorator directly, but it requires a `dependentKey` parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return alias(dependentKey).readOnly() as PropertyDecorator; @@ -831,7 +831,7 @@ export function readOnly(dependentKey: string) { export function deprecatingAlias(dependentKey: string, options: DeprecationOptions) { assert( 'You attempted to use @deprecatingAlias as a decorator directly, but it requires `dependentKey` and `options` parameters', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return computed(dependentKey, { diff --git a/packages/@ember/object/lib/computed/reduce_computed_macros.ts b/packages/@ember/object/lib/computed/reduce_computed_macros.ts index 525c9611746..679c7b4ca99 100644 --- a/packages/@ember/object/lib/computed/reduce_computed_macros.ts +++ b/packages/@ember/object/lib/computed/reduce_computed_macros.ts @@ -3,7 +3,7 @@ */ import { DEBUG } from '@glimmer/env'; import { assert } from '@ember/debug'; -import { autoComputed, isElementDescriptor } from '@ember/-internals/metal'; +import { autoComputed, isDecoratorCall } from '@ember/-internals/metal'; import { computed, get } from '@ember/object'; import { compare } from '@ember/utils'; import EmberArray, { A as emberA, uniqBy as uniqByArray } from '@ember/array'; @@ -104,7 +104,7 @@ function multiArrayMacro( export function sum(dependentKey: string) { assert( 'You attempted to use @sum as a decorator directly, but it requires a `dependentKey` parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return reduceMacro(dependentKey, (sum: number, item: number) => sum + item, 0, 'sum'); @@ -168,7 +168,7 @@ export function sum(dependentKey: string) { export function max(dependentKey: string) { assert( 'You attempted to use @max as a decorator directly, but it requires a `dependentKey` parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return reduceMacro(dependentKey, (max, item) => Math.max(max, item), -Infinity, 'max'); @@ -231,7 +231,7 @@ export function max(dependentKey: string) { export function min(dependentKey: string) { assert( 'You attempted to use @min as a decorator directly, but it requires a `dependentKey` parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); return reduceMacro(dependentKey, (min, item) => Math.min(min, item), Infinity, 'min'); @@ -328,7 +328,7 @@ export function map( ): PropertyDecorator { assert( 'You attempted to use @map as a decorator directly, but it requires atleast `dependentKey` and `callback` parameters', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); assert( @@ -412,7 +412,7 @@ export function map( export function mapBy(dependentKey: string, propertyKey: string) { assert( 'You attempted to use @mapBy as a decorator directly, but it requires `dependentKey` and `propertyKey` parameters', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); assert( @@ -554,7 +554,7 @@ export function filter( ): PropertyDecorator { assert( 'You attempted to use @filter as a decorator directly, but it requires atleast `dependentKey` and `callback` parameters', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); assert( @@ -636,7 +636,7 @@ export function filter( export function filterBy(dependentKey: string, propertyKey: string, value?: unknown) { assert( 'You attempted to use @filterBy as a decorator directly, but it requires atleast `dependentKey` and `propertyKey` parameters', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); assert( @@ -696,7 +696,7 @@ export function uniq( ): PropertyDecorator { assert( 'You attempted to use @uniq/@union as a decorator directly, but it requires atleast one dependent key parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); let args = [dependentKey, ...additionalDependentKeys]; @@ -765,7 +765,7 @@ export function uniq( export function uniqBy(dependentKey: string, propertyKey: string) { assert( 'You attempted to use @uniqBy as a decorator directly, but it requires `dependentKey` and `propertyKey` parameters', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); assert( @@ -864,7 +864,7 @@ export let union = uniq; export function intersect(dependentKey: string, ...additionalDependentKeys: string[]) { assert( 'You attempted to use @intersect as a decorator directly, but it requires atleast one dependent key parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); let args = [dependentKey, ...additionalDependentKeys]; @@ -953,7 +953,7 @@ export function intersect(dependentKey: string, ...additionalDependentKeys: stri export function setDiff(setAProperty: string, setBProperty: string) { assert( 'You attempted to use @setDiff as a decorator directly, but it requires atleast one dependent key parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); assert('`setDiff` computed macro requires exactly two dependent arrays.', arguments.length === 2); @@ -1011,7 +1011,7 @@ export function setDiff(setAProperty: string, setBProperty: string) { export function collect(dependentKey: string, ...additionalDependentKeys: string[]) { assert( 'You attempted to use @collect as a decorator directly, but it requires atleast one dependent key parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); let dependentKeys = [dependentKey, ...additionalDependentKeys]; @@ -1188,7 +1188,7 @@ export function sort( ): PropertyDecorator { assert( 'You attempted to use @sort as a decorator directly, but it requires atleast an `itemsKey` parameter', - !isElementDescriptor(Array.prototype.slice.call(arguments)) + !isDecoratorCall(Array.prototype.slice.call(arguments)) ); if (DEBUG) { diff --git a/packages/@ember/service/tests/service_test.js b/packages/@ember/service/tests/service_test.js index 6edbb76ab36..b73a19175cf 100644 --- a/packages/@ember/service/tests/service_test.js +++ b/packages/@ember/service/tests/service_test.js @@ -50,7 +50,6 @@ moduleFor( let owner = buildOwner(); class MainService extends Service {} - class Foo extends EmberObject { @inject main; } @@ -107,5 +106,46 @@ moduleFor( runDestroy(owner); } + + ['@test can be replaced by assignment'](assert) { + let owner = buildOwner(); + + class MainService extends Service {} + + class Foo extends EmberObject { + @service main; + } + + owner.register('service:main', MainService); + owner.register('foo:main', Foo); + + let foo = owner.lookup('foo:main'); + let replacement = {}; + foo.main = replacement; + assert.strictEqual(foo.main, replacement, 'replaced'); + + runDestroy(owner); + } + + ['@test throws when used in wrong syntactic position'](assert) { + // I'm allowing the assertions to be different under the new decorator + // standard because the assertions on the old one were pretty bad. + if (import.meta.env.VITE_STABLE_DECORATORS) { + assert.throws(() => { + // eslint-disable-next-line no-unused-vars + class Foo extends EmberObject { + @service main() {} + } + }, /The @service decorator does not support method main/); + + assert.throws(() => { + @service + // eslint-disable-next-line no-unused-vars + class Foo extends EmberObject {} + }, /The @service decorator does not support class Foo/); + } else { + assert.expect(0); + } + } } ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f4d82e72d8..932c9aacc6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,8 +139,11 @@ importers: '@aws-sdk/client-s3': specifier: ^3.731.0 version: 3.750.0 + '@babel/plugin-proposal-decorators': + specifier: ^7.25.9 + version: 7.25.9(@babel/core@7.26.9) '@babel/plugin-transform-typescript': - specifier: ^7.22.9 + specifier: ^7.26.8 version: 7.26.8(@babel/core@7.26.9) '@babel/preset-env': specifier: ^7.16.11 @@ -1822,6 +1825,9 @@ importers: smoke-tests/scenarios: devDependencies: + '@babel/plugin-proposal-decorators': + specifier: ^7.25.9 + version: 7.25.9(@babel/core@7.26.9) '@embroider/compat': specifier: npm:@embroider/compat@latest version: 3.8.3(@embroider/core@3.5.2) @@ -1843,6 +1849,9 @@ importers: '@types/node': specifier: ^20.12.7 version: 20.17.19 + ember-template-imports: + specifier: ^4.3.0 + version: 4.3.0 qunit: specifier: ^2.20.1 version: 2.24.1 diff --git a/rollup.config.mjs b/rollup.config.mjs index 41fa88d892a..b4c38479049 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -248,10 +248,14 @@ export function hiddenDependencies() { 'module' ).path, ...walkGlimmerDeps(['@glimmer/compiler']), - 'decorator-transforms/runtime': resolve( - findFromProject('decorator-transforms').root, - 'dist/runtime.js' - ), + ...(process.env.VITE_STABLE_DECORATORS + ? {} + : { + 'decorator-transforms/runtime': resolve( + findFromProject('decorator-transforms').root, + 'dist/runtime.js' + ), + }), }; } diff --git a/smoke-tests/app-template/ember-cli-build.js b/smoke-tests/app-template/ember-cli-build.js index 77e5ad90726..5f24e71d09b 100644 --- a/smoke-tests/app-template/ember-cli-build.js +++ b/smoke-tests/app-template/ember-cli-build.js @@ -5,7 +5,7 @@ const { maybeEmbroider } = require('@embroider/test-setup'); module.exports = function (defaults) { const app = new EmberApp(defaults, { - // Add options here + /* SCENARIO_INSERTION_TARGET */ }); // Use `app.import` to add additional libraries to the generated diff --git a/smoke-tests/scenarios/modern-decorator-test.ts b/smoke-tests/scenarios/modern-decorator-test.ts new file mode 100644 index 00000000000..96633426263 --- /dev/null +++ b/smoke-tests/scenarios/modern-decorator-test.ts @@ -0,0 +1,163 @@ +import { appScenarios } from './scenarios'; +import type { PreparedApp } from 'scenario-tester'; +import * as QUnit from 'qunit'; +const { module: Qmodule, test } = QUnit; + +appScenarios + .map('modern-decorators', (project) => { + project.files['ember-cli-build.js'] = project.files['ember-cli-build.js'].replace( + '/* SCENARIO_INSERTION_TARGET */', + ` + 'ember-cli-babel': { + disableDecoratorTransforms: true, + }, + babel: { + plugins: [ + [require.resolve('@babel/plugin-proposal-decorators'), { version: '2023-11' }], + ], + },` + ); + + project.linkDevDependency('ember-template-imports', { baseDir: __dirname }) + project.linkDevDependency('@babel/plugin-proposal-decorators', { baseDir: __dirname }) + + project.mergeFiles({ + app: { + services: { + 'my-example.js': ` + import Service from '@ember/service'; + export default class MyExample extends Service { + message = 'Message from MyExample'; + + constructor(...args) { + super(...args); + if (globalThis.myExampleTracker) { + globalThis.myExampleTracker.initialized = true; + } + } + } + ` + } + }, + tests: { + unit: { + 'tracked-accessor-test.gjs': ` + import { module, test } from 'qunit'; + import { setupRenderingTest } from 'ember-qunit'; + import { render, click } from '@ember/test-helpers'; + import { on } from '@ember/modifier'; + import { tracked } from '@glimmer/tracking'; + import Component from '@glimmer/component'; + + module('Unit | tracked-accessor', function(hooks) { + setupRenderingTest(hooks); + + test('interactive update', async function(assert) { + class Example extends Component { + @tracked accessor count = 0; + inc = () => { this.count++ }; + + + } + + await render(); + assert.dom('.example span').hasText('0'); + await click('.example button'); + assert.dom('.example span').hasText('1'); + }); + }); + `, + 'service-accessor-test.gjs': ` + import { module, test } from 'qunit'; + import { setupRenderingTest } from 'ember-qunit'; + import { render, click } from '@ember/test-helpers'; + import { on } from '@ember/modifier'; + import { tracked } from '@glimmer/tracking'; + import Service, { service } from '@ember/service'; + import Component from '@glimmer/component'; + + module('Unit | service-accessor', function(hooks) { + setupRenderingTest(hooks); + + test('service is available', async function(assert) { + class Example extends Component { + @service accessor myExample; + + + } + + await render(); + assert.dom('.example').hasText('Message from MyExample'); + }); + + test('service is lazy', async function(assert) { + class InitTracker { + @tracked accessor initialized = false; + } + globalThis.myExampleTracker = new InitTracker(); + class Example extends Component { + @service accessor myExample; + @tracked accessor reveal = false; + + revealIt = () => { this.reveal = true } + + + } + + await render(); + assert.dom('.example span').hasText('Service Not Initialized') + await click('.example button'); + assert.dom('.example span').hasText('Service Initialized') + }); + + test('service can be replaced by assignment', async function(assert) { + class ReplacementTest extends Service { + @service accessor myExample; + } + this.owner.register('service:replacement-test', ReplacementTest); + let r = this.owner.lookup('service:replacement-test'); + assert.strictEqual(r.myExample.message, 'Message from MyExample'); + r.myExample = { message: 'overridden' }; + assert.strictEqual(r.myExample.message, 'overridden'); + }); + }); + ` + }, + }, + }); + }) + .forEachScenario((scenario) => { + Qmodule(scenario.name, function (hooks) { + let app: PreparedApp; + hooks.before(async () => { + app = await scenario.prepare(); + }); + + test(`ember test`, async function (assert) { + let result = await app.execute(`ember test`); + assert.equal(result.exitCode, 0, result.output); + }); + }); + }); diff --git a/smoke-tests/scenarios/package.json b/smoke-tests/scenarios/package.json index 6dde37efa2f..47a0b6986d0 100644 --- a/smoke-tests/scenarios/package.json +++ b/smoke-tests/scenarios/package.json @@ -2,6 +2,7 @@ "name": "ember-source-scenarios", "private": true, "devDependencies": { + "@babel/plugin-proposal-decorators": "^7.25.9", "@embroider/compat": "npm:@embroider/compat@latest", "@embroider/core": "npm:@embroider/core@latest", "@embroider/webpack": "npm:@embroider/webpack@latest", @@ -9,6 +10,7 @@ "@swc/core": "^1.4.17", "@swc/types": "^0.1.6", "@types/node": "^20.12.7", + "ember-template-imports": "^4.3.0", "qunit": "^2.20.1", "scenario-tester": "^4.0.0", "typescript": "5.1", diff --git a/vite.config.mjs b/vite.config.mjs index 8fa253626f1..57615cfea36 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -22,6 +22,7 @@ export default defineConfig(({ mode }) => { const build = { rollupOptions: { preserveEntrySignatures: 'strict', + treeshake: false, output: { preserveModules: true, }, @@ -43,6 +44,9 @@ export default defineConfig(({ mode }) => { optimizeDeps: { noDiscovery: true }, publicDir: 'tests/public', build, + + // the stock esbuild support for typescript is horribly broken. For example, + // it will simply remove your decorators. esbuild: false, }; });