Skip to content

Commit 9e6483a

Browse files
johnjenkinsJohn Jenkins
and
John Jenkins
authored
fix: runtime decorators (#6076)
* fix: make decorators great again * chore: WIP. Pretty much there * chore: pretty much there.. sans tests * chore: tidying up * chore: make it work properly again * chore: first unit test plus edge smoothing * chore: pretty much dun * chore: clearer jsdoc * chore: tidy * chore: formatting --------- Co-authored-by: John Jenkins <john.jenkins@nanoporetech.com>
1 parent 5d29255 commit 9e6483a

30 files changed

+680
-266
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,4 @@ test/wdio/test-components-no-external-runtime
5959
test/wdio/www-global-script/
6060
test/wdio/www-prerender-script
6161
test/wdio/www-invisible-prehydration/
62+
test/wdio/test-ts-target-output

src/client/client-host-ref.ts

+77-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BUILD } from '@app-data';
2+
import { MEMBER_FLAGS } from '@utils/constants';
23

34
import type * as d from '../declarations';
45

@@ -35,10 +36,13 @@ export const getHostRef = (ref: d.RuntimeRef): d.HostRef | undefined => hostRefs
3536
*
3637
* @param lazyInstance the lazy instance of interest
3738
* @param hostRef that instances `HostRef` object
38-
* @returns a reference to the host ref WeakMap
3939
*/
40-
export const registerInstance = (lazyInstance: any, hostRef: d.HostRef) =>
40+
export const registerInstance = (lazyInstance: any, hostRef: d.HostRef) => {
4141
hostRefs.set((hostRef.$lazyInstance$ = lazyInstance), hostRef);
42+
if (BUILD.modernPropertyDecls && (BUILD.state || BUILD.prop)) {
43+
reWireGetterSetter(lazyInstance, hostRef);
44+
}
45+
};
4246

4347
/**
4448
* Register a host element for a Stencil component, setting up various metadata
@@ -67,7 +71,77 @@ export const registerHost = (hostElement: d.HostElement, cmpMeta: d.ComponentRun
6771
hostElement['s-p'] = [];
6872
hostElement['s-rc'] = [];
6973
}
70-
return hostRefs.set(hostElement, hostRef);
74+
75+
const ref = hostRefs.set(hostElement, hostRef);
76+
77+
if (!BUILD.lazyLoad && BUILD.modernPropertyDecls && (BUILD.state || BUILD.prop)) {
78+
reWireGetterSetter(hostElement, hostRef);
79+
}
80+
81+
return ref;
7182
};
7283

7384
export const isMemberInElement = (elm: any, memberName: string) => memberName in elm;
85+
86+
/**
87+
* - Re-wires component prototype `get` / `set` with instance `@State` / `@Prop` decorated fields.
88+
* - Makes sure the initial value from the `Element` is synced to the instance `@Prop` decorated fields.
89+
*
90+
* Background:
91+
* During component init, Stencil loops through any `@Prop()` or `@State()` decorated properties
92+
* and sets up getters and setters for each (within `src/runtime/proxy-component.ts`) on a component prototype.
93+
*
94+
* These accessors sync-up class instances with their `Element` and controls re-renders.
95+
* With modern JS, compiled classes (e.g. `target: 'es2022'`) compiled Stencil components went from:
96+
*
97+
* ```ts
98+
* class MyComponent {
99+
* constructor() {
100+
* this.prop1 = 'value1';
101+
* }
102+
* }
103+
* ```
104+
* To:
105+
* ```ts
106+
* class MyComponent {
107+
* prop1 = 'value2';
108+
* // ^^ These override the accessors originally set on the prototype
109+
* }
110+
* ```
111+
*
112+
* @param instance - class instance to re-wire
113+
* @param hostRef - component reference meta
114+
*/
115+
const reWireGetterSetter = (instance: any, hostRef: d.HostRef) => {
116+
const cmpMeta = hostRef.$cmpMeta$;
117+
const members = Object.entries(cmpMeta.$members$ ?? {});
118+
119+
members.map(([memberName, [memberFlags]]) => {
120+
if (
121+
BUILD.state &&
122+
BUILD.prop &&
123+
(memberFlags & MEMBER_FLAGS.Getter) === 0 &&
124+
(memberFlags & MEMBER_FLAGS.Prop || memberFlags & MEMBER_FLAGS.State)
125+
) {
126+
const ogValue = instance[memberName];
127+
128+
// Get the original Stencil prototype `get` / `set`
129+
const lazyDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(instance), memberName);
130+
131+
// Re-wire original accessors to the new instance
132+
Object.defineProperty(instance, memberName, {
133+
get() {
134+
return lazyDescriptor.get.call(this);
135+
},
136+
set(newValue) {
137+
lazyDescriptor.set.call(this, newValue);
138+
},
139+
configurable: true,
140+
enumerable: true,
141+
});
142+
instance[memberName] = hostRef.$instanceValues$.has(memberName)
143+
? hostRef.$instanceValues$.get(memberName)
144+
: ogValue;
145+
}
146+
});
147+
};

