Repository: woutdp/live_svelte
Pull request #171: feat: <:loading /> slot for server-rendered markup
Author: login: davydog187
Created: 2025-04-18T11:20:41Z | Merged: 2025-04-18T16:58:39Z

Adds a new slot called `<:loading />` that allows a user to server render markup to display while waiting for the Svelte component to mount on the client.

This is useful if you want to display default markup to prevent the page from jumping once the client mounts, or to indicate to the user that something is loading.

- [x] Integrate with front-end and ensure it is removed after loading
- and through updates
- [x] Consider raising if you have `ssr: true`
- [x] Update documentation
- [x] Add tests

Closes #166

Linked issue #166: LiveView rendered default markup
Hello! Thank you for the great library! We use this at TV Labs for powering some of our most complex tools/UIs.

## Background

I have a LiveSvelte component that renders our notifications button and modal. It looks something like this

```elixir
  use LiveSvelte.Components

  attr :socket, :any, default: nil
  attr :org_user, :any, required: true

  def render(assigns) do
    ~H"""
        <.Knock
          :if={@socket}
          socket={@socket}
          api_key={@knock_public_key}
          channel_id={@knock_channel_id}
          org_user_id={@org_user_id}
        />
    """
  end
```

On the client side, I render a Bell icon and then there's a whole modal for displaying the notifications

## Issue

