-
Notifications
You must be signed in to change notification settings - Fork 12.8k
ES2022 Class Decorators not working when Class has self-static member #52004
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
Comments
The binding for |
When targeting ES2022+, the emit for the example above is currently: let PingMessage = PingMessage_1 = class PingMessage {
static { this.Default = new PingMessage_1(); }
constructor(Value = "Ping") {
this.Value = Value;
}
};
PingMessage = PingMessage_1 = __decorate([
Message,
__metadata("design:paramtypes", [String])
], PingMessage); However, let PingMessage = PingMessage_1 = class PingMessage {
static { PingMessage_1 = this; }
static { this.Default = new PingMessage_1(); }
constructor(Value = "Ping") {
this.Value = Value;
}
};
PingMessage = PingMessage_1 = __decorate([
Message,
__metadata("design:paramtypes", [String])
], PingMessage); Please note that class decorators and static initializers that reference As a workaround, you could lazily initialize the value so that the constructor is run later: @Message
class PingMessage
{
private static _Default: PingMessage | undefined;
public static get Default() { return PingMessage._Default ??= new PingMessage(); }
public constructor(public readonly Value: string = "Ping") { }
} |
No worries, feel free to close the issue, there are several ways to work around this, I made a similar workaround, just wanted to report this issue. |
Came across this issue recently. I believe it is in fact a TS bug that needs to be resolved. Given the following TS Code: function createFromClass(caller: string, classRef: unknown) {
console.log(`${caller}:`, classRef);
return 'foo';
}
function SomeDecorator(): ClassDecorator {
return () => { };
}
class UndecoratedClass {
static test = createFromClass('UndecoratedClass', UndecoratedClass);
}
@SomeDecorator()
class DecoratedClass {
static test = createFromClass('DecoratedClass', DecoratedClass);
} If this is run targeting let DecoratedClass = DecoratedClass_1 = class DecoratedClass {
static test = createFromClass('DecoratedClass', DecoratedClass_1);
};
DecoratedClass = DecoratedClass_1 = __decorate([ SomeDecorator() ], DecoratedClass); When there is no decorator, then the intermediary reference is not created and it all works as expected. Proposed solutionNot sure all the reasons for the intermediary class reference, but potentially an alternative would be for TS to generate the following JS code when a decorator is used: let DecoratedClass = (() => {
class DecoratedClass {
static {
this.test = createFromClass('DecoratedClass', DecoratedClass);
}
};
return __decorate([ SomeDecorator() ], DecoratedClass);
})() The code above effectively uses a shadowed variable name within the IIFE as the intermediary class reference to the same effect, but without the reference initialisation timing issue. (EDIT: It could even be trimmed down to the following, but it really depends on the reason for the intermediary reference as to how far we simplify this.) let DecoratedClass = (() =>
__decorate([ SomeDecorator() ],
class DecoratedClass {
static {
this.test = createFromClass('DecoratedClass', DecoratedClass);
}
}
)
)(); |
I am seeing more and more Angular projects running into this issue. |
ECMAScript classes are "doubly bound", which means that the class name (i.e., class A {
static foo() { console.log(A.name); }
}
// replace A with B
A = class B extends A { }
// print the current name of A
console.log(A.name); // B
// indirectly print the name of A that the 'foo' method sees:
A.foo(); // A
I'm not sure what you mean by "shadowed variable" in this context, but unfortunately this doesn't work per the reason I stated above.
This also wouldn't work since The only change I could see us making would be to more closely emulate native decorators with regards to the timing of static initializer evaluation, such that where we previously ran static initializers before decorators, we would run them after decorators. However, I'm not certain whether there are customers that might be affected by such a major semantics change. I'm more likely to err on the side of caution and make it an error to reference the class name immediately within a static initializer (much like we do for a TDZ check for let/const). I would still suggest using lazy initialization for these fields, as I mentioned above. |
@rbuckton I have spent some time trying to understand the problem, and I found your commit here which resolved this issue as well as the double binding problem.
Expand to see comment bodyI think I have found a solution that would cover these cases and would additionally allow code to function in the same way when targeting ES2022 as when we target ES2021 or earlier. Given the following TS code: @SomeDecorator()
class DecoratedClass {
static test1 = doSomething('DecoratedClass', DecoratedClass);
static test2 = DecoratedClass;
static test3() { return DecoratedClass; }
} and assuming that the function SomeDecorator() {
return (target) => {
return class ModifiedClass extends target {};
};
} Today, if we compile with ES2021, we get: let DecoratedClass = DecoratedClass_1 = class DecoratedClass {
static test3() { return DecoratedClass_1; }
};
DecoratedClass.test1 = doSomething('DecoratedClass', DecoratedClass_1);
DecoratedClass.test2 = DecoratedClass_1;
DecoratedClass = DecoratedClass_1 = __decorate([
SomeDecorator()
], DecoratedClass); This results in the following observations:
Today, if we compile with ES2022, we get: let DecoratedClass = DecoratedClass_1 = class DecoratedClass {
static { this.test1 = doSomething('DecoratedClass', DecoratedClass_1); }
static { this.test2 = DecoratedClass_1; }
static test3() { return DecoratedClass_1; }
};
DecoratedClass = DecoratedClass_1 = __decorate([
SomeDecorator()
], DecoratedClass); This results in the following observations:
1 and 2 are breaking changes! This would be fixed if the code generated when targeting ES2022 was changed to: let DecoratedClass = DecoratedClass_1 = class DecoratedClass {
static { this.test = doSomething('DecoratedClass', this); }
static { this.test1 = this; }
static test2() { return DecoratedClass_1; }
};
DecoratedClass = DecoratedClass_1 = __decorate([
SomeDecorator()
], DecoratedClass); As you can see, references to Do you think that this is a viable solution? PS. I know that native decorators would do things differently here, but the "Experimental Decorators" have a different historical behavior in this regard, so I think that the historical behavior should be maintained for the experimental decorators. |
Expand to see comment bodyJust an additional note that a static initialiser like this: static test4 = accessLater(function foo() { return DecoratedClass }); would also still need to return the aliased class But the potential issue here is that, depending on when this function is executed, the aliased class may not yet be available. |
Ok, I think I have got a solution that will work for all scenarios. Trimmed TS example code: console.log('=== Declaring: Original');
@SomeDecorator()
class Original {
static test1 = init('Original', Original);
static test2 = Original;
static test3() { return Original; }
static test4 = doNothing(function () { return Original });
static test5 = Original?.test4();
}
console.log('--- After Declaration, should be Wrapped:', Original.name);
console.log(`'test1' should be Original:`, Original.test1?.name);
console.log(`'test2' should be Original:`, Original.test2?.name);
console.log(`'test3()' should return Wrapped:`, Original.test3()?.name);
console.log(`'test4()' should return Wrapped:`, Original.test4()?.name);
console.log(`'test5' should be Original:`, Original.test5?.name); When run using ES2021, the output is:
All expectations align. But, when run using ES2022, the output is:
Many cases are broken for ES2022. Here is the current code emitted for the ES2022 target: let Original = Original_1 = class Original {
static { this.test1 = init('Original', Original_1); }
static { this.test2 = Original_1; }
static test3() { return Original_1; }
static { this.test4 = doNothing(function () { return Original_1; }); }
static { this.test5 = Original_1?.test4(); }
};
Original = Original_1 = __decorate([
SomeDecorator()
], Original); I think the best solution that covers all bases here is to add a static initialiser block at the top of the emitted class: static { Original_1 = this; } Now the alias will be available to all the static initialiser blocks. Everything works! Here is an example of the tweaked JS output code (playground) with just this one line added: let Original = Original_1 = class Original {
static { Original_1 = this; } // <-- PROPOSAL TO ADD THIS LINE
static { this.test1 = init('Original', Original_1); }
static { this.test2 = Original_1; }
static test3() { return Original_1; }
static { this.test4 = doNothing(function () { return Original_1; }); }
static { this.test5 = Original_1?.test4(); }
};
Original = Original_1 = __decorate([
SomeDecorator()
], Original); @rbuckton what do you think of this solution? PS. potentially the line: |
Yes, this will work fine. I should have a PR up for this shortly.
No, this will not work. Inside of a class body, the bound name of the class is immutable (constant) and cannot be changed. |
Sorry, I misread your suggestion. To clarify, you're stating that the |
Will this be backported to 4.9? We are running into this issue in Angular 15 which uses TS 4.9 and targets ES2022 by default. |
I agree that a backport would be really great. This bug is coming up many times in the Angular community. |
How likely is to be fixed in 5.0 branch? Angular 16 currently depends on 5.0 branch and looks like is expected to be fixed on TypeScript side angular/angular#49415 |
In general we only backport critical crashes or regression specific to a particular release, or issues without any workarounds. This doesn't appear to qualify for any of those criteria. |
This is still an issue with Angular 16.1.4 and Typescript 5.0.4 and a tsconfig with lib: es2022, module: es2022 and target of es2022. It's fine with a target of es2021. According to the Angular upgrade matrix (https://gist.github.com/LayZeeDK/c822cc812f75bb07b7c55d07ba2719b3), for Angular 16.x.x, you must use Typescript >=4.9.5 <5.1.0 Has anyone tried Typescript >=5.1.0 with Angular ^16.1.4? If no one knows the compatibility results, I'll give it a try and see what happens. |
To answer this question: Yes, using TypeScript 5.1.3 with Angular 16.2.12 did the trick. |
Still an issue with Angular 17 (tried, 17.3.12) and TypeScript 5.4 (tried, 5.4.3) |
Bug Report
Class decorators are not working properly when the class has a static field of the same type and is instantiating it statically. Seems like this only happens when targeting ES2022.
🔎 Search Terms
Various combinations of the below search strings. Haven't been able to find anything useful.
Been searching for several days now, these are just the most recent ones I could remember:
🕗 Version & Regression Information
Regressed when
tsconfig.compilerOptions.target
is ES2022. Other ES versions that I tried seem to be working as expected.Tried Nightly version in the playground, could still repro it (after I changed target to ES2022 in TSConfig).
⏯ Playground Link
Bug repro in Playground
You can try setting e.g. ES2021, and the code works, but not on ES2022.
💻 Code
src\index.ts
:package.json
:tsconfig.json
:🙁 Actual behavior
~\dist\index.js:13
static { this.Default = new PingMessage_1(); } // [A]
^
TypeError: undefined is not a constructor
at Function.<static_initializer> (~\dist\index.js:13:29)
at Object. (~\dist\index.js:12:19)
at Module._compile (node:internal/modules/cjs/loader:1155:14)
at Object.Module._extensions..js (node:internal/modules/cjs/loader:1209:10)
at Module.load (node:internal/modules/cjs/loader:1033:32)
at Function.Module._load (node:internal/modules/cjs/loader:868:12)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)
at node:internal/main/run_main_module:22:47
🙂 Expected behavior
Console.logs:
The text was updated successfully, but these errors were encountered: