woutdp / live_svelte

Svelte inside Phoenix LiveView with seamless end-to-end reactivity
https://hexdocs.pm/live_svelte
MIT License
1.01k stars 38 forks source link

[QUESTION] SSR Rendering #26

Closed gevera closed 1 year ago

gevera commented 1 year ago

I have a question regarding server side rendering

LightStatusBar.svelte

<script>
  import { tweened } from "svelte/motion";
  import { cubicOut } from "svelte/easing";
  export let brightness = 0;

  const progress = tweened(0, {
    duration: 400,
    easing: cubicOut,
  });

  const updateProgress = (b) => progress.set(b / 100);

  $: updateProgress(brightness);
</script>

<progress class="border-none w-full rounded-lg h-12" value={$progress} />
<div
  class="h-12 rounded-md w-full font-mono font-semibold"
>
  <div class="text-center w-full flex items-center justify-center h-full">
    {brightness > 0 ? `${brightness}%` : "OFF"}
  </div>
</div>

<style>
  :root{
    --progColor: linear-gradient(to right, hsl(6, 100%, 80%), hsl(356, 100%, 65%));
    --progHeight: 20px;
}

progress, progress::-webkit-progress-value {
    width: 100%;
    border: 0;
    height: var(--progHeight);
    border-radius: 20px;
    background: var(--progColor);
}
progress::-webkit-progress-bar {
    width: 100%;
    border: 0;
    height: var(--progHeight);
    border-radius: 20px;
    background: white;
}

progress::-moz-progress-bar {
    width: 100%;
    border: 0;
    height: var(--progHeight);
    border-radius: 20px;
    background: var(--progColor);
}

</style>

LightControllers.svelte

<script>
  export let pushEvent;
  export let isOn = false;
  const toggleLight = () => {
    isOn = !isOn;
    pushEvent(isOn ? "on" : "off");
  };
</script>

<div class="my-2 flex justify-between items-center">
  <button
    type="button"
    class={`${
      isOn ? "bg-brand" : "bg-gray-200"
    } relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand focus:ring-offset-2`}
    role="switch"
    aria-checked="false"
    on:click={toggleLight}
  >
    <span class="sr-only">Use setting</span>
    <span
      aria-hidden="true"
      class={`${
        isOn ? "translate-x-5" : "translate-x-0"
      } pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`}
    />
  </button>
  <div class="flex gap-2">
    <button
      phx-click="down"
      class="flex items-center justify-center text-xl font-bold w-10 h-10 p-2 rounded bg-slate-100 hover:bg-brand active:bg-brand shadow-md hover:shadow-xl"
    >
      <svg
        xmlns="http://www.w3.org/2000/svg"
        fill="none"
        viewBox="0 0 24 24"
        stroke-width="1.5"
        stroke="currentColor"
        class="w-6 h-6"
      >
        <path
          stroke-linecap="round"
          stroke-linejoin="round"
          d="M19.5 8.25l-7.5 7.5-7.5-7.5"
        />
      </svg>
    </button>
    <button
      phx-click="up"
      class="flex items-center justify-center text-xl font-bold w-10 h-10 p-2 rounded bg-slate-100 hover:bg-brand active:bg-brand shadow-md hover:shadow-xl"
    >
      <svg
        xmlns="http://www.w3.org/2000/svg"
        fill="none"
        viewBox="0 0 24 24"
        stroke-width="1.5"
        stroke="currentColor"
        class="w-6 h-6"
      >
        <path
          stroke-linecap="round"
          stroke-linejoin="round"
          d="M4.5 15.75l7.5-7.5 7.5 7.5"
        />
      </svg>
    </button>
  </div>
</div>

live_page.ex

