Skip to content

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

@cdevos-purse

Description

@cdevos-purse

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 lit/lit#2489 (comment))

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions