From fb1e73c451ed326af0155f083adb171e4322d7d2 Mon Sep 17 00:00:00 2001 From: Denis Donici Date: Fri, 6 Mar 2026 12:20:42 +0200 Subject: [PATCH] chore: added svelte store example --- .../assets/svelte/StoreCounter.svelte | 44 ++++++++++++ example_project/assets/svelte/store.ts | 7 ++ .../lib/example_web/components/layouts.ex | 1 + .../controllers/page_html/home.html.heex | 12 ++++ .../lib/example_web/live/live_stores.ex | 66 +++++++++++++++++ example_project/lib/example_web/router.ex | 1 + .../priv/static/.vite/manifest.json | 4 +- .../example_web/live/live_stores_test.exs | 67 ++++++++++++++++++ .../phoenix_test/live_stores_test.exs | 70 +++++++++++++++++++ 9 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 example_project/assets/svelte/StoreCounter.svelte create mode 100644 example_project/assets/svelte/store.ts create mode 100644 example_project/lib/example_web/live/live_stores.ex create mode 100644 example_project/test/example_web/live/live_stores_test.exs create mode 100644 example_project/test/example_web/phoenix_test/live_stores_test.exs diff --git a/example_project/assets/svelte/StoreCounter.svelte b/example_project/assets/svelte/StoreCounter.svelte new file mode 100644 index 0000000..7b61baf --- /dev/null +++ b/example_project/assets/svelte/StoreCounter.svelte @@ -0,0 +1,44 @@ + + +
+
+ + {label} + + +
+ + + + {$sharedCount} + + + +
+ + {$sharedCountIsOdd ? "Odd" : "Even"} + +
+ + +
+
+
diff --git a/example_project/assets/svelte/store.ts b/example_project/assets/svelte/store.ts new file mode 100644 index 0000000..16587c1 --- /dev/null +++ b/example_project/assets/svelte/store.ts @@ -0,0 +1,7 @@ +import {writable, derived} from "svelte/store" + +// Shared writable store — a module singleton. +// Every component that imports this gets the exact same store instance, +// so any write is immediately visible in all subscribers across the page. +export const sharedCount = writable(0) +export const sharedCountIsOdd = derived(sharedCount, count => count % 2 === 1) diff --git a/example_project/lib/example_web/components/layouts.ex b/example_project/lib/example_web/components/layouts.ex index da56c70..ed3cb52 100644 --- a/example_project/lib/example_web/components/layouts.ex +++ b/example_project/lib/example_web/components/layouts.ex @@ -50,6 +50,7 @@ defmodule ExampleWeb.Layouts do %{label: "Client Loading", to: ~p"/live-client-side-loading"}, %{label: "Rich Editor (@attach)", to: ~p"/live-editor"}, %{label: "Runed Utilities", to: ~p"/live-runed"}, + %{label: "Svelte Stores", to: ~p"/live-stores"}, %{label: "SSR Demo", to: ~p"/live-ssr"} ] }, diff --git a/example_project/lib/example_web/controllers/page_html/home.html.heex b/example_project/lib/example_web/controllers/page_html/home.html.heex index 1e80a50..b8982c0 100644 --- a/example_project/lib/example_web/controllers/page_html/home.html.heex +++ b/example_project/lib/example_web/controllers/page_html/home.html.heex @@ -192,6 +192,18 @@ - Use Editor.js via Svelte's @attach directive +
  • + + Runed utilites + + - Svelte runed utilities syncing with backend +
  • +
  • + + Svelte stores + + - Svelte writable stores used across components +
  • diff --git a/example_project/lib/example_web/live/live_stores.ex b/example_project/lib/example_web/live/live_stores.ex new file mode 100644 index 0000000..4779f30 --- /dev/null +++ b/example_project/lib/example_web/live/live_stores.ex @@ -0,0 +1,66 @@ +defmodule ExampleWeb.LiveStores do + use ExampleWeb, :live_view + + def mount(_params, _session, socket) do + {:ok, assign(socket, server_value: nil, sync_count: 0)} + end + + def handle_event("sync_store", %{"value" => value}, socket) do + {:noreply, assign(socket, server_value: value, sync_count: socket.assigns.sync_count + 1)} + end + + def render(assigns) do + ~H""" +
    +
    +

    Svelte Stores

    +

    + Two instances of the same component share a single + writable + store. Clicking +1 in either card instantly updates both — no props, no events, no server round-trip. +

    + +
    +
    +
    + <.svelte + name="StoreCounter" + props={%{label: "Instance A"}} + socket={@socket} + /> +
    +
    + <.svelte + name="StoreCounter" + props={%{label: "Instance B"}} + socket={@socket} + /> +
    +
    + +
    +
    + + Server state + +

    + Last synced value: + + <%= if @server_value != nil do %> + {@server_value} + <% else %> + not yet synced + <% end %> + +

    +

    + Sync count: {@sync_count} +

    +
    +
    +
    +
    +
    + """ + end +end diff --git a/example_project/lib/example_web/router.ex b/example_project/lib/example_web/router.ex index 9572d22..192761b 100644 --- a/example_project/lib/example_web/router.ex +++ b/example_project/lib/example_web/router.ex @@ -49,6 +49,7 @@ defmodule ExampleWeb.Router do live("/live-navigation/:page", LiveNavigation) live("/live-editor", LiveEditor) live("/live-runed", LiveRuned) + live("/live-stores", LiveStores) live("/live-ssr", LiveSsr) live("/live-composition", LiveComposition) end diff --git a/example_project/priv/static/.vite/manifest.json b/example_project/priv/static/.vite/manifest.json index f503c5d..99abfc9 100644 --- a/example_project/priv/static/.vite/manifest.json +++ b/example_project/priv/static/.vite/manifest.json @@ -18,12 +18,12 @@ "isDynamicEntry": true }, "css/app.css": { - "file": "assets/app-DwTE1PSy.css", + "file": "assets/app-DB4pPYVV.css", "src": "css/app.css", "isEntry": true }, "js/app.js": { - "file": "assets/app-Bmf8wBN3.js", + "file": "assets/app-LUJMG2_W.js", "name": "app", "src": "js/app.js", "isEntry": true, diff --git a/example_project/test/example_web/live/live_stores_test.exs b/example_project/test/example_web/live/live_stores_test.exs new file mode 100644 index 0000000..0c540f3 --- /dev/null +++ b/example_project/test/example_web/live/live_stores_test.exs @@ -0,0 +1,67 @@ +defmodule ExampleWeb.LiveStoresTest do + @moduledoc """ + E2E tests for the /live-stores LiveView with the StoreCounter Svelte component. + The key assertion: both component instances share the same writable store — + incrementing in one card immediately updates the other. + """ + use ExampleWeb.FeatureCase, async: false + + @moduletag :e2e + + test "page loads and shows heading", %{session: session} do + session + |> visit("/live-stores") + |> assert_has(Query.css("h1", text: "Svelte Stores")) + end + + test "both StoreCounter instances render", %{session: session} do + session + |> visit("/live-stores") + |> assert_has(Query.css("[data-testid='store-instance-1']")) + |> assert_has(Query.css("[data-testid='store-instance-2']")) + end + + test "both instances show initial count of 0", %{session: session} do + session = visit(session, "/live-stores") + + # Wait for Svelte to mount + session |> find(Query.css("[data-testid='store-count']", count: 2)) + + counts = all(session, Query.css("[data-testid='store-count']")) + assert Enum.map(counts, &Wallaby.Element.text/1) == ["0", "0"] + end + + test "incrementing in instance 1 updates both components (store is shared)", %{ + session: session + } do + session = visit(session, "/live-stores") + + # Wait for both components to mount + session |> find(Query.css("[data-testid='store-count']", count: 2)) + + # Click +1 inside instance 1 only + instance1 = find(session, Query.css("[data-testid='store-instance-1']")) + click(instance1, Query.css("[data-testid='store-increment']")) + + # Both counters must now show 1 — the store is shared + session |> find(Query.css("[data-testid='store-count']", count: 2, minimum: 2)) + counts = all(session, Query.css("[data-testid='store-count']")) + assert Enum.map(counts, &Wallaby.Element.text/1) == ["1", "1"] + end + + test "reset button sets store back to 0 in both components", %{session: session} do + session = visit(session, "/live-stores") + + session |> find(Query.css("[data-testid='store-count']", count: 2)) + + # Increment via instance 2, then reset via instance 1 + instance2 = find(session, Query.css("[data-testid='store-instance-2']")) + click(instance2, Query.css("[data-testid='store-increment']")) + + instance1 = find(session, Query.css("[data-testid='store-instance-1']")) + click(instance1, Query.css("[data-testid='store-reset']")) + + counts = all(session, Query.css("[data-testid='store-count']")) + assert Enum.map(counts, &Wallaby.Element.text/1) == ["0", "0"] + end +end diff --git a/example_project/test/example_web/phoenix_test/live_stores_test.exs b/example_project/test/example_web/phoenix_test/live_stores_test.exs new file mode 100644 index 0000000..4d8b41b --- /dev/null +++ b/example_project/test/example_web/phoenix_test/live_stores_test.exs @@ -0,0 +1,70 @@ +defmodule ExampleWeb.PhoenixTest.LiveStoresTest do + @moduledoc """ + PhoenixTest (in-process) for LiveStores (/live-stores). + Validates server-side rendering, data-props contract, and sync_store event handling. + Store sharing between instances is client-only; these tests validate the LiveView layer. + """ + use ExampleWeb.ConnCase, async: false + import PhoenixTest + import Phoenix.LiveViewTest + + @moduletag :phoenix_test + + test "renders page heading", %{conn: conn} do + conn + |> visit("/live-stores") + |> assert_has("h1", text: "Svelte Stores") + end + + test "renders two StoreCounter components", %{conn: conn} do + conn + |> visit("/live-stores") + |> assert_has("[data-name='StoreCounter']", count: 2) + end + + test "both components receive label prop", %{conn: conn} do + conn + |> visit("/live-stores") + |> assert_has("[data-props*='Instance A']") + |> assert_has("[data-props*='Instance B']") + end + + test "server value shows not yet synced initially", %{conn: conn} do + conn + |> visit("/live-stores") + |> assert_has("[data-testid='server-value']", text: "not yet synced") + end + + test "initial sync count is 0", %{conn: conn} do + conn + |> visit("/live-stores") + |> assert_has("[data-testid='sync-count']", text: "0") + end + + test "sync_store event updates server value", %{conn: conn} do + conn + |> visit("/live-stores") + |> unwrap(fn view -> + render_click(view, "sync_store", %{"value" => 42}) + end) + |> assert_has("[data-testid='server-value']", text: "42") + end + + test "sync_store event increments sync count", %{conn: conn} do + conn + |> visit("/live-stores") + |> unwrap(fn view -> render_click(view, "sync_store", %{"value" => 1}) end) + |> assert_has("[data-testid='sync-count']", text: "1") + |> unwrap(fn view -> render_click(view, "sync_store", %{"value" => 2}) end) + |> assert_has("[data-testid='sync-count']", text: "2") + end + + test "sync_store event replaces previous server value", %{conn: conn} do + conn + |> visit("/live-stores") + |> unwrap(fn view -> render_click(view, "sync_store", %{"value" => 5}) end) + |> assert_has("[data-testid='server-value']", text: "5") + |> unwrap(fn view -> render_click(view, "sync_store", %{"value" => 99}) end) + |> assert_has("[data-testid='server-value']", text: "99") + end +end