defmodule LightBulbWeb.LivePage do
  use LightBulbWeb, :live_view
  require Logger

  def mount(_params, _session, socket) do
    Logger.info(socket)
    {:ok, assign(socket, %{brightness: 10, previous: nil})}
  end

  def render(assigns) do
    ~H"""
    <div class="max-w-screen-xl mx-auto p-4 flex flex-col gap-4">
      <h1 class="text-center text-2xl font-light my-4">Light Bulb Controller</h1>
      <LiveSvelte.render name="LightStatusBar" props={%{brightness: @brightness}} />
      <LiveSvelte.render name="LightControllers" props={%{isOn: isOn?(@brightness)}} />
    </div>
    """
  end

  def handle_event("up", _, socket) do
    brightness = socket.assigns.brightness
    Logger.info brightness
    case brightness do
      100 ->
        {:noreply, socket}

      _ ->
        new_brightness = brightness + 10
        {:noreply, assign(socket, %{brightness: new_brightness, previous: brightness})}
    end
  end

  def handle_event("down", _, socket) do
    brightness = socket.assigns.brightness
    Logger.info brightness

    case brightness do
      0 ->
        {:noreply, socket}

      _ ->
        new_brightness = brightness - 10
        {:noreply, assign(socket, %{brightness: new_brightness, previous: brightness})}
    end
  end

  def handle_event("on", _, socket) do
    previous = socket.assigns.previous
    brightness = socket.assigns.brightness
    {:noreply, assign(socket, %{brightness: previous, previous: brightness})}
  end

  def handle_event("off", _, socket) do
    previous = socket.assigns.brightness
    {:noreply, assign(socket, %{brightness: 0, previous: previous})}
  end

  defp isOn?(brightness) do
    brightness > 0
  end
end

Even thought I trigger Pheonix function up/down to increase or decrease the brightness, on SSR I do get the initial state of 10% image What am I missing? Do I need to change somehow the handle_event functions or is it on the Svelte side? Thank You

woutdp commented 1 year ago

You need to save the state in LiveView. A way you can do this is for example use ETS: https://elixir-lang.org/getting-started/mix-otp/ets.html

The state is not being saved server side and resets on every page load. SSR in Svelte just makes sure on initial page load you have some rendered content instead of a blank page.

I'll also add your example to the example_project folder as it's quite nice!

gevera commented 1 year ago

chat_page.ex

defmodule LightBulbWeb.ChatPage do
  use LightBulbWeb, :live_view
  require Logger

  def mount(_params, _session, socket) do
    :ets.new(:messages_table, [:set, :protected, :named_table])
    {:ok, assign(socket, messages: get_messages())}
  end

  def render(assigns) do
    ~H"""
    <div class="max-w-screen-xl mx-auto p-4 flex flex-col gap-4">
      <h1 class="text-center text-2xl font-light my-4">Messages</h1>
      <LiveSvelte.render name="ChatInput" />
      <LiveSvelte.render name="ChatList" props={%{messages: @messages}} />
    </div>
    """
  end

  def handle_event("add_message", %{"message" => message}, socket) do
    id = System.unique_integer([:positive])
    :ets.insert(:messages_table, {id, message})
    messages = get_messages()
    {:noreply, assign(socket, %{messages: messages})}
  end

  def handle_event("remove_message", %{"id" => id}, socket) do
    :ets.delete(:messages_table, id)
    messages = get_messages()
    {:noreply, assign(socket, %{messages: messages})}
  end

  defp get_messages() do
    message_list = :ets.tab2list(:messages_table)
    Logger.info(message_list)
    message_list |> Enum.map(fn {id, text} -> %{id: id, text: text} end)
  end
end

ChatList.svelte

<script>
  import { fly } from "svelte/transition";
  import { flip } from "svelte/animate";
  export let messages = [];
  export let pushEvent;
  const removeItem = (id) => {
    pushEvent("remove_message", { id: id }, () => {});
  };
</script>

