Skip to content

Commit dffb49d

Browse files
dwirzDominique Wirzchristian-bromann
authored
fix: correctly call proxied formAssociated callbacks (#6046)
Co-authored-by: Dominique Wirz <[email protected]> Co-authored-by: Christian Bromann <[email protected]>
1 parent 6331d9a commit dffb49d

File tree

10 files changed

+167
-11
lines changed

10 files changed

+167
-11
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ unused-exports*.txt
5555

5656
# wdio test output
5757
test/wdio/test-components
58+
test/wdio/test-components-no-external-runtime
5859
test/wdio/www-global-script/
5960
test/wdio/www-prerender-script
6061
test/wdio/www-invisible-prehydration/

src/runtime/proxy-component.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,24 +29,25 @@ export const proxyComponent = (
2929
* @ref https://web.dev/articles/more-capable-form-controls#lifecycle_callbacks
3030
*/
3131
if (BUILD.formAssociated && cmpMeta.$flags$ & CMP_FLAGS.formAssociated && flags & PROXY_FLAGS.isElementConstructor) {
32-
FORM_ASSOCIATED_CUSTOM_ELEMENT_CALLBACKS.forEach((cbName) =>
32+
FORM_ASSOCIATED_CUSTOM_ELEMENT_CALLBACKS.forEach((cbName) => {
33+
const originalFormAssociatedCallback = prototype[cbName];
3334
Object.defineProperty(prototype, cbName, {
3435
value(this: d.HostElement, ...args: any[]) {
3536
const hostRef = getHostRef(this);
36-
const elm = BUILD.lazyLoad ? hostRef.$hostElement$ : this;
37-
const instance: d.ComponentInterface = BUILD.lazyLoad ? hostRef.$lazyInstance$ : elm;
37+
const instance: d.ComponentInterface = BUILD.lazyLoad ? hostRef.$lazyInstance$ : this;
3838
if (!instance) {
39-
hostRef.$onReadyPromise$.then((instance: d.ComponentInterface) => {
40-
const cb = instance[cbName];
41-
typeof cb === 'function' && cb.call(instance, ...args);
39+
hostRef.$onReadyPromise$.then((asyncInstance: d.ComponentInterface) => {
40+
const cb = asyncInstance[cbName];
41+
typeof cb === 'function' && cb.call(asyncInstance, ...args);
4242
});
4343
} else {
44-
const cb = instance[cbName];
44+
// Use the method on `instance` if `lazyLoad` is set, otherwise call the original method to avoid an infinite loop.
45+
const cb = BUILD.lazyLoad ? instance[cbName] : originalFormAssociatedCallback;
4546
typeof cb === 'function' && cb.call(instance, ...args);
4647
}
4748
},
48-
}),
49-
);
49+
});
50+
});
5051
}
5152

5253
if ((BUILD.member && cmpMeta.$members$) || (BUILD.watchCallback && (cmpMeta.$watchers$ || Cstr.watchers))) {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Config } from '../../internal/index.js';
2+
3+
export const config: Config = {
4+
namespace: 'TestNoExternalRuntimeApp',
5+
tsconfig: 'tsconfig-no-external-runtime.json',
6+
outputTargets: [
7+
{
8+
type: 'dist-custom-elements',
9+
dir: 'test-components-no-external-runtime',
10+
externalRuntime: false,
11+
includeGlobalScripts: false,
12+
},
13+
],
14+
srcDir: 'no-external-runtime',
15+
};
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/* eslint-disable */
2+
/* tslint:disable */
3+
/**
4+
* This is an autogenerated file created by the Stencil compiler.
5+
* It contains typing information for all components that exist in this project.
6+
*/
7+
import { HTMLStencilElement, JSXBase } from "@stencil/core/internal";
8+
export namespace Components {
9+
interface CustomElementsFormAssociated {
10+
}
11+
}
12+
declare global {
13+
interface HTMLCustomElementsFormAssociatedElement extends Components.CustomElementsFormAssociated, HTMLStencilElement {
14+
}
15+
var HTMLCustomElementsFormAssociatedElement: {
16+
prototype: HTMLCustomElementsFormAssociatedElement;
17+
new (): HTMLCustomElementsFormAssociatedElement;
18+
};
19+
interface HTMLElementTagNameMap {
20+
"custom-elements-form-associated": HTMLCustomElementsFormAssociatedElement;
21+
}
22+
}
23+
declare namespace LocalJSX {
24+
interface CustomElementsFormAssociated {
25+
}
26+
interface IntrinsicElements {
27+
"custom-elements-form-associated": CustomElementsFormAssociated;
28+
}
29+
}
30+
export { LocalJSX as JSX };
31+
declare module "@stencil/core" {
32+
export namespace JSX {
33+
interface IntrinsicElements {
34+
"custom-elements-form-associated": LocalJSX.CustomElementsFormAssociated & JSXBase.HTMLAttributes<HTMLCustomElementsFormAssociatedElement>;
35+
}
36+
}
37+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { h } from '@stencil/core';
2+
import { render } from '@wdio/browser-runner/stencil';
3+
4+
import { defineCustomElement } from '../../test-components-no-external-runtime/custom-elements-form-associated.js';
5+
6+
describe('custome elements form associated', function () {
7+
beforeEach(() => {
8+
defineCustomElement();
9+
render({
10+
template: () => (
11+
<form>
12+
<custom-elements-form-associated name="test-input"></custom-elements-form-associated>
13+
<input type="reset" value="Reset" />
14+
</form>
15+
),
16+
});
17+
});
18+
19+
it('should render without errors', async () => {
20+
const elm = $('custom-elements-form-associated');
21+
await expect(elm).toBePresent();
22+
});
23+
24+
describe('form associated custom element lifecycle callback', () => {
25+
it('should trigger "formAssociated"', async () => {
26+
const formEl = $('form');
27+
await expect(formEl).toHaveProperty('ariaLabel', 'asdfasdf');
28+
});
29+
30+
it('should trigger "formResetCallback"', async () => {
31+
const resetBtn = $('input[type="reset"]');
32+
await resetBtn.click();
33+
34+
await resetBtn.waitForStable();
35+
36+
const formEl = $('form');
37+
await expect(formEl).toHaveProperty('ariaLabel', 'formResetCallback called');
38+
});
39+
40+
it('should trigger "formDisabledCallback"', async () => {
41+
const elm = document.body.querySelector('custom-elements-form-associated');
42+
const formEl = $('form');
43+
44+
elm.setAttribute('disabled', 'disabled');
45+
46+
await formEl.waitForStable();
47+
await expect(formEl).toHaveProperty('ariaLabel', 'formDisabledCallback called with true');
48+
49+
elm.removeAttribute('disabled');
50+
await formEl.waitForStable();
51+
await expect(formEl).toHaveProperty('ariaLabel', 'formDisabledCallback called with false');
52+
});
53+
});
54+
55+
it('should link up to the surrounding form', async () => {
56+
// this shows that the element has, through the `ElementInternals`
57+
// interface, been able to set a value in the surrounding form
58+
await browser.waitUntil(
59+
async () => {
60+
const formEl = document.body.querySelector('form');
61+
expect(new FormData(formEl).get('test-input')).toBe('my default value');
62+
return true;
63+
},
64+
{ timeoutMsg: 'form associated value never changed' },
65+
);
66+
});
67+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { AttachInternals, Component, h } from '@stencil/core';
2+
3+
@Component({
4+
tag: 'custom-elements-form-associated',
5+
formAssociated: true,
6+
shadow: true,
7+
})
8+
export class CustomElementsFormAssociated {
9+
@AttachInternals() internals: ElementInternals;
10+
11+
componentWillLoad() {
12+
this.internals.setFormValue('my default value');
13+
}
14+
15+
formAssociatedCallback(form: HTMLCustomElementsFormAssociatedElement) {
16+
form.ariaLabel = 'formAssociated called';
17+
// this is a regression test for #5106 which ensures that `this` is
18+
// resolved correctly
19+
this.internals.setValidity({});
20+
}
21+
22+
render() {
23+
return <input type="text" />;
24+
}
25+
}

test/wdio/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
"type": "module",
44
"version": "0.0.0",
55
"scripts": {
6-
"build": "run-s build.test-sibling build.main build.global-script build.prerender build.invisible-prehydration",
6+
"build": "run-s build.no-external-runtime build.test-sibling build.main build.global-script build.prerender build.invisible-prehydration",
77
"build.main": "node ../../bin/stencil build --debug --es5",
88
"build.global-script": "node ../../bin/stencil build --debug --es5 --config global-script.stencil.config.ts",
99
"build.test-sibling": "cd test-sibling && npm run build",
1010
"build.prerender": "node ../../bin/stencil build --config prerender.stencil.config.ts --prerender --debug && node ./test-prerender/prerender.js && node ./test-prerender/no-script-build.js",
1111
"build.invisible-prehydration": "node ../../bin/stencil build --debug --es5 --config invisible-prehydration.stencil.config.ts",
12+
"build.no-external-runtime": "node ../../bin/stencil build --debug --es5 --config no-external-runtime.stencil.config.ts",
1213
"test": "run-s build wdio",
1314
"wdio": "wdio run ./wdio.conf.ts"
1415
},

test/wdio/setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const testRequiresManualSetup =
1313
window.__wdioSpec__.includes('custom-elements-output-tag-class-different') ||
1414
window.__wdioSpec__.includes('custom-elements-delegates-focus') ||
1515
window.__wdioSpec__.includes('custom-elements-output') ||
16+
window.__wdioSpec__.includes('no-external-runtime') ||
1617
window.__wdioSpec__.includes('global-script') ||
1718
window.__wdioSpec__.endsWith('custom-tag-name.test.tsx') ||
1819
window.__wdioSpec__.endsWith('page-list.test.ts');
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"extends": "./tsconfig-stencil.json",
3+
"include": ["no-external-runtime"],
4+
"exclude": ["no-external-runtime/**/*.test.tsx"]
5+
}

test/wdio/tsconfig-stencil.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838
"./test-sibling/**/*.tsx",
3939
// we also exclude the files in the invisible-prehydration directory
4040
"./invisible-prehydration/**/*.tsx",
41-
"./invisible-prehydration/**/*.ts"
41+
"./invisible-prehydration/**/*.ts",
42+
// exclude no-external-runtime because they are built separately with `externalRuntime: false`
43+
"./no-external-runtime/**/*.tsx",
44+
"./no-external-runtime/**/*.ts",
4245
]
4346
}

0 commit comments

Comments
 (0)