sand4rt / playwright-ct-web

Playwright Web component testing.
https://www.npmjs.com/package/@sand4rt/experimental-ct-web
MIT License
40 stars 3 forks source link

Final-form subscribe callback called for no reason at mount #76

Open cdevos-purse opened 7 hours ago

cdevos-purse commented 7 hours ago

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.

image

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

image

Any idea? Cheers