deven-org / boiler

A fully customisable and tokenised design system boilerplate built by Accenture Song
https://boilerds.com/
MIT License
18 stars 7 forks source link

Explore state manipulation and signalling approaches for shared instance ownership #1085

Closed thrbnhrtmnn closed 4 months ago

thrbnhrtmnn commented 6 months ago

Context and Problem Statement

In this research task we should evaluate various methods of mutating the state of shared element instances via their public API and how these mutations are signalled to instance owners. This is especially relevant for scenarios where we use slots for component composition but it also has a general impact on how we design stateful components. Many aspects of this topic are similar to the design philosophy of controlled vs. uncontrolled components in react. The research should focus on evaluating different approaches to state manipulation and data flow in behavioural components, document the pros and cons, report the findings and initiate a team decision.

We can evaluate these different methods in the context of our radio-group component.

Slotting <blr-radio> elements into our <blr-radio-group> component makes the<blr-radio-group> a controlling component for any slotted <blr-radio> element. When one radio changes it's state to checked the other radio's inside the same group need to be un-checked. That means that we change the state of element instances which are not solely owned by us.

The following are some very rough ideas that come to mind and could be expanded on:

Direct State Manipulation vs. Signal Only vs. Mixed Mode

Direct State Manipulation

This approach assumes that the element instances which are slotted into our components by ui-library users can be manipulated by our components directly via their public API and only notify consuming parties after we made changes to an elements state. This is the default behaviour for many HTML input elements.

In this case we need to ensure that both: changes done through an element's public API and changes done to an element's state by an end user (eg: by clicking on it) can be traced in userland and vice versa so that no synchronisation issues occur.

Example of a synchronisation issue introduced into component land through userland manipulation:

  1. End user clicks on radio option-4.
  2. radio-group state is option-4. The radio-group knows this because it subscribed to the blrChange ui event for each singular option.
  3. A button in userland / application is programmed to set radio option-1 to checked when clicked.
  4. radio-group state is still option-4 since we only listen to ui events at the moment. The visual state of the radio-group is option-4 & option-1 checked.
  5. The state of the radio-group is now out of sync with userland code and the display state is out of synch for the end user.

To tackle this issue we need the ability to track changes made through an element's public API (similar to our ui events for end user interactions). This also needs to work the other way around.

Example of a synchronisation issue introduced into userland by the controlling component:

  1. Radio group state is option-1.
  2. Userland / application code subscribes to blrChange ui event of radio option-1.
  3. End user clicks on radio option-4.
  4. Radio group sets radio option-1 to unchecked through it's public API.
  5. Userland / application code is never notified of the change because it wasn't the end user that changed the state of option-1. It was a programmatic change done by our radio group.
  6. Userland / application code is now out of sync.

When settling for this approach we pose the risk of making changes to a component's state during async operations which could lead to race conditions.

Signal Only

This approach assumes that the state of slotted element instances as well as any other state of a component is never to be modified directly. Instead, when a controlling component wants to make a change, it signals it's intent to listeners further up the hierarchy. The owning party is then responsible to react to that intent and either mutate the state of any related components or ignore it - making our components effectively stateless.

Mixed Mode

This method would modify the process of change signalling proposed in Direct State Manipulation to such a degree that controlling components such as radio-group wouldn't just make changes to the state of shared element instances without consent.

Example:

  1. Radio group state is option-1
  2. End user clicks on radio option-4
  3. Radio group sends request to un-check radio option-1
  4. Userland code gets notified and applies or discards the proposed changes to radio option-1

A control-mode option on each controlling component could dictate the default behaviour of state change requests (assume-control, forfeit-control, ...?).

Where

With this approach we could have the best of both worlds and have self-sufficient components where reactive data flow is less of a concern (with forfeit-control mode) and circumvent the risk of race conditions in scenarios where reactive data flow and state management is a more of a concern (with assume-control mode).

Acceptance Criteria

Code of Conduct

angsherpa456 commented 6 months ago

