Skip to content

Commit d196b66

Browse files
feat(compiler, runtime): add support for form-associated elements
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). A minimal Stencil component that uses the new APIs to integrate with a form could look like this: ```tsx import { Component, h, AttachInternals } from '@stencil/core'; \@component({ tag: 'my-component', styleUrl: 'my-component.css', shadow: true, formAssociated: true }) export class MyComponent { @AttachInternals() internals: ElementInternals; render() { return <div>Hello, World!</div>; } } ``` The new form-association technology is exposed to the - 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: stenciljs/site#1247
1 parent b1dd4ac commit d196b66

38 files changed

+794
-56
lines changed

src/app-data/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const BUILD: BuildConditionals = {
5656
cssAnnotations: true,
5757
state: true,
5858
style: true,
59+
formAssociated: false,
5960
svg: true,
6061
updatable: true,
6162
vdomAttribute: true,

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

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const getBuildFeatures = (cmps: ComponentCompilerMeta[]): BuildFeatures =
3232
cmpWillLoad: cmps.some((c) => c.hasComponentWillLoadFn),
3333
cmpWillUpdate: cmps.some((c) => c.hasComponentWillUpdateFn),
3434
cmpWillRender: cmps.some((c) => c.hasComponentWillRenderFn),
35+
formAssociated: cmps.some((c) => c.formAssociated),
3536

3637
connectedCallback: cmps.some((c) => c.hasConnectedCallbackFn),
3738
disconnectedCallback: cmps.some((c) => c.hasDisconnectedCallbackFn),

src/compiler/optimize/optimize-module.ts

+27-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import sourceMapMerge from 'merge-source-map';
2-
import type { CompressOptions, MangleOptions, MinifyOptions, SourceMapOptions } from 'terser';
2+
import type { CompressOptions, MangleOptions, ManglePropertiesOptions, MinifyOptions, SourceMapOptions } from 'terser';
33
import ts from 'typescript';
44

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

8989
mangleOptions.properties = {
90-
regex: '^\\$.+\\$$',
9190
debug: isDebug,
91+
...getTerserManglePropertiesConfig(),
9292
};
9393

9494
compressOpts.inline = 1;
@@ -135,12 +135,12 @@ export const getTerserOptions = (config: Config, sourceTarget: SourceTarget, pre
135135
if (sourceTarget === 'es5') {
136136
opts.ecma = opts.format.ecma = 5;
137137
opts.compress = false;
138-
opts.mangle = true;
138+
opts.mangle = {
139+
properties: getTerserManglePropertiesConfig(),
140+
};
139141
} else {
140142
opts.mangle = {
141-
properties: {
142-
regex: '^\\$.+\\$$',
143-
},
143+
properties: getTerserManglePropertiesConfig(),
144144
};
145145
opts.compress = {
146146
pure_getters: true,
@@ -158,7 +158,10 @@ export const getTerserOptions = (config: Config, sourceTarget: SourceTarget, pre
158158
}
159159

160160
if (prettyOutput) {
161-
opts.mangle = { keep_fnames: true };
161+
opts.mangle = {
162+
keep_fnames: true,
163+
properties: getTerserManglePropertiesConfig(),
164+
};
162165
opts.compress = {};
163166
opts.compress.drop_console = false;
164167
opts.compress.drop_debugger = false;
@@ -171,6 +174,23 @@ export const getTerserOptions = (config: Config, sourceTarget: SourceTarget, pre
171174
return opts;
172175
};
173176

177+
/**
178+
* Get baseline configuration for the 'properties' option for terser's mangle
179+
* configuration.
180+
*
181+
* @returns an object with our baseline property mangling configuration
182+
*/
183+
function getTerserManglePropertiesConfig(): ManglePropertiesOptions {
184+
const options = {
185+
regex: '^\\$.+\\$$',
186+
// we need to reserve this name so that it can be accessed on `hostRef`
187+
// at runtime
188+
reserved: ['$hostElement$'],
189+
} satisfies ManglePropertiesOptions;
190+
191+
return options;
192+
}
193+
174194
/**
175195
* This method is likely to be called by a worker on the compiler context, rather than directly.
176196
* @param input the source code to minify

src/compiler/output-targets/dist-custom-elements/index.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,9 @@ export const generateEntryPoint = (
306306
};
307307

308308
/**
309-
* Get the series of custom transformers that will be applied to a Stencil project's source code during the TypeScript
310-
* transpilation process
309+
* Get the series of custom transformers, specific to the needs of the
310+
* `dist-custom-elements` output target, that will be applied to a Stencil
311+
* project's source code during the TypeScript transpilation process
311312
*
312313
* @param config the configuration for the Stencil project
313314
* @param compilerCtx the current compiler context

src/compiler/transformers/component-build-conditionals.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ export const setComponentBuildConditionals = (cmpMeta: d.ComponentCompilerMeta)
3838
cmpMeta.hasListenerTarget = cmpMeta.listeners.some((l) => !!l.target);
3939
}
4040

41-
cmpMeta.hasMember = cmpMeta.hasProp || cmpMeta.hasState || cmpMeta.hasElement || cmpMeta.hasMethod;
41+
cmpMeta.hasMember =
42+
cmpMeta.hasProp || cmpMeta.hasState || cmpMeta.hasElement || cmpMeta.hasMethod || cmpMeta.formAssociated;
4243

4344
cmpMeta.isUpdateable = cmpMeta.hasProp || cmpMeta.hasState;
4445
if (cmpMeta.styles.length > 0) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type * as d from '@stencil/core/declarations';
2+
import ts from 'typescript';
3+
4+
import { HOST_REF_ARG } from './constants';
5+
6+
/**
7+
* Create a binding for an `ElementInternals` object compatible with a
8+
* lazy-load ready Stencil component.
9+
*
10+
* In order to create a lazy-loaded form-associated component we need to access
11+
* the underlying host element (via the "$hostElement$" prop on {@link d.HostRef})
12+
* to make the `attachInternals` call on the right element. This means that the
13+
* code generated by this function depends on there being a variable in scope
14+
* called {@link HOST_REF_ARG} with type {@link HTMLElement}.
15+
*
16+
* If an `@AttachInternals` decorator is present on a component like this:
17+
*
18+
* ```ts
19+
* @AttachInternals()
20+
* internals: ElementInternals;
21+
* ```
22+
*
23+
* then this transformer will create syntax nodes which represent the
24+
* following TypeScript source:
25+
*
26+
* ```ts
27+
* if (hostRef.$hostElement$["s-ei"]) {
28+
* this.internals = hostRef.$hostElement$["s-ei"];
29+
* } else {
30+
* this.internals = hostRef.$hostElement$.attachInternals();
31+
* hostRef.$hostElement$["s-ei"] = this.internals;
32+
* }
33+
* ```
34+
*
35+
* The `"s-ei"` prop on a {@link d.HostElement} may hold a reference to the
36+
* `ElementInternals` instance for that host. We store a reference to it
37+
* there in order to support HMR because `.attachInternals` may only be
38+
* called on an `HTMLElement` one time, so we need to store a reference to
39+
* the returned value across HMR updates.
40+
*
41+
* @param cmp metadata about the component of interest, gathered during compilation
42+
* @returns a list of expression statements
43+
*/
44+
export function createLazyAttachInternalsBinding(cmp: d.ComponentCompilerMeta): ts.Statement[] {
45+
if (cmp.formAssociated && cmp.attachInternalsMemberName) {
46+
return [
47+
ts.factory.createIfStatement(
48+
// the condition for the `if` statement here is just whether the
49+
// following is defined:
50+
//
51+
// ```ts
52+
// hostRef.$hostElement$["s-ei"]
53+
// ```
54+
hostRefElementInternalsPropAccess(),
55+
ts.factory.createBlock(
56+
[
57+
// this `ts.factory` call creates the following statement:
58+
//
59+
// ```ts
60+
// this.${ cmp.formInternalsMemberName } = hostRef.$hostElement$['s-ei'];
61+
// ```
62+
ts.factory.createExpressionStatement(
63+
ts.factory.createBinaryExpression(
64+
ts.factory.createPropertyAccessExpression(
65+
ts.factory.createThis(),
66+
// use the name set on the {@link d.ComponentCompilerMeta}
67+
ts.factory.createIdentifier(cmp.attachInternalsMemberName),
68+
),
69+
ts.factory.createToken(ts.SyntaxKind.EqualsToken),
70+
hostRefElementInternalsPropAccess(),
71+
),
72+
),
73+
],
74+
true,
75+
),
76+
ts.factory.createBlock(
77+
[
78+
// this `ts.factory` call creates the following statement:
79+
//
80+
// ```ts
81+
// this.${ cmp.attachInternalsMemberName } = hostRef.$hostElement$.attachInternals();
82+
// ```
83+
ts.factory.createExpressionStatement(
84+
ts.factory.createBinaryExpression(
85+
ts.factory.createPropertyAccessExpression(
86+
ts.factory.createThis(),
87+
// use the name set on the {@link d.ComponentCompilerMeta}
88+
ts.factory.createIdentifier(cmp.attachInternalsMemberName),
89+
),
90+
ts.factory.createToken(ts.SyntaxKind.EqualsToken),
91+
ts.factory.createCallExpression(
92+
ts.factory.createPropertyAccessExpression(
93+
ts.factory.createPropertyAccessExpression(
94+
ts.factory.createIdentifier(HOST_REF_ARG),
95+
ts.factory.createIdentifier('$hostElement$'),
96+
),
97+
ts.factory.createIdentifier('attachInternals'),
98+
),
99+
undefined,
100+
[],
101+
),
102+
),
103+
),
104+
// this `ts.factory` call produces the following:
105+
//
106+
// ```ts
107+
// hostRef.$hostElement$['s-ei'] = this.${ cmp.attachInternalsMemberName };
108+
// ```
109+
ts.factory.createExpressionStatement(
110+
ts.factory.createBinaryExpression(
111+
hostRefElementInternalsPropAccess(),
112+
ts.factory.createToken(ts.SyntaxKind.EqualsToken),
113+
ts.factory.createPropertyAccessExpression(
114+
ts.factory.createThis(),
115+
// use the name set on the {@link d.ComponentCompilerMeta}
116+
ts.factory.createIdentifier(cmp.attachInternalsMemberName),
117+
),
118+
),
119+
),
120+
],
121+
true,
122+
),
123+
),
124+
];
125+
} else {
126+
return [];
127+
}
128+
}
129+
130+
/**
131+
* Create TS syntax nodes which represent accessing the `"s-ei"` (stencil
132+
* element internals) property on `$hostElement$` (a {@link d.HostElement}) on a
133+
* {@link d.HostRef} element which is called {@link HOST_REF_ARG}.
134+
*
135+
* The corresponding TypeScript source will look like:
136+
*
137+
* ```ts
138+
* hostRef.$hostElement$["s-ei"]
139+
* ```
140+
*
141+
* @returns TS syntax nodes
142+
*/
143+
function hostRefElementInternalsPropAccess(): ts.ElementAccessExpression {
144+
return ts.factory.createElementAccessExpression(
145+
ts.factory.createPropertyAccessExpression(
146+
ts.factory.createIdentifier(HOST_REF_ARG),
147+
ts.factory.createIdentifier('$hostElement$'),
148+
),
149+
ts.factory.createStringLiteral('s-ei'),
150+
);
151+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/**
2+
* Used to create an identifier for an argument to the constructor of a
3+
* transformed, lazy-build specific class.
4+
*/
5+
export const HOST_REF_ARG = 'hostRef';

src/compiler/transformers/component-lazy/lazy-constructor.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type * as d from '../../../declarations';
44
import { addCoreRuntimeApi, REGISTER_INSTANCE, RUNTIME_APIS } from '../core-runtime-apis';
55
import { addCreateEvents } from '../create-event';
66
import { updateConstructor } from '../transform-utils';
7+
import { createLazyAttachInternalsBinding } from './attach-internals';
8+
import { HOST_REF_ARG } from './constants';
79

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

27-
const cstrStatements = [registerInstanceStatement(moduleFile), ...addCreateEvents(moduleFile, cmp)];
29+
const cstrStatements = [
30+
registerInstanceStatement(moduleFile),
31+
...addCreateEvents(moduleFile, cmp),
32+
...createLazyAttachInternalsBinding(cmp),
33+
];
2834

2935
updateConstructor(classNode, classMembers, cstrStatements, cstrMethodArgs);
3036
};
@@ -50,5 +56,3 @@ const registerInstanceStatement = (moduleFile: d.Module): ts.ExpressionStatement
5056
]),
5157
);
5258
};
53-
54-
const HOST_REF_ARG = 'hostRef';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type * as d from '@stencil/core/declarations';
2+
import ts from 'typescript';
3+
4+
/**
5+
* Create a binding for an `ElementInternals` object compatible with a 'native'
6+
* component (i.e. one which extends `HTMLElement` and is distributed as a
7+
* standalone custom element).
8+
*
9+
* Since a 'native' custom element will extend `HTMLElement` we can call
10+
* `this.attachInternals` directly, binding it to the name annotated by the
11+
* developer with the `@AttachInternals` decorator.
12+
*
13+
* Thus if an `@AttachInternals` decorator is present on a component like
14+
* this:
15+
*
16+
* ```ts
17+
* @AttachInternals()
18+
* internals: ElementInternals;
19+
* ```
20+
*
21+
* then this transformer will emit TS syntax nodes representing the
22+
* following TypeScript source code:
23+
*
24+
* ```ts
25+
* this.internals = this.attachInternals();
26+
* ```
27+
*
28+
* @param cmp metadata about the component of interest, gathered during
29+
* compilation
30+
* @returns an expression statement syntax tree node
31+
*/
32+
export function createNativeAttachInternalsBinding(cmp: d.ComponentCompilerMeta): ts.ExpressionStatement[] {
33+
if (cmp.formAssociated && cmp.attachInternalsMemberName) {
34+
return [
35+
ts.factory.createExpressionStatement(
36+
ts.factory.createBinaryExpression(
37+
ts.factory.createPropertyAccessExpression(
38+
ts.factory.createThis(),
39+
// use the name set on the {@link d.ComponentCompilerMeta}
40+
ts.factory.createIdentifier(cmp.attachInternalsMemberName),
41+
),
42+
ts.factory.createToken(ts.SyntaxKind.EqualsToken),
43+
ts.factory.createCallExpression(
44+
ts.factory.createPropertyAccessExpression(
45+
ts.factory.createThis(),
46+
ts.factory.createIdentifier('attachInternals'),
47+
),
48+
undefined,
49+
[],
50+
),
51+
),
52+
),
53+
];
54+
} else {
55+
return [];
56+
}
57+
}

src/compiler/transformers/component-native/native-constructor.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import ts from 'typescript';
33
import type * as d from '../../../declarations';
44
import { addCreateEvents } from '../create-event';
55
import { updateConstructor } from '../transform-utils';
6+
import { createNativeAttachInternalsBinding } from './attach-internals';
67

78
/**
89
* Updates a constructor to include:
@@ -28,8 +29,11 @@ export const updateNativeConstructor = (
2829
return;
2930
}
3031

31-
const nativeCstrStatements = [...nativeInit(cmp), ...addCreateEvents(moduleFile, cmp)];
32-
32+
const nativeCstrStatements: ts.Statement[] = [
33+
...nativeInit(cmp),
34+
...addCreateEvents(moduleFile, cmp),
35+
...createNativeAttachInternalsBinding(cmp),
36+
];
3337
updateConstructor(classNode, classMembers, nativeCstrStatements);
3438
};
3539

0 commit comments

Comments
 (0)