Valian / live_vue

End-to-end reactivity for Phoenix LiveView and Vue
https://hex.pm/packages/live_vue
MIT License
240 stars 13 forks source link

It's not possible to have nested stateful LiveVue components inside a LiveVue parent component #14

Open vheathen opened 5 months ago

vheathen commented 5 months ago

Seems that currently it's not possible to have nested LiveVue components added via slots

Here is an example:

  def render(assigns) do
    ~H"""
    <div class="w-[350px] flex flex-col text-center h-full justify-center gap-2">
      <div>
        <%!-- Top level component --%>
        <.Card class="m-5">
          <div class="p-10 flex flex-col text-center h-full justify-center gap-2">

            <p id="with-socket">
              <%!-- Nested component with socket, goes away after mount --%>
              <.Counter count={@count} v-socket={@socket} id="inner-with-socket" v-on:inc={JS.push("inc_counter")} />
            </p>

            <p id="without-socket">
              <%!-- Nested component without socket, shown, but static, doesn't have even a local state --%>
              <.Counter count={@count} id="inner-without-socket" v-on:inc={JS.push("inc_counter")} />
            </p>

          </div>
        </.Card>
      </div>

      <%!-- Another top level component --%>
      <div>
        <.Counter count={@count} v-socket={@socket} id="outer-with-socket" v-on:inc={JS.push("inc_counter")}  />
      </div>
    </div>
    """
  end

Card is a very simple Vue component which has only a slot inside:

<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>

<template>
  <div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)">
    <slot />
  </div>
</template>

The very first Counter.vue disappears (this is slightly visually modified module included to LiveVue) disappears after the view mounted to the server.

The second one is there, but it doesn't have any state or behaviour - event the slider itself doesn't change the button label.

The third one - which is outside of the Card - is working as intended.

image

Funny enough that the top visible Counter gets one update when the server state changes the first time (the first "Increase counter" click) but then it stops.

I tried to look into the code but haven't found a good way to implement the case above.

Also, I noticed that each component gets its own application instance. I wonder if it makes sense to have only one app? In this case components can share state. Or it can be configurable. Don't think it worth spending time on that now TBH, but in the future can be interesting.

But the question is how should nested Vue components behave is an interesting topic. Currently to change server state from the nested Vue components (I mean, actual Vue components without anything in-between) it is necessary to emit events from the bottom to the top level one, which is actually got a VueHook, and then this component will emit an event to the LiveView controller.

May be an option here is to implement a server-side reactive store of some kind? It is possible to integrate pinia, but it will be too heavy only for that purpose, I think.

Anyway, I believe this topic worth another thread :-D

Valian commented 5 months ago

@vheathen Great points! I'll try to explain why it looks as it looks:

  1. You can't mount standalone Vue component into a page. It always has to be wrapped by top-level app created with createApp. I don't see a way of having a single Vue app mounted into multiple places (maybe portals? but it would be quite complex and might cause bugs).

  2. To keep slots interactive, they're fully rendered on the server and sent as an HTML over the wire. In other words, they doesn't use phoenix-driven update & render cycle since I couldn't find a way of making it work (once Vue "takes over" rendering phoenix can't update & execute hooks inside). So currently content in slots can't use phoenix hook - it's the same as in LiveSvelte. If you could find a way to make it work, it would be amazing :)

  3. To emit events directly to live view from a nested compontent you can use

import {useLiveVue} from "live_vue"

const live = useLiveVue()

live.pushEvent("ping")

or you can subscribe to handle custom events as well. useLiveVue gives you the hook instance, so you can use anything from JS interoperability.

  1. I was thinking about exposing a state component that would synchronize passed props to reactive object on the frontend, so it could be shared by multiple components, if necessary. But right now it's just a thought 😉