Skip to content

Stable decorators #20868

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 24 commits into
base: main
Choose a base branch
from
Draft
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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 11 additions & 7 deletions babel.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
{
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -401,4 +402,4 @@
}
},
"packageManager": "[email protected]"
}
}
1 change: 1 addition & 0 deletions packages/@ember/-internals/metal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export {
ComputedDescriptor,
type ElementDescriptor,
isElementDescriptor,
isDecoratorCall,
nativeDescDecorator,
descriptorForDecorator,
descriptorForProperty,
Expand Down
4 changes: 2 additions & 2 deletions packages/@ember/-internals/metal/lib/alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { ExtendedMethodDecorator } from './decorator';
import {
ComputedDescriptor,
descriptorForDecorator,
isElementDescriptor,
isDecoratorCall,
makeComputedDecorator,
} from './decorator';
import { defineProperty } from './properties';
Expand All @@ -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
Expand Down
28 changes: 27 additions & 1 deletion packages/@ember/-internals/metal/lib/cached.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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;`
Expand Down Expand Up @@ -123,6 +132,23 @@ export const cached: MethodDecorator = (...args: any[]) => {
};
};

function cached2023(args: Parameters<Decorator>) {
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!'
Expand Down
9 changes: 9 additions & 0 deletions packages/@ember/-internals/metal/lib/computed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions packages/@ember/-internals/metal/lib/decorator-util.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, unknown>,
context: ClassAccessorDecoratorContext
) => ClassAccessorDecoratorResult<unknown, unknown>;

export type Decorator =
| ClassMethodDecorator
| ClassGetterDecorator
| ClassSetterDecorator
| ClassFieldDecorator
| ClassDecorator
| ClassAutoAccessorDecorator;

export function isModernDecoratorArgs(args: unknown[]): args is Parameters<Decorator> {
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<Decorator>):
| {
kind: 'method';
value: Parameters<ClassMethodDecorator>[0];
context: Parameters<ClassMethodDecorator>[1];
}
| {
kind: 'getter';
value: Parameters<ClassGetterDecorator>[0];
context: Parameters<ClassGetterDecorator>[1];
}
| {
kind: 'setter';
value: Parameters<ClassSetterDecorator>[0];
context: Parameters<ClassSetterDecorator>[1];
}
| {
kind: 'field';
value: Parameters<ClassFieldDecorator>[0];
context: Parameters<ClassFieldDecorator>[1];
}
| {
kind: 'class';
value: Parameters<ClassDecorator>[0];
context: Parameters<ClassDecorator>[1];
}
| {
kind: 'accessor';
value: Parameters<ClassAutoAccessorDecorator>[0];
context: Parameters<ClassAutoAccessorDecorator>[1];
} {
return {
kind: args[1].kind,
value: args[0],
context: args[1],
} as ReturnType<typeof identifyModernDecoratorArgs>;
}
Loading