Skip to content

Commit

Permalink
fix: correctly call proxied formAssociated callbacks
Browse files Browse the repository at this point in the history
  • Loading branch information
Dominique Wirz committed Nov 11, 2024
1 parent 4cabb30 commit 4fdbee0
Show file tree
Hide file tree
Showing 10 changed files with 167 additions and 11 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ unused-exports*.txt

# wdio test output
test/wdio/test-components
test/wdio/test-components-no-external-runtime
test/wdio/www-global-script/
test/wdio/www-prerender-script
test/wdio/www-invisible-prehydration/
19 changes: 10 additions & 9 deletions src/runtime/proxy-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,25 @@ export const proxyComponent = (
* @ref https://web.dev/articles/more-capable-form-controls#lifecycle_callbacks
*/
if (BUILD.formAssociated && cmpMeta.$flags$ & CMP_FLAGS.formAssociated && flags & PROXY_FLAGS.isElementConstructor) {
FORM_ASSOCIATED_CUSTOM_ELEMENT_CALLBACKS.forEach((cbName) =>
FORM_ASSOCIATED_CUSTOM_ELEMENT_CALLBACKS.forEach((cbName) => {
const originalFormAssociatedCallback = prototype[cbName];
Object.defineProperty(prototype, cbName, {
value(this: d.HostElement, ...args: any[]) {
const hostRef = getHostRef(this);
const elm = BUILD.lazyLoad ? hostRef.$hostElement$ : this;
const instance: d.ComponentInterface = BUILD.lazyLoad ? hostRef.$lazyInstance$ : elm;
const instance: d.ComponentInterface = BUILD.lazyLoad ? hostRef.$lazyInstance$ : this;
if (!instance) {
hostRef.$onReadyPromise$.then((instance: d.ComponentInterface) => {
const cb = instance[cbName];
typeof cb === 'function' && cb.call(instance, ...args);
hostRef.$onReadyPromise$.then((asyncInstance: d.ComponentInterface) => {
const cb = asyncInstance[cbName];
typeof cb === 'function' && cb.call(asyncInstance, ...args);
});
} else {
const cb = instance[cbName];
// Use the method on `instance` if `lazyLoad` is set, otherwise call the original method to avoid an infinite loop.
const cb = BUILD.lazyLoad ? instance[cbName] : originalFormAssociatedCallback;
typeof cb === 'function' && cb.call(instance, ...args);
}
},
}),
);
});
});
}

if ((BUILD.member && cmpMeta.$members$) || (BUILD.watchCallback && (cmpMeta.$watchers$ || Cstr.watchers))) {
Expand Down
15 changes: 15 additions & 0 deletions test/wdio/no-external-runtime.stencil.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Config } from '../../internal/index.js';

export const config: Config = {
namespace: 'TestNoExternalRuntimeApp',
tsconfig: 'tsconfig-no-external-runtime.json',
outputTargets: [
{
type: 'dist-custom-elements',
dir: 'test-components-no-external-runtime',
externalRuntime: false,
includeGlobalScripts: false,
},
],
srcDir: 'no-external-runtime',
};
37 changes: 37 additions & 0 deletions test/wdio/no-external-runtime/components.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/* eslint-disable */
/* tslint:disable */
/**
* This is an autogenerated file created by the Stencil compiler.
* It contains typing information for all components that exist in this project.
*/
import { HTMLStencilElement, JSXBase } from "@stencil/core/internal";
export namespace Components {
interface CustomElementsFormAssociated {
}
}
declare global {
interface HTMLCustomElementsFormAssociatedElement extends Components.CustomElementsFormAssociated, HTMLStencilElement {
}
var HTMLCustomElementsFormAssociatedElement: {
prototype: HTMLCustomElementsFormAssociatedElement;
new (): HTMLCustomElementsFormAssociatedElement;
};
interface HTMLElementTagNameMap {
"custom-elements-form-associated": HTMLCustomElementsFormAssociatedElement;
}
}
declare namespace LocalJSX {
interface CustomElementsFormAssociated {
}
interface IntrinsicElements {
"custom-elements-form-associated": CustomElementsFormAssociated;
}
}
export { LocalJSX as JSX };
declare module "@stencil/core" {
export namespace JSX {
interface IntrinsicElements {
"custom-elements-form-associated": LocalJSX.CustomElementsFormAssociated & JSXBase.HTMLAttributes<HTMLCustomElementsFormAssociatedElement>;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { h } from '@stencil/core';
import { render } from '@wdio/browser-runner/stencil';

import { defineCustomElement } from '../../test-components-no-external-runtime/custom-elements-form-associated.js';

describe('custome elements form associated', function () {
beforeEach(() => {
defineCustomElement();
render({
template: () => (
<form>
<custom-elements-form-associated name="test-input"></custom-elements-form-associated>
<input type="reset" value="Reset" />
</form>
),
});
});

it('should render without errors', async () => {
const elm = $('custom-elements-form-associated');
await expect(elm).toBePresent();
});

describe('form associated custom element lifecycle callback', () => {
it('should trigger "formAssociated"', async () => {
const formEl = $('form');
await expect(formEl).toHaveProperty('ariaLabel', 'asdfasdf');
});

it('should trigger "formResetCallback"', async () => {
const resetBtn = $('input[type="reset"]');
await resetBtn.click();

await resetBtn.waitForStable();

const formEl = $('form');
await expect(formEl).toHaveProperty('ariaLabel', 'formResetCallback called');
});

it('should trigger "formDisabledCallback"', async () => {
const elm = document.body.querySelector('custom-elements-form-associated');
const formEl = $('form');

elm.setAttribute('disabled', 'disabled');

await formEl.waitForStable();
await expect(formEl).toHaveProperty('ariaLabel', 'formDisabledCallback called with true');

elm.removeAttribute('disabled');
await formEl.waitForStable();
await expect(formEl).toHaveProperty('ariaLabel', 'formDisabledCallback called with false');
});
});

it('should link up to the surrounding form', async () => {
// this shows that the element has, through the `ElementInternals`
// interface, been able to set a value in the surrounding form
await browser.waitUntil(
async () => {
const formEl = document.body.querySelector('form');
expect(new FormData(formEl).get('test-input')).toBe('my default value');
return true;
},
{ timeoutMsg: 'form associated value never changed' },
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { AttachInternals, Component, h } from '@stencil/core';

@Component({
tag: 'custom-elements-form-associated',
formAssociated: true,
shadow: true,
})
export class CustomElementsFormAssociated {
@AttachInternals() internals: ElementInternals;

componentWillLoad() {
this.internals.setFormValue('my default value');
}

formAssociatedCallback(form: HTMLCustomElementsFormAssociatedElement) {
form.ariaLabel = 'formAssociated called';
// this is a regression test for #5106 which ensures that `this` is
// resolved correctly
this.internals.setValidity({});
}

render() {
return <input type="text" />;
}
}
3 changes: 2 additions & 1 deletion test/wdio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
"type": "module",
"version": "0.0.0",
"scripts": {
"build": "run-s build.test-sibling build.main build.global-script build.prerender build.invisible-prehydration",
"build": "run-s build.no-external-runtime build.test-sibling build.main build.global-script build.prerender build.invisible-prehydration",
"build.main": "node ../../bin/stencil build --debug --es5",
"build.global-script": "node ../../bin/stencil build --debug --es5 --config global-script.stencil.config.ts",
"build.test-sibling": "cd test-sibling && npm run build",
"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",
"build.invisible-prehydration": "node ../../bin/stencil build --debug --es5 --config invisible-prehydration.stencil.config.ts",
"build.no-external-runtime": "node ../../bin/stencil build --debug --es5 --config no-external-runtime.stencil.config.ts",
"test": "run-s build wdio",
"wdio": "wdio run ./wdio.conf.ts"
},
Expand Down
1 change: 1 addition & 0 deletions test/wdio/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const testRequiresManualSetup =
window.__wdioSpec__.includes('custom-elements-output-tag-class-different') ||
window.__wdioSpec__.includes('custom-elements-delegates-focus') ||
window.__wdioSpec__.includes('custom-elements-output') ||
window.__wdioSpec__.includes('no-external-runtime') ||
window.__wdioSpec__.includes('global-script') ||
window.__wdioSpec__.endsWith('custom-tag-name.test.tsx') ||
window.__wdioSpec__.endsWith('page-list.test.ts');
Expand Down
5 changes: 5 additions & 0 deletions test/wdio/tsconfig-no-external-runtime.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig-stencil.json",
"include": ["no-external-runtime"],
"exclude": ["no-external-runtime/**/*.test.tsx"]
}
5 changes: 4 additions & 1 deletion test/wdio/tsconfig-stencil.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
"./test-sibling/**/*.tsx",
// we also exclude the files in the invisible-prehydration directory
"./invisible-prehydration/**/*.tsx",
"./invisible-prehydration/**/*.ts"
"./invisible-prehydration/**/*.ts",
// exclude no-external-runtime because they are built separately with `externalRuntime: false`
"./no-external-runtime/**/*.tsx",
"./no-external-runtime/**/*.ts",
]
}

0 comments on commit 4fdbee0

Please sign in to comment.