Skip to content

Commit

Permalink
feat(compiler, runtime): add support for form-associated elements (#4784
Browse files Browse the repository at this point in the history
)

This adds support for building form-associated custom elements in
Stencil components, allowing Stencil components to participate in HTML
forms in a rich manner. This is a popular request in the Stencil
community (see #2284).

The new form-association technology is exposed to the component author
via the following changes:

- A new option called `formAssociated` has been added to the
  [`ComponentOptions`](https://github.com/ionic-team/stencil/blob/06f6fad174c32b270ce239afab5002c23d30ccbc/src/declarations/stencil-public-runtime.ts#L10-L55)
  interface.
- A new `@AttachInternals()` decorator can be used to indicate a
  property on a Stencil component to which an
  [`ElementInternals`](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals)
  object will be bound at runtime.
- Using `@AttachInternals()` is supported both for lazy builds
  ([`www`](https://stenciljs.com/docs/www),
  [`dist`](https://stenciljs.com/docs/distribution)) as well as for
  [`dist-custom-elements`](https://stenciljs.com/docs/custom-elements).

The new behavior is implemented at compile-time, and so should result in
only very minimal increases in code / bundle size. Support exists for
using form-associated components in both the lazy and the CE output
targets, as well as some extremely minimal provisions for testing.

Documentation for this feature was added to the Stencil site here:
ionic-team/stencil-site#1247
  • Loading branch information
alicewriteswrongs committed Oct 16, 2023
1 parent b1dd4ac commit 5976c9b
Show file tree
Hide file tree
Showing 38 changed files with 794 additions and 56 deletions.
1 change: 1 addition & 0 deletions src/app-data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const BUILD: BuildConditionals = {
cssAnnotations: true,
state: true,
style: true,
formAssociated: false,
svg: true,
updatable: true,
vdomAttribute: true,
Expand Down
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 @@ -32,6 +32,7 @@ export const getBuildFeatures = (cmps: ComponentCompilerMeta[]): BuildFeatures =
cmpWillLoad: cmps.some((c) => c.hasComponentWillLoadFn),
cmpWillUpdate: cmps.some((c) => c.hasComponentWillUpdateFn),
cmpWillRender: cmps.some((c) => c.hasComponentWillRenderFn),
formAssociated: cmps.some((c) => c.formAssociated),

connectedCallback: cmps.some((c) => c.hasConnectedCallbackFn),
disconnectedCallback: cmps.some((c) => c.hasDisconnectedCallbackFn),
Expand Down
34 changes: 27 additions & 7 deletions src/compiler/optimize/optimize-module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import sourceMapMerge from 'merge-source-map';
import type { CompressOptions, MangleOptions, MinifyOptions, SourceMapOptions } from 'terser';
import type { CompressOptions, MangleOptions, ManglePropertiesOptions, MinifyOptions, SourceMapOptions } from 'terser';
import ts from 'typescript';

import type { CompilerCtx, Config, OptimizeJsResult, SourceMap, SourceTarget } from '../../declarations';
Expand Down Expand Up @@ -87,8 +87,8 @@ export const optimizeModule = async (
}

mangleOptions.properties = {
regex: '^\\$.+\\$$',
debug: isDebug,
...getTerserManglePropertiesConfig(),
};

compressOpts.inline = 1;
Expand Down Expand Up @@ -135,12 +135,12 @@ export const getTerserOptions = (config: Config, sourceTarget: SourceTarget, pre
if (sourceTarget === 'es5') {
opts.ecma = opts.format.ecma = 5;
opts.compress = false;
opts.mangle = true;
opts.mangle = {
properties: getTerserManglePropertiesConfig(),
};
} else {
opts.mangle = {
properties: {
regex: '^\\$.+\\$$',
},
properties: getTerserManglePropertiesConfig(),
};
opts.compress = {
pure_getters: true,
Expand All @@ -158,7 +158,10 @@ export const getTerserOptions = (config: Config, sourceTarget: SourceTarget, pre
}

if (prettyOutput) {
opts.mangle = { keep_fnames: true };
opts.mangle = {
keep_fnames: true,
properties: getTerserManglePropertiesConfig(),
};
opts.compress = {};
opts.compress.drop_console = false;
opts.compress.drop_debugger = false;
Expand All @@ -171,6 +174,23 @@ export const getTerserOptions = (config: Config, sourceTarget: SourceTarget, pre
return opts;
};

/**
* Get baseline configuration for the 'properties' option for terser's mangle
* configuration.
*
* @returns an object with our baseline property mangling configuration
*/
function getTerserManglePropertiesConfig(): ManglePropertiesOptions {
const options = {
regex: '^\\$.+\\$$',
// we need to reserve this name so that it can be accessed on `hostRef`
// at runtime
reserved: ['$hostElement$'],
} satisfies ManglePropertiesOptions;

return options;
}

/**
* This method is likely to be called by a worker on the compiler context, rather than directly.
* @param input the source code to minify
Expand Down
5 changes: 3 additions & 2 deletions src/compiler/output-targets/dist-custom-elements/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,9 @@ export const generateEntryPoint = (
};

/**
* Get the series of custom transformers that will be applied to a Stencil project's source code during the TypeScript
* transpilation process
* Get the series of custom transformers, specific to the needs of the
* `dist-custom-elements` output target, that will be applied to a Stencil
* project's source code during the TypeScript transpilation process
*
* @param config the configuration for the Stencil project
* @param compilerCtx the current compiler context
Expand Down
3 changes: 2 additions & 1 deletion src/compiler/transformers/component-build-conditionals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export const setComponentBuildConditionals = (cmpMeta: d.ComponentCompilerMeta)
cmpMeta.hasListenerTarget = cmpMeta.listeners.some((l) => !!l.target);
}

cmpMeta.hasMember = cmpMeta.hasProp || cmpMeta.hasState || cmpMeta.hasElement || cmpMeta.hasMethod;
cmpMeta.hasMember =
cmpMeta.hasProp || cmpMeta.hasState || cmpMeta.hasElement || cmpMeta.hasMethod || cmpMeta.formAssociated;

cmpMeta.isUpdateable = cmpMeta.hasProp || cmpMeta.hasState;
if (cmpMeta.styles.length > 0) {
Expand Down
151 changes: 151 additions & 0 deletions src/compiler/transformers/component-lazy/attach-internals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type * as d from '@stencil/core/declarations';
import ts from 'typescript';

import { HOST_REF_ARG } from './constants';

/**
* Create a binding for an `ElementInternals` object compatible with a
* lazy-load ready Stencil component.
*
* In order to create a lazy-loaded form-associated component we need to access
* the underlying host element (via the "$hostElement$" prop on {@link d.HostRef})
* to make the `attachInternals` call on the right element. This means that the
* code generated by this function depends on there being a variable in scope
* called {@link HOST_REF_ARG} with type {@link HTMLElement}.
*
* If an `@AttachInternals` decorator is present on a component like this:
*
* ```ts
* @AttachInternals()
* internals: ElementInternals;
* ```
*
* then this transformer will create syntax nodes which represent the
* following TypeScript source:
*
* ```ts
* if (hostRef.$hostElement$["s-ei"]) {
* this.internals = hostRef.$hostElement$["s-ei"];
* } else {
* this.internals = hostRef.$hostElement$.attachInternals();
* hostRef.$hostElement$["s-ei"] = this.internals;
* }
* ```
*
* The `"s-ei"` prop on a {@link d.HostElement} may hold a reference to the
* `ElementInternals` instance for that host. We store a reference to it
* there in order to support HMR because `.attachInternals` may only be
* called on an `HTMLElement` one time, so we need to store a reference to
* the returned value across HMR updates.
*
* @param cmp metadata about the component of interest, gathered during compilation
* @returns a list of expression statements
*/
export function createLazyAttachInternalsBinding(cmp: d.ComponentCompilerMeta): ts.Statement[] {
if (cmp.formAssociated && cmp.attachInternalsMemberName) {
return [
ts.factory.createIfStatement(
// the condition for the `if` statement here is just whether the
// following is defined:
//
// ```ts
// hostRef.$hostElement$["s-ei"]
// ```
hostRefElementInternalsPropAccess(),
ts.factory.createBlock(
[
// this `ts.factory` call creates the following statement:
//
// ```ts
// this.${ cmp.formInternalsMemberName } = hostRef.$hostElement$['s-ei'];
// ```
ts.factory.createExpressionStatement(
ts.factory.createBinaryExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createThis(),
// use the name set on the {@link d.ComponentCompilerMeta}
ts.factory.createIdentifier(cmp.attachInternalsMemberName),
),
ts.factory.createToken(ts.SyntaxKind.EqualsToken),
hostRefElementInternalsPropAccess(),
),
),
],
true,
),
ts.factory.createBlock(
[
// this `ts.factory` call creates the following statement:
//
// ```ts
// this.${ cmp.attachInternalsMemberName } = hostRef.$hostElement$.attachInternals();
// ```
ts.factory.createExpressionStatement(
ts.factory.createBinaryExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createThis(),
// use the name set on the {@link d.ComponentCompilerMeta}
ts.factory.createIdentifier(cmp.attachInternalsMemberName),
),
ts.factory.createToken(ts.SyntaxKind.EqualsToken),
ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier(HOST_REF_ARG),
ts.factory.createIdentifier('$hostElement$'),
),
ts.factory.createIdentifier('attachInternals'),
),
undefined,
[],
),
),
),
// this `ts.factory` call produces the following:
//
// ```ts
// hostRef.$hostElement$['s-ei'] = this.${ cmp.attachInternalsMemberName };
// ```
ts.factory.createExpressionStatement(
ts.factory.createBinaryExpression(
hostRefElementInternalsPropAccess(),
ts.factory.createToken(ts.SyntaxKind.EqualsToken),
ts.factory.createPropertyAccessExpression(
ts.factory.createThis(),
// use the name set on the {@link d.ComponentCompilerMeta}
ts.factory.createIdentifier(cmp.attachInternalsMemberName),
),
),
),
],
true,
),
),
];
} else {
return [];
}
}

/**
* Create TS syntax nodes which represent accessing the `"s-ei"` (stencil
* element internals) property on `$hostElement$` (a {@link d.HostElement}) on a
* {@link d.HostRef} element which is called {@link HOST_REF_ARG}.
*
* The corresponding TypeScript source will look like:
*
* ```ts
* hostRef.$hostElement$["s-ei"]
* ```
*
* @returns TS syntax nodes
*/
function hostRefElementInternalsPropAccess(): ts.ElementAccessExpression {
return ts.factory.createElementAccessExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier(HOST_REF_ARG),
ts.factory.createIdentifier('$hostElement$'),
),
ts.factory.createStringLiteral('s-ei'),
);
}
5 changes: 5 additions & 0 deletions src/compiler/transformers/component-lazy/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Used to create an identifier for an argument to the constructor of a
* transformed, lazy-build specific class.
*/
export const HOST_REF_ARG = 'hostRef';
10 changes: 7 additions & 3 deletions src/compiler/transformers/component-lazy/lazy-constructor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type * as d from '../../../declarations';
import { addCoreRuntimeApi, REGISTER_INSTANCE, RUNTIME_APIS } from '../core-runtime-apis';
import { addCreateEvents } from '../create-event';
import { updateConstructor } from '../transform-utils';
import { createLazyAttachInternalsBinding } from './attach-internals';
import { HOST_REF_ARG } from './constants';

/**
* Update the constructor for a Stencil component's class in order to prepare
Expand All @@ -24,7 +26,11 @@ export const updateLazyComponentConstructor = (
ts.factory.createParameterDeclaration(undefined, undefined, ts.factory.createIdentifier(HOST_REF_ARG)),
];

const cstrStatements = [registerInstanceStatement(moduleFile), ...addCreateEvents(moduleFile, cmp)];
const cstrStatements = [
registerInstanceStatement(moduleFile),
...addCreateEvents(moduleFile, cmp),
...createLazyAttachInternalsBinding(cmp),
];

updateConstructor(classNode, classMembers, cstrStatements, cstrMethodArgs);
};
Expand All @@ -50,5 +56,3 @@ const registerInstanceStatement = (moduleFile: d.Module): ts.ExpressionStatement
]),
);
};

const HOST_REF_ARG = 'hostRef';
57 changes: 57 additions & 0 deletions src/compiler/transformers/component-native/attach-internals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type * as d from '@stencil/core/declarations';
import ts from 'typescript';

/**
* Create a binding for an `ElementInternals` object compatible with a 'native'
* component (i.e. one which extends `HTMLElement` and is distributed as a
* standalone custom element).
*
* Since a 'native' custom element will extend `HTMLElement` we can call
* `this.attachInternals` directly, binding it to the name annotated by the
* developer with the `@AttachInternals` decorator.
*
* Thus if an `@AttachInternals` decorator is present on a component like
* this:
*
* ```ts
* @AttachInternals()
* internals: ElementInternals;
* ```
*
* then this transformer will emit TS syntax nodes representing the
* following TypeScript source code:
*
* ```ts
* this.internals = this.attachInternals();
* ```
*
* @param cmp metadata about the component of interest, gathered during
* compilation
* @returns an expression statement syntax tree node
*/
export function createNativeAttachInternalsBinding(cmp: d.ComponentCompilerMeta): ts.ExpressionStatement[] {
if (cmp.formAssociated && cmp.attachInternalsMemberName) {
return [
ts.factory.createExpressionStatement(
ts.factory.createBinaryExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createThis(),
// use the name set on the {@link d.ComponentCompilerMeta}
ts.factory.createIdentifier(cmp.attachInternalsMemberName),
),
ts.factory.createToken(ts.SyntaxKind.EqualsToken),
ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createThis(),
ts.factory.createIdentifier('attachInternals'),
),
undefined,
[],
),
),
),
];
} else {
return [];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ts from 'typescript';
import type * as d from '../../../declarations';
import { addCreateEvents } from '../create-event';
import { updateConstructor } from '../transform-utils';
import { createNativeAttachInternalsBinding } from './attach-internals';

/**
* Updates a constructor to include:
Expand All @@ -28,8 +29,11 @@ export const updateNativeConstructor = (
return;
}

const nativeCstrStatements = [...nativeInit(cmp), ...addCreateEvents(moduleFile, cmp)];

const nativeCstrStatements: ts.Statement[] = [
...nativeInit(cmp),
...addCreateEvents(moduleFile, cmp),
...createNativeAttachInternalsBinding(cmp),
];
updateConstructor(classNode, classMembers, nativeCstrStatements);
};

Expand Down
Loading

0 comments on commit 5976c9b

Please sign in to comment.