src/compiler/app-core/app-data.ts

+1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const getBuildFeatures = (cmps: ComponentCompilerMeta[]): BuildFeatures =
5050
member: cmps.some((c) => c.hasMember),
5151
method: cmps.some((c) => c.hasMethod),
5252
mode: cmps.some((c) => c.hasMode),
53+
modernPropertyDecls: cmps.some((c) => c.hasModernPropertyDecls),
5354
observeAttribute: cmps.some((c) => c.hasAttribute || c.hasWatchCallback),
5455
prop: cmps.some((c) => c.hasProp),
5556
propBoolean: cmps.some((c) => c.hasPropBoolean),

src/compiler/output-targets/dist-lazy/lazy-build-conditionals.ts

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const getLazyBuildConditionals = (
1818

1919
const hasHydrateOutputTargets = config.outputTargets.some(isOutputTargetHydrate);
2020
build.hydrateClientSide = hasHydrateOutputTargets;
21+
build.modernPropertyDecls = cmps.some((c) => c.hasModernPropertyDecls);
2122

2223
updateBuildConditionals(config, build);
2324

src/compiler/transformers/decorators-to-static/attach-internals.ts

+2-7
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,7 @@ import { buildError } from '@utils';
22
import ts from 'typescript';
33

44
import type * as d from '../../../declarations';
5-
import {
6-
convertValueToLiteral,
7-
createStaticGetter,
8-
retrieveTsDecorators,
9-
tsPropDeclNameAsString,
10-
} from '../transform-utils';
5+
import { convertValueToLiteral, createStaticGetter, retrieveTsDecorators, tsPropDeclName } from '../transform-utils';
116
import { isDecoratorNamed } from './decorator-utils';
127

138
/**
@@ -56,7 +51,7 @@ export const attachInternalsDecoratorsToStatic = (
5651

5752
const [decoratedProp] = attachInternalsMembers;
5853

59-
const name = tsPropDeclNameAsString(decoratedProp, typeChecker);
54+
const { staticName: name } = tsPropDeclName(decoratedProp, typeChecker);
6055

6156
newMembers.push(createStaticGetter('attachInternalsMemberName', convertValueToLiteral(name)));
6257
};

src/compiler/transformers/decorators-to-static/convert-decorators.ts

+14-201
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,11 @@ import { augmentDiagnosticWithNode, buildError } from '@utils';
22
import ts from 'typescript';
33

44
import type * as d from '../../../declarations';
5-
import {
6-
retrieveTsDecorators,
7-
retrieveTsModifiers,
8-
tsPropDeclNameAsString,
9-
updateConstructor,
10-
} from '../transform-utils';
5+
import { retrieveTsDecorators, retrieveTsModifiers, updateConstructor } from '../transform-utils';
116
import { attachInternalsDecoratorsToStatic } from './attach-internals';
127
import { componentDecoratorToStatic } from './component-decorator';
138
import { isDecoratorNamed } from './decorator-utils';
14-
import {
15-
CLASS_DECORATORS_TO_REMOVE,
16-
CONSTRUCTOR_DEFINED_MEMBER_DECORATORS,
17-
MEMBER_DECORATORS_TO_REMOVE,
18-
} from './decorators-constants';
9+
import { CLASS_DECORATORS_TO_REMOVE, MEMBER_DECORATORS_TO_REMOVE } from './decorators-constants';
1910
import { elementDecoratorsToStatic } from './element-decorator';
2011
import { eventDecoratorsToStatic } from './event-decorator';
2112
import { ImportAliasMap } from './import-alias-map';
@@ -163,14 +154,11 @@ const visitClassDeclaration = (
163154
);
164155
}
165156

166-
// We call the `handleClassFields` method which handles transforming any
167-
// class fields, removing them from the class and adding statements to the
168-
// class' constructor which instantiate them there instead.
169-
const updatedClassFields = handleClassFields(classNode, filteredMethodsAndFields, typeChecker, importAliasMap);
170-
171157
validateMethods(diagnostics, classMembers);
172158

173159
const currentDecorators = retrieveTsDecorators(classNode);
160+
const updatedClassFields: ts.ClassElement[] = updateConstructor(classNode, filteredMethodsAndFields, []);
161+
174162
return ts.factory.updateClassDeclaration(
175163
classNode,
176164
[
@@ -232,7 +220,7 @@ const removeStencilMethodDecorators = (
232220
} else if (ts.isGetAccessor(member)) {
233221
return ts.factory.updateGetAccessorDeclaration(
234222
member,
235-
ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined,
223+
[...(newDecorators ?? []), ...(retrieveTsModifiers(member) ?? [])],
236224
member.name,
237225
member.parameters,
238226
member.type,
@@ -243,25 +231,15 @@ const removeStencilMethodDecorators = (
243231
err.messageText = 'A get accessor should be decorated before a set accessor';
244232
augmentDiagnosticWithNode(err, member);
245233
} else if (ts.isPropertyDeclaration(member)) {
246-
if (shouldInitializeInConstructor(member, importAliasMap)) {
247-
// if the current class member is decorated with either 'State' or
248-
// 'Prop' we need to modify the property declaration to transform it
249-
// from a class field but we handle this in the `handleClassFields`
250-
// method below, so we just want to return the class member here
251-
// untouched.
252-
return member;
253-
} else {
254-
// update the property to remove decorators
255-
const modifiers = retrieveTsModifiers(member);
256-
return ts.factory.updatePropertyDeclaration(
257-
member,
258-
[...(newDecorators ?? []), ...(modifiers ?? [])],
259-
member.name,
260-
member.questionToken,
261-
member.type,
262-
member.initializer,
263-
);
264-
}
234+
const modifiers = retrieveTsModifiers(member);
235+
return ts.factory.updatePropertyDeclaration(
236+
member,
237+
[...(newDecorators ?? []), ...(modifiers ?? [])],
238+
member.name,
239+
member.questionToken,
240+
member.type,
241+
member.initializer,
242+
);
265243
} else {
266244
const err = buildError(diagnostics);
267245
err.messageText = 'Unknown class member encountered!';
@@ -309,168 +287,3 @@ export const filterDecorators = (
309287
// return the node's original decorators, or undefined
310288
return decorators;
311289
};
312-
313-
/**
314-
* This updates a Stencil component class declaration AST node to handle any
315-
* class fields with Stencil-specific decorators (`@State`, `@Prop`, etc). For
316-
* reasons explained below, we need to remove these fields from the class and
317-
* add code to the class's constructor to instantiate them manually.
318-
*
319-
* When a class field is decorated with a Stencil-defined decorator, we rely on
320-
* defining our own setters and getters (using `Object.defineProperty`) to
321-
* implement the behavior we want. Unfortunately, in ES2022 and newer versions
322-
* of the EcmaScript standard the behavior for class fields like the following
323-
* is incompatible with using manually-defined getters and setters:
324-
*
325-
* ```ts
326-
* class MyClass {
327-
* foo = "bar"
328-
* }
329-
* ```
330-
*
331-
* In ES2022+ if we try to use `Object.defineProperty` on this class's
332-
* prototype in order to define a `set` and `get` function for the
333-
* property `foo` it will not override the default behavior of the
334-
* instance field `foo`, so doing something like the following:
335-
*
336-
* ```ts
337-
* Object.defineProperty(MyClass.prototype, "foo", {
338-
* get() {
339-
* return "Foo is: " + this.foo
340-
* }
341-
* });
342-
* ```
343-
*
344-
* and then calling `myClassInstance.foo` will _not_ return `"Foo is: bar"` but
345-
* just `"bar"`. This is because the standard ECMAScript behavior is now to use
346-
* the internals of `Object.defineProperty` on a class instance to instantiate
347-
* fields, and that call at instantiation-time overrides what's set on the
348-
* prototype. For details, see the accepted ECMAScript proposal for this
349-
* behavior:
350-
*
351-
* https://github.com/tc39/proposal-class-fields#public-fields-created-with-objectdefineproperty
352-
*
353-
* Why is this important? With `target` set to an ECMAScript version prior to
354-
* ES2022 TypeScript by default would emit a class which instantiated the field
355-
* in its constructor, something like this:
356-
*
357-
* ```ts
358-
* class CompiledMyClass {
359-
* constructor() {
360-
* this.foo = "bar"
361-
* }
362-
* }
363-
* ```
364-
*
365-
* This plays nicely with later using `Object.defineProperty` on the prototype
366-
* to define getters and setters, or simply with defining them right on the
367-
* class (see the code in `proxyComponent`, `proxyCustomElement`, and friends).
368-
*
369-
* However, with a `target` of ES2022 or higher (e.g. `ESNext`) default
370-
* behavior for TypeScript is instead to emit code like this:
371-
*
372-
* ```ts
373-
* class CompiledMyClass {
374-
* foo = "bar"
375-
* }
376-
* ```
377-
*
378-
* This output is more correct because the compiled code 1) more closely
379-
* resembles the TypeScript source and 2) is using standard JS syntax instead
380-
* of desugaring it. There is an announcement in the release notes for
381-
* TypeScript v3.7 which explains some helpful background about the change,
382-
* and about the `useDefineForClassFields` TypeScript option which lets you
383-
* opt-in to the old output:
384-
*
385-
* https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#the-usedefineforclassfields-flag-and-the-declare-property-modifier
386-
*
387-
* For our use-case, however, the ES2022+ behavior doesn't work, since we need
388-
* to be able to define getters and setters on these fields. We could require
389-
* that the TypeScript configuration used for Stencil have the
390-
* `useDefineForClassFields` setting set to `false`, but that would have the
391-
* undesirable side-effect that class fields which are _not_
392-
* decorated with a Stencil decorator would also be instantiated in the
393-
* constructor.
394-
*
395-
* So instead, we take matters into our own hands. When we encounter a class
396-
* field which is decorated with a Stencil decorator we remove it from the
397-
* class and add a statement to the constructor to instantiate it with the
398-
* correct default value.
399-
*
400-
* **Note**: this function will modify a constructor if one is already present on
401-
* the class or define a new one otherwise.
402-
*
403-
* @param classNode a TypeScript AST node for a Stencil component class
404-
* @param classMembers the class members that we need to update
405-
* @param typeChecker a reference to the {@link ts.TypeChecker}
406-
* @param importAliasMap a map of Stencil decorator names to their import names
407-
* @returns a list of updated class elements which can be inserted into the class
408-
*/
409-
function handleClassFields(
410-
classNode: ts.ClassDeclaration,
411-
classMembers: ts.ClassElement[],
412-
typeChecker: ts.TypeChecker,
413-
importAliasMap: ImportAliasMap,
414-
): ts.ClassElement[] {
415-
const statements: ts.ExpressionStatement[] = [];
416-
const updatedClassMembers: ts.ClassElement[] = [];
417-
418-
for (const member of classMembers) {
419-
if (shouldInitializeInConstructor(member, importAliasMap) && ts.isPropertyDeclaration(member)) {
420-
const memberName = tsPropDeclNameAsString(member, typeChecker);
421-
422-
// this is a class field that we'll need to handle, so lets push a statement for
423-
// initializing the value onto our statements list
424-
statements.push(
425-
ts.factory.createExpressionStatement(
426-
ts.factory.createBinaryExpression(
427-
ts.factory.createPropertyAccessExpression(ts.factory.createThis(), ts.factory.createIdentifier(memberName)),
428-
ts.factory.createToken(ts.SyntaxKind.EqualsToken),
429-
// if the member has no initializer we should default to setting it to
430-
// just 'undefined'
431-
member.initializer ?? ts.factory.createIdentifier('undefined'),
432-
),
433-
),
434-
);
435-
} else {
436-
// if it's not a class field that is decorated with a Stencil decorator then
437-
// we just push it onto our class member list
438-
updatedClassMembers.push(member);
439-
}
440-
}
441-
442-
if (statements.length === 0) {
443-
// we didn't encounter any class fields we need to update, so we can
444-
// just return the list of class members (no need to create an empty
445-
// constructor)
446-
return updatedClassMembers;
447-
} else {
448-
// create or update a constructor which contains the initializing statements
449-
// we created above
450-
return updateConstructor(classNode, updatedClassMembers, statements);
451-
}
452-
}
453-
454-
/**
455-
* Check whether a given class element should be rewritten from a class field
456-
* to a constructor-initialized value. This is basically the case for fields
457-
* decorated with `@Prop` and `@State`. See {@link handleClassFields} for more
458-
* details.
459-
*
460-
* @param member the member to check
461-
* @param importAliasMap a map of Stencil decorator names to their import names
462-
* @returns whether this should be rewritten or not
463-
*/
464-
const shouldInitializeInConstructor = (member: ts.ClassElement, importAliasMap: ImportAliasMap): boolean => {
465-
const currentDecorators = retrieveTsDecorators(member);
466-
if (currentDecorators === undefined) {
467-
// decorators have already been removed from this element, indicating that
468-
// we don't need to do anything
469-
return false;
470-
}
471-
const filteredDecorators = filterDecorators(
472-
currentDecorators,
473-
CONSTRUCTOR_DEFINED_MEMBER_DECORATORS.map((decorator) => importAliasMap.get(decorator)),
474-
);
475-
return currentDecorators !== filteredDecorators;
476-
};

0 commit comments

Comments
 (0)