Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
cdevos-purse opened this issue Nov 22, 2024 · 5 comments
Open

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

cdevos-purse opened this issue Nov 22, 2024 · 5 comments
Labels
bug Something isn't working not confirmed

Comments

@cdevos-purse
Copy link

cdevos-purse commented Nov 22, 2024

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

@cdevos-purse cdevos-purse added the bug Something isn't working label Nov 22, 2024
@sand4rt
Copy link
Owner

sand4rt commented Nov 22, 2024

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 __?

@cdevos-purse
Copy link
Author

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

@cdevos-purse
Copy link
Author

Hi, did you have the time to take a look at it by any chance?

Cheers,

@sand4rt
Copy link
Owner

sand4rt commented Nov 27, 2024

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

@cdevos-purse
Copy link
Author

cdevos-purse commented Nov 27, 2024

Fair enough, we'll try to dig deeper on this if we get the chance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working not confirmed
Projects
None yet
Development

No branches or pull requests

2 participants