The radio-group component was adjusted to use the slot instead of the native input element as radio buttons. The changes can be found in here https://github.com/deven-org/boiler/pull/1079/commits/6a5c68ab0b90ff7bed906c102666030ccd9522cf.

The current changes depend on the click event (The wrapper of the slot in radio-group component listens to the event bubbled by the slotted element[radio-button]). Therefore, it is limited in terms of functionality i.e. changes made from outside of click event does not work, a point in case is while changing the checked status from the Chrome DevTool console.

angsherpa456 commented 5 months ago

Findings and Observations

The potential solution was explored while resolving this PR https://github.com/deven-org/boiler/pull/1079 where the steps taken and major key observations were as follows:

1. Slotting Implementation The blr-radio elements were slotted into the blr-radio-group like this: `

`

2. Attribute Change Issues Observed that changing the checked attribute from the developer console was not detected by the Lit component, breaking sync between the radio buttons.

3. Ineffective Methods Methods like slot-change and MutationObserver did not observe property changes, proving ineffective.

4. Optimal Synchronization Achieved optimal sync by looping through slotted elements (blr-radio) inside the blr-radio-group and customizing their getter and setter methods. This ensured that any change of property/attribute, regardless of source, was observed and the component could maintain a single checked state. The also ease up the class modification of the slotted element based on the attribute change eg: checked/unchecked.

5. Uncontrolled Component Approach Using the parent component (<div class="blr-radio-group ${classes}"><slot></slot></div>) as an uncontrolled component, similar to React, proved effective. As shown in the following example form component where state was used to set and hold the value through event handlers rather than getting the value directly from the element.

import { LitElement, html } from 'lit'; import { TAG_NAME } from './renderFunction'; import { LitElementCustom } from '../../utils/lit-element-custom'; import { state } from 'lit/decorators.js';

export class BlrForm extends LitElementCustom { @state() formValue = { selectedOption: '', checkboxChecked: false, description: '', selectedValue: '' };

protected handleSubmit = () => { console.log('submitted with ', this.formValue); };

handleInputChange(e) { const inputName = e.target.localName; if (inputName === 'blr-radio') { this.formValue.selectedOption = e.detail.selectedValue; } else if (inputName === 'blr-checkbox') { this.formValue.checkboxChecked = e.detail.checkedState; } else if (inputName === 'blr-input-field-text') { this.formValue.description = e.detail.originalEvent.target.value; } else if (inputName === 'blr-select') { this.formValue.selectedValue = e.detail.selectedValue; } } protected render() { return html`

<blr-radio-group theme="Light" size-variant="md" value="" label="Label" aria-label="" radio-id="radioId" name="Radio Group" @blrRadioGroupValueChange=${this.handleInputChange}

<blr-input-field-text theme="Light" size-variant="md" type="text" placeholder="Placeholder-text" value="" max-length="140" has-label="true" label="Label-text" label-appendix="(Appendix)" has-icon="true" icon="blr360" arialabel="InputFieldText" name="InputFieldText" input-field-text-id="Input Id" @blrTextValueChange="${this.handleInputChange}" <blr-checkbox theme="Light" size-variant="md" has-label="true" label="Label-text" arialabel="checkbox" checkbox-id="checkboxId" name="checkInputId" @blrCheckedChange=${this.handleInputChange} <blr-select theme="Light" size-variant="md" has-label="true" label="Label-text" label-appendix="(Appendix)" icon="blrChevronDown" arialabel="Select" select-id="selectId" name="select" @blrSelectedValueChange=${this.handleInputChange}

<blr-button-text theme="Light" variant="cta" sizeVariant="md" label="Submit" hasIcon="true" iconPosition="leading" icon="blr360" textButtonId="button-id" @blrClick=${this.handleSubmit}

`; } }

if (!customElements.get(TAG_NAME)) { customElements.define(TAG_NAME, BlrForm); }

export type BlrFormType = Omit<BlrForm, keyof LitElement>;