Skip to content
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

refactor: proxy component #6084

Draft
wants to merge 20 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,4 @@ test/wdio/test-components-no-external-runtime
test/wdio/www-global-script/
test/wdio/www-prerender-script
test/wdio/www-invisible-prehydration/
test/wdio/test-ts-target-output
80 changes: 77 additions & 3 deletions src/client/client-host-ref.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BUILD } from '@app-data';
import { MEMBER_FLAGS } from '@utils/constants';

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

Expand Down Expand Up @@ -35,10 +36,13 @@ export const getHostRef = (ref: d.RuntimeRef): d.HostRef | undefined => hostRefs
*
* @param lazyInstance the lazy instance of interest
* @param hostRef that instances `HostRef` object
* @returns a reference to the host ref WeakMap
*/
export const registerInstance = (lazyInstance: any, hostRef: d.HostRef) =>
export const registerInstance = (lazyInstance: any, hostRef: d.HostRef) => {
hostRefs.set((hostRef.$lazyInstance$ = lazyInstance), hostRef);
if (BUILD.modernPropertyDecls && (BUILD.state || BUILD.prop)) {
reWireGetterSetter(lazyInstance, hostRef);
}
};

/**
* Register a host element for a Stencil component, setting up various metadata
Expand Down Expand Up @@ -67,7 +71,77 @@ export const registerHost = (hostElement: d.HostElement, cmpMeta: d.ComponentRun
hostElement['s-p'] = [];
hostElement['s-rc'] = [];
}
return hostRefs.set(hostElement, hostRef);

const ref = hostRefs.set(hostElement, hostRef);

if (!BUILD.lazyLoad && BUILD.modernPropertyDecls && (BUILD.state || BUILD.prop)) {
reWireGetterSetter(hostElement, hostRef);
}

return ref;
};

export const isMemberInElement = (elm: any, memberName: string) => memberName in elm;

/**
* - Re-wires component prototype `get` / `set` with instance `@State` / `@Prop` decorated fields.
* - Makes sure the initial value from the `Element` is synced to the instance `@Prop` decorated fields.
*
* Background:
* During component init, Stencil loops through any `@Prop()` or `@State()` decorated properties
* and sets up getters and setters for each (within `src/runtime/proxy-component.ts`) on a component prototype.
*
* These accessors sync-up class instances with their `Element` and controls re-renders.
* With modern JS, compiled classes (e.g. `target: 'es2022'`) compiled Stencil components went from:
*
* ```ts
* class MyComponent {
* constructor() {
* this.prop1 = 'value1';
* }
* }
* ```
* To:
* ```ts
* class MyComponent {
* prop1 = 'value2';
* // ^^ These override the accessors originally set on the prototype
* }
* ```
*
* @param instance - class instance to re-wire
* @param hostRef - component reference meta
*/
const reWireGetterSetter = (instance: any, hostRef: d.HostRef) => {
const cmpMeta = hostRef.$cmpMeta$;
const members = Object.entries(cmpMeta.$members$ ?? {});

members.map(([memberName, [memberFlags]]) => {
if (
BUILD.state &&
BUILD.prop &&
(memberFlags & MEMBER_FLAGS.Getter) === 0 &&
(memberFlags & MEMBER_FLAGS.Prop || memberFlags & MEMBER_FLAGS.State)
) {
const ogValue = instance[memberName];

// Get the original Stencil prototype `get` / `set`
const lazyDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(instance), memberName);

// Re-wire original accessors to the new instance
Object.defineProperty(instance, memberName, {
get() {
return lazyDescriptor.get.call(this);
},
set(newValue) {
lazyDescriptor.set.call(this, newValue);
},
configurable: true,
enumerable: true,
});
instance[memberName] = hostRef.$instanceValues$.has(memberName)
? hostRef.$instanceValues$.get(memberName)
: ogValue;
}
});
};
1 change: 1 addition & 0 deletions src/compiler/app-core/app-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const getBuildFeatures = (cmps: ComponentCompilerMeta[]): BuildFeatures =
member: cmps.some((c) => c.hasMember),
method: cmps.some((c) => c.hasMethod),
mode: cmps.some((c) => c.hasMode),
modernPropertyDecls: cmps.some((c) => c.hasModernPropertyDecls),
observeAttribute: cmps.some((c) => c.hasAttribute || c.hasWatchCallback),
prop: cmps.some((c) => c.hasProp),
propBoolean: cmps.some((c) => c.hasPropBoolean),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const getLazyBuildConditionals = (

const hasHydrateOutputTargets = config.outputTargets.some(isOutputTargetHydrate);
build.hydrateClientSide = hasHydrateOutputTargets;
build.modernPropertyDecls = cmps.some((c) => c.hasModernPropertyDecls);

updateBuildConditionals(config, build);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@ import { buildError } from '@utils';
import ts from 'typescript';

import type * as d from '../../../declarations';
import {
convertValueToLiteral,
createStaticGetter,
retrieveTsDecorators,
tsPropDeclNameAsString,
} from '../transform-utils';
import { convertValueToLiteral, createStaticGetter, retrieveTsDecorators, tsPropDeclName } from '../transform-utils';
import { isDecoratorNamed } from './decorator-utils';

/**
Expand Down Expand Up @@ -56,7 +51,7 @@ export const attachInternalsDecoratorsToStatic = (

const [decoratedProp] = attachInternalsMembers;

const name = tsPropDeclNameAsString(decoratedProp, typeChecker);
const { staticName: name } = tsPropDeclName(decoratedProp, typeChecker);

newMembers.push(createStaticGetter('attachInternalsMemberName', convertValueToLiteral(name)));
};
215 changes: 14 additions & 201 deletions src/compiler/transformers/decorators-to-static/convert-decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,11 @@ import { augmentDiagnosticWithNode, buildError } from '@utils';
import ts from 'typescript';

import type * as d from '../../../declarations';
import {
retrieveTsDecorators,
retrieveTsModifiers,
tsPropDeclNameAsString,
updateConstructor,
} from '../transform-utils';
import { retrieveTsDecorators, retrieveTsModifiers, updateConstructor } from '../transform-utils';
import { attachInternalsDecoratorsToStatic } from './attach-internals';
import { componentDecoratorToStatic } from './component-decorator';
import { isDecoratorNamed } from './decorator-utils';
import {
CLASS_DECORATORS_TO_REMOVE,
CONSTRUCTOR_DEFINED_MEMBER_DECORATORS,
MEMBER_DECORATORS_TO_REMOVE,
} from './decorators-constants';
import { CLASS_DECORATORS_TO_REMOVE, MEMBER_DECORATORS_TO_REMOVE } from './decorators-constants';
import { elementDecoratorsToStatic } from './element-decorator';
import { eventDecoratorsToStatic } from './event-decorator';
import { ImportAliasMap } from './import-alias-map';
Expand Down Expand Up @@ -163,14 +154,11 @@ const visitClassDeclaration = (
);
}

// We call the `handleClassFields` method which handles transforming any
// class fields, removing them from the class and adding statements to the
// class' constructor which instantiate them there instead.
const updatedClassFields = handleClassFields(classNode, filteredMethodsAndFields, typeChecker, importAliasMap);

validateMethods(diagnostics, classMembers);

const currentDecorators = retrieveTsDecorators(classNode);
const updatedClassFields: ts.ClassElement[] = updateConstructor(classNode, filteredMethodsAndFields, []);

return ts.factory.updateClassDeclaration(
classNode,
[
Expand Down Expand Up @@ -232,7 +220,7 @@ const removeStencilMethodDecorators = (
} else if (ts.isGetAccessor(member)) {
return ts.factory.updateGetAccessorDeclaration(
member,
ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined,
[...(newDecorators ?? []), ...(retrieveTsModifiers(member) ?? [])],
member.name,
member.parameters,
member.type,
Expand All @@ -243,25 +231,15 @@ const removeStencilMethodDecorators = (
err.messageText = 'A get accessor should be decorated before a set accessor';
augmentDiagnosticWithNode(err, member);
} else if (ts.isPropertyDeclaration(member)) {
if (shouldInitializeInConstructor(member, importAliasMap)) {
// if the current class member is decorated with either 'State' or
// 'Prop' we need to modify the property declaration to transform it
// from a class field but we handle this in the `handleClassFields`
// method below, so we just want to return the class member here
// untouched.
return member;
} else {
// update the property to remove decorators
const modifiers = retrieveTsModifiers(member);
return ts.factory.updatePropertyDeclaration(
member,
[...(newDecorators ?? []), ...(modifiers ?? [])],
member.name,
member.questionToken,
member.type,
member.initializer,
);
}
const modifiers = retrieveTsModifiers(member);
return ts.factory.updatePropertyDeclaration(
member,
[...(newDecorators ?? []), ...(modifiers ?? [])],
member.name,
member.questionToken,
member.type,
member.initializer,
);
} else {
const err = buildError(diagnostics);
err.messageText = 'Unknown class member encountered!';
Expand Down Expand Up @@ -309,168 +287,3 @@ export const filterDecorators = (
// return the node's original decorators, or undefined
return decorators;
};

/**
* This updates a Stencil component class declaration AST node to handle any
* class fields with Stencil-specific decorators (`@State`, `@Prop`, etc). For
* reasons explained below, we need to remove these fields from the class and
* add code to the class's constructor to instantiate them manually.
*
* When a class field is decorated with a Stencil-defined decorator, we rely on
* defining our own setters and getters (using `Object.defineProperty`) to
* implement the behavior we want. Unfortunately, in ES2022 and newer versions
* of the EcmaScript standard the behavior for class fields like the following
* is incompatible with using manually-defined getters and setters:
*
* ```ts
* class MyClass {
* foo = "bar"
* }
* ```
*
* In ES2022+ if we try to use `Object.defineProperty` on this class's
* prototype in order to define a `set` and `get` function for the
* property `foo` it will not override the default behavior of the
* instance field `foo`, so doing something like the following:
*
* ```ts
* Object.defineProperty(MyClass.prototype, "foo", {
* get() {
* return "Foo is: " + this.foo
* }
* });
* ```
*
* and then calling `myClassInstance.foo` will _not_ return `"Foo is: bar"` but
* just `"bar"`. This is because the standard ECMAScript behavior is now to use
* the internals of `Object.defineProperty` on a class instance to instantiate
* fields, and that call at instantiation-time overrides what's set on the
* prototype. For details, see the accepted ECMAScript proposal for this
* behavior:
*
* https://github.com/tc39/proposal-class-fields#public-fields-created-with-objectdefineproperty
*
* Why is this important? With `target` set to an ECMAScript version prior to
* ES2022 TypeScript by default would emit a class which instantiated the field
* in its constructor, something like this:
*
* ```ts
* class CompiledMyClass {
* constructor() {
* this.foo = "bar"
* }
* }
* ```
*
* This plays nicely with later using `Object.defineProperty` on the prototype
* to define getters and setters, or simply with defining them right on the
* class (see the code in `proxyComponent`, `proxyCustomElement`, and friends).
*
* However, with a `target` of ES2022 or higher (e.g. `ESNext`) default
* behavior for TypeScript is instead to emit code like this:
*
* ```ts
* class CompiledMyClass {
* foo = "bar"
* }
* ```
*
* This output is more correct because the compiled code 1) more closely
* resembles the TypeScript source and 2) is using standard JS syntax instead
* of desugaring it. There is an announcement in the release notes for
* TypeScript v3.7 which explains some helpful background about the change,
* and about the `useDefineForClassFields` TypeScript option which lets you
* opt-in to the old output:
*
* https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#the-usedefineforclassfields-flag-and-the-declare-property-modifier
*
* For our use-case, however, the ES2022+ behavior doesn't work, since we need
* to be able to define getters and setters on these fields. We could require
* that the TypeScript configuration used for Stencil have the
* `useDefineForClassFields` setting set to `false`, but that would have the
* undesirable side-effect that class fields which are _not_
* decorated with a Stencil decorator would also be instantiated in the
* constructor.
*
* So instead, we take matters into our own hands. When we encounter a class
* field which is decorated with a Stencil decorator we remove it from the
* class and add a statement to the constructor to instantiate it with the
* correct default value.
*
* **Note**: this function will modify a constructor if one is already present on
* the class or define a new one otherwise.
*
* @param classNode a TypeScript AST node for a Stencil component class
* @param classMembers the class members that we need to update
* @param typeChecker a reference to the {@link ts.TypeChecker}
* @param importAliasMap a map of Stencil decorator names to their import names
* @returns a list of updated class elements which can be inserted into the class
*/
function handleClassFields(
classNode: ts.ClassDeclaration,
classMembers: ts.ClassElement[],
typeChecker: ts.TypeChecker,
importAliasMap: ImportAliasMap,
): ts.ClassElement[] {
const statements: ts.ExpressionStatement[] = [];
const updatedClassMembers: ts.ClassElement[] = [];

for (const member of classMembers) {
if (shouldInitializeInConstructor(member, importAliasMap) && ts.isPropertyDeclaration(member)) {
const memberName = tsPropDeclNameAsString(member, typeChecker);

// this is a class field that we'll need to handle, so lets push a statement for
// initializing the value onto our statements list
statements.push(
ts.factory.createExpressionStatement(
ts.factory.createBinaryExpression(
ts.factory.createPropertyAccessExpression(ts.factory.createThis(), ts.factory.createIdentifier(memberName)),
ts.factory.createToken(ts.SyntaxKind.EqualsToken),
// if the member has no initializer we should default to setting it to
// just 'undefined'
member.initializer ?? ts.factory.createIdentifier('undefined'),
),
),
);
} else {
// if it's not a class field that is decorated with a Stencil decorator then
// we just push it onto our class member list
updatedClassMembers.push(member);
}
}

if (statements.length === 0) {
// we didn't encounter any class fields we need to update, so we can
// just return the list of class members (no need to create an empty
// constructor)
return updatedClassMembers;
} else {
// create or update a constructor which contains the initializing statements
// we created above
return updateConstructor(classNode, updatedClassMembers, statements);
}
}

/**
* Check whether a given class element should be rewritten from a class field
* to a constructor-initialized value. This is basically the case for fields
* decorated with `@Prop` and `@State`. See {@link handleClassFields} for more
* details.
*
* @param member the member to check
* @param importAliasMap a map of Stencil decorator names to their import names
* @returns whether this should be rewritten or not
*/
const shouldInitializeInConstructor = (member: ts.ClassElement, importAliasMap: ImportAliasMap): boolean => {
const currentDecorators = retrieveTsDecorators(member);
if (currentDecorators === undefined) {
// decorators have already been removed from this element, indicating that
// we don't need to do anything
return false;
}
const filteredDecorators = filterDecorators(
currentDecorators,
CONSTRUCTOR_DEFINED_MEMBER_DECORATORS.map((decorator) => importAliasMap.get(decorator)),
);
return currentDecorators !== filteredDecorators;
};
Loading