As far as I can tell, LiveSvelte allows [SSR](https://hexdocs.pm/live_svelte/readme.html#ssr-server-side-rendering) via shelling out to NodeJS and executing JS to render on the server side.

From my perspective, I have no interest in putting NodeJS in the critical path of rendering my product. Instead, it would be great if `LiveSvelte` allowed default markup to be rendered by LiveView that is replaced by the Svelte component as it initialized


Imagine my component instead looked like this

```elixir
  use LiveSvelte.Components

  attr :socket, :any, default: nil
  attr :org_user, :any, required: true

  def render(assigns) do
    ~H"""
        <.Knock
          :if={@socket}
          socket={@socket}
          api_key={@knock_public_key}
          channel_id={@knock_channel_id}
          org_user_id={@org_user_id}
        >
          <.spinner />
          Loading notifications hold tight!
      </.Knock
    """
  end
```

It seems that the default slot is already being used, but a `<:loading />` slot or something similar could also be used.

This would give users optionality in choosing how they want to handle SRR

Thank you for your consideration
Linked issue comments: user: login: woutdp
created_at: 2025-04-17T16:40:39Z
body: I like this idea a lot, feel free to create a PR that implements this. A `<:loading />` slot is perfect. I think implementation might be pretty straightforward. If you don't create a PR yourself I might pick up this feature at some point but that might take some time.
author_association: OWNER
user: login: davydog187
created_at: 2025-04-17T16:41:41Z
body: on it
author_association: CONTRIBUTOR

Diff:
diff --git a/README.md b/README.md
index a068934..c502990 100644
--- a/README.md
+++ b/README.md
@@ -631,6 +631,20 @@ Named slots also work:
 This works because of the Snippet API provided by Svelte. Be careful though, it's a new feature that might not be working 100% of the time, I'd love to see what limitations you hit with it. One limitation is that you can't slot other Svelte components.
 
 
+### Showing fallback content with :loading
+
+When SSR is disabled, a full‑page refresh briefly shows a blank content before Svelte finishes loading.
+Use the `:loading` slot to render a placeholder during that gap:
+
+```elixir
+<.svelte name="Example" ssr={false}>
+  <:loading>
+    <p>Until your LiveSvelte component renders client-side, this will be displayed</p>
+  </:loading>
+</.svelte>
+```
+
+
 ## Caveats
 
 ### "Secret State"
diff --git a/assets/js/live_svelte/hooks.svelte.js b/assets/js/live_svelte/hooks.svelte.js
index 443599e..390c727 100644
--- a/assets/js/live_svelte/hooks.svelte.js
+++ b/assets/js/live_svelte/hooks.svelte.js
@@ -81,6 +81,11 @@ export function getHooks(components) {
                 window.addEventListener(`${liveJsonElement}_patched`, _event => update_state(this), false)
             }
 
+            // This is required for the loading slot to be cleared once we mount the component
+            if (!this.el.hasAttribute("data-ssr")) {
+                this.el.innerHTML = ""
+            }
+
             const hydrateOrMount = this.el.hasAttribute("data-ssr") ? hydrate : mount
 
             this._instance = hydrateOrMount(Component, {
diff --git a/example_project/assets/svelte/ClientSideLoading.svelte b/example_project/assets/svelte/ClientSideLoading.svelte
new file mode 100644
index 0000000..18e79f5
--- /dev/null
+++ b/example_project/assets/svelte/ClientSideLoading.svelte
@@ -0,0 +1 @@
+<p>This is the component!</p>
diff --git a/example_project/lib/example_web/components/layouts/app.html.heex b/example_project/lib/example_web/components/layouts/app.html.heex
index 25bc1c5..510a03b 100644
--- a/example_project/lib/example_web/components/layouts/app.html.heex
+++ b/example_project/lib/example_web/components/layouts/app.html.heex
@@ -119,6 +119,12 @@
       >
         15
       </a>
+      <a
+        href={~p"/client-side-loading"}
+        class="font-semibold leading-6 text-zinc-900 hover:text-zinc-700 hover:underline p-2"
+      >
+        16
+      </a>
     </div>
 
     <div class="flex items-center gap-4">
diff --git a/example_project/lib/example_web/live/live_client_side_loading.ex b/example_project/lib/example_web/live/live_client_side_loading.ex
new file mode 100644
index 0000000..7e4a295
--- /dev/null
+++ b/example_project/lib/example_web/live/live_client_side_loading.ex
@@ -0,0 +1,29 @@
+defmodule ExampleWeb.LiveClientSideLoading do
+  use ExampleWeb, :live_view
+
+  def render(assigns) do
+    ~H"""
+    <h1 class="text-3xl bold mb-4">Examples of the loading slot</h1>
+    <div class="flex flex-col gap-4">
+      <div>
+        <h2 class="text-xl">Client side</h2>
+        <.svelte name="ClientSideLoading" ssr={false}>
+          <:loading>
+            <div class="animate-spin h-4 w-4 border-4 border-blue-500 rounded-full border-t-transparent"></div>
+          </:loading>
+        </.svelte>
+      </div>
+
+      <div>
+        <h2 class="text-xl">Server side (should not be used)</h2>
+        <p class="bold italic">This should flicker and throw a console warning</p>
+        <.svelte name="ClientSideLoading" socket={@socket}>
+          <:loading>
+            <div class="animate-spin h-4 w-4 border-4 border-blue-500 rounded-full border-t-transparent"></div>
+          </:loading>
+        </.svelte>
+      </div>
+    </div>
+    """
+  end
+end
diff --git a/example_project/lib/example_web/router.ex b/example_project/lib/example_web/router.ex
index bc687bf..d9e5949 100644
--- a/example_project/lib/example_web/router.ex
+++ b/example_project/lib/example_web/router.ex
@@ -34,6 +34,7 @@ defmodule ExampleWeb.Router do
     live "/slots-simple", LiveSlotsSimple
     live "/slots-dynamic", LiveSlotsDynamic
     live "/composition", LiveComposition
+    live "/client-side-loading", LiveClientSideLoading
   end
 
   # Other scopes may use custom stacks.
diff --git a/lib/live_svelte.ex b/lib/live_svelte.ex
index d824b0b..f87768d 100644
--- a/lib/live_svelte.ex
+++ b/lib/live_svelte.ex
@@ -44,6 +44,10 @@ defmodule LiveSvelte do
 
   slot :inner_block, doc: "Inner block of the Svelte component"
 
+  slot(:loading,
+    doc: "LiveView rendered markup to show while the component is loading client-side"
+  )
+
   @doc """
   Renders a Svelte component on the server.
   """
@@ -52,6 +56,13 @@ defmodule LiveSvelte do
     dead = assigns.socket == nil or not LiveView.connected?(assigns.socket)
     ssr_active = Application.get_env(:live_svelte, :ssr, true)
 
+    if init and ssr_active and assigns.ssr and assigns.loading != [] do
+      IO.warn(
+        "The <:loading /> slot is incompatible with server-side rendering (ssr). Either remove the <:loading /> slot or set ssr={false}",
+        Macro.Env.stacktrace(__ENV__)
+      )
+    end
+
     slots =
       assigns
       |> Slots.rendered_slot_map()
@@ -101,6 +112,7 @@ defmodule LiveSvelte do
           <%= raw(@ssr_render["css"]["code"]) %>
         </style>
         <%= raw(@ssr_render["html"]) %>
+        <%= render_slot(@loading) %>
       </div>
     </.live_json>
     """


Reviews:
user: login: davydog187
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T16:44:17Z
user: login: davydog187
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T16:57:46Z

Review comments:
diff_hunk: @@ -52,6 +56,13 @@ defmodule LiveSvelte do
     dead = assigns.socket == nil or not LiveView.connected?(assigns.socket)
     ssr_active = Application.get_env(:live_svelte, :ssr, true)
 
+    if init and ssr_active and assigns.ssr and assigns.loading != [] do
+      IO.warn(
+        "The loading slot is incompatible with server-side rendering (ssr). Either remove the loading slot or disable ssr",
path: lib/live_svelte.ex
user: login: davydog187
body: ```suggestion
        "The <:loading /> slot is incompatible with server-side rendering (ssr). Either remove the <:loading /> slot or set ssr={false}",
```
created_at: 2025-04-18T16:44:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -631,6 +631,20 @@ Named slots also work:
 This works because of the Snippet API provided by Svelte. Be careful though, it's a new feature that might not be working 100% of the time, I'd love to see what limitations you hit with it. One limitation is that you can't slot other Svelte components.
 
 
+### Showing fallback content with :loading
+
+When SSR is disabled, a full‑page refresh briefly shows a blank content before Svelte finishes loading.
+Use the `:loading` slot to render a placeholder during that gap:
+
+```elixir
+<.svelte name="Example" ssr={false}>
+  <:loading>
+    <p>This will show up while the Svelte component is being loaded</p>
path: README.md
user: login: davydog187
body: ```suggestion
    <p>Until your LiveSvelte component renders client-side, this will be displayed</p>
```
created_at: 2025-04-18T16:57:46Z
author_association: CONTRIBUTOR

PR comments:
user: login: woutdp
created_at: 2025-04-18T16:42:06Z
body: @davydog187 
Feel free to take a look, I'll merge if you think it's good to go.
I'll add documentation next
author_association: OWNER
user: login: davydog187
created_at: 2025-04-18T16:45:33Z
body: Thanks for the assist @woutdp. Besides the warning message update it looks good to me. We may want to update the docs a bit more as well
author_association: CONTRIBUTOR
user: login: woutdp
created_at: 2025-04-18T16:56:26Z
body: Thanks for this, excited for this feature, I'll make a new release today.

Any input on the docs?
https://github.com/davydog187/live_svelte/tree/allow-default-markup?tab=readme-ov-file#showing-fallback-content-with-loading


author_association: OWNER
user: login: davydog187
created_at: 2025-04-18T16:58:04Z
body: @woutdp one tweak and then ship it 🚀 
author_association: CONTRIBUTOR
user: login: woutdp
created_at: 2025-04-18T16:58:46Z
body: Boom, thank you!
author_association: OWNER