<div class="my-4 p-2">
  {#if messages.length}
    <ul class="list-inside list-disc p-4 flex flex-col gap-2">
      {#each messages as message (message.id)}
        <li
          transition:fly
          animate:flip
          class="italic flex items-center justify-between w-full border rounded-md p-2"
        >
          <div>
            {message.text}
          </div>
          <button
            on:click={() => removeItem(message.id)}
            class="flex items-center justify-center text-xl font-bold w-10 h-10 p-2 rounded bg-slate-100 hover:bg-brand active:bg-brand shadow-md hover:shadow-xl"
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              fill="none"
              viewBox="0 0 24 24"
              stroke-width="1.5"
              stroke="currentColor"
              class="w-6 h-6"
            >
              <path
                stroke-linecap="round"
                stroke-linejoin="round"
                d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
              />
            </svg>
          </button>
        </li>
      {/each}
    </ul>
  {:else}
    <h5 class="text-lg">No messages yet</h5>
  {/if}
</div>

ChatInput.svelte

<script>
  export let pushEvent;
  let message = "";
  const sendMessage = () => {
    pushEvent("add_message", { message: message }, () => {});
    message = "";
  };
</script>

<form
  on:submit|preventDefault={sendMessage}
  class="flex flex-col sm:flex-row justify-between sm:items-center gap-4"
>
  <input
    type="text"
    name="message"
    required
    bind:value={message}
    class="flex-grow ring-brand focus:outline-brand focus:ring-0 border-none bg-slate-200 font-mono"
    placeholder="Type in your message..."
  />
  <button
    type="submit"
    class="flex-shrink bg-brand px-6 py-2 rounded-full uppercase text-white font-semibold hover:bg-orange-500 hover:shadow-xl"
    >Send</button
  >
</form>

Still not getting SSR even with ETS storage as ou have suggested =/

woutdp commented 1 year ago

As a sidenote ETS was not working for me, but also has nothing to do with SSR, I think to make it work you need to do the following:

chat_page.ex

defmodule LightBulbWeb.ChatPage do
  use LightBulbWeb, :live_view
  require Logger

  @table :messages_table

  def mount(_params, _session, socket) do
    {:ok, assign(socket, messages: get_messages())}
  end

  def render(assigns) do
    ~H"""
    <div class="max-w-screen-xl mx-auto p-4 flex flex-col gap-4">
      <h1 class="text-center text-2xl font-light my-4">Messages</h1>
      <LiveSvelte.render name="ChatInput" />
      <LiveSvelte.render name="ChatList" props={%{messages: @messages}} />
    </div>
    """
  end

  def handle_event("add_message", %{"message" => message}, socket) do
    id = System.unique_integer([:positive])
    :ets.insert(@table, {id, message})
    messages = get_messages()
    {:noreply, assign(socket, %{messages: messages})}
  end

  def handle_event("remove_message", %{"id" => id}, socket) do
    :ets.delete(@table, id)
    messages = get_messages()
    {:noreply, assign(socket, %{messages: messages})}
  end

  defp get_messages() do
    message_list = :ets.tab2list(@table)
    Logger.info(message_list)
    message_list |> Enum.map(fn {id, text} -> %{id: id, text: text} end)
  end
end

application.ex

  def start(_type, _args) do
    children = [
      {NodeJS.Supervisor, [path: Application.app_dir(:example, "/priv/static/assets"), pool_size: 4]},
      # Start the Telemetry supervisor
      ExampleWeb.Telemetry,
      # Start the Ecto repository
      Example.Repo,
      # Start the PubSub system
      {Phoenix.PubSub, name: Example.PubSub},
      # Start Finch
      {Finch, name: Example.Finch},
      # Start the Endpoint (http/https)
      ExampleWeb.Endpoint
      # Start a worker by calling: Example.Worker.start_link(arg)
      # {Example.Worker, arg}
    ]

    # Start the ETS table
    :ets.new(:messages_table, [:set, :public, :named_table])

    # See https://hexdocs.pm/elixir/Supervisor.html
    # for other strategies and supported options
    opts = [strategy: :one_for_one, name: Example.Supervisor]
    Supervisor.start_link(children, opts)
  end
woutdp commented 1 year ago

Fixed, was a configuration issue