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

Events interoperability proposal #833

Open
bjufre opened this issue Oct 21, 2022 · 11 comments
Open

Events interoperability proposal #833

bjufre opened this issue Oct 21, 2022 · 11 comments

Comments

@bjufre
Copy link

bjufre commented Oct 21, 2022

After some discussion/back and forth in Discord, I thought it might be a better idea to express y concerns/ideas here in a more descriptive way; this is a continuation of the following message from your Discord server.

For brevity and for the sake of specificity, this proposal and its examples will be based around Vue (and v3 to be more specific).

Problem

As we know Mitosis allows us to write component in the "lower common denominator" for frameworks such as React, Vue, Svelte, etc. This is such a power that we didn't have before. Nonetheless, I think that there's some room for improvement regarding how Vue components are generated in general (and maybe this related to other frameworks?).

It's not uncommon for a Design System to have components such as modals or more involved input elements with different elements inside them, (div + label + possible error message) so that it's easier for developer to use those buildings blocks as a whole. Nonetheless, right now a component like the following written in Mitosis:

import { Show } from "@builder.io/mitosis";

interface Props {
  name: string;
  label: string;
  onChange: (event) => void;
  errorMessage?: string;
}

export default function SuperInputField(props: Props) {
  return (
    <div class="my-super-duper-input-field">
      <label for={props.name}>{props.label}</label>
      <input
        id={props.name}
        name={props.name}
        onChange={(event) => props.onChange(event)}
      />
      <Show when={props.errorMessage}>
        <p class="form-message danger">{props.errorMessage}</p>
      </Show>
    </div>
  );
}

Gets converted to this equivalent Vue 3 (composition API):

<template>
  <div class="my-super-duper-input-field">
    <label :for="name">{{ label }}</label>
    <input :id="name" :name="name" @input="onChange($event)" />

    <template v-if="errorMessage">
      <p class="form-message danger">{{ errorMessage }}</p>
    </template>
  </div>
</template>

<script setup>
const props = defineProps(["name", "label", "onChange", "errorMessage"]);
</script>

Which looks fine at first glance but presents a few problems:

  • In frameworks similar to React (Solid, etc) it's common to pass functions to be executed by the child components "whenever". But this is not the case with Vue. Vue standard way of doing this is through events between parent and child. Meaning that the correct code for this specific output should be:
<template>
  <div class="my-super-duper-input-field">
    <label :for="name">{{ label }}</label>
    <input :id="name" :name="name" @input="emit('change', $event)" />

    <template v-if="errorMessage">
      <p class="form-message danger">{{ errorMessage }}</p>
    </template>
  </div>
</template>

<script setup>
const props = defineProps(["name", "label", "errorMessage"]);
const emit = defineEmits(['change']);
</script>

