Final-form subscribe callback called for no reason at mount
Hi there !
I'm trying to component-test an input field that would be decorated by a connector for final-form.
However, it looks like a final-form function ( subscribe ) is call during mount for no reason.
Here's the code for my test
import { test, expect } from '@sand4rt/experimental-ct-web';
import { ControlledInput } from './ControlledInput.element';
import { createForm } from './final-form/Form';
test.describe('Controlled input', () => {
test('render props', async ({ mount }) => {
const form = createForm({
initialValues: { value: '' },
onSubmit: () => {},
});
const component = await mount(ControlledInput, {
props: {
label: 'Label',
descriptiveText: 'Descriptive text',
form: form as any,
name: 'value',
},
});
await expect(component).toContainText('Label');
await expect(component).toContainText('Descriptive text');
});
});
That's the component itself
import {css, html, LitElement, PropertyValues} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {
InputType,
PaymentFormFieldValueFormatter,
} from './types';
import {FieldState, FormApi} from 'final-form';
import {FinalFormController} from './final-form/RegisterFieldDecorator';
function showValidState(state: FieldState<Record<string, any>[string]>) {
if (!state.dirty){
return null;
}
if(!state.valid){
return false;
}
if(state.valid){
return true;
}
return state.active;
}
@customElement('controlled-input')
export class ControlledInput extends LitElement {
@state()
inhibateValidation = false;
@property()
// @ts-expect-error
form!: FormApi;
@property()
shell = false;
@property()
label: string | undefined = undefined;
@property()
descriptiveText: string | undefined = undefined;
@property()
placeholder: string | undefined = undefined;
@property()
type: InputType = 'text';
@property()
name?: string;
@property({type: 'string', reflect: true})
validationMode: 'onblur' | 'onchange' = 'onchange';
@property()
formatter: PaymentFormFieldValueFormatter = (v: string | null) => v;
// @ts-expect-error
#controller: FinalFormController<any>;
protected override firstUpdated(_changedProperties: PropertyValues) {
super.firstUpdated(_changedProperties);
try {
this.#controller = new FinalFormController(
this,
this.form,
this.formatter
);
} catch (e) {
console.log(e);
}
}
protected override updated(_changedProperties: PropertyValues) {
super.updated(_changedProperties);
if (_changedProperties.has('form')) {
this.dispatchEvent(
new CustomEvent('onregister', {detail: this.#controller.form})
);
}
}
static override get styles() {
return css`
[....]
`;
}
override render() {
console.log({controller : this.#controller});
if (!this.#controller) return html``;
console.log({controller : this.#controller});
const {register, form} = this.#controller;
const state = form.getFieldState(this.name ?? '');
const dataValid = showValidState(state ?? {} as any);
const ariaInvalid = !state?.active && state?.dirty && !!state?.error;
const afterText = (ariaInvalid && state?.error) || this.descriptiveText;
const descriptiveText = html`
<p
class="${dataValid ? 'valid' : ''} ${state?.dirty && state?.error
? 'error'
: ''}"
>
${afterText}
</p>
`;
if (this.shell) {
return html`
<div class="basic-container">
<input type="hidden" ${register(this.name ?? '')} />
<label for="${this.id}">${this.label}</label>
<span
id="${this.id}"
class="${this.className} ${state?.active ? 'focused' : ''}"
aria-invalid="${ariaInvalid}"
data-valid="${dataValid}"
>
<slot></slot>
</span>
${descriptiveText}
</div>
`;
}
if (this.type === 'checkbox') {
return html`
<div class="inline-container">
<input
id="${this.id}"
class="${this.className} ${state?.active ? 'focused' : ''}"
placeholder="${this.placeholder}"
type="checkbox"
aria-invalid="${ariaInvalid}"
data-valid="${dataValid}"
${register(this.name ?? '')}
/>
<label for="${this.id}"> ${afterText} </label>
</div>
`;
}
//
return html`
<div class="basic-container">
<label for="${this.id}">${this.label}</label>
<input
id="${this.id}"
class="${this.className} ${state?.active ? 'focused' : ''}"
placeholder="${this.placeholder}"
type="${this.type}"
aria-invalid="${ariaInvalid}"
data-valid="${dataValid}"
${register(this.name ?? '')}
/>
${afterText}
</div>
`;
}
}
and that's the decorator
import {
noChange,
nothing,
ReactiveController,
ReactiveControllerHost,
} from 'lit';
import {
Directive,
directive,
ElementPart,
PartInfo,
PartType,
} from 'lit/directive.js';
import {
FieldConfig,
FormApi,
FormSubscription,
formSubscriptionItems,
Unsubscribe,
} from 'final-form';
import {PaymentFormFieldValueFormatter} from '../types';
export type {Config} from 'final-form';
const allFormSubscriptionItems = formSubscriptionItems.reduce(
(acc, item) => ((acc[item as keyof FormSubscription] = true), acc),
{} as FormSubscription
);
export class FinalFormController<FormValues> implements ReactiveController {
#host: ReactiveControllerHost;
#unsubscribe: Unsubscribe | null = null;
form: FormApi<FormValues>;
formatter?: PaymentFormFieldValueFormatter;
// https://final-form.org/docs/final-form/types/Config
constructor(
host: ReactiveControllerHost,
formApi: FormApi<FormValues>,
formatter?: PaymentFormFieldValueFormatter
) {
this.form = formApi;
this.formatter = formatter;
(this.#host = host).addController(this);
}
hostConnected() {
try {
this.#unsubscribe = this.form.subscribe(() => {
this.#host.requestUpdate();
}, allFormSubscriptionItems);
}
catch (e){
console.warn("Subscribe failed for some reason",e);
}
}
hostUpdate() {}
hostDisconnected() {
this.#unsubscribe?.();
}
// https://final-form.org/docs/final-form/types/FieldConfig
register = <K extends keyof FormValues>(
name: K,
fieldConfig?: FieldConfig<FormValues[K]>
) => {
console.log(`Registering ${name}`);
try {
return registerDirective(this.form, name, fieldConfig, this.formatter);
}
catch (e){
console.warn(e);
throw e;
}
};
}
class RegisterDirective extends Directive {
#registered = false;
constructor(partInfo: PartInfo) {
super(partInfo);
if (partInfo.type !== PartType.ELEMENT) {
throw new Error(
'The `register` directive must be used in the `element` attribute'
);
}
}
override update(
part: ElementPart,
[form, name, fieldConfig, formatter]: Parameters<this['render']>
) {
if (!this.#registered) {
form.registerField(
name,
(fieldState) => {
const {blur, change, focus, value} = fieldState;
const el = part.element as HTMLInputElement | HTMLSelectElement;
el.name = String(name);
if (!this.#registered) {
el.addEventListener('blur', () => blur());
el.addEventListener('input', (event) => {
if (el.type === 'checkbox') {
change((event.target as HTMLInputElement).checked);
} else {
let newValue = (event.target as HTMLInputElement).value;
if (!event.type.includes('deleteContent') && formatter) {
newValue = formatter(newValue) ?? '';
}
change(newValue);
}
});
el.addEventListener('focus', () => focus());
}
// initial values sync
if (el.type === 'checkbox') {
(el as HTMLInputElement).checked = value === true;
} else {
el.value = value === undefined ? '' : value;
}
},
{value: true},
fieldConfig
);
this.#registered = true;
}
return noChange;
}
// Can't get generics carried over from directive call
render(
_form: FormApi<any>,
_name: PropertyKey,
_fieldConfig?: FieldConfig<any>,
_flormatter?: PaymentFormFieldValueFormatter
) {
return nothing;
}
}
const registerDirective = directive(RegisterDirective);
The decorator is working fine in a browser (largely inspired by https://github.com/lit/lit/discussions/2489#discussioncomment-6105401)
I'm definitely not expert neither in playwright nor lit-elements, but i don't see why the form.subscribe function would be called on mount by this piece of code
Any idea? Cheers
At first glance, createForm might cause an issue if it contains classes or functions. Do you have a minimal reproducible GitHub repo i could run and debug __?
Sure, i stripped everything from my repo and public published it
https://github.com/cdevos-purse/experimental-ct-final-form-bug
run playwright via npm run test:e2e:dev -- -c playwright-ct.config.ts --debug
Hi, did you have the time to take a look at it by any chance?
Cheers,
Hi, thanks for sharing the repo. I looked into the issue, and it appears to be related to your code rather than the library itself. Unfortunately, i don't have the resources to look into this any further
Fair enough, we'll try to dig deeper on this if we get the chance.