NOTE the new const emit = defineEmits(['change']); and the @input event in the template (which shouldn't it be @change; since that was the one we listened to in the original Mitosis component?

  • Furthermore, with Vue we have things like v-model which allows for two way data binding which is really common on form elements and what not. The problem with the current approach is that this would work on the majority of frameworks that follow the "React" way, but not for those that communicate between parent-child components via events such as Vue. For Vue and more specific Vue 3, to make a "custom component" work with the v-model directive, that component must accept modelValue as a prop and emit update:modelValue this allows for this usage: <SuperInputField v-model="value" /> which gets expanded by Vue to: <SuperInputField :model-value="value" @update:modelValue="newValue => value = newValue" />
    • Similar to this, Vue allows to have multiple v-model directives right now with v-model:title="title" as long as the component accepts a prop named title and emits an event named update:title with the new value for it.

The problems mentioned above might be "too specific" to Vue or similar frameworks like Svelte which also allows for custom event dispatching. But I really think that nailing this or a subset of this in Mitosis can go a long way for the adoption of the tool to write performant and seamless design systems that just work with it. And specially more so with Vue as it's one of the "main" frameworks out there.

Proposed solutions/ideas

I would propose to tackle this issue in a two way approach maybe?

Let me elaborate a little bit into what I mean by this.

  1. My first try would be to map the events in props like onClick, onInput, onSomething and automatically convert them to listeners like @click, @input, @something as they should. This would get us the majority of the way there in terms of pairity with Vue.
  2. My second try would be taking a stab at the v-model implementation, but I would leave this out of the "main compiler" and expose it as a plugin (maybe?) that people can use with useMetadata passing some configuration that then the plugin would map correctly to the different outputs, specially Vue and its variants.

Example of point #2

Given this component:

import { Show, useMetadata } from "@builder.io/mitosis";

interface Props {
  name: string;
  label: string;
  value: any;
  onUpdateValue: (event) => void;
  errorMessage?: string;
}

useMetadata({
  vModel: {
    modelValue: 'value',
    events: {
      'update:modelValue': 'onUpdateValue',
    },
  },
})

export default function SuperInputField(props: Props) {
  return (
    <div class="my-super-duper-input-field">
      <label for={props.name}>{props.label}</label>
      <input
        id={props.name}
        name={props.name}
        value={props.value}
        onChange={(event) => props.onUpdateValue(event.target.value)}
      />
      <Show when={props.errorMessage}>
        <p class="form-message danger">{props.errorMessage}</p>
      </Show>
    </div>
  );
}

And a config (mitosis.config.js) file like:

const { vModelPlugin } = require('@builder.io/mitosis/vue'); // <- or whatever

module.exports = {
  // ...rest configuration
  plugins: [
    // ... rest of plugins
    vModelPlugin
  ];
};

Mitosis would generate the following output for Vue:

<template>
  <div class="my-super-duper-input-field">
    <label :for="name">{{ label }}</label>
    <input :id="name" :name="name" :value="value" @input="emit('update:modelValue', $event.target.value)" />

    <template v-if="errorMessage">
      <p class="form-message danger">{{ errorMessage }}</p>
    </template>
  </div>
</template>

<script setup>
const props = defineProps(["name", "label", "value", "errorMessage"]);
const emit = defineEmits(['update:modelValue']);
</script>

With point #1 we would allow better parity with Vue out of the box. And with point #2 we would make the "feature/implementation" an opt-in for the developers based on their targets and actual requirements.

Closing

As mentioned before, having the #1 point working out of the box with Mitosis I think should be a "must" as that falls (IMHO) under the "lowest common denominator" category; and the vModelPlugin would be a layer on top of that.

I would be more than happy to try and discuss this further and even try and help with moving this forward in terms of implementation.

Thank you! 🙏 💪 🚀

@samijaber
Copy link
Contributor

samijaber commented Nov 2, 2022

First, thanks a lot for laying out this well-written proposal. I appreciate that we have more and more folks like yourself laying out ideas around how we can improve Mitosis 😄. I'm catching up on notifications after a very busy few weeks, so I also appreciate your patience.

  1. My first try would be to map the events in props like onClick, onInput, onSomething and automatically convert them to listeners like @click, @input, @something as they should. This would get us the majority of the way there in terms of pairity with Vue.

This is already the case! Event handlers all get mapped to these listeners. See this example. It's done in this code:

// TODO: proper babel transform to replace. Util for this
if (isAssignmentExpression) {
return ` @${event}="${encodeQuotes(removeSurroundingBlock(valueWRenamedEvent))}" `;
} else {
return ` @${event}="${encodeQuotes(
removeSurroundingBlock(removeSurroundingBlock(valueWRenamedEvent)),
)}" `;
}

Are you seeing something done differently for these event listeners? If so, that's most likely a bug 🤔

@samijaber
Copy link
Contributor

samijaber commented Nov 2, 2022

My first thought is that I would prefer that the outputs generated by Mitosis all have the exact same API for consistency. This means that a component would have the same props, whether it's the React, Qwik, Svelte or Vue version. But you make a good point that Vue has this neat API around allowing parents to add event handlers there, so why not take advantage of that. It will be more natural to folks consuming your Vue components since that's the convention there.

I'm down to try your idea out and see if folks find it valuable! I will add an explanation in a follow-up comment in this PR for anyone who'd be interested in implementing this (whether yourself or someone else), outlining what changes need to be made to Mitosis (& where).

@samijaber
Copy link
Contributor

@bjufre I have some ideas around this, but first I need one important piece: can you give me an example of how this SuperInputField example would be consumed in Vue? i.e. share with me a Vue 3 component that would be a parent of SuperInputField, and how it would provide event handlers.

That would fill the knowledge gap for me and help lay out what needs to be done to support this (if it's possible!)

@bjufre
Copy link
Author

bjufre commented Nov 15, 2022

@samijaber first of all thank you for your patience; I've been dealing with some crazy stuff at work and I had to deal with it and put on hold any other efforts to move the project that I would use Mitosis for.

Secondly thank you for the thorough response, I will do my best to provide a good answer to try and spark the discussion further.

I think that a possible good example is to showcase how a composable Modal component might be created and used for easy to use later on within an engineering team. One might create a Modal component, but at the same time, in order to limit and constraint further how the design system is used, we might introduce a ConfirmModal that would encapsulate the common setup for this kind of scenarios; this might look something like:

Confirmation Modal

Given this Mitosis component:

import Modal from './components/Modal';
import Button from './components/Button';

interface Props {
  show: boolean;
  children: any; // <- What's the best way to type this with Mitosis?
  cancelLabel?: string;
  confirmLabel: string;
  onCancel: () => void;
  onConfirm: () => void;
}

export default function ConfirmationModal(props: Props) {
  return (
    <Modal
      show={prop.show}
      type="confirm"
      slotFooter={(
        <div class="d-flex justify-between align-center">
          <Button type="ghost" onClick={props.onCancel}>{props.cancelLabel || 'Cancel'}</Button>
          <Button tone="danger" onClick={props.onConfirm}>{props.confirmLabel}</Button>
        </div>
      )}
    >
      {props.children}
    </Modal>
  )
}

I would expect the following Vue output:

<template>
  <Modal type="confirm" :show="show">
    <slot />
    <template #footer>
      <div class="d-flex justify-between align-center">
        <Button type="ghost" @click="emit('cancel')">{{ cancelLabel }}</Button>
        <Button tone="danger" @click="emit('confirm')">{{ confirmLabel }}</Button>
      </div>
    </template>
  </Modal>
</template>

<script setup lang="ts">
import Modal from './components/Modal';
import Button from './components/Button';

interface Props {
  children: any;
  cancelLabel: string;
  confirmLabel: string;
}

const props = defineProps<Props>;
const emit = defineEmits<{
  (e: 'confirm'): void;
  (e: 'cancel'): void;
}>;
</script>

Note that the types for the emit are derived from the original Mitosis props.

Usage:

<template>
  <!-- ...Rest of the page -->
  <!-- Some "entity actions" -->
  <div class="actions">
    <Button type="primary" @click="openConfirm">Delete item</Button>
  </div>

  <!-- Our super modal -->
  <ConfirmModal :show="showConfirm" confirmLabel="Delete" @cancel="closeConfirm" @confirm="deleteEntity">
    Are you sure that you want to delete this entity?
  </ConfirmModal>
</template>

<script setup lang="ts">
import Button from '@design-system/vue/Button';
import ConfirmModal from '@design-system/vue/ConfirmModal';
import { ref } from 'vue';

const showConfirm = ref<boolean>(false);

function openConfirm() {
  showConfirm.value = true;
}

function closeConfirm() {
  showConfirm.value = false;
}
</script>

In this example we've used some composition between "supposed" components inside the same design system created with Mitosis. And it shows why it might be important to use event emitters/handlers instead of rely in prop drilling.

@kingzez
Copy link
Collaborator

kingzez commented Nov 15, 2022

I'm interested in this proposal, it's really necessary in vue, looking forward to this proposal will implement 💪

@findlay-best-wishes
Copy link
Contributor

The idea is so great,Event listener is more convient for vuer.

@bjufre
Copy link
Author

bjufre commented Dec 16, 2022

@samijaber I know you’re super busy. But is there anything else I can do to continue the conversation?

@nivb52
Copy link

nivb52 commented Jun 22, 2023

@samijaber,
Are you planning to go forward with implementation ?

@samijaber
Copy link
Contributor

Hey folks,

I've been holding off on looking into this proposal due to its highly complex nature, relative to the small benefits I see.

Complexity

The complexity I envision comes from us potentially needing:

  • a new type of API in Mitosis to identify these events, or at least additional logic to handle generating the events
  • additional logic that analyzes the types of the entire project to make sure we're aware of when an event is being listened to by a parent Mitosis component, so we can accurately convert the parent's code to using the @event syntax instead of providing an onEvent prop.

PS: I have other priorities at the moment that prevent me from working on this. I am always willing to outline the work needed to get it done, but even that would be a decent chunk of non-trivial new architecture. The good news is that we are currently solving the second bullet point (analyzing an entire Mitosis project's TypeScript types) for another big task (implementing fine-grained reactivity/signals across frameworks). So once we have an example of how to do that, it will definitely make this proposal easier to tackle. 🙏🏽

Benefits

What I'm stumped on are the benefits of this approach. Again, I am not a Vue expert, so I might totally be missing something here, but I don't understand what these events offer that you can't do with prop drilling (besides maybe some syntactic sugar). I definitely understand that it might not be idiomatic Vue code though.

I looked at @bjufre and it feels like prop drilling would still work there...so my follow-up question is:

what can you do with Vue custom events that is impossible to do without them?

I would highly appreciate examples of Mitosis output that show such limitations.

Because as it currently stands, Vue's custom events look like a "better alternative to prop drilling" to me, but it doesn't unlock any new abilities. And I'd rather spend the limited resources available to make Mitosis cover more ground (like using signals/reactive stores, support CSS file imports, etc.).

@niklaswolf
Copy link

Because as it currently stands, Vue's custom events look like a "better alternative to prop drilling" to me, but it doesn't unlock any new abilities.

I'd say you're generally right about that, but the events-approach is the de-facto standard in Vue. All the docs etc do not use prop drilling, but the event approach. As a result, Vue users for example expect components from component libraries to also follow this approach, using prop drilling would feel/be just ... weird.
Furthermore there is also quite a lot syntactic sugar for using the event-approach (also used in docs etc.)

But I totally get that this is a really hard problem to solve for a compiler like mitosis while maintaining a functionally equal output for all frameworks and that the limited resources might be spend on other topics

@bastiW
Copy link

bastiW commented Jul 2, 2024

It is not only about Vue it is also about Angular. They also have the concept of Outputs.

I suggest useOutputs to define all its outputs. For Frameworks like vue and angular it will be transpiled in vue and angular to their output / emit code. In Frameworks like React, we would need to merge it with the props. We could use the on... convention to add them.

API suggestion

The API was inspired by Vue Instead of defineEmits it uses useOutputs.

import { useOutputs } from '@builder.io/mitosis';
import { ChangeEvent, InputEvent } from './ofa-text-input.lite';


export default function MyModal(props: any) {

  const outputs = useOutputs<{
    userNameChange: [name: string]
    cancel: void
    confirm: void
  }>()


  return (
    <div>
      <h1>Enter username you want to delete. </h1>
      <input onChange={(event) => outputs.userNameChange(event.target.value)} />
      <button onClick={() => outputs.canel()}>Cancel</button>
      <button onClick={() => outputs.confirm()}>Confirm</button>
    </div>
  );
}


Output

Angular

Angular component

import { Component, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'my-modal',
  template: `
    <div>
      <h1>Enter username you want to delete.</h1>
      <input (input)="onUserNameChange($event)" />
      <button (click)="onCancel()">Cancel</button>
      <button (click)="onConfirm()">Confirm</button>
    </div>
  `,
})
export class MyModal {
  @Output() userNameChange = new EventEmitter<string>();
  @Output() cancel = new EventEmitter<void>();
  @Output() confirm = new EventEmitter<void>();

  onUserNameChange(event: Event) {
    this.userNameChange.emit((event.target as HTMLInputElement).value);
  }

  onCancel() {
    this.cancel.emit();
  }

  onConfirm() {
    this.confirm.emit();
  }
}

Angular usage

<my-modal (userNameChange)="handleUserNameChange($event)" (cancel)="handleCancel()" (confirm)="handleConfirm()"></my-modal>

Vue component

<script setup lang="ts">
import { ref, defineEmits } from 'vue';

const emit = defineEmits<{
  (e: 'userNameChange', name: string): void;
  (e: 'cancel'): void;
  (e: 'confirm'): void;
}>();

function onChange(event: Event) {
  emit('userNameChange', (event.target as HTMLInputElement).value);
}

function onCancel() {
  emit('cancel');
}

function onConfirm() {
  emit('confirm');
}
</script>

<template>
  <div>
    <h1>Enter username you want to delete.</h1>
    <input @change="onChange" />
    <button @click="onCancel">Cancel</button>
    <button @click="onConfirm">Confirm</button>
  </div>
</template>

Vue usage

<MyModal @userNameChange="handleUserNameChange" @cancel="handleCancel" @confirm="handleConfirm" />

Disclaimer: I Partly used OpenAI to generate the transpiled output.
Read more: Here is an OpenAI-generated list of some other frameworks that I am not familiar with: https://chatgpt.com/share/98213b24-3e00-46a0-a740-d43d79ff45fb

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

7 participants