├── test ├── test_helper.exs ├── e2e │ ├── features │ │ ├── slot │ │ │ ├── slot_test.vue │ │ │ ├── live.ex │ │ │ └── slot.spec.js │ │ ├── prop-diff │ │ │ ├── prop-display.vue │ │ │ ├── live.ex │ │ │ └── prop-diff.spec.js │ │ ├── basic │ │ │ ├── live.ex │ │ │ ├── counter.vue │ │ │ └── basic.spec.js │ │ ├── navigation │ │ │ ├── live.ex │ │ │ ├── navigation.vue │ │ │ └── navigation.spec.js │ │ ├── event │ │ │ ├── live.ex │ │ │ ├── event_test.vue │ │ │ └── event.spec.js │ │ ├── upload │ │ │ ├── live.ex │ │ │ └── upload-test.vue │ │ ├── event-reply │ │ │ └── live.ex │ │ ├── stream │ │ │ └── live.ex │ │ └── form │ │ │ └── live.ex │ ├── playwright.config.js │ ├── vite.config.js │ ├── js │ │ └── app.js │ ├── utils.js │ ├── README.md │ └── test_helper.exs ├── live_vue_encoder_live_stream_test.exs ├── support │ └── ssr_test_server.mjs ├── live_vue_ssr_nodejs_test.exs ├── mix │ └── tasks │ │ └── live_vue.install_test.exs ├── live_vue_ssr_test.exs └── live_vue_test.exs ├── .prettierignore ├── live_vue_logo.png ├── conductor.json ├── live_vue_logo_rounded.png ├── guides ├── images │ └── lifecycle.png ├── installation.md ├── deployment.md ├── getting_started.md ├── testing.md └── faq.md ├── assets ├── serverElementPolyfill.d.ts ├── index.ts ├── link.ts ├── app.ts ├── types.ts ├── vitePlugin.js ├── phoenixFallbackTypes.ts ├── server.ts ├── hooks.ts └── utils.test.ts ├── .formatter.exs ├── .prettierrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── frontend.yml │ ├── e2e.yml │ └── elixir.yml ├── vitest.config.ts ├── tsconfig.json ├── config └── config.exs ├── .gitignore ├── conductor-setup.sh ├── LICENSE.md ├── lib └── live_vue │ ├── ssr │ ├── node_js.ex │ └── vite_js.ex │ ├── slots.ex │ ├── reload.ex │ ├── components.ex │ ├── ssr.ex │ └── test.ex ├── package.json ├── CLAUDE.md ├── mix.exs └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | priv/ 2 | deps/ 3 | doc/ 4 | -------------------------------------------------------------------------------- /live_vue_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Valian/live_vue/HEAD/live_vue_logo.png -------------------------------------------------------------------------------- /conductor.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "setup": "./conductor-setup.sh" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /live_vue_logo_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Valian/live_vue/HEAD/live_vue_logo_rounded.png -------------------------------------------------------------------------------- /guides/images/lifecycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Valian/live_vue/HEAD/guides/images/lifecycle.png -------------------------------------------------------------------------------- /assets/serverElementPolyfill.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Element extends Record {} 3 | } 4 | 5 | export {} 6 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | import_deps: [:phoenix], 4 | line_length: 120, 5 | plugins: [Phoenix.LiveView.HTMLFormatter, Styler], 6 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 7 | ] 8 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSameLine": false, 4 | "bracketSpacing": true, 5 | "printWidth": 120, 6 | "semi": false, 7 | "singleQuote": false, 8 | "tabWidth": 2, 9 | "trailingComma": "es5" 10 | } 11 | -------------------------------------------------------------------------------- /test/e2e/features/slot/slot_test.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 13 | -------------------------------------------------------------------------------- /test/e2e/features/prop-diff/prop-display.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: LiveVue Discussions 4 | url: https://github.com/Valian/live_vue/discussions 5 | about: Please ask and answer questions here 6 | - name: LiveVue Documentation 7 | url: https://hexdocs.pm/live_vue 8 | about: Check out the official documentation -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'jsdom', 6 | globals: true, 7 | include: ['assets/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'] 8 | }, 9 | resolve: { 10 | alias: { 11 | '@': '/assets' 12 | } 13 | } 14 | }) -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for LiveVue 4 | title: 'FEATURE: ' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | ## Problem Statement 10 | 13 | 14 | ## Proposed Solution 15 | 16 | 17 | -------------------------------------------------------------------------------- /test/e2e/features/basic/live.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.E2E.TestLive do 2 | @moduledoc false 3 | use Phoenix.LiveView 4 | 5 | def mount(_params, _session, socket) do 6 | {:ok, assign(socket, :counter, 0)} 7 | end 8 | 9 | def handle_event("increment", %{"value" => value}, socket) do 10 | {:noreply, assign(socket, :counter, socket.assigns.counter + value)} 11 | end 12 | 13 | def render(assigns) do 14 | ~H""" 15 | 16 | """ 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ES2020", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "lib": ["ES2020", "DOM"], 12 | "types": ["node"], 13 | "baseUrl": ".", 14 | "paths": { 15 | "live_vue": ["./assets/index"] 16 | } 17 | }, 18 | "include": ["assets/**/*.ts"], 19 | "exclude": ["node_modules", "**/*.test.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /test/e2e/features/basic/counter.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /test/live_vue_encoder_live_stream_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.Encoder.LiveStreamTest do 2 | use ExUnit.Case 3 | 4 | alias LiveVue.Encoder 5 | 6 | defmodule TestUser do 7 | @moduledoc false 8 | 9 | defstruct [:id, :name, :age] 10 | 11 | defimpl Encoder do 12 | def encode(user, opts) do 13 | result = %{id: user.id, name: user.name} 14 | if opts[:encode_age], do: Map.put(result, :age, user.age), else: result 15 | end 16 | end 17 | end 18 | 19 | defmodule TestPost do 20 | @moduledoc false 21 | @derive Encoder 22 | defstruct [:id, :title, :content, :author_id] 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/e2e/features/navigation/live.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.E2E.NavigationLive do 2 | @moduledoc false 3 | use Phoenix.LiveView 4 | 5 | def mount(params, _session, socket) do 6 | {:ok, assign(socket, params: params, query_params: %{})} 7 | end 8 | 9 | def handle_params(params, uri, socket) do 10 | query_params = URI.parse(uri).query 11 | parsed_query = if query_params, do: URI.decode_query(query_params), else: %{} 12 | {:noreply, assign(socket, params: params, query_params: parsed_query)} 13 | end 14 | 15 | def render(assigns) do 16 | ~H""" 17 | 20 | """ 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /assets/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | LiveVueApp, 3 | LiveVueOptions, 4 | SetupContext, 5 | VueComponent, 6 | LiveHook, 7 | ComponentMap, 8 | UploadConfig, 9 | UploadEntry, 10 | UploadOptions, 11 | AsyncResult, 12 | } from "./types.js" 13 | export { createLiveVue } from "./app.js" 14 | export { getHooks } from "./hooks.js" 15 | export { useLiveVue, useLiveEvent, useLiveNavigation, useLiveUpload, useEventReply, useLiveConnection } from "./use.js" 16 | export { 17 | useLiveForm, 18 | useField, 19 | useArrayField, 20 | type Form, 21 | type FormField, 22 | type FormFieldArray, 23 | type FormOptions, 24 | type UseLiveFormReturn, 25 | } from "./useLiveForm.js" 26 | export { findComponent } from "./utils.js" 27 | export { default as Link } from "./link.js" 28 | -------------------------------------------------------------------------------- /test/support/ssr_test_server.mjs: -------------------------------------------------------------------------------- 1 | // Test SSR server for NodeJS SSR tests 2 | // This is a minimal implementation that echoes back the component info 3 | 4 | export function render(name, props, slots) { 5 | // Simulate the real SSR behavior 6 | if (name === "WithPreloadLinks") { 7 | return `
${name}
`; 8 | } 9 | 10 | if (name === "Error") { 11 | throw new Error("Intentional test error"); 12 | } 13 | 14 | // Default: return simple HTML 15 | const propsStr = JSON.stringify(props); 16 | const slotsStr = JSON.stringify(slots); 17 | return `
SSR Rendered: ${name}
`; 18 | } 19 | -------------------------------------------------------------------------------- /test/e2e/playwright.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test" 2 | 3 | export default defineConfig({ 4 | testDir: "./features", 5 | forbidOnly: !!process.env.CI, 6 | retries: process.env.CI ? 2 : 0, 7 | expect: { 8 | timeout: 1000, 9 | }, 10 | use: { 11 | trace: "retain-on-failure", 12 | screenshot: "only-on-failure", 13 | baseURL: "http://localhost:4004/", 14 | }, 15 | webServer: { 16 | command: "cd ../.. && MIX_ENV=e2e mix run test/e2e/test_helper.exs", 17 | url: "http://127.0.0.1:4004/health", 18 | reuseExistingServer: !process.env.CI, 19 | timeout: 60_000, 20 | }, 21 | projects: [ 22 | { name: "chromium", use: { ...devices["Desktop Chrome"] } }, 23 | // { name: "firefox", use: { ...devices["Desktop Firefox"] } }, 24 | // { name: "webkit", use: { ...devices["Desktop Safari"] } }, 25 | ], 26 | }) 27 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :live_vue, 4 | # for dev LiveVue.SSR.ViteJS 5 | # for prod LiveVue.SSR.NodeJS 6 | ssr_module: nil, 7 | 8 | # if we should by default use ssr or not. 9 | # can be overridden by v-ssr={true|false} attribute 10 | ssr: nil, 11 | 12 | # in dev most likely http://localhost:5173 13 | vite_host: nil, 14 | 15 | # it's relative to LiveVue.SSR.NodeJS.server_path, so "priv" directory 16 | # that file is created by Vite "build-server" command 17 | ssr_filepath: "./static/server.mjs", 18 | 19 | # it's a name of gettext module that will be used for translations 20 | # it's used in LiveVue.Form protocol implementation 21 | # by default it's not-enabled 22 | gettext_backend: nil, 23 | 24 | # if false, we will always update full props and not send diffs 25 | # defaults to true as it greatly reduces payload size 26 | enable_props_diff: true 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | live_vue-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # NPM dependencies 29 | node_modules/ 30 | 31 | .DS_Store 32 | *.code-workspace 33 | 34 | RELEASE.md 35 | 36 | .elixir_ls/ 37 | .vscode/ 38 | 39 | # Playwright 40 | test/e2e/priv/static/ 41 | test-results/ 42 | playwright-report/ -------------------------------------------------------------------------------- /conductor-setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "🔧 Setting up LiveVue workspace..." 5 | 6 | # Check for required tools 7 | if ! command -v mix &> /dev/null; then 8 | echo "❌ Error: mix (Elixir) is not installed" 9 | echo "Please install Elixir from https://elixir-lang.org/install.html" 10 | exit 1 11 | fi 12 | 13 | if ! command -v npm &> /dev/null; then 14 | echo "❌ Error: npm (Node.js) is not installed" 15 | echo "Please install Node.js from https://nodejs.org/" 16 | exit 1 17 | fi 18 | 19 | echo "✓ Found mix and npm" 20 | 21 | # Install Elixir dependencies 22 | echo "📦 Installing Elixir dependencies..." 23 | mix deps.get 24 | 25 | # Install Node dependencies 26 | echo "📦 Installing Node dependencies..." 27 | npm ci 28 | 29 | # Install Playwright browsers for E2E tests 30 | echo "🎭 Installing Playwright browsers..." 31 | npm run e2e:install 32 | 33 | echo "✅ Setup complete! Run 'cd example_project && mix phx.server' to start development." 34 | -------------------------------------------------------------------------------- /.github/workflows/frontend.yml: -------------------------------------------------------------------------------- 1 | name: Frontend CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | frontend-tests: 11 | name: Frontend tests Node.js ${{ matrix.node }} 12 | runs-on: ubuntu-24.04 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | node: ['20', '22', '24'] 18 | 19 | steps: 20 | - uses: actions/checkout@v4.2.2 21 | - name: Set up Elixir 22 | uses: erlef/setup-beam@v1 23 | with: 24 | elixir-version: '1.15' 25 | otp-version: '26' 26 | - name: Get Elixir dependencies 27 | run: mix deps.get 28 | - name: Set up Node.js 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ matrix.node }} 32 | cache: 'npm' 33 | - name: Install npm dependencies 34 | run: npm install 35 | - name: Type check with TypeScript 36 | run: npx tsc --noEmit 37 | - name: Run frontend tests 38 | run: npm test -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2024 Jakub Skalecki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /test/e2e/features/navigation/navigation.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 36 | -------------------------------------------------------------------------------- /test/e2e/features/slot/live.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.E2E.SlotTestLive do 2 | @moduledoc false 3 | use Phoenix.LiveView 4 | 5 | def mount(_params, _session, socket) do 6 | {:ok, socket} 7 | end 8 | 9 | def render(assigns) do 10 | ~H""" 11 |
12 |

Non-ASCII Slot Test

13 | 14 | 15 | Zażółć gęślą jaźń 16 | 17 | 18 | 19 | こんにちは世界 20 | 21 | 22 | 23 | Hello 🌍 World 🎉 Party 🚀 24 | 25 | 26 | 27 | Привет мир! 你好世界! مرحبا بالعالم 28 | 29 | 30 | 31 | Ñoño café résumé naïve 32 | 33 |
34 | """ 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/live_vue/ssr/node_js.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.SSR.NodeJS do 2 | @moduledoc """ 3 | Implements SSR by using NodeJS package. 4 | 5 | Under the hood, it invokes "render" function exposed by `server.js` file. 6 | You can see how `server.js` is created by looking at `assets.deploy` command 7 | and `package.json` build-server script. 8 | """ 9 | 10 | @behaviour LiveVue.SSR 11 | 12 | def render(name, props, slots) do 13 | filename = Application.get_env(:live_vue, :ssr_filepath, "./static/server.mjs") 14 | 15 | try do 16 | NodeJS.call!({filename, "render"}, [name, props, slots], 17 | binary: true, 18 | esm: true 19 | ) 20 | catch 21 | :exit, {:noproc, _} -> 22 | message = """ 23 | NodeJS is not configured. Please add the following to your application.ex: 24 | {NodeJS.Supervisor, [path: LiveVue.SSR.NodeJS.server_path(), pool_size: 4]}, 25 | """ 26 | 27 | raise %LiveVue.SSR.NotConfigured{message: message} 28 | end 29 | end 30 | 31 | def server_path do 32 | {:ok, path} = :application.get_application() 33 | Application.app_dir(path, "/priv") 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/live_vue/slots.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.Slots do 2 | @moduledoc false 3 | 4 | import Phoenix.Component 5 | 6 | @doc false 7 | def rendered_slot_map(assigns) when assigns == %{}, do: %{} 8 | 9 | def rendered_slot_map(assigns) do 10 | for( 11 | {key, [%{__slot__: _}] = slot} <- assigns, 12 | into: %{}, 13 | do: 14 | case(key) do 15 | # we raise here because :inner_block is always there and we want to avoid 16 | # it overriding the default slot content 17 | :default -> raise "Instead of using <:default> use <:inner_block> slot" 18 | :inner_block -> {:default, render(%{slot: slot})} 19 | slot_name -> {slot_name, render(%{slot: slot})} 20 | end 21 | ) 22 | end 23 | 24 | @doc false 25 | def base_encode_64(assigns) do 26 | for {key, value} <- assigns, into: %{}, do: {key, Base.encode64(value)} 27 | end 28 | 29 | @doc false 30 | defp render(assigns) do 31 | ~H""" 32 | <%= if assigns[:slot] do %> 33 | {render_slot(@slot)} 34 | <% end %> 35 | """ 36 | |> Phoenix.HTML.Safe.to_iodata() 37 | |> List.to_string() 38 | |> String.trim() 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help improve LiveVue 4 | title: 'BUG: ' 5 | labels: 'bug' 6 | assignees: '' 7 | --- 8 | 9 | ### Description 10 | 14 | 15 | ### Actual Behavior 16 | 17 | 18 | ### Expected Behavior 19 | 20 | 21 | ## Environment 22 | 35 | 36 | ``` 37 | # Paste the output here 38 | Operating system: 39 | Browser (if relevant): 40 | ``` 41 | -------------------------------------------------------------------------------- /test/e2e/vite.config.js: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import { defineConfig } from "vite" 3 | 4 | import vue from "@vitejs/plugin-vue" 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig(({ command }) => { 8 | const isDev = false 9 | 10 | return { 11 | base: "/assets", 12 | plugins: [vue()], 13 | resolve: { 14 | alias: { 15 | vue: path.resolve(__dirname, "../../node_modules/vue"), 16 | "@": path.resolve(__dirname, "."), 17 | live_vue: path.resolve(__dirname, "../../assets/index.ts"), 18 | }, 19 | }, 20 | build: { 21 | commonjsOptions: { transformMixedEsModules: true }, 22 | target: "es2020", 23 | outDir: "./test/e2e/priv/static/assets", 24 | emptyOutDir: true, 25 | sourcemap: isDev, 26 | manifest: false, 27 | rollupOptions: { 28 | input: { 29 | app: path.resolve(__dirname, "./js/app.js"), 30 | }, 31 | output: { 32 | // remove hashes to match phoenix way of handling assets 33 | entryFileNames: "[name].js", 34 | chunkFileNames: "[name].js", 35 | assetFileNames: "[name][extname]", 36 | }, 37 | }, 38 | }, 39 | } 40 | }) 41 | -------------------------------------------------------------------------------- /test/e2e/js/app.js: -------------------------------------------------------------------------------- 1 | import "phoenix_html" 2 | import { Socket } from "phoenix" 3 | import { LiveSocket } from "phoenix_live_view" 4 | 5 | // live_vue related imports 6 | import { getHooks, createLiveVue, findComponent } from "live_vue" 7 | import { h } from "vue" 8 | 9 | // polyfill recommended by Vite https://vitejs.dev/config/build-options#build-modulepreload 10 | import "vite/modulepreload-polyfill" 11 | 12 | // Create the liveVue app directly here 13 | const liveVueApp = createLiveVue({ 14 | resolve: name => { 15 | const components = { 16 | ...import.meta.glob("../features/**/*.vue", { eager: true }), 17 | } 18 | 19 | return findComponent(components, name) 20 | }, 21 | setup: ({ createApp, component, props, slots, plugin, el }) => { 22 | const app = createApp({ render: () => h(component, props, slots) }) 23 | app.use(plugin) 24 | app.mount(el) 25 | return app 26 | }, 27 | }) 28 | 29 | let csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") 30 | let liveSocket = new LiveSocket("/live", Socket, { 31 | params: { _csrf_token: csrfToken }, 32 | hooks: getHooks(liveVueApp), 33 | }) 34 | 35 | // connect if there are any LiveViews on the page 36 | liveSocket.connect() 37 | window.liveSocket = liveSocket 38 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: E2E Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | e2e-tests: 11 | name: E2E tests 12 | runs-on: ubuntu-24.04 13 | 14 | env: 15 | MIX_ENV: e2e 16 | 17 | steps: 18 | - uses: actions/checkout@v5 19 | 20 | - name: Set up Elixir 21 | uses: erlef/setup-beam@v1 22 | with: 23 | elixir-version: "1.15" 24 | otp-version: "26" 25 | 26 | - name: Restore Elixir dependency cache 27 | uses: actions/cache@v4 28 | id: deps-cache 29 | with: 30 | path: deps 31 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 32 | restore-keys: ${{ runner.os }}-mix- 33 | 34 | - name: Install Elixir dependencies 35 | if: steps.deps-cache.outputs.cache-hit != 'true' 36 | run: mix deps.get 37 | 38 | - name: Set up Node.js 39 | uses: actions/setup-node@v4 40 | with: 41 | node-version: "24" 42 | cache: "npm" 43 | 44 | - name: Install npm dependencies (root) 45 | run: npm install 46 | 47 | - name: Install E2E dependencies and browsers 48 | run: npm run e2e:install 49 | 50 | - name: Run E2E tests 51 | run: npm run e2e:test 52 | -------------------------------------------------------------------------------- /lib/live_vue/reload.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.Reload do 2 | @moduledoc """ 3 | Utilities for easier integration with Vite in development 4 | """ 5 | 6 | use Phoenix.Component 7 | 8 | attr :assets, :list, required: true 9 | slot :inner_block, required: true, doc: "what should be rendered when Vite path is not defined" 10 | 11 | @doc """ 12 | Renders the vite assets in development, and in production falls back to normal compiled assets 13 | """ 14 | def vite_assets(assigns) do 15 | assigns = 16 | assigns 17 | |> assign(:stylesheets, for(path <- assigns.assets, String.ends_with?(path, ".css"), do: path)) 18 | |> assign(:javascripts, for(path <- assigns.assets, String.ends_with?(path, ".js"), do: path)) 19 | 20 | # TODO - maybe make it configurable in other way than by presence of vite_host config? 21 | ~H""" 22 | <%= if Application.get_env(:live_vue, :vite_host) do %> 23 | 25 | 26 | 28 | <% else %> 29 | {render_slot(@inner_block)} 30 | <% end %> 31 | """ 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/e2e/features/event/live.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.E2E.EventLive do 2 | @moduledoc false 3 | use Phoenix.LiveView 4 | 5 | def mount(_params, _session, socket) do 6 | {:ok, assign(socket, message: "", event_count: 0)} 7 | end 8 | 9 | def handle_event("send_notification", %{"message" => message}, socket) do 10 | # Send a server event to the Vue component 11 | send(self(), {:broadcast_event, "notification", %{message: message, timestamp: :os.system_time(:millisecond)}}) 12 | {:noreply, assign(socket, message: message, event_count: socket.assigns.event_count + 1)} 13 | end 14 | 15 | def handle_event("send_custom_event", %{"data" => data}, socket) do 16 | # Send a custom event with structured data 17 | send(self(), {:broadcast_event, "custom_event", %{data: data, count: socket.assigns.event_count + 1}}) 18 | {:noreply, assign(socket, event_count: socket.assigns.event_count + 1)} 19 | end 20 | 21 | def handle_info({:broadcast_event, event_name, payload}, socket) do 22 | # Push the event to the client 23 | {:noreply, push_event(socket, event_name, payload)} 24 | end 25 | 26 | def render(assigns) do 27 | ~H""" 28 |
29 |
Message: {@message}
30 |
Event Count: {@event_count}
31 | 32 |
33 | """ 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/live_vue/components.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.Components do 2 | @moduledoc """ 3 | Macros to improve the developer experience of crossing the Liveview/Vue boundary. 4 | """ 5 | 6 | @doc """ 7 | Generates functions local to your current module that can be used to render Vue components. 8 | TODO: This could perhaps be optimized to only read the files once per compilation. 9 | 10 | ## Examples 11 | 12 | ```elixir 13 | use LiveVue.Components, vue_root: ["./assets/vue", "./lib/my_app_web"] 14 | ``` 15 | """ 16 | defmacro __using__(opts) do 17 | opts 18 | |> Keyword.get(:vue_root, ["./assets/vue"]) 19 | |> List.wrap() 20 | |> Enum.flat_map(fn vue_root -> 21 | if String.contains?(vue_root, "*"), 22 | do: 23 | raise(""" 24 | Glob pattern is not supported in :vue_root, please specify a list of directories. 25 | 26 | Example: 27 | 28 | use LiveVue.Components, vue_root: ["./assets/vue", "./lib/my_app_web"] 29 | """) 30 | 31 | vue_root 32 | |> Path.join("**/*.vue") 33 | |> Path.wildcard() 34 | |> Enum.map(&Path.basename(&1, ".vue")) 35 | end) 36 | |> Enum.uniq() 37 | |> Enum.map(&name_to_function/1) 38 | end 39 | 40 | defp name_to_function(name) do 41 | quote do 42 | def unquote(:"#{name}")(assigns) do 43 | assigns 44 | |> Map.put(:"v-component", unquote(name)) 45 | |> LiveVue.vue() 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/e2e/features/basic/basic.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test" 2 | import { syncLV } from "../../utils.js" 3 | 4 | test.describe("LiveVue Basic E2E Tests", () => { 5 | test("renders Vue component inside LiveView and handles increment events", async ({ page }) => { 6 | await page.goto("/test") 7 | await syncLV(page) 8 | 9 | // Verify Vue component is mounted and displays initial count 10 | await expect(page.locator('[phx-hook="VueHook"]')).toBeVisible() 11 | await expect(page.locator("[data-pw-counter]")).toHaveText("0") 12 | 13 | // Verify the diff slider is present and has default value 14 | const diffSlider = page.locator('input[type="range"]') 15 | await expect(diffSlider).toBeVisible() 16 | await expect(diffSlider).toHaveValue("1") 17 | 18 | // Test incrementing by default value (1) 19 | await page.locator("button").click() 20 | await syncLV(page) 21 | await expect(page.locator('[phx-hook="VueHook"] [data-pw-counter]')).toHaveText("1") 22 | 23 | // Test incrementing by a different value (3) 24 | await diffSlider.fill("3") 25 | await page.locator("button").click() 26 | await syncLV(page) 27 | await expect(page.locator('[phx-hook="VueHook"] [data-pw-counter]')).toHaveText("4") 28 | 29 | // Test incrementing by maximum value (10) 30 | await diffSlider.fill("10") 31 | await page.locator("button").click() 32 | await syncLV(page) 33 | await expect(page.locator('[phx-hook="VueHook"] [data-pw-counter]')).toHaveText("14") 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /test/e2e/features/slot/slot.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test" 2 | import { syncLV } from "../../utils.js" 3 | 4 | test.describe("LiveVue Slot Non-ASCII Character Tests", () => { 5 | test("renders non-ASCII characters in slots correctly", async ({ page }) => { 6 | await page.goto("/slot-test") 7 | await syncLV(page) 8 | 9 | // Test 1: Polish characters 10 | const polishSlot = page.locator('[data-pw-label]:has-text("Polish")').locator('..').locator('[data-pw-slot]') 11 | await expect(polishSlot).toContainText("Zażółć gęślą jaźń") 12 | 13 | // Test 2: Japanese characters 14 | const japaneseSlot = page.locator('[data-pw-label]:has-text("Japanese")').locator('..').locator('[data-pw-slot]') 15 | await expect(japaneseSlot).toContainText("こんにちは世界") 16 | 17 | // Test 3: Emoji 18 | const emojiSlot = page.locator('[data-pw-label]:has-text("Emoji")').locator('..').locator('[data-pw-slot]') 19 | await expect(emojiSlot).toContainText("Hello 🌍 World 🎉 Party 🚀") 20 | 21 | // Test 4: Mixed scripts (Russian, Chinese, Arabic) 22 | const mixedSlot = page.locator('[data-pw-label]:has-text("Mixed")').locator('..').locator('[data-pw-slot]') 23 | await expect(mixedSlot).toContainText("Привет мир!") 24 | await expect(mixedSlot).toContainText("你好世界!") 25 | await expect(mixedSlot).toContainText("مرحبا بالعالم") 26 | 27 | // Test 5: Special Latin characters 28 | const specialSlot = page.locator('[data-pw-label]:has-text("Special")').locator('..').locator('[data-pw-slot]') 29 | await expect(specialSlot).toContainText("Ñoño café résumé naïve") 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /lib/live_vue/ssr.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.SSR.NotConfigured do 2 | @moduledoc false 3 | 4 | defexception [:message] 5 | end 6 | 7 | defmodule LiveVue.SSR do 8 | @moduledoc """ 9 | A behaviour for rendering Vue components server-side. 10 | 11 | To define a custom renderer, change the application config in `config.exs`: 12 | 13 | config :live_vue, ssr_module: MyCustomSSRModule 14 | 15 | Exposes a telemetry span for each render under key `[:live_vue, :ssr]` 16 | """ 17 | 18 | require Logger 19 | 20 | @type component_name :: String.t() 21 | @type props :: %{optional(String.t() | atom) => any} 22 | @type slots :: %{optional(String.t() | atom) => any} 23 | 24 | @typedoc """ 25 | A render response which should have shape 26 | 27 | %{ 28 | html: string, 29 | preloadLinks: string 30 | } 31 | """ 32 | @type render_response :: %{optional(String.t() | atom) => any} 33 | 34 | @callback render(component_name, props, slots) :: render_response | no_return 35 | 36 | @spec render(component_name, props, slots) :: render_response | no_return 37 | def render(name, props, slots) do 38 | case Application.get_env(:live_vue, :ssr_module, nil) do 39 | nil -> 40 | %{preloadLinks: "", html: ""} 41 | 42 | mod -> 43 | meta = %{component: name, props: props, slots: slots} 44 | 45 | body = 46 | :telemetry.span([:live_vue, :ssr], meta, fn -> 47 | {mod.render(name, props, slots), meta} 48 | end) 49 | 50 | with body when is_binary(body) <- body do 51 | case String.split(body, "", parts: 2) do 52 | [links, html] -> %{preloadLinks: links, html: html} 53 | [body] -> %{preloadLinks: "", html: body} 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/e2e/utils.js: -------------------------------------------------------------------------------- 1 | import { expect } from "@playwright/test" 2 | 3 | // Wait for LiveView to be ready and Vue components to be mounted 4 | export const syncLV = async page => { 5 | // Use longer timeout for connection - websocket setup can take time under load 6 | const connectionTimeout = { timeout: 5000 } 7 | const promises = [ 8 | expect(page.locator(".phx-connected").first()).toBeVisible(connectionTimeout), 9 | expect(page.locator(".phx-change-loading")).toHaveCount(0), 10 | expect(page.locator(".phx-click-loading")).toHaveCount(0), 11 | expect(page.locator(".phx-submit-loading")).toHaveCount(0), 12 | new Promise(resolve => setTimeout(resolve, 50)), 13 | ] 14 | return Promise.all(promises) 15 | } 16 | 17 | // Execute code inside LiveView process 18 | export const evalLV = async (page, code, selector = "[data-phx-main]") => 19 | await page.evaluate( 20 | ([code, selector]) => { 21 | return new Promise(resolve => { 22 | window.liveSocket.main.withinTargets(selector, (targetView, targetCtx) => { 23 | targetView.pushEvent( 24 | "event", 25 | document.body, 26 | targetCtx, 27 | "sandbox:eval", 28 | { value: code }, 29 | {}, 30 | ({ result, error }) => { 31 | if (error) { 32 | throw new Error(error) 33 | } 34 | resolve(result) 35 | } 36 | ) 37 | }) 38 | }) 39 | }, 40 | [code, selector] 41 | ) 42 | 43 | // Generate random string for test isolation 44 | export const randomString = length => { 45 | const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" 46 | let result = "" 47 | for (let i = 0; i < length; i++) { 48 | result += chars.charAt(Math.floor(Math.random() * chars.length)) 49 | } 50 | return result 51 | } 52 | -------------------------------------------------------------------------------- /test/e2e/features/upload/live.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.E2E.UploadTestLive do 2 | @moduledoc false 3 | use Phoenix.LiveView 4 | 5 | def render(assigns) do 6 | ~H""" 7 |
8 |

Upload Test

9 |
Mode: {@upload_mode}
10 |
Uploaded files: {length(@uploaded_files)}
11 | 12 | 19 |
20 | """ 21 | end 22 | 23 | def mount(%{"mode" => mode}, _session, socket) do 24 | auto_upload = mode == "auto" 25 | 26 | {:ok, 27 | socket 28 | |> assign(:upload_mode, mode) 29 | |> assign(:uploaded_files, []) 30 | |> allow_upload(:test_files, 31 | accept: ~w(.txt .pdf .jpg .png), 32 | max_entries: 3, 33 | # 1MB 34 | max_file_size: 1_000_000, 35 | auto_upload: auto_upload 36 | )} 37 | end 38 | 39 | def handle_event("validate", _params, socket) do 40 | {:noreply, socket} 41 | end 42 | 43 | def handle_event("cancel-upload", %{"ref" => ref}, socket) do 44 | {:noreply, cancel_upload(socket, :test_files, ref)} 45 | end 46 | 47 | def handle_event("save", _params, socket) do 48 | uploaded_files = 49 | consume_uploaded_entries(socket, :test_files, fn %{path: path}, entry -> 50 | # Simulate processing the file 51 | file_info = %{ 52 | name: entry.client_name, 53 | size: entry.client_size, 54 | type: entry.client_type 55 | } 56 | 57 | # Clean up the temporary file 58 | File.rm(path) 59 | 60 | {:ok, file_info} 61 | end) 62 | 63 | {:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded_files))} 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | name: Build and test ${{ matrix.elixir }} / OTP ${{ matrix.otp }} 12 | runs-on: ubuntu-24.04 13 | 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | MIX_ENV: test 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | include: 22 | - elixir: "1.15" 23 | otp: "25" 24 | - elixir: "1.17" 25 | otp: "27" 26 | - elixir: "1.18" 27 | otp: "28" 28 | - elixir: "1.19" 29 | otp: "28" 30 | 31 | steps: 32 | - uses: actions/checkout@v5 33 | - name: Set up Elixir 34 | uses: erlef/setup-beam@v1 35 | with: 36 | elixir-version: ${{ matrix.elixir }} 37 | otp-version: ${{ matrix.otp }} 38 | - name: Restore dependency cache 39 | uses: actions/cache@v4 40 | id: deps-cache 41 | with: 42 | path: deps 43 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix-${{ hashFiles('**/mix.lock') }} 44 | restore-keys: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix- 45 | - name: Restore build cache 46 | uses: actions/cache@v4 47 | with: 48 | path: _build 49 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-build-${{ hashFiles('**/mix.lock') }} 50 | restore-keys: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-build- 51 | - name: Install dependencies 52 | if: steps.deps-cache.outputs.cache-hit != 'true' 53 | run: mix deps.get 54 | - name: Install phx_new 55 | run: mix archive.install hex phx_new 1.8.0 --force 56 | - name: Compile application 57 | run: mix compile 58 | - name: Run tests 59 | run: mix test 60 | - name: Run tests and coverage 61 | if: matrix.elixir == '1.17' && matrix.otp == '27' 62 | run: mix coveralls.github 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "live_vue", 3 | "version": "1.0.0-rc.4", 4 | "description": "E2E reactivity for Vue and LiveView", 5 | "license": "MIT", 6 | "author": "Jakub Skałecki ", 7 | "type": "module", 8 | "scripts": { 9 | "format": "npx prettier --write assets", 10 | "test": "vitest", 11 | "test:watch": "vitest --watch", 12 | "test:ui": "vitest --ui", 13 | "e2e:install": "npx playwright install --with-deps chromium", 14 | "e2e:server": "MIX_ENV=e2e mix run test/e2e/test_helper.exs", 15 | "e2e:build": "npx vite build --config test/e2e/vite.config.js", 16 | "e2e:test": "npx vite build --config test/e2e/vite.config.js && npx playwright test --config test/e2e/playwright.config.js", 17 | "e2e:test:headed": "npx vite build --config test/e2e/vite.config.js && npx playwright test --config test/e2e/playwright.config.js --headed", 18 | "e2e:test:debug": "npx vite build --config test/e2e/vite.config.js && npx playwright test --config test/e2e/playwright.config.js --debug" 19 | }, 20 | "devDependencies": { 21 | "phoenix": "file:deps/phoenix", 22 | "phoenix_html": "file:deps/phoenix_html", 23 | "phoenix_live_view": "file:deps/phoenix_live_view", 24 | "@playwright/test": "^1.53.0", 25 | "@types/node": "^22.9.1", 26 | "@vitejs/plugin-vue": "^5.2.0", 27 | "@vitest/ui": "^3.2.4", 28 | "jsdom": "^26.1.0", 29 | "prettier": "2.8.7", 30 | "typescript": "^5.6.2", 31 | "vite": "^5.4.8", 32 | "vitest": "^3.2.4", 33 | "vue": "^3.5.10" 34 | }, 35 | "main": "assets/index.ts", 36 | "types": "assets/index.ts", 37 | "exports": { 38 | "./vitePlugin": { 39 | "import": "./assets/vitePlugin.js" 40 | }, 41 | "./server": { 42 | "import": "./assets/server.ts", 43 | "types": "./assets/server.ts" 44 | }, 45 | ".": { 46 | "import": "./assets/index.ts", 47 | "types": "./assets/index.ts" 48 | } 49 | }, 50 | "repository": { 51 | "type": "git", 52 | "url": "git://github.com/Valian/live_vue.git" 53 | }, 54 | "files": [ 55 | "README.md", 56 | "LICENSE.md", 57 | "package.json", 58 | "assets" 59 | ], 60 | "overrides": { 61 | "nanoid": "^3.3.8", 62 | "rollup": "^4.22.4" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/live_vue/ssr/vite_js.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.SSR.ViteJS do 2 | @moduledoc """ 3 | Implements SSR by making a POST request to `http://{:vite_host}/ssr_render`. 4 | 5 | `ssr_render` is implemented as a Vite plugin. You have to add it to the `vite.config.js` plugins section. 6 | 7 | ```javascript 8 | import liveVuePlugin from "live_vue/vitePlugin" 9 | 10 | { 11 | publicDir: "static", 12 | plugins: [vue(), liveVuePlugin()], 13 | // ... 14 | } 15 | ``` 16 | """ 17 | @behaviour LiveVue.SSR 18 | 19 | def render(name, props, slots) do 20 | data = Jason.encode!(%{name: name, props: props, slots: slots}) 21 | url = vite_path("/ssr_render") 22 | params = {String.to_charlist(url), [], ~c"application/json", data} 23 | 24 | case :httpc.request(:post, params, [], []) do 25 | {:ok, {{_, 200, _}, _headers, body}} -> 26 | :erlang.list_to_binary(body) 27 | 28 | {:ok, {{_, 500, _}, _headers, body}} -> 29 | case Jason.decode(body) do 30 | {:ok, %{"error" => %{"message" => msg, "loc" => loc, "frame" => frame}}} -> 31 | {:error, "#{msg}\n#{loc["file"]}:#{loc["line"]}:#{loc["column"]}\n#{frame}"} 32 | 33 | {:ok, %{"error" => %{"stack" => stack}}} -> 34 | {:error, stack} 35 | 36 | _ -> 37 | {:error, "Unexpected Vite SSR response: 500 #{body}"} 38 | end 39 | 40 | {:ok, {{_, status, code}, _headers, _body}} -> 41 | {:error, "Unexpected Vite SSR response: #{status} #{code}"} 42 | 43 | {:error, {:failed_connect, [{:to_address, {url, port}}, {_, _, code}]}} -> 44 | {:error, "Unable to connect to Vite #{url}:#{port}: #{code}"} 45 | end 46 | end 47 | 48 | @doc """ 49 | A handy utility returning path relative to Vite JS host. 50 | """ 51 | def vite_path(path) do 52 | case Application.get_env(:live_vue, :vite_host) do 53 | nil -> 54 | message = """ 55 | Vite.js host is not configured. Please add the following to config/dev.ex 56 | 57 | config :live_vue, vite_host: "http://localhost:5173" 58 | 59 | and ensure vite.js is running 60 | """ 61 | 62 | raise %LiveVue.SSR.NotConfigured{message: message} 63 | 64 | host -> 65 | # we get rid of assets prefix since for vite /assets is root 66 | Path.join(host, path) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /assets/link.ts: -------------------------------------------------------------------------------- 1 | import { computed, defineComponent, h } from "vue" 2 | 3 | type PhxLinkAttrs = { 4 | href: string 5 | "data-phx-link"?: "redirect" | "patch" 6 | "data-phx-link-state"?: "replace" | "push" 7 | } 8 | 9 | export default defineComponent({ 10 | props: { 11 | /** 12 | * Uses traditional browser navigation to the new location. 13 | * This means the whole page is reloaded on the browser. 14 | */ 15 | href: { 16 | type: String, 17 | default: null, 18 | }, 19 | /** 20 | * Patches the current LiveView. 21 | * The `handle_params` callback of the current LiveView will be invoked and the minimum content 22 | * will be sent over the wire, as any other LiveView diff. 23 | */ 24 | patch: { 25 | type: String, 26 | default: null, 27 | }, 28 | /** 29 | * Navigates to a LiveView. 30 | * When redirecting across LiveViews, the browser page is kept, but a new LiveView process 31 | * is mounted and its contents is loaded on the page. It is only possible to navigate 32 | * between LiveViews declared under the same router 33 | * [`live_session`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Router.html#live_session/3). 34 | * When used outside of a LiveView or across live sessions, it behaves like a regular 35 | * browser redirect. 36 | */ 37 | navigate: { 38 | type: String, 39 | default: null, 40 | }, 41 | /** 42 | * When using `:patch` or `:navigate`, 43 | * should the browser's history be replaced with `pushState`? 44 | */ 45 | replace: { 46 | type: Boolean, 47 | default: false, 48 | }, 49 | }, 50 | setup(props, { attrs, slots }) { 51 | const linkAttrs = computed(() => { 52 | if (!props.patch && !props.navigate) { 53 | return { 54 | href: props.href || "#", 55 | } 56 | } 57 | 58 | return { 59 | href: (props.navigate ? props.navigate : props.patch) || "#", 60 | "data-phx-link": props.navigate ? "redirect" : "patch", 61 | "data-phx-link-state": props.replace ? "replace" : "push", 62 | } 63 | }) 64 | 65 | return () => { 66 | return h( 67 | "a", 68 | { 69 | ...attrs, 70 | ...linkAttrs.value, 71 | }, 72 | slots 73 | ) 74 | } 75 | }, 76 | }) 77 | -------------------------------------------------------------------------------- /test/e2e/features/navigation/navigation.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test' 2 | import { syncLV } from '../../utils.js' 3 | 4 | test.describe('useLiveNavigation', () => { 5 | test('should patch query params and navigate between routes', async ({ page }) => { 6 | // Start at the first navigation route 7 | await page.goto('/navigation/test1') 8 | await syncLV(page) 9 | 10 | // Verify initial state 11 | await expect(page.locator('#current-params')).toContainText('"page":"test1"') 12 | await expect(page.locator('#current-query')).toContainText('{}') 13 | 14 | // Test patch functionality - should update query params 15 | await page.click('#patch-btn') 16 | await syncLV(page) 17 | 18 | // Verify query params were updated 19 | await expect(page.locator('#current-query')).toContainText('"foo":"bar"') 20 | await expect(page.locator('#current-query')).toContainText('"timestamp"') 21 | // URL should still have the same page param but with query params 22 | await expect(page).toHaveURL(/\/navigation\/test1\?.*foo=bar/) 23 | 24 | // Test navigate functionality - should change to alt route 25 | await page.click('#navigate-btn') 26 | await syncLV(page) 27 | 28 | // Verify navigation to alt route 29 | await expect(page.locator('#current-params')).toContainText('"page":"test2"') 30 | await expect(page.locator('#current-query')).toContainText('"baz":"qux"') 31 | await expect(page).toHaveURL(/\/navigation\/alt\/test2\?.*baz=qux/) 32 | 33 | // Test navigate back 34 | await page.click('#navigate-back-btn') 35 | await syncLV(page) 36 | 37 | // Verify navigation back to original route 38 | await expect(page.locator('#current-params')).toContainText('"page":"test1"') 39 | await expect(page.locator('#current-query')).toContainText('{}') 40 | await expect(page).toHaveURL('/navigation/test1') 41 | }) 42 | 43 | test('should handle direct route access with params', async ({ page }) => { 44 | // Test direct access to alt route with query params 45 | await page.goto('/navigation/alt/direct?test=value&count=42') 46 | await syncLV(page) 47 | 48 | // Verify params and query params are correctly parsed 49 | await expect(page.locator('#current-params')).toContainText('"page":"direct"') 50 | await expect(page.locator('#current-query')).toContainText('"test":"value"') 51 | await expect(page.locator('#current-query')).toContainText('"count":"42"') 52 | }) 53 | }) -------------------------------------------------------------------------------- /assets/app.ts: -------------------------------------------------------------------------------- 1 | import { type App, type Component, h } from "vue" 2 | import type { 3 | ComponentOrComponentModule, 4 | ComponentOrComponentPromise, 5 | SetupContext, 6 | LiveVueOptions, 7 | ComponentMap, 8 | LiveVueApp, 9 | } from "./types.js" 10 | 11 | /** 12 | * Initializes a Vue app with the given options and mounts it to the specified element. 13 | * It's a default implementation of the `setup` option, which can be overridden. 14 | * If you want to override it, simply provide your own implementation of the `setup` option. 15 | */ 16 | export const defaultSetup = ({ createApp, component, props, slots, plugin, el }: SetupContext) => { 17 | const app = createApp({ render: () => h(component, props, slots) }) 18 | app.use(plugin) 19 | app.mount(el) 20 | return app 21 | } 22 | 23 | export const migrateToLiveVueApp = ( 24 | components: ComponentMap, 25 | options: { initializeApp?: (context: SetupContext) => App } = {} 26 | ): LiveVueApp => { 27 | if ("resolve" in components && "setup" in components) { 28 | return components as LiveVueApp 29 | } else { 30 | console.warn("deprecation warning:\n\nInstead of passing components, use createLiveVue({resolve, setup})") 31 | return createLiveVue({ 32 | resolve: (name: string) => { 33 | for (const [key, value] of Object.entries(components)) { 34 | if (key.endsWith(`${name}.vue`) || key.endsWith(`${name}/index.vue`)) { 35 | return value 36 | } 37 | } 38 | }, 39 | setup: options.initializeApp, 40 | }) 41 | } 42 | } 43 | 44 | const resolveComponent = async (component: ComponentOrComponentModule): Promise => { 45 | if (typeof component === "function") { 46 | // it's an async component, let's try to load it 47 | component = await (component as () => Promise)() 48 | } else if (component instanceof Promise) { 49 | component = await component 50 | } 51 | 52 | if (component && "default" in component) { 53 | // if there's a default export, use it 54 | component = component.default 55 | } 56 | 57 | return component 58 | } 59 | 60 | export const createLiveVue = ({ resolve, setup }: LiveVueOptions) => { 61 | return { 62 | setup: setup || defaultSetup, 63 | resolve: async (path: string): Promise => { 64 | let component = resolve(path) 65 | if (!component) throw new Error(`Component ${path} not found!`) 66 | return await resolveComponent(component) 67 | }, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test/e2e/features/event/event_test.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | -------------------------------------------------------------------------------- /test/live_vue_ssr_nodejs_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.SSR.NodeJSTest do 2 | use ExUnit.Case 3 | 4 | alias LiveVue.SSR.NodeJS, as: NodeJSRenderer 5 | 6 | @moduletag :nodejs_ssr 7 | 8 | # Path to our test support directory 9 | @test_support_path Path.expand("support", __DIR__) 10 | # Just the filename - NodeJS.Supervisor resolves relative to its path 11 | @test_server_filename "ssr_test_server.mjs" 12 | 13 | describe "render/3 with NodeJS.Supervisor running" do 14 | setup do 15 | # Start NodeJS.Supervisor for this test (from the nodejs package) 16 | # The path should be the directory containing our test server 17 | start_supervised!({NodeJS.Supervisor, [path: @test_support_path, pool_size: 1]}) 18 | 19 | # Configure to use our test server - just the filename since NodeJS resolves relative to path 20 | Application.put_env(:live_vue, :ssr_filepath, @test_server_filename) 21 | 22 | on_exit(fn -> 23 | Application.delete_env(:live_vue, :ssr_filepath) 24 | end) 25 | 26 | :ok 27 | end 28 | 29 | test "renders component and returns HTML" do 30 | result = NodeJSRenderer.render("TestComponent", %{"count" => 42}, %{}) 31 | 32 | assert is_binary(result) 33 | assert result =~ "SSR Rendered: TestComponent" 34 | assert result =~ "TestComponent" 35 | end 36 | 37 | test "passes props to the render function" do 38 | result = NodeJSRenderer.render("MyComponent", %{"name" => "John", "age" => 30}, %{}) 39 | 40 | assert result =~ "John" 41 | assert result =~ "30" 42 | end 43 | 44 | test "passes slots to the render function" do 45 | result = NodeJSRenderer.render("SlotComponent", %{}, %{"default" => "

Content

"}) 46 | 47 | assert result =~ "Content" 48 | end 49 | 50 | test "handles preload links delimiter" do 51 | result = NodeJSRenderer.render("WithPreloadLinks", %{}, %{}) 52 | 53 | assert result =~ "" 55 | end 56 | end 57 | 58 | describe "render/3 without NodeJS.Supervisor" do 59 | test "raises NotConfigured when NodeJS.Supervisor is not running" do 60 | # Make sure no supervisor is running 61 | # Configure to use our test server filename 62 | Application.put_env(:live_vue, :ssr_filepath, @test_server_filename) 63 | 64 | on_exit(fn -> 65 | Application.delete_env(:live_vue, :ssr_filepath) 66 | end) 67 | 68 | assert_raise LiveVue.SSR.NotConfigured, 69 | ~r/NodeJS is not configured/, 70 | fn -> 71 | NodeJSRenderer.render("TestComponent", %{}, %{}) 72 | end 73 | end 74 | end 75 | 76 | describe "server_path/0" do 77 | test "returns the priv directory path for the current application" do 78 | # server_path/0 uses :application.get_application() which returns :undefined 79 | # when called outside of application context (like in tests) 80 | # This is expected behavior - it's designed to be called from within an application 81 | assert_raise MatchError, fn -> 82 | NodeJSRenderer.server_path() 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /guides/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | LiveVue replaces `esbuild` with [Vite](https://vitejs.dev/) for both client side code and SSR to achieve an amazing development experience. 4 | 5 | ## Why Vite? 6 | 7 | - Vite provides a best-in-class Hot-Reload functionality and offers [many benefits](https://vitejs.dev/guide/why#why-vite) 8 | - `esbuild` package doesn't support plugins, so we would need to setup a custom build process anyway 9 | - In production, we'll use [elixir-nodejs](https://github.com/revelrylabs/elixir-nodejs) for SSR 10 | 11 | If you don't need SSR, you can easily disable it with one line of configuration. 12 | 13 | ## Prerequisites 14 | 15 | - Node.js installed (version 19 or later recommended) 16 | - Elixir 1.13+ 17 | - **Phoenix 1.8+** (required for Igniter installer) 18 | - [Igniter](https://hexdocs.pm/igniter/) installed (see below) 19 | 20 | ## Quick Start (Recommended) 21 | 22 | ### Installing Igniter 23 | 24 | First, install the Igniter archive: 25 | 26 | ```bash 27 | mix archive.install hex igniter_new 28 | ``` 29 | 30 | ### New Project 31 | 32 | Create a new Phoenix project with LiveVue pre-installed: 33 | 34 | ```bash 35 | mix igniter.new my_app --with phx.new --install live_vue 36 | ``` 37 | 38 | This command will: 39 | - Create a new Phoenix project using `phx.new` 40 | - Install and configure LiveVue automatically 41 | - Set up Vite, Vue, TypeScript, and all necessary files 42 | - Create a working Vue demo component 43 | 44 | ### Existing Project 45 | 46 | To add LiveVue to an existing Phoenix 1.8+ project: 47 | 48 | ```bash 49 | mix igniter.install live_vue@1.0.0-rc.4 50 | ``` 51 | 52 | This will automatically configure your project with all necessary LiveVue setup. 53 | 54 | > #### Important Limitations {: .warning} 55 | > 56 | > - **Phoenix 1.8+ required**: The Igniter installer depends on `phoenix_vite` and modern Phoenix features that are only available in Phoenix 1.8+ 57 | > - **Pre-Igniter LiveVue upgrade not supported**: If you have an existing LiveVue installation from before the Igniter installer was introduced, upgrading via `mix igniter.install live_vue@1.0.0-rc.4` is not supported due to significant changes in the installation process. On the other hand, you should be able to bump version of LiveVue in your `mix.exs` file and everything should still work. 58 | > - **LiveVue itself works with Phoenix 1.7**: While the automated installer requires Phoenix 1.8+, the LiveVue library itself is compatible with Phoenix 1.7 if installed manually 59 | 60 | ## Manual Installation 61 | 62 | > #### Outdated Manual Instructions {: .warning} 63 | > 64 | > Manual installation instructions are currently outdated and don't work with current versions of dependencies (Tailwind, Phoenix, etc). 65 | > 66 | > **We strongly recommend using the Igniter installation above.** 67 | 68 | The manual installation process involves many complex steps including: 69 | - Configuring Vite and Vue dependencies 70 | - Setting up TypeScript and PostCSS 71 | - Updating Phoenix configuration files 72 | - Configuring Tailwind for Vue files 73 | - Setting up SSR for production 74 | - And many more manual steps... 75 | 76 | For the current version, please use the Igniter installation above. 77 | 78 | ## Next Steps 79 | 80 | Now that you have LiveVue installed, check out our [Getting Started Guide](getting_started.md) to create your first Vue component! -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # LiveVue Library Development 2 | 3 | Vue.js + Phoenix LiveView integration library. Version 1.0.0-rc.4. 4 | 5 | ## Quick Reference 6 | 7 | ```bash 8 | # Tests 9 | mix test # Elixir tests 10 | npm test # Vitest (assets/*.test.ts) 11 | npm run e2e:test # Playwright E2E (test/e2e/) 12 | 13 | # Setup 14 | mix setup # First-time setup (deps + npm install) 15 | ``` 16 | 17 | ## Project Structure 18 | 19 | ``` 20 | lib/ 21 | ├── live_vue.ex # Main module, ~VUE sigil 22 | ├── live_vue/components.ex # <.vue> component, props handling 23 | ├── live_vue/encoder.ex # JSON encoding for Vue props 24 | ├── live_vue/slots.ex # Slot interoperability 25 | └── live_vue/ssr/ # SSR: NodeJS and ViteJS modes 26 | assets/ 27 | ├── index.ts # Main entry, getHooks() 28 | ├── hooks.ts # Phoenix LiveView hooks 29 | ├── use.ts # Vue composables (useLiveEvent, etc.) 30 | ├── useLiveForm.ts # Form handling with Ecto changesets 31 | ├── jsonPatch.ts # Efficient prop diffing 32 | └── vitePlugin.js # Vite plugin for component discovery 33 | test/e2e/ # Playwright E2E tests with Phoenix server 34 | ``` 35 | 36 | ## Key Patterns 37 | 38 | ### Component Usage (Elixir) 39 | ```elixir 40 | # In LiveView template 41 | <.vue count={@count} v-component="Counter" v-socket={@socket} /> 42 | 43 | # Or with ~VUE sigil 44 | ~VUE""" 45 | 46 | """ 47 | ``` 48 | 49 | ### Vue Composables (TypeScript) 50 | - `useLiveVue()` - Access to `$live.pushEvent()`, props 51 | - `useLiveEvent(name, handler)` - LiveView event subscription 52 | - `useLiveNavigation()` - `patch()` and `navigate()` helpers 53 | - `useLiveForm(formName)` - Server-side validation with Ecto 54 | - `useLiveUpload(uploadName)` - File upload integration 55 | 56 | ### SSR Modes 57 | - `LiveVue.SSR.NodeJS` - Node.js subprocess (default) 58 | - `LiveVue.SSR.ViteJS` - HTTP to Vite dev server (dev mode) 59 | 60 | ## E2E Testing 61 | 62 | Colocated feature structure in `test/e2e/features/`: 63 | 64 | ``` 65 | test/e2e/features/ 66 | ├── basic/ # Each feature is a directory 67 | │ ├── live.ex # LiveView module 68 | │ ├── counter.vue # Vue component(s) 69 | │ └── basic.spec.js # Playwright test 70 | ├── form/ 71 | ├── stream/ 72 | └── ... 73 | ``` 74 | 75 | To add a new E2E test: 76 | 1. Create `test/e2e/features/my-feature/` 77 | 2. Add `live.ex` (LiveView), `*.vue` (components), `*.spec.js` (test) 78 | 3. Add route to `test/e2e/test_helper.exs` router 79 | 80 | Key utilities in `test/e2e/utils.js`: 81 | - `syncLV(page)` - Wait for LiveView connection 82 | - `evalLV(page, code)` - Execute Elixir in LiveView process 83 | 84 | ## Conventions 85 | 86 | Commit format: `type: description` (feat/fix/docs/test/refactor/chore) 87 | 88 | ## Release Process 89 | 90 | No JS build step required. `package.json` exports point directly to TypeScript source files (`assets/*.ts`). Vite handles TS transpilation when consumers bundle their app. 91 | 92 | For hex.pm releases, `mix release.{patch,minor,major}` runs expublish (commits, tags, publishes). 93 | 94 | ## Notes 95 | 96 | - This is a library - use E2E tests (`npm run e2e:test`) for testing 97 | - CI: Elixir (.github/workflows/elixir.yml), Frontend (.github/workflows/frontend.yml) 98 | -------------------------------------------------------------------------------- /test/e2e/README.md: -------------------------------------------------------------------------------- 1 | # LiveVue End-to-End Tests 2 | 3 | This directory contains end-to-end tests for the LiveVue library using Playwright. 4 | 5 | ## Setup 6 | 7 | 1. Install Playwright browsers: 8 | ```bash 9 | npm run e2e:install 10 | ``` 11 | 12 | ## Running Tests 13 | 14 | ```bash 15 | npm run e2e:test # Run all tests 16 | npm run e2e:test:headed # Run with browser UI 17 | npm run e2e:test:debug # Debug interactively 18 | ``` 19 | 20 | ## Structure 21 | 22 | Tests are organized as colocated features - each feature has its LiveView, Vue components, and tests in one directory: 23 | 24 | ``` 25 | test/e2e/ 26 | ├── features/ 27 | │ ├── basic/ # Basic counter test 28 | │ │ ├── live.ex # LiveView module (LiveVue.E2E.TestLive) 29 | │ │ ├── counter.vue # Vue component 30 | │ │ └── basic.spec.js # Playwright test 31 | │ ├── form/ # Form validation tests 32 | │ ├── stream/ # LiveView streams tests 33 | │ ├── event/ # useLiveEvent tests 34 | │ ├── event-reply/ # Event reply tests 35 | │ ├── navigation/ # useLiveNavigation tests 36 | │ ├── prop-diff/ # Prop diffing tests 37 | │ ├── slot/ # Slot rendering tests 38 | │ └── upload/ # File upload tests 39 | ├── js/ 40 | │ └── app.js # Vue/LiveSocket bootstrap 41 | ├── test_helper.exs # Phoenix endpoint, routes, layout 42 | ├── utils.js # Test utilities 43 | ├── playwright.config.js 44 | └── vite.config.js 45 | ``` 46 | 47 | ## Adding a New Test Feature 48 | 49 | 1. Create directory: `test/e2e/features/my-feature/` 50 | 51 | 2. Add LiveView (`live.ex`): 52 | ```elixir 53 | defmodule LiveVue.E2E.MyFeatureLive do 54 | use Phoenix.LiveView 55 | 56 | def mount(_params, _session, socket) do 57 | {:ok, assign(socket, :data, "hello")} 58 | end 59 | 60 | def render(assigns) do 61 | ~H""" 62 | 63 | """ 64 | end 65 | end 66 | ``` 67 | 68 | 3. Add Vue component (`my_component.vue`): 69 | ```vue 70 | 73 | 76 | ``` 77 | 78 | 4. Add route to `test_helper.exs`: 79 | ```elixir 80 | live "/my-feature", MyFeatureLive 81 | ``` 82 | 83 | 5. Add test (`my-feature.spec.js`): 84 | ```javascript 85 | import { test, expect } from "@playwright/test" 86 | import { syncLV } from "../../utils.js" 87 | 88 | test("my feature works", async ({ page }) => { 89 | await page.goto("/my-feature") 90 | await syncLV(page) 91 | await expect(page.getByTestId("output")).toHaveText("hello") 92 | }) 93 | ``` 94 | 95 | ## Test Utilities 96 | 97 | - `syncLV(page)` - Wait for LiveView to connect and finish loading 98 | - `evalLV(page, code)` - Execute Elixir code in LiveView process (returns result) 99 | 100 | ## Test Server 101 | 102 | Runs on http://localhost:4004. Routes are defined in `test_helper.exs`. 103 | 104 | ## Notes 105 | 106 | - LiveView modules are compiled via `elixirc_paths(:e2e)` in `mix.exs` 107 | - Vue components are discovered via `import.meta.glob("../features/**/*.vue")` 108 | - Each feature can have multiple Vue components if needed 109 | -------------------------------------------------------------------------------- /assets/types.ts: -------------------------------------------------------------------------------- 1 | // Conditional imports with fallback types for phoenix_live_view < 1.1 2 | import type { App, Component, createApp, createSSRApp, h, Plugin } from "vue" 3 | 4 | // Try to import from phoenix_live_view first, fallback to our definitions if not available 5 | import type { 6 | LiveSocketInstanceInterface as PhoenixLiveSocketInstanceInterface, 7 | ViewHook as PhoenixViewHook, 8 | Hook as PhoenixHook, 9 | } from "phoenix_live_view" 10 | 11 | // If using phoenix_live_view < 1.1, use our fallback types 12 | import type { 13 | LiveSocketInstanceInterface as FallbackLiveSocketInstanceInterface, 14 | ViewHook as FallbackViewHook, 15 | Hook as FallbackHook, 16 | } from "./phoenixFallbackTypes" 17 | 18 | // Re-export with our preferred names, using phoenix_live_view types if available 19 | export type LiveSocketInstanceInterface = PhoenixLiveSocketInstanceInterface extends undefined 20 | ? FallbackLiveSocketInstanceInterface 21 | : PhoenixLiveSocketInstanceInterface 22 | 23 | export type ViewHook = PhoenixViewHook extends undefined ? FallbackViewHook : PhoenixViewHook 24 | export type Hook = PhoenixHook extends undefined ? FallbackHook : PhoenixHook 25 | 26 | export type ComponentOrComponentModule = Component | { default: Component } 27 | export type ComponentOrComponentPromise = ComponentOrComponentModule | Promise 28 | export type ComponentMap = Record 29 | 30 | export type VueComponent = ComponentOrComponentPromise 31 | 32 | type VueComponentInternal = Parameters[0] 33 | type VuePropsInternal = Parameters[1] 34 | type VueSlotsInternal = Parameters[2] 35 | 36 | export type VueArgs = { 37 | props: VuePropsInternal 38 | slots: VueSlotsInternal 39 | app: App 40 | } 41 | 42 | // all the functions and additional properties that are available on the LiveHook 43 | export type LiveHook = ViewHook & { vue: VueArgs; liveSocket: LiveSocketInstanceInterface } 44 | 45 | // Phoenix LiveView Upload types for client-side use 46 | export interface UploadEntry { 47 | ref: string 48 | client_name: string 49 | client_size: number 50 | client_type: string 51 | progress: number 52 | done: boolean 53 | valid: boolean 54 | preflighted: boolean 55 | errors: string[] 56 | } 57 | 58 | export interface UploadConfig { 59 | ref: string 60 | name: string 61 | accept: string | false 62 | max_entries: number 63 | auto_upload: boolean 64 | entries: UploadEntry[] 65 | errors: { ref: string; error: string }[] 66 | } 67 | 68 | export type UploadOptions = { 69 | changeEvent?: string 70 | submitEvent: string 71 | } 72 | 73 | // Phoenix LiveView AsyncResult type for client-side use 74 | export interface AsyncResult { 75 | ok: boolean 76 | loading: string[] | null 77 | failed: any | null 78 | result: T | null 79 | } 80 | 81 | export interface SetupContext { 82 | createApp: typeof createSSRApp | typeof createApp 83 | component: VueComponentInternal 84 | props: Record 85 | slots: Record unknown> 86 | plugin: Plugin<[]> 87 | el: Element 88 | ssr: boolean 89 | } 90 | 91 | export type LiveVueOptions = { 92 | resolve: (path: string) => ComponentOrComponentPromise | undefined | null 93 | setup?: (context: SetupContext) => App 94 | } 95 | 96 | export type LiveVueApp = { 97 | setup: (context: SetupContext) => App 98 | resolve: (path: string) => ComponentOrComponentPromise 99 | } 100 | 101 | export interface LiveVue { 102 | VueHook: ViewHook 103 | } 104 | -------------------------------------------------------------------------------- /assets/vitePlugin.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @typedef {Object} PluginOptions 5 | * @property {string} [path] - SSR render endpoint path (default: "/ssr_render") 6 | * @property {string} [entrypoint] - SSR entrypoint file (default: "./js/server.js") 7 | */ 8 | 9 | /** 10 | * @param {string} path 11 | * @returns {"css-update" | "js-update" | null} 12 | */ 13 | function hotUpdateType(path) { 14 | if (path.endsWith("css")) return "css-update" 15 | if (path.endsWith("js")) return "js-update" 16 | return null 17 | } 18 | 19 | /** 20 | * @param {import("http").ServerResponse} res 21 | * @param {number} statusCode 22 | * @param {unknown} data 23 | */ 24 | const jsonResponse = (res, statusCode, data) => { 25 | res.statusCode = statusCode 26 | res.setHeader("Content-Type", "application/json") 27 | res.end(JSON.stringify(data)) 28 | } 29 | 30 | /** 31 | * Custom JSON parsing middleware 32 | * @param {import("http").IncomingMessage & { body?: Record }} req 33 | * @param {import("http").ServerResponse} res 34 | * @param {() => Promise} next 35 | */ 36 | const jsonMiddleware = (req, res, next) => { 37 | let data = "" 38 | 39 | req.on("data", chunk => { 40 | data += chunk 41 | }) 42 | 43 | req.on("end", () => { 44 | try { 45 | req.body = JSON.parse(data) 46 | next() 47 | } catch (error) { 48 | jsonResponse(res, 400, { error: "Invalid JSON" }) 49 | } 50 | }) 51 | 52 | req.on("error", err => { 53 | console.error(err) 54 | jsonResponse(res, 500, { error: "Internal Server Error" }) 55 | }) 56 | } 57 | 58 | /** 59 | * LiveVue Vite plugin for SSR and hot reload support 60 | * @param {PluginOptions} [opts] 61 | * @returns {import("vite").Plugin} 62 | */ 63 | function liveVuePlugin(opts = {}) { 64 | return { 65 | name: "live-vue", 66 | handleHotUpdate({ file, modules, server, timestamp }) { 67 | if (file.match(/\.(heex|ex)$/)) { 68 | const invalidatedModules = new Set() 69 | for (const mod of modules) { 70 | server.moduleGraph.invalidateModule(mod, invalidatedModules, timestamp, true) 71 | } 72 | 73 | const updates = Array.from(invalidatedModules).flatMap(m => { 74 | const { file } = m 75 | 76 | if (!file) return [] 77 | 78 | const updateType = hotUpdateType(file) 79 | 80 | if (!updateType) return [] 81 | 82 | return { 83 | type: updateType, 84 | path: m.url, 85 | acceptedPath: m.url, 86 | timestamp: timestamp, 87 | } 88 | }) 89 | 90 | server.ws.send({ 91 | type: "update", 92 | updates, 93 | }) 94 | 95 | return [] 96 | } 97 | }, 98 | configureServer(server) { 99 | process.stdin.on("close", () => process.exit(0)) 100 | process.stdin.resume() 101 | 102 | const path = opts.path || "/ssr_render" 103 | const entrypoint = opts.entrypoint || "./js/server.js" 104 | server.middlewares.use(function liveVueMiddleware(req, res, next) { 105 | if (req.method == "POST" && req.url?.split("?", 1)[0] === path) { 106 | jsonMiddleware(req, res, async () => { 107 | try { 108 | const render = (await server.ssrLoadModule(entrypoint)).render 109 | const html = await render(req.body?.name, req.body?.props, req.body?.slots) 110 | res.end(html) 111 | } catch (e) { 112 | e instanceof Error && server.ssrFixStacktrace(e) 113 | jsonResponse(res, 500, { error: e }) 114 | } 115 | }) 116 | } else { 117 | next() 118 | } 119 | }) 120 | }, 121 | } 122 | } 123 | 124 | export default liveVuePlugin 125 | -------------------------------------------------------------------------------- /test/e2e/features/event-reply/live.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.E2E.EventReplyTestLive do 2 | @moduledoc false 3 | use Phoenix.LiveView 4 | 5 | def render(assigns) do 6 | ~H""" 7 |
8 |

Event Reply Test

9 |
10 |
Counter: {@counter}
11 |
User: {@user_name || "none"}
12 |
Last message: {@last_message || "none"}
13 |
14 | 15 | 21 |
22 | """ 23 | end 24 | 25 | def mount(_params, _session, socket) do 26 | {:ok, 27 | socket 28 | |> assign(:counter, 0) 29 | |> assign(:user_name, nil) 30 | |> assign(:last_message, nil)} 31 | end 32 | 33 | # Simple increment event that returns the new value 34 | def handle_event("increment", %{"by" => by}, socket) do 35 | new_counter = socket.assigns.counter + by 36 | socket = assign(socket, :counter, new_counter) 37 | {:reply, %{counter: new_counter, timestamp: DateTime.utc_now()}, socket} 38 | end 39 | 40 | # Get user data event that simulates fetching user info 41 | def handle_event("get-user", %{"id" => user_id}, socket) do 42 | # Simulate different user responses based on ID 43 | user_data = 44 | case user_id do 45 | 1 -> %{id: 1, name: "John Doe", email: "john@example.com"} 46 | 2 -> %{id: 2, name: "Jane Smith", email: "jane@example.com"} 47 | _ -> %{id: user_id, name: "Unknown User", email: "unknown@example.com"} 48 | end 49 | 50 | socket = assign(socket, :user_name, user_data.name) 51 | {:reply, user_data, socket} 52 | end 53 | 54 | # Event that simulates server error 55 | def handle_event("error-event", _params, socket) do 56 | # Return an error response 57 | {:reply, %{error: "Something went wrong on the server"}, socket} 58 | end 59 | 60 | # Event that simulates slow response (for cancel testing) 61 | def handle_event("slow-event", %{"delay" => delay}, socket) do 62 | # Simulate processing delay 63 | Process.sleep(delay) 64 | message = "Slow response after #{delay}ms" 65 | socket = assign(socket, :last_message, message) 66 | {:reply, %{message: message, completed_at: DateTime.utc_now()}, socket} 67 | end 68 | 69 | # Event without parameters 70 | def handle_event("ping", _params, socket) do 71 | message = "pong at #{DateTime.utc_now()}" 72 | socket = assign(socket, :last_message, message) 73 | {:reply, %{response: "pong", timestamp: DateTime.utc_now()}, socket} 74 | end 75 | 76 | # Event that returns different data types (wrapped in maps since only maps can be returned) 77 | def handle_event("get-data-type", %{"type" => type}, socket) do 78 | response = 79 | case type do 80 | "string" -> %{data: "Hello World"} 81 | "number" -> %{data: 42} 82 | "boolean" -> %{data: true} 83 | "array" -> %{data: [1, 2, 3, "four", true]} 84 | "object" -> %{data: %{nested: %{value: "test"}, count: 5}} 85 | "null" -> %{data: nil} 86 | _ -> %{data: "unknown type"} 87 | end 88 | 89 | {:reply, response, socket} 90 | end 91 | 92 | # Event that validates parameters and returns errors 93 | def handle_event("validate-input", %{"input" => input}, socket) do 94 | cond do 95 | String.length(input) < 3 -> 96 | {:reply, %{valid: false, error: "Input too short"}, socket} 97 | 98 | String.length(input) > 20 -> 99 | {:reply, %{valid: false, error: "Input too long"}, socket} 100 | 101 | true -> 102 | {:reply, %{valid: true, message: "Input is valid"}, socket} 103 | end 104 | end 105 | 106 | # Event that doesn't return a reply (should timeout or error) 107 | def handle_event("no-reply", _params, socket) do 108 | {:noreply, socket} 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /assets/phoenixFallbackTypes.ts: -------------------------------------------------------------------------------- 1 | // Fallback types for phoenix_live_view versions < 1.1 that don't export types 2 | // These types match the structure from deps/phoenix_live_view/assets/js/types/ 3 | 4 | // Conditionally import Socket type from phoenix if available 5 | // Uses a helper type to safely extract Socket when it exists 6 | type GetPhoenixSocket = T extends { Socket: infer S } ? S : any 7 | type PhoenixSocket = GetPhoenixSocket 8 | 9 | // Check if DOM types are available and use them, otherwise use any 10 | type ServerHTMLElement = unknown extends HTMLElement ? any : HTMLElement 11 | type ServerEvent = unknown extends Event ? any : Event 12 | type ServerFileList = unknown extends FileList ? any : FileList 13 | 14 | // Declare minimal DOM types if they don't exist (server environment) 15 | declare global { 16 | // Minimal interface for server compatibility 17 | interface HTMLElement {} 18 | interface Event {} 19 | interface FileList {} 20 | } 21 | 22 | export interface LiveSocketInstanceInterface { 23 | version(): string 24 | isProfileEnabled(): boolean 25 | isDebugEnabled(): boolean 26 | isDebugDisabled(): boolean 27 | enableDebug(): void 28 | enableProfiling(): void 29 | disableDebug(): void 30 | disableProfiling(): void 31 | enableLatencySim(upperBoundMs: number): void 32 | disableLatencySim(): void 33 | getLatencySim(): number | null 34 | getSocket(): PhoenixSocket 35 | connect(): void 36 | disconnect(callback?: () => void): void 37 | replaceTransport(transport: any): void 38 | execJS(el: ServerHTMLElement, encodedJS: string, eventType?: string | null): void 39 | js(): any // LiveSocketJSCommands - avoiding full interface for simplicity 40 | 41 | // Navigation methods used by useLiveNavigation 42 | pushHistoryPatch(event: ServerEvent, href: string, kind: string, el: any): void 43 | historyRedirect(event: ServerEvent, href: string, kind: string, el: any, callback: any): void 44 | } 45 | 46 | export type OnReply = (reply: any, ref: number) => any 47 | export type CallbackRef = { 48 | event: string 49 | callback: (payload: any) => any 50 | } 51 | export type PhxTarget = string | number | ServerHTMLElement 52 | 53 | export interface HookInterface { 54 | el: ServerHTMLElement 55 | liveSocket: LiveSocketInstanceInterface 56 | mounted?: () => void 57 | beforeUpdate?: () => void 58 | updated?: () => void 59 | destroyed?: () => void 60 | disconnected?: () => void 61 | reconnected?: () => void 62 | js(): any // HookJSCommands 63 | pushEvent(event: string, payload: any, onReply: OnReply): void 64 | pushEvent(event: string, payload?: any): Promise 65 | pushEventTo(selectorOrTarget: PhxTarget, event: string, payload: object, onReply: OnReply): void 66 | pushEventTo( 67 | selectorOrTarget: PhxTarget, 68 | event: string, 69 | payload?: object 70 | ): Promise< 71 | PromiseSettledResult<{ 72 | reply: any 73 | ref: number 74 | }>[] 75 | > 76 | handleEvent(event: string, callback: (payload: any) => any): CallbackRef 77 | removeHandleEvent(ref: CallbackRef): void 78 | upload(name: any, files: any): any 79 | uploadTo(selectorOrTarget: PhxTarget, name: any, files: any): any 80 | [key: PropertyKey]: any 81 | } 82 | 83 | export interface Hook { 84 | mounted?: (this: T & HookInterface) => void 85 | beforeUpdate?: (this: T & HookInterface) => void 86 | updated?: (this: T & HookInterface) => void 87 | destroyed?: (this: T & HookInterface) => void 88 | disconnected?: (this: T & HookInterface) => void 89 | reconnected?: (this: T & HookInterface) => void 90 | [key: PropertyKey]: any 91 | } 92 | 93 | export declare class ViewHook implements HookInterface { 94 | el: ServerHTMLElement 95 | liveSocket: LiveSocketInstanceInterface 96 | static makeID(): number 97 | static elementID(el: ServerHTMLElement): any 98 | constructor(view: any | null, el: ServerHTMLElement, callbacks?: Hook) 99 | mounted(): void 100 | beforeUpdate(): void 101 | updated(): void 102 | destroyed(): void 103 | disconnected(): void 104 | reconnected(): void 105 | js(): any 106 | pushEvent(event: string, payload?: any, onReply?: OnReply): Promise 107 | pushEventTo( 108 | selectorOrTarget: PhxTarget, 109 | event: string, 110 | payload?: object, 111 | onReply?: OnReply 112 | ): Promise< 113 | PromiseSettledResult<{ 114 | reply: any 115 | ref: any 116 | }>[] 117 | > 118 | handleEvent(event: string, callback: (payload: any) => any): CallbackRef 119 | removeHandleEvent(ref: CallbackRef): void 120 | upload(name: string, files: ServerFileList): any 121 | uploadTo(selectorOrTarget: PhxTarget, name: string, files: ServerFileList): any 122 | } 123 | -------------------------------------------------------------------------------- /guides/deployment.md: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | Deploying a LiveVue app is similar to deploying a regular Phoenix app, with one key requirement: **Node.js version 19 or later must be installed** in your production environment. 4 | 5 | > #### SSR Configuration {: .tip} 6 | > 7 | > For detailed SSR configuration options, see [Configuration](configuration.md#server-side-rendering-ssr). This guide focuses on deployment-specific setup. 8 | 9 | ## General Requirements 10 | 11 | 1. Node.js 19+ installed in production 12 | 2. Standard Phoenix deployment requirements 13 | 3. Build assets before deployment 14 | 15 | ## Fly.io Deployment Guide 16 | 17 | Here's a detailed guide for deploying to [Fly.io](https://fly.io/). Similar principles apply to other hosting providers. 18 | 19 | ### 1. Generate Dockerfile 20 | 21 | First, generate a Phoenix release Dockerfile: 22 | 23 | ```bash 24 | mix phx.gen.release --docker 25 | ``` 26 | 27 | ### 2. Modify Dockerfile 28 | 29 | Update the generated Dockerfile to include Node.js: 30 | 31 | ```dockerfile 32 | # Build Stage 33 | FROM hexpm/elixir:1.14.4-erlang-25.3.2-debian-bullseye-20230227-slim AS builder 34 | 35 | # Set environment variables 36 | ...(about 15 lines omitted)... 37 | 38 | # Install build dependencies 39 | RUN apt-get update -y && apt-get install -y build-essential git curl \ 40 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 41 | 42 | # Install Node.js for build stage 43 | RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash - && apt-get install -y nodejs 44 | 45 | # Copy application code 46 | COPY . . 47 | 48 | # Install npm dependencies 49 | RUN cd /app/assets && npm install 50 | 51 | ...(about 20 lines omitted)... 52 | 53 | # Production Stage 54 | FROM ${RUNNER_IMAGE} 55 | 56 | RUN apt-get update -y && \ 57 | apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates curl \ 58 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 59 | 60 | # Install Node.js for production 61 | RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash - && apt-get install -y nodejs 62 | 63 | ...(remaining dockerfile content)... 64 | ``` 65 | 66 | Key changes: 67 | - Add `curl` to build dependencies 68 | - Install Node.js in both build and production stages 69 | - Add npm install step for assets 70 | 71 | ### 3. Launch on Fly.io 72 | 73 | 1. Initialize your app: 74 | ```bash 75 | fly launch 76 | ``` 77 | 78 | 2. Configure database when prompted: 79 | ```bash 80 | ? Do you want to tweak these settings before proceeding? (y/N) y 81 | ``` 82 | 83 | 3. In the configuration window: 84 | - Choose "Fly Postgres" for database 85 | - Name your database 86 | - Consider development configuration for cost savings 87 | - Review other settings as needed 88 | 89 | 4. After deployment completes, open your app: 90 | ```bash 91 | fly apps open 92 | ``` 93 | 94 | ## Other Deployment Options 95 | 96 | ### Heroku 97 | 98 | For Heroku deployment: 99 | 1. Use the [Phoenix buildpack](https://hexdocs.pm/phoenix/heroku.html) 100 | 2. Add Node.js buildpack: 101 | ```bash 102 | heroku buildpacks:add --index 1 heroku/nodejs 103 | ``` 104 | 105 | ### Docker 106 | 107 | If using your own Docker setup: 108 | 1. Ensure Node.js 19+ is installed 109 | 2. Follow standard Phoenix deployment practices 110 | 3. Configure SSR for production (see [Configuration](configuration.md#production-ssr-setup)) 111 | 112 | ### Custom Server 113 | 114 | For bare metal or VM deployments: 115 | 1. Install Node.js 19+: 116 | ```bash 117 | curl -fsSL https://deb.nodesource.com/setup_19.x | bash - 118 | apt-get install -y nodejs 119 | ``` 120 | 121 | 2. Follow standard [Phoenix deployment guide](https://hexdocs.pm/phoenix/deployment.html) 122 | 123 | ## Production Checklist 124 | 125 | - [ ] Node.js 19+ installed 126 | - [ ] Assets built (`mix assets.build`) 127 | - [ ] SSR configured properly (see [Configuration](configuration.md#production-ssr-setup)) 128 | - [ ] Database configured 129 | - [ ] Environment variables set 130 | - [ ] SSL certificates configured (if needed) 131 | - [ ] Production secrets generated 132 | - [ ] Release configuration tested 133 | 134 | ## Troubleshooting 135 | 136 | ### Common Issues 137 | 138 | 1. **SSR Not Working** 139 | - Verify Node.js installation 140 | - Check SSR configuration (see [Configuration](configuration.md#ssr-troubleshooting)) 141 | - Ensure server bundle exists in `priv/static/server.mjs` 142 | 143 | 2. **Asset Loading Issues** 144 | - Verify assets were built 145 | - Check digest configuration 146 | - Inspect network requests 147 | 148 | 3. **Performance Issues** 149 | - Consider adjusting NodeJS pool size (see [Configuration](configuration.md#production-ssr-setup)) 150 | 151 | ## Next Steps 152 | 153 | - Review [FAQ](faq.md) for common questions 154 | - Join our [GitHub Discussions](https://github.com/Valian/live_vue/discussions) for help 155 | - Consider contributing to [LiveVue](https://github.com/Valian/live_vue) -------------------------------------------------------------------------------- /test/e2e/features/stream/live.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.E2E.StreamLive do 2 | @moduledoc false 3 | use Phoenix.LiveView 4 | 5 | def render(assigns) do 6 | ~H""" 7 |
8 | 9 |
10 | """ 11 | end 12 | 13 | def mount(_params, _session, socket) do 14 | # Initialize with some sample items 15 | items = [ 16 | %{id: 1, name: "Item 1", description: "First item"}, 17 | %{id: 2, name: "Item 2", description: "Second item"}, 18 | %{id: 3, name: "Item 3", description: "Third item"} 19 | ] 20 | 21 | socket = 22 | socket 23 | |> stream_configure(:items, dom_id: &"songs-#{&1.id}") 24 | |> stream(:items, items) 25 | |> assign(:next_id, 4) 26 | 27 | {:ok, socket} 28 | end 29 | 30 | def handle_event("add_item", %{"name" => name, "description" => description}, socket) do 31 | new_item = %{ 32 | id: socket.assigns.next_id, 33 | name: name, 34 | description: description 35 | } 36 | 37 | socket = 38 | socket 39 | |> stream_insert(:items, new_item) 40 | |> assign(:next_id, socket.assigns.next_id + 1) 41 | 42 | {:noreply, socket} 43 | end 44 | 45 | def handle_event("remove_item", %{"id" => id}, socket) do 46 | {:noreply, stream_delete_by_dom_id(socket, :items, "songs-#{id}")} 47 | end 48 | 49 | def handle_event("clear_stream", _params, socket) do 50 | # Reset the stream with empty list 51 | socket = 52 | socket 53 | |> stream(:items, [], reset: true) 54 | |> assign(:next_id, 1) 55 | 56 | {:noreply, socket} 57 | end 58 | 59 | def handle_event("reset_stream", _params, socket) do 60 | # Reset with initial items 61 | items = [ 62 | %{id: 1, name: "Item 1", description: "First item"}, 63 | %{id: 2, name: "Item 2", description: "Second item"}, 64 | %{id: 3, name: "Item 3", description: "Third item"} 65 | ] 66 | 67 | socket = 68 | socket 69 | |> stream(:items, items, at: -1, reset: true) 70 | |> assign(:next_id, 4) 71 | 72 | {:noreply, socket} 73 | end 74 | 75 | def handle_event("reset_stream_at_0", _params, socket) do 76 | # Reset with initial items 77 | items = [ 78 | %{id: 1, name: "Item 1", description: "First item"}, 79 | %{id: 2, name: "Item 2", description: "Second item"}, 80 | %{id: 3, name: "Item 3", description: "Third item"} 81 | ] 82 | 83 | socket = 84 | socket 85 | |> stream(:items, items, at: 0, reset: true) 86 | |> assign(:next_id, 4) 87 | 88 | {:noreply, socket} 89 | end 90 | 91 | def handle_event("add_multiple_start", _params, socket) do 92 | # Add multiple items at the start with positive limit (keep first 5 items) 93 | new_items = [ 94 | %{id: socket.assigns.next_id, name: "Start Item A", description: "Added at start A"}, 95 | %{id: socket.assigns.next_id + 1, name: "Start Item B", description: "Added at start B"}, 96 | %{id: socket.assigns.next_id + 2, name: "Start Item C", description: "Added at start C"} 97 | ] 98 | 99 | socket = 100 | socket 101 | |> stream(:items, new_items, at: 0, limit: 5) 102 | |> assign(:next_id, socket.assigns.next_id + 3) 103 | 104 | {:noreply, socket} 105 | end 106 | 107 | def handle_event("add_multiple_end", _params, socket) do 108 | # Add multiple items at the end with negative limit (keep last 5 items) 109 | new_items = [ 110 | %{id: socket.assigns.next_id, name: "End Item X", description: "Added at end X"}, 111 | %{id: socket.assigns.next_id + 1, name: "End Item Y", description: "Added at end Y"}, 112 | %{id: socket.assigns.next_id + 2, name: "End Item Z", description: "Added at end Z"} 113 | ] 114 | 115 | socket = 116 | socket 117 | |> stream(:items, new_items, at: -1, limit: -5) 118 | |> assign(:next_id, socket.assigns.next_id + 3) 119 | 120 | {:noreply, socket} 121 | end 122 | 123 | def handle_event("add_with_positive_limit", %{"limit" => limit_str}, socket) do 124 | limit = String.to_integer(limit_str) 125 | 126 | new_item = %{ 127 | id: socket.assigns.next_id, 128 | name: "Limited Item +#{limit}", 129 | description: "Added with positive limit #{limit}" 130 | } 131 | 132 | socket = 133 | socket 134 | |> stream_insert(:items, new_item, at: 0, limit: limit) 135 | |> assign(:next_id, socket.assigns.next_id + 1) 136 | 137 | {:noreply, socket} 138 | end 139 | 140 | def handle_event("add_with_negative_limit", %{"limit" => limit_str}, socket) do 141 | # Make it negative 142 | limit = String.to_integer(limit_str) * -1 143 | 144 | new_item = %{ 145 | id: socket.assigns.next_id, 146 | name: "Limited Item #{limit}", 147 | description: "Added with negative limit #{limit}" 148 | } 149 | 150 | socket = 151 | socket 152 | |> stream_insert(:items, new_item, limit: limit) 153 | |> assign(:next_id, socket.assigns.next_id + 1) 154 | 155 | {:noreply, socket} 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /test/e2e/features/event/event.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test' 2 | import { syncLV } from '../../utils.js' 3 | 4 | test.describe('useLiveEvent', () => { 5 | test('should receive notification events from server', async ({ page }) => { 6 | await page.goto('/events') 7 | await syncLV(page) 8 | 9 | // Verify initial state 10 | await expect(page.locator('#notification-count')).toContainText('Notification Count: 0') 11 | await expect(page.locator('#custom-count')).toContainText('Custom Event Count: 0') 12 | 13 | // Send a notification event 14 | await page.fill('#message-input', 'Hello from test!') 15 | await page.click('#send-notification-btn') 16 | await syncLV(page) 17 | 18 | // Verify notification event was received 19 | await expect(page.locator('#notification-count')).toContainText('Notification Count: 1') 20 | await expect(page.locator('.notification-event')).toContainText('Hello from test!') 21 | 22 | // Verify the message appears in both LiveView state and Vue component 23 | await expect(page.locator('#message-display')).toContainText('Message: Hello from test!') 24 | await expect(page.locator('#event-count')).toContainText('Event Count: 1') 25 | 26 | // Send another notification 27 | await page.fill('#message-input', 'Second message') 28 | await page.click('#send-notification-btn') 29 | await syncLV(page) 30 | 31 | // Verify both notifications are received 32 | await expect(page.locator('#notification-count')).toContainText('Notification Count: 2') 33 | await expect(page.locator('.notification-event')).toHaveCount(2) 34 | await expect(page.locator('.notification-event').nth(1)).toContainText('Second message') 35 | }) 36 | 37 | test('should receive custom events from server', async ({ page }) => { 38 | await page.goto('/events') 39 | await syncLV(page) 40 | 41 | // Send a custom event 42 | await page.fill('#custom-data-input', 'Custom data payload') 43 | await page.click('#send-custom-btn') 44 | await syncLV(page) 45 | 46 | // Verify custom event was received 47 | await expect(page.locator('#custom-count')).toContainText('Custom Event Count: 1') 48 | await expect(page.locator('.custom-event')).toContainText('Custom data payload (count: 1)') 49 | await expect(page.locator('#event-count')).toContainText('Event Count: 1') 50 | 51 | // Send another custom event 52 | await page.fill('#custom-data-input', 'Another payload') 53 | await page.click('#send-custom-btn') 54 | await syncLV(page) 55 | 56 | // Verify both custom events are received 57 | await expect(page.locator('#custom-count')).toContainText('Custom Event Count: 2') 58 | await expect(page.locator('.custom-event')).toHaveCount(2) 59 | await expect(page.locator('.custom-event').nth(1)).toContainText('Another payload (count: 2)') 60 | }) 61 | 62 | test('should handle mixed notification and custom events', async ({ page }) => { 63 | await page.goto('/events') 64 | await syncLV(page) 65 | 66 | // Send notification 67 | await page.fill('#message-input', 'Mixed test notification') 68 | await page.click('#send-notification-btn') 69 | await syncLV(page) 70 | 71 | // Send custom event 72 | await page.fill('#custom-data-input', 'Mixed test custom') 73 | await page.click('#send-custom-btn') 74 | await syncLV(page) 75 | 76 | // Send another notification 77 | await page.fill('#message-input', 'Second notification') 78 | await page.click('#send-notification-btn') 79 | await syncLV(page) 80 | 81 | // Verify all events are received correctly 82 | await expect(page.locator('#notification-count')).toContainText('Notification Count: 2') 83 | await expect(page.locator('#custom-count')).toContainText('Custom Event Count: 1') 84 | await expect(page.locator('#event-count')).toContainText('Event Count: 3') 85 | 86 | // Verify event contents 87 | await expect(page.locator('.notification-event').nth(0)).toContainText('Mixed test notification') 88 | await expect(page.locator('.custom-event').nth(0)).toContainText('Mixed test custom (count: 2)') 89 | await expect(page.locator('.notification-event').nth(1)).toContainText('Second notification') 90 | }) 91 | 92 | test('should handle rapid sequential events', async ({ page }) => { 93 | await page.goto('/events') 94 | await syncLV(page) 95 | 96 | // Send multiple events rapidly 97 | for (let i = 1; i <= 5; i++) { 98 | await page.fill('#message-input', `Rapid message ${i}`) 99 | await page.click('#send-notification-btn') 100 | } 101 | 102 | await syncLV(page) 103 | 104 | // Verify all events are received 105 | await expect(page.locator('#notification-count')).toContainText('Notification Count: 5') 106 | await expect(page.locator('.notification-event')).toHaveCount(5) 107 | await expect(page.locator('#event-count')).toContainText('Event Count: 5') 108 | 109 | // Verify first and last messages 110 | await expect(page.locator('.notification-event').nth(0)).toContainText('Rapid message 1') 111 | await expect(page.locator('.notification-event').nth(4)).toContainText('Rapid message 5') 112 | }) 113 | }) -------------------------------------------------------------------------------- /assets/server.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import { basename, resolve } from "path" 3 | import type { ViewHook } from "phoenix_live_view" 4 | import { type App, type Component, createSSRApp, h } from "vue" 5 | import { renderToString, type SSRContext } from "vue/server-renderer" 6 | import { migrateToLiveVueApp } from "./app.js" 7 | import type { LiveVueOptions, VueArgs } from "./types.js" 8 | import { mapValues } from "./utils.js" 9 | 10 | type Components = Record 11 | type Manifest = Record 12 | 13 | const mockLive: Partial> & { 14 | el: {} 15 | liveSocket: {} 16 | removeHandleEvent: () => void 17 | upload: () => void 18 | uploadTo: () => void 19 | vue: Omit & { app: object } 20 | } = { 21 | el: {}, 22 | liveSocket: { socket: { connectionState: () => "closed" } } as any, 23 | pushEvent: () => Promise.resolve(0), 24 | pushEventTo: () => Promise.resolve([]), 25 | handleEvent: () => ({ event: "", callback: () => {} }), 26 | removeHandleEvent: () => {}, 27 | upload: () => {}, 28 | uploadTo: () => {}, 29 | vue: { 30 | props: {}, 31 | slots: {}, 32 | app: {}, 33 | }, 34 | } 35 | export const getRender = (componentsOrApp: Components | LiveVueOptions, manifest: Manifest = {}) => { 36 | const { resolve, setup } = migrateToLiveVueApp(componentsOrApp) 37 | 38 | return async (name: string, props: Record, slots: Record) => { 39 | const component = await resolve(name) 40 | const slotComponents = mapValues(slots, html => () => h("div", { innerHTML: html })) 41 | const app = setup({ 42 | createApp: createSSRApp, 43 | component, 44 | props, 45 | slots: slotComponents, 46 | plugin: { 47 | install: (app: App) => { 48 | // we don't want to mount the app in SSR 49 | app.mount = (...args: unknown[]): any => undefined 50 | // we don't have hook instance in SSR, so we need to mock it 51 | app.provide("_live_vue", Object.assign({}, mockLive)) 52 | }, 53 | }, 54 | // @ts-ignore - this is just an IDE issue. the compiler is correctly processing this with the server tsconfig 55 | el: {}, 56 | ssr: true, 57 | }) 58 | 59 | if (!app) throw new Error("Setup function did not return a Vue app!") 60 | 61 | const ctx: SSRContext = {} 62 | const html = await renderToString(app, ctx) 63 | 64 | // the SSR manifest generated by Vite contains module -> chunk/asset mapping 65 | // which we can then use to determine what files need to be preloaded for this 66 | // request. 67 | const preloadLinks = renderPreloadLinks(ctx.modules, manifest) 68 | // easy to split structure 69 | return preloadLinks + "" + html 70 | } 71 | } 72 | /** 73 | * Loads the manifest file from the given path and returns a record of the assets. 74 | * Manifest file is a JSON file generated by Vite for the client build. 75 | * We need to load it to know which files to preload for the given page. 76 | * @param path - The path to the manifest file. 77 | * @returns A record of the assets. 78 | */ 79 | export const loadManifest = (path: string): Record => { 80 | try { 81 | // it's generated only in prod build 82 | const content = fs.readFileSync(resolve(path), "utf-8") 83 | return JSON.parse(content) 84 | } catch (e) { 85 | // manifest is not available in dev, so let's just ignore it 86 | return {} 87 | } 88 | } 89 | 90 | function renderPreloadLinks(modules: SSRContext["modules"], manifest: Manifest) { 91 | let links = "" 92 | const seen = new Set() 93 | modules.forEach((id: string) => { 94 | const files = manifest[id] 95 | if (files) { 96 | files.forEach(file => { 97 | if (!seen.has(file)) { 98 | seen.add(file) 99 | const filename = basename(file) 100 | if (manifest[filename]) { 101 | for (const depFile of manifest[filename]) { 102 | links += renderPreloadLink(depFile) 103 | seen.add(depFile) 104 | } 105 | } 106 | links += renderPreloadLink(file) 107 | } 108 | }) 109 | } 110 | }) 111 | return links 112 | } 113 | 114 | function renderPreloadLink(file: string) { 115 | if (file.endsWith(".js")) { 116 | return `` 117 | } else if (file.endsWith(".css")) { 118 | return `` 119 | } else if (file.endsWith(".woff")) { 120 | return ` ` 121 | } else if (file.endsWith(".woff2")) { 122 | return ` ` 123 | } else if (file.endsWith(".gif")) { 124 | return ` ` 125 | } else if (file.endsWith(".jpg") || file.endsWith(".jpeg")) { 126 | return ` ` 127 | } else if (file.endsWith(".png")) { 128 | return ` ` 129 | } else { 130 | // TODO 131 | return "" 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /test/mix/tasks/live_vue.install_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.LiveVue.InstallTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Igniter.Test 5 | 6 | describe "live_vue.install" do 7 | test "installs successfully with core Vue components" do 8 | project = 9 | phx_test_project() 10 | |> Igniter.create_new_file( 11 | "AGENTS.md", 12 | "# My Project Agents\n\nExisting content here. " 13 | ) 14 | |> Igniter.compose_task("live_vue.install", []) 15 | |> apply_igniter!() 16 | 17 | # Verify content contains expected LiveVue patterns 18 | vue_index = project.rewrite.sources["assets/vue/index.ts"] 19 | assert vue_index.content =~ "createLiveVue" 20 | assert vue_index.content =~ "findComponent" 21 | 22 | vue_demo = project.rewrite.sources["assets/vue/VueDemo.vue"] 23 | assert vue_demo.content =~ "useLiveVue" 24 | 25 | server_js = project.rewrite.sources["assets/js/server.js"] 26 | assert server_js.content =~ "getRender" 27 | 28 | # Check for LiveVue usage 29 | web_file = project.rewrite.sources["lib/test_web.ex"] 30 | assert web_file.content =~ "use LiveVue" 31 | assert web_file.content =~ "use LiveVue.Components" 32 | 33 | # Check if config.exs was updated 34 | config_exs = project.rewrite.sources["config/config.exs"] 35 | assert config_exs.content =~ ~r/\[args: \[\], cd: __DIR__\]/ 36 | 37 | # Check if Vite config was updated 38 | vite_config = project.rewrite.sources["assets/vite.config.mjs"] 39 | assert vite_config.content =~ "vue()" 40 | assert vite_config.content =~ "liveVuePlugin()" 41 | assert vite_config.content =~ "import vue from" 42 | assert vite_config.content =~ "import liveVuePlugin from" 43 | assert vite_config.content =~ "manifest: false" 44 | assert vite_config.content =~ "ssrManifest: false" 45 | assert vite_config.content =~ "ssr: { noExternal: process.env.NODE_ENV === \"production\" ? true : undefined }," 46 | 47 | # Check if tsconfig.json was updated 48 | tsconfig = project.rewrite.sources["tsconfig.json"] 49 | assert tsconfig.content =~ ~s("baseUrl": ".") 50 | assert tsconfig.content =~ ~s("module": "ESNext") 51 | assert tsconfig.content =~ ~s("moduleResolution": "bundler") 52 | assert tsconfig.content =~ ~s("noEmit": true) 53 | assert tsconfig.content =~ ~s("skipLibCheck": true) 54 | 55 | assert tsconfig.content =~ """ 56 | "paths": { 57 | "*": [ "./deps/*", "node_modules/*" ] 58 | },\ 59 | """ 60 | 61 | assert tsconfig.content =~ ~s("types": [ "vite/client" ]) 62 | 63 | # Check that tsconfig uses correct web folder (not hardcoded my_app_web) 64 | assert tsconfig.content =~ ~s("./lib/test_web/**/*") 65 | 66 | # Check if mix.exs was updated 67 | mix_exs = project.rewrite.sources["mix.exs"] 68 | assert mix_exs.content =~ ~r/build --manifest --emptyOutDir true/ 69 | 70 | assert mix_exs.content =~ 71 | ~r/build --ssrManifest --emptyOutDir false --ssr js\/server\.js --outDir \.\.\/priv\/static/ 72 | 73 | # Check for vue_demo route in dev section 74 | router_file = project.rewrite.sources["lib/test_web/router.ex"] 75 | assert router_file.content =~ ~r/live "\/vue_demo", TestWeb.VueDemoLive/ 76 | 77 | # Check for LiveVue-specific content 78 | home_template = project.rewrite.sources["lib/test_web/controllers/page_html/home.html.heex"] 79 | assert home_template.content =~ ~r/End-to-end reactivity for your Live Vue apps/ 80 | assert home_template.content =~ ~r/VueDemo.vue/ 81 | assert home_template.content =~ ~r/vue_demo.ex/ 82 | assert home_template.content =~ ~s(href={~p"/dev/vue_demo"}) 83 | 84 | # Check for LiveView content 85 | live_view_file = project.rewrite.sources["lib/test_web/live/vue_demo_live.ex"] 86 | assert live_view_file.content =~ ~r/defmodule TestWeb.VueDemoLive/ 87 | assert live_view_file.content =~ ~r/v-component="VueDemo"/ 88 | assert live_view_file.content =~ ~r/handle_event\(\"add_todo\"/ 89 | 90 | # Check that SSR production setup was applied 91 | app_file = project.rewrite.sources["lib/test/application.ex"] 92 | assert app_file.content =~ ~r/NodeJS\.Supervisor/ 93 | assert app_file.content =~ ~r/path: LiveVue\.SSR\.NodeJS\.server_path\(\)/ 94 | assert app_file.content =~ ~r/pool_size: 4/ 95 | 96 | # Check that AGENTS.md was updated with usage rules 97 | agents_md = project.rewrite.sources["AGENTS.md"] 98 | assert agents_md.content =~ "# My Project Agents" 99 | assert agents_md.content =~ "Existing content here." 100 | assert agents_md.content =~ "" 101 | assert agents_md.content =~ "" 102 | assert agents_md.content =~ "# LiveVue Usage Rules" 103 | assert agents_md.content =~ "Component Organization" 104 | assert String.ends_with?(agents_md.content, "\n") 105 | end 106 | 107 | test "installs successfully with bun flag" do 108 | project = 109 | phx_test_project() 110 | |> Igniter.compose_task("live_vue.install", ["--bun"]) 111 | |> apply_igniter!() 112 | 113 | # Verify bun dependency is added 114 | mix_exs = project.rewrite.sources["mix.exs"] 115 | assert mix_exs.content =~ "{:bun," 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/e2e/features/form/live.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.E2E.FormTestLive do 2 | @moduledoc false 3 | use Phoenix.LiveView 4 | 5 | import Ecto.Changeset 6 | 7 | defmodule Profile do 8 | @moduledoc false 9 | use Ecto.Schema 10 | 11 | import Ecto.Changeset 12 | 13 | @derive LiveVue.Encoder 14 | @primary_key false 15 | embedded_schema do 16 | field(:bio, :string) 17 | field(:skills, {:array, :string}, default: []) 18 | end 19 | 20 | def changeset(profile, attrs) do 21 | profile 22 | |> cast(attrs, [:bio, :skills], empty_values: []) 23 | |> validate_length(:bio, min: 10, max: 200, message: "must be between 10 and 200 characters") 24 | |> validate_skills() 25 | end 26 | 27 | defp validate_skills(changeset) do 28 | case get_field(changeset, :skills) do 29 | nil -> 30 | changeset 31 | 32 | [] -> 33 | changeset 34 | 35 | skills -> 36 | # Check if all skills are non-empty strings 37 | invalid_skills = 38 | Enum.filter(skills, fn skill -> 39 | !is_binary(skill) || String.trim(skill) == "" 40 | end) 41 | 42 | if Enum.empty?(invalid_skills) do 43 | changeset 44 | else 45 | add_error(changeset, :skills, "cannot contain empty values") 46 | end 47 | end 48 | end 49 | end 50 | 51 | defmodule Item do 52 | @moduledoc false 53 | use Ecto.Schema 54 | 55 | import Ecto.Changeset 56 | 57 | @derive LiveVue.Encoder 58 | @primary_key false 59 | embedded_schema do 60 | field(:title, :string) 61 | field(:tags, {:array, :string}, default: []) 62 | end 63 | 64 | def changeset(item, attrs) do 65 | item 66 | |> cast(attrs, [:title, :tags]) 67 | |> validate_required([:title]) 68 | |> validate_length(:title, min: 3, max: 100) 69 | |> validate_tags() 70 | end 71 | 72 | defp validate_tags(changeset) do 73 | Ecto.Changeset.validate_change(changeset, :tags, fn :tags, tags -> 74 | if Enum.any?(tags, fn tag -> !is_binary(tag) || String.length(String.trim(tag)) < 3 end) do 75 | [tags: "must be at least 3 characters long"] 76 | else 77 | [] 78 | end 79 | end) 80 | end 81 | end 82 | 83 | defmodule TestForm do 84 | @moduledoc false 85 | use Ecto.Schema 86 | 87 | import Ecto.Changeset 88 | 89 | @derive LiveVue.Encoder 90 | @primary_key false 91 | embedded_schema do 92 | field(:name, :string) 93 | field(:email, :string) 94 | field(:age, :integer) 95 | field(:acceptTerms, :boolean, default: false) 96 | field(:newsletter, :boolean, default: false) 97 | field(:preferences, {:array, :string}, default: []) 98 | embeds_one(:profile, Profile) 99 | embeds_many(:items, Item) 100 | end 101 | 102 | def changeset(form, attrs) do 103 | form 104 | |> cast(attrs, [:name, :email, :age, :acceptTerms, :newsletter, :preferences]) 105 | |> validate_required([:name, :email], message: "is required") 106 | |> validate_length(:name, min: 2, max: 50) 107 | |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must be a valid email address") 108 | |> validate_number(:age, greater_than: 0, less_than: 150, message: "must be between 1 and 149") 109 | |> validate_acceptance(:acceptTerms, message: "must be accepted to proceed") 110 | |> validate_preferences() 111 | |> cast_embed(:profile) 112 | |> cast_embed(:items) 113 | end 114 | 115 | defp validate_preferences(changeset) do 116 | case get_field(changeset, :preferences) do 117 | nil -> 118 | changeset 119 | 120 | [] -> 121 | changeset 122 | 123 | preferences -> 124 | valid_preferences = ["email", "sms", "push"] 125 | invalid_preferences = Enum.filter(preferences, fn pref -> pref not in valid_preferences end) 126 | 127 | if Enum.empty?(invalid_preferences) do 128 | changeset 129 | else 130 | add_error(changeset, :preferences, "contains invalid options: #{Enum.join(invalid_preferences, ", ")}") 131 | end 132 | end 133 | end 134 | end 135 | 136 | def mount(_params, _session, socket) do 137 | form = %TestForm{} |> TestForm.changeset(%{}) |> to_form(as: :test_form) 138 | {:ok, assign(socket, :form, form)} 139 | end 140 | 141 | def handle_event("validate", %{"test_form" => form_params}, socket) do 142 | form = 143 | %TestForm{} 144 | |> TestForm.changeset(form_params) 145 | |> Map.put(:action, :validate) 146 | |> to_form(as: :test_form) 147 | 148 | LiveVue.Encoder.encode(form) 149 | 150 | {:noreply, assign(socket, :form, form)} 151 | end 152 | 153 | def handle_event("submit", %{"test_form" => form_params}, socket) do 154 | changeset = TestForm.changeset(%TestForm{}, form_params) 155 | 156 | if changeset.valid? do 157 | case Ecto.Changeset.apply_action(changeset, :insert) do 158 | {:ok, data} -> 159 | IO.puts("\n=== FORM SUBMITTED SUCCESSFULLY ===") 160 | IO.inspect(data, pretty: true, limit: :infinity) 161 | IO.puts("\n====================================") 162 | 163 | form = %TestForm{} |> TestForm.changeset(%{}) |> to_form(as: :test_form) 164 | 165 | {:reply, %{reset: true}, 166 | socket 167 | |> put_flash(:info, "Form submitted successfully!") 168 | |> assign(:form, form)} 169 | 170 | {:error, changeset} -> 171 | form = to_form(changeset, as: :test_form) 172 | {:noreply, assign(socket, :form, form)} 173 | end 174 | else 175 | form = to_form(%{changeset | action: :insert}, as: :test_form) 176 | {:noreply, assign(socket, :form, form)} 177 | end 178 | end 179 | 180 | def render(assigns) do 181 | ~H""" 182 |
183 | 184 |
185 | """ 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /assets/hooks.ts: -------------------------------------------------------------------------------- 1 | import { createApp, createSSRApp, h, reactive, type App } from "vue" 2 | import { migrateToLiveVueApp } from "./app.js" 3 | import type { ComponentMap, LiveVueApp, LiveVueOptions, Hook } from "./types.js" 4 | import { liveInjectKey } from "./use.js" 5 | import { mapValues, fromUtf8Base64 } from "./utils.js" 6 | import { applyPatch, type Operation } from "./jsonPatch.js" 7 | 8 | /** 9 | * Parses the JSON object from the element's attribute and returns them as an object. 10 | */ 11 | const getAttributeJson = (el: HTMLElement, attributeName: string): Record | null => { 12 | const data = el.getAttribute(attributeName) 13 | return data ? JSON.parse(data) : null 14 | } 15 | 16 | /** 17 | * Parses the slots from the element's attributes and returns them as a record. 18 | * The slots are parsed from the "data-slots" attribute. 19 | * The slots are converted to a function that returns a div with the innerHTML set to the base64 decoded slot. 20 | */ 21 | const getSlots = (el: HTMLElement): Record any> => { 22 | const dataSlots = getAttributeJson(el, "data-slots") || {} 23 | return mapValues(dataSlots, base64 => () => h("div", { innerHTML: fromUtf8Base64(base64).trim() })) 24 | } 25 | 26 | const getDiff = (el: HTMLElement, attributeName: string): Operation[] => { 27 | const dataPropsDiff = getAttributeJson(el, attributeName) || [] 28 | return dataPropsDiff.map(([op, path, value]: [string, string, any]) => ({ 29 | op, 30 | path, 31 | value, 32 | })) 33 | } 34 | 35 | /** 36 | * Parses the event handlers from the element's attributes and returns them as a record. 37 | * The handlers are parsed from the "data-handlers" attribute. 38 | * The handlers are converted to snake case and returned as a record. 39 | * A special case is made for the "JS.push" event, where the event is replaced with $event. 40 | * @param el - The element to parse the handlers from. 41 | * @param liveSocket - The LiveSocket instance. 42 | * @returns The handlers as an object. 43 | */ 44 | const getHandlers = (el: HTMLElement, liveSocket: any): Record void> => { 45 | const handlers = getAttributeJson(el, "data-handlers") || {} 46 | const result: Record void> = {} 47 | for (const handlerName in handlers) { 48 | const ops = handlers[handlerName] 49 | const snakeCaseName = `on${handlerName.charAt(0).toUpperCase() + handlerName.slice(1)}` 50 | result[snakeCaseName] = event => { 51 | // a little bit of magic to replace the event with the value of the input 52 | const parsedOps = JSON.parse(ops) 53 | const replacedOps = parsedOps.map(([op, args, ...other]: [string, any, ...any[]]) => { 54 | if (op === "push" && !args.value) args.value = event 55 | return [op, args, ...other] 56 | }) 57 | liveSocket.execJS(el, JSON.stringify(replacedOps)) 58 | } 59 | } 60 | return result 61 | } 62 | 63 | /** 64 | * Parses the props from the element's attributes and returns them as an object. 65 | * The props are parsed from the "data-props" attribute. 66 | * The props are merged with the event handlers from the "data-handlers" attribute. 67 | * @param el - The element to parse the props from. 68 | * @param liveSocket - The LiveSocket instance. 69 | * @returns The props as an object. 70 | */ 71 | const getProps = (el: HTMLElement, liveSocket: any): Record => ({ 72 | ...(getAttributeJson(el, "data-props") || {}), 73 | ...getHandlers(el, liveSocket), 74 | }) 75 | 76 | export const getVueHook = ({ resolve, setup }: LiveVueApp): Hook => ({ 77 | async mounted() { 78 | const componentName = this.el.getAttribute("data-name") as string 79 | const component = await resolve(componentName) 80 | 81 | const makeApp = this.el.getAttribute("data-ssr") === "true" ? createSSRApp : createApp 82 | 83 | const props = reactive(getProps(this.el, this.liveSocket)) 84 | const slots = reactive(getSlots(this.el)) 85 | // let's apply initial stream diff here, since all stream changes are sent in that attribute 86 | applyPatch(props, getDiff(this.el, "data-streams-diff")) 87 | 88 | this.vue = { props, slots, app: null } 89 | const app = setup({ 90 | createApp: makeApp, 91 | component, 92 | props, 93 | slots, 94 | plugin: { 95 | install: (app: App) => { 96 | app.provide(liveInjectKey, this) 97 | app.config.globalProperties.$live = this 98 | }, 99 | }, 100 | el: this.el, 101 | ssr: false, 102 | }) 103 | 104 | if (!app) throw new Error("Setup function did not return a Vue app!") 105 | 106 | this.vue.app = app 107 | }, 108 | updated() { 109 | if (this.el.getAttribute("data-use-diff") === "true") { 110 | applyPatch(this.vue.props, getDiff(this.el, "data-props-diff")) 111 | } else { 112 | Object.assign(this.vue.props, getProps(this.el, this.liveSocket)) 113 | } 114 | // we're always applying streams diff, since all stream changes are sent in that attribute 115 | applyPatch(this.vue.props, getDiff(this.el, "data-streams-diff")) 116 | Object.assign(this.vue.slots ?? {}, getSlots(this.el)) 117 | }, 118 | destroyed() { 119 | const instance = this.vue.app 120 | // TODO - is there maybe a better way to cleanup the app? 121 | if (instance) { 122 | window.addEventListener("phx:page-loading-stop", () => instance.unmount(), { once: true }) 123 | } 124 | }, 125 | }) 126 | 127 | /** 128 | * Returns the hooks for the LiveVue app. 129 | * @param components - The components to use in the app. 130 | * @param options - The options for the LiveVue app. 131 | * @returns The hooks for the LiveVue app. 132 | */ 133 | type VueHooks = { VueHook: Hook } 134 | type getHooksAppFn = (app: LiveVueApp) => VueHooks 135 | type getHooksComponentsOptions = { initializeApp?: LiveVueOptions["setup"] } 136 | type getHooksComponentsFn = (components: ComponentMap, options?: getHooksComponentsOptions) => VueHooks 137 | 138 | export const getHooks: getHooksComponentsFn | getHooksAppFn = ( 139 | componentsOrApp: ComponentMap | LiveVueApp, 140 | options?: getHooksComponentsOptions 141 | ) => { 142 | const app = migrateToLiveVueApp(componentsOrApp, options ?? {}) 143 | return { VueHook: getVueHook(app) } 144 | } 145 | -------------------------------------------------------------------------------- /assets/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest" 2 | import { findComponent, toUtf8Base64, fromUtf8Base64 } from "./utils" 3 | import type { ComponentMap } from "./types" 4 | 5 | describe("findComponent", () => { 6 | const MockWorkspaceComponent = { 7 | template: "
Mock Component
", 8 | } 9 | 10 | const MockCreateWorkspaceComponent = { 11 | template: "
Create Workspace Component
", 12 | } 13 | 14 | it("should find exact component match for 'workspace'", () => { 15 | const components: ComponentMap = { 16 | "../../lib/live_vue/web/pages/workspace.vue": MockWorkspaceComponent, 17 | "../../lib/live_vue/web/pages/create-workspace.vue": MockCreateWorkspaceComponent, 18 | } 19 | 20 | const result = findComponent(components, "workspace") 21 | 22 | expect(result).toBe(MockWorkspaceComponent) 23 | }) 24 | 25 | it("should NOT match 'workspace' to 'create-workspace'", () => { 26 | const components: ComponentMap = { 27 | "../../lib/live_vue/web/pages/create-workspace.vue": MockCreateWorkspaceComponent, 28 | "../../lib/live_vue/web/pages/workspace.vue": MockWorkspaceComponent, 29 | } 30 | 31 | const result = findComponent(components, "workspace") 32 | 33 | expect(result).toBe(MockWorkspaceComponent) 34 | }) 35 | 36 | it("should find 'create-workspace' component when requested", () => { 37 | const components: ComponentMap = { 38 | "../../lib/live_vue/web/pages/workspace.vue": MockWorkspaceComponent, 39 | "../../lib/live_vue/web/pages/create-workspace.vue": MockCreateWorkspaceComponent, 40 | } 41 | 42 | const result = findComponent(components, "create-workspace") 43 | 44 | expect(result).toBe(MockCreateWorkspaceComponent) 45 | }) 46 | 47 | it("should throw error when component is not found", () => { 48 | const components: ComponentMap = { 49 | "../../lib/leuchtturm/web/pages/workspace.vue": MockWorkspaceComponent, 50 | } 51 | 52 | expect(() => findComponent(components, "nonexistent")).toThrow("Component 'nonexistent' not found!") 53 | }) 54 | 55 | it("should handle index.vue files", () => { 56 | const components: ComponentMap = { 57 | "../../lib/live_vue/web/pages/workspace/index.vue": MockWorkspaceComponent, 58 | } 59 | 60 | const result = findComponent(components, "workspace") 61 | 62 | expect(result).toBe(MockWorkspaceComponent) 63 | }) 64 | 65 | it("should handle index.vue files with multiple nested paths", () => { 66 | const components: ComponentMap = { 67 | "../../lib/live_vue/web/pages/admin/workspace/index.vue": MockWorkspaceComponent, 68 | "../../lib/live_vue/web/pages/public/dashboard/index.vue": MockCreateWorkspaceComponent, 69 | } 70 | 71 | const result1 = findComponent(components, "workspace") 72 | const result2 = findComponent(components, "dashboard/index.vue") 73 | 74 | expect(result1).toBe(MockWorkspaceComponent) 75 | expect(result2).toBe(MockCreateWorkspaceComponent) 76 | }) 77 | 78 | it("should avoid false matches due to substring matching", () => { 79 | const components: ComponentMap = { 80 | "../../lib/live_vue/web/pages/create-workspace.vue": MockCreateWorkspaceComponent, 81 | "../../lib/live_vue/web/pages/workspace.vue": MockWorkspaceComponent, 82 | } 83 | 84 | const result = findComponent(components, "workspace") 85 | 86 | expect(result).toBe(MockWorkspaceComponent) 87 | }) 88 | 89 | it("should find component by path suffix", () => { 90 | const components: ComponentMap = { 91 | "../../lib/live_vue/web/components/admin/workspace.vue": MockWorkspaceComponent, 92 | "../../lib/live_vue/web/components/public/workspace.vue": MockCreateWorkspaceComponent, 93 | } 94 | 95 | const result1 = findComponent(components, "admin/workspace") 96 | const result2 = findComponent(components, "public/workspace.vue") 97 | 98 | expect(result1).toBe(MockWorkspaceComponent) 99 | expect(result2).toBe(MockCreateWorkspaceComponent) 100 | }) 101 | 102 | it("should throw ambiguous error when filename matches multiple components", () => { 103 | const components: ComponentMap = { 104 | "../../lib/live_vue/web/components/workspace/index.vue": MockWorkspaceComponent, 105 | "../../lib/live_vue/web/components/create/workspace.vue": MockCreateWorkspaceComponent, 106 | } 107 | 108 | expect(() => findComponent(components, "workspace")).toThrow("Component 'workspace' is ambiguous") 109 | }) 110 | 111 | it("should throw ambiguous error for multiple index.vue matches", () => { 112 | const components: ComponentMap = { 113 | "../../lib/live_vue/web/pages/admin/workspace/index.vue": MockWorkspaceComponent, 114 | "../../lib/live_vue/web/pages/user/workspace/index.vue": MockCreateWorkspaceComponent, 115 | } 116 | 117 | expect(() => findComponent(components, "workspace")).toThrow("Component 'workspace' is ambiguous") 118 | }) 119 | 120 | it("should handle mix of index.vue and regular .vue files", () => { 121 | const components: ComponentMap = { 122 | "../../lib/live_vue/web/pages/workspace/index.vue": MockWorkspaceComponent, 123 | "../../lib/live_vue/web/pages/workspace.vue": MockCreateWorkspaceComponent, 124 | } 125 | 126 | expect(() => findComponent(components, "workspace")).toThrow("Component 'workspace' is ambiguous") 127 | }) 128 | 129 | it("should handle empty component name gracefully", () => { 130 | const components: ComponentMap = { 131 | "../../lib/live_vue/web/pages/workspace.vue": MockWorkspaceComponent, 132 | } 133 | 134 | expect(() => findComponent(components, "")).toThrow("Component '' not found!") 135 | }) 136 | }) 137 | 138 | describe("base64 functions", () => { 139 | 140 | it("should symmetrically encode to and decode from base64", () => { 141 | for (const input of ["", "Hello!", "Wrocław", "🦡", "中國"]) { 142 | const base64 = toUtf8Base64(input); 143 | expect(fromUtf8Base64(base64)).toEqual(input); 144 | } 145 | }) 146 | 147 | it("should decode bas 64 strings encoded by Elixir", () => { 148 | for (const [base64, decoded] of [ 149 | ["", ""], 150 | ["Q2Fmw6k=", "Café"], 151 | ["8J+SgEZlZGVyaWNh", "💀Federica"], 152 | ["5YiY5oWI5qyj", "刘慈欣"] 153 | ]) { 154 | expect(fromUtf8Base64(base64)).toEqual(decoded); 155 | } 156 | }) 157 | }) 158 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.MixProject do 2 | use Mix.Project 3 | 4 | @version "1.0.0-rc.4" 5 | @repo_url "https://github.com/Valian/live_vue" 6 | 7 | def project do 8 | [ 9 | app: :live_vue, 10 | version: @version, 11 | consolidate_protocols: Mix.env() != :test, 12 | elixir: "~> 1.15", 13 | elixirc_paths: elixirc_paths(Mix.env()), 14 | start_permanent: Mix.env() == :prod, 15 | listeners: listeners(Mix.env()), 16 | aliases: aliases(), 17 | deps: deps(), 18 | 19 | # Hex 20 | description: "E2E reactivity for Vue and LiveView", 21 | package: package(), 22 | 23 | # Docs 24 | name: "LiveVue", 25 | docs: [ 26 | name: "LiveVue", 27 | logo: "live_vue_logo_rounded.png", 28 | source_ref: "v#{@version}", 29 | source_url: @repo_url, 30 | homepage_url: @repo_url, 31 | main: "readme", 32 | extras: [ 33 | "README.md": [title: "LiveVue"], 34 | 35 | # Getting Started 36 | "guides/installation.md": [title: "Installation"], 37 | "guides/getting_started.md": [title: "Getting Started"], 38 | 39 | # Core Usage 40 | "guides/basic_usage.md": [title: "Basic Usage"], 41 | "guides/forms.md": [title: "Forms and Validation"], 42 | "guides/configuration.md": [title: "Configuration"], 43 | 44 | # Reference 45 | "guides/component_reference.md": [title: "Component Reference"], 46 | "guides/client_api.md": [title: "Client-Side API"], 47 | 48 | # Advanced Topics 49 | "guides/architecture.md": [title: "How LiveVue Works"], 50 | "guides/testing.md": [title: "Testing"], 51 | "guides/deployment.md": [title: "Deployment"], 52 | 53 | # Help & Troubleshooting 54 | "guides/faq.md": [title: "FAQ"], 55 | "guides/troubleshooting.md": [title: "Troubleshooting"], 56 | "guides/comparison.md": [title: "LiveVue vs Alternatives"], 57 | "CHANGELOG.md": [title: "Changelog"] 58 | ], 59 | extra_section: "GUIDES", 60 | groups_for_extras: [ 61 | Introduction: ["README.md"], 62 | "Getting Started": [ 63 | "guides/installation.md", 64 | "guides/getting_started.md" 65 | ], 66 | "Core Usage": [ 67 | "guides/basic_usage.md", 68 | "guides/forms.md", 69 | "guides/configuration.md" 70 | ], 71 | Reference: [ 72 | "guides/component_reference.md", 73 | "guides/client_api.md" 74 | ], 75 | "Advanced Topics": [ 76 | "guides/architecture.md", 77 | "guides/testing.md", 78 | "guides/deployment.md" 79 | ], 80 | "Help & Troubleshooting": [ 81 | "guides/faq.md", 82 | "guides/troubleshooting.md", 83 | "guides/comparison.md" 84 | ], 85 | Other: ["CHANGELOG.md"] 86 | ], 87 | links: %{ 88 | "GitHub" => @repo_url 89 | } 90 | ], 91 | test_coverage: [tool: ExCoveralls] 92 | ] 93 | end 94 | 95 | def cli do 96 | [ 97 | preferred_envs: [ 98 | precommit: :test, 99 | "test.watch": :test, 100 | coveralls: :test, 101 | "coveralls.detail": :test, 102 | "coveralls.post": :test, 103 | "coveralls.html": :test 104 | ] 105 | ] 106 | end 107 | 108 | defp package do 109 | [ 110 | maintainers: ["Jakub Skalecki"], 111 | licenses: ["MIT"], 112 | links: %{ 113 | Changelog: @repo_url <> "/blob/master/CHANGELOG.md", 114 | GitHub: @repo_url 115 | }, 116 | files: ~w(assets lib mix.exs package.json .formatter.exs LICENSE.md README.md CHANGELOG.md usage-rules.md) 117 | ] 118 | end 119 | 120 | # Run "mix help compile.app" to learn about applications. 121 | def application do 122 | conditionals = 123 | case Application.get_env(:live_vue, :ssr_module) do 124 | # Needed to use :httpc.request 125 | LiveVue.SSR.ViteJS -> [:inets] 126 | _ -> [] 127 | end 128 | 129 | [ 130 | extra_applications: [:logger] ++ conditionals 131 | ] 132 | end 133 | 134 | defp elixirc_paths(:e2e), do: ["lib", "test/e2e/features"] 135 | defp elixirc_paths(_), do: ["lib"] 136 | 137 | defp listeners(:e2e), do: [Phoenix.CodeReloader] 138 | defp listeners(_), do: [] 139 | 140 | # Run "mix help deps" to learn about dependencies. 141 | defp deps do 142 | [ 143 | {:jason, "~> 1.2"}, 144 | {:nodejs, "~> 3.1"}, 145 | {:phoenix, ">= 1.7.0"}, 146 | {:phoenix_live_view, ">= 0.18.0"}, 147 | {:telemetry, "~> 0.4 or ~> 1.0"}, 148 | {:jsonpatch, "~> 2.3"}, 149 | {:igniter, "~> 0.6", optional: true}, 150 | {:phoenix_vite, "~> 0.4"}, 151 | {:lazy_html, ">= 0.1.0", optional: true}, 152 | {:ecto, "~> 3.0", optional: true}, 153 | {:phoenix_ecto, "~> 4.0", optional: true}, 154 | 155 | # dev dependencies 156 | {:ex_doc, "~> 0.38", only: :dev, runtime: false, warn_if_outdated: true}, 157 | {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false}, 158 | {:expublish, "~> 2.5", only: [:dev], runtime: false}, 159 | {:excoveralls, "~> 0.18", only: :test}, 160 | {:makeup_html, "~> 0.1.0", only: :dev, runtime: false}, 161 | {:styler, "~> 1.5", only: [:dev, :test], runtime: false}, 162 | 163 | # e2e dependencies 164 | {:bandit, "~> 1.5", only: :e2e}, 165 | {:phoenix_live_reload, "~> 1.2", only: :e2e} 166 | ] 167 | end 168 | 169 | defp aliases do 170 | [ 171 | docs: ["docs", ©_images/1], 172 | setup: ["deps.get", "cmd npm install"], 173 | precommit: ["test", "format", "e2e.test", "assets.test"], 174 | "assets.test": ["cmd npm test"], 175 | "e2e.test": ["cmd npm run e2e:test"], 176 | "release.patch": ["expublish.patch --branch=main --disable-publish"], 177 | "release.minor": ["expublish.minor --branch=main --disable-publish"], 178 | "release.major": ["expublish.major --branch=main --disable-publish"] 179 | ] 180 | end 181 | 182 | defp copy_images(_) do 183 | File.mkdir_p!("./doc/images") 184 | File.cp_r("./guides/images", "./doc/images") 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /test/e2e/test_helper.exs: -------------------------------------------------------------------------------- 1 | # Configure Phoenix JSON encoder 2 | Application.put_env(:phoenix, :json_library, Jason) 3 | 4 | # Configure the test endpoint 5 | Application.put_env(:live_vue, LiveVue.E2E.Endpoint, 6 | http: [ip: {127, 0, 0, 1}, port: 4004], 7 | adapter: Bandit.PhoenixAdapter, 8 | server: true, 9 | live_view: [signing_salt: "aaaaaaaa"], 10 | secret_key_base: String.duplicate("a", 64), 11 | debug_errors: true, 12 | code_reloader: true, 13 | pubsub_server: LiveVue.E2E.PubSub, 14 | reloadable_apps: [:live_vue], 15 | live_reload: [ 16 | patterns: [ 17 | ~r"lib/live_vue/.*(ex|exs)$", 18 | ~r"test/e2e/features/.*\.(ex|exs|vue|js|ts)$" 19 | ] 20 | ] 21 | ) 22 | 23 | Application.put_env(:live_vue, :enable_props_diff, true) 24 | Application.put_env(:live_vue, :ssr_default, false) 25 | 26 | Process.register(self(), :e2e_helper) 27 | 28 | defmodule LiveVue.E2E.ErrorHTML do 29 | def render(template, _), do: Phoenix.Controller.status_message_from_template(template) 30 | end 31 | 32 | defmodule LiveVue.E2E.Layout do 33 | @moduledoc false 34 | use Phoenix.Component 35 | 36 | def render("root.html", assigns) do 37 | ~H""" 38 | 39 | 40 | 41 | 42 | 43 | 44 | LiveVue E2E 45 | 46 | 47 | {@inner_content} 48 | 49 | 50 | """ 51 | end 52 | 53 | def render("live.html", assigns) do 54 | ~H""" 55 | 57 | 59 | 61 | 62 | {@inner_content} 63 | """ 64 | end 65 | end 66 | 67 | defmodule LiveVue.E2E.Hooks do 68 | @moduledoc false 69 | import Phoenix.LiveView 70 | 71 | alias Phoenix.LiveView.Socket 72 | 73 | require Logger 74 | 75 | def on_mount(:default, _params, _session, socket) do 76 | socket 77 | |> attach_hook(:eval_handler, :handle_event, &handle_eval_event/3) 78 | |> then(&{:cont, &1}) 79 | end 80 | 81 | defp handle_eval_event("sandbox:eval", %{"value" => code}, socket) do 82 | {result, _} = Code.eval_string(code, [socket: socket], __ENV__) 83 | Logger.debug("lv:#{inspect(self())} eval result: #{inspect(result)}") 84 | 85 | case result do 86 | {:noreply, %Socket{} = socket} -> {:halt, %{}, socket} 87 | %Socket{} = socket -> {:halt, %{}, socket} 88 | result -> {:halt, %{"result" => result}, socket} 89 | end 90 | end 91 | 92 | defp handle_eval_event(_, _, socket), do: {:cont, socket} 93 | end 94 | 95 | defmodule LiveVue.E2E.HealthController do 96 | import Plug.Conn 97 | 98 | def init(opts), do: opts 99 | 100 | def index(conn, _params) do 101 | send_resp(conn, 200, "OK") 102 | end 103 | end 104 | 105 | defmodule LiveVue.E2E.Router do 106 | use Phoenix.Router 107 | 108 | import Phoenix.LiveView.Router 109 | 110 | alias LiveVue.E2E.Layout 111 | 112 | pipeline :browser do 113 | plug :accepts, ["html"] 114 | plug :fetch_session 115 | plug :fetch_live_flash 116 | plug :protect_from_forgery 117 | plug :put_root_layout, html: {Layout, :root} 118 | end 119 | 120 | live_session :default, 121 | layout: {Layout, :live}, 122 | on_mount: {LiveVue.E2E.Hooks, :default} do 123 | scope "/", LiveVue.E2E do 124 | pipe_through(:browser) 125 | 126 | live "/test", TestLive 127 | live "/prop-diff-test", PropDiffTestLive 128 | live "/navigation/:page", NavigationLive 129 | live "/navigation/alt/:page", NavigationLive 130 | live "/events", EventLive 131 | live "/upload/:mode", UploadTestLive 132 | live "/streams", StreamLive 133 | live "/form-test", FormTestLive 134 | live "/event-reply-test", EventReplyTestLive 135 | live "/slot-test", SlotTestLive 136 | end 137 | end 138 | 139 | scope "/", LiveVue.E2E do 140 | pipe_through(:browser) 141 | 142 | get "/health", HealthController, :index 143 | end 144 | end 145 | 146 | defmodule LiveVue.E2E.Endpoint do 147 | use Phoenix.Endpoint, otp_app: :live_vue 148 | 149 | @session_options [ 150 | store: :cookie, 151 | key: "_lv_e2e_key", 152 | signing_salt: "aaaaaaaa", 153 | same_site: "Lax" 154 | ] 155 | 156 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 157 | 158 | # Code reloading configuration 159 | if code_reloading? do 160 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 161 | plug Phoenix.LiveReloader 162 | plug Phoenix.CodeReloader 163 | end 164 | 165 | plug Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix" 166 | plug Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view" 167 | plug Plug.Static, from: "test/e2e/priv/static/assets", at: "/assets" 168 | 169 | plug :health_check 170 | plug :halt 171 | 172 | plug Plug.Parsers, 173 | parsers: [:urlencoded, :multipart, :json], 174 | pass: ["*/*"], 175 | json_decoder: Phoenix.json_library() 176 | 177 | plug Plug.Session, @session_options 178 | plug LiveVue.E2E.Router 179 | 180 | defp health_check(%{request_path: "/health"} = conn, _opts) do 181 | conn 182 | |> Plug.Conn.send_resp(200, "OK") 183 | |> Plug.Conn.halt() 184 | end 185 | 186 | defp health_check(conn, _opts), do: conn 187 | 188 | defp halt(%{request_path: "/halt"}, _opts) do 189 | send(:e2e_helper, :halt) 190 | Process.sleep(:infinity) 191 | end 192 | 193 | defp halt(conn, _opts), do: conn 194 | end 195 | 196 | # Start PubSub and Endpoint 197 | # Note: LiveView modules are compiled from test/e2e/features/*/live.ex via elixirc_paths in mix.exs 198 | {:ok, _} = 199 | Supervisor.start_link( 200 | [ 201 | LiveVue.E2E.Endpoint, 202 | {Phoenix.PubSub, name: LiveVue.E2E.PubSub} 203 | ], 204 | strategy: :one_for_one 205 | ) 206 | 207 | IO.puts("Starting e2e server on port #{LiveVue.E2E.Endpoint.config(:http)[:port]}") 208 | 209 | if not IEx.started?() do 210 | spawn(fn -> 211 | IO.read(:stdio, :line) 212 | send(:e2e_helper, :halt) 213 | end) 214 | 215 | receive do 216 | :halt -> :ok 217 | end 218 | end 219 | -------------------------------------------------------------------------------- /guides/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | Now that you have LiveVue installed, let's create your first Vue component and integrate it with LiveView. 4 | 5 | ## Creating Your First Component 6 | 7 | Let's create a simple counter component that demonstrates the reactivity between Vue and LiveView. 8 | 9 | 1. Create `assets/vue/Counter.vue`: 10 | 11 | ```html 12 | 21 | 22 | 31 | ``` 32 | 33 | 2. Create a LiveView to handle the counter state (`lib/my_app_web/live/counter_live.ex`): 34 | 35 | ```elixir 36 | defmodule MyAppWeb.CounterLive do 37 | use MyAppWeb, :live_view 38 | 39 | def render(assigns) do 40 | ~H""" 41 | <.vue count={@count} v-component="Counter" v-socket={@socket} /> 42 | """ 43 | end 44 | 45 | def mount(_params, _session, socket) do 46 | {:ok, assign(socket, count: 0)} 47 | end 48 | 49 | def handle_event("inc", %{"diff" => value}, socket) do 50 | {:noreply, update(socket, :count, &(&1 + value))} 51 | end 52 | end 53 | ``` 54 | 55 | 3. Add the route in your `router.ex`: 56 | 57 | ```elixir 58 | live "/counter", CounterLive 59 | ``` 60 | 61 | Start server and visit `http://localhost:4000/counter` to see your counter in action! 62 | If it's not working correctly, see [Troubleshooting](troubleshooting.md). 63 | 64 | ## Adding Smooth Transitions 65 | 66 | One of Vue's strengths is its built-in transition system. Let's enhance our counter with smooth animations and nice tailwind styling: 67 | 68 | 1. Create `assets/vue/AnimatedCounter.vue`: 69 | 70 | ```html 71 | 77 | 78 | 112 | 113 | 129 | ``` 130 | 131 | 2. Update your LiveView to use the animated version: 132 | 133 | ```elixir 134 | def render(assigns) do 135 | ~H""" 136 |
137 |

Animated Counter

138 | <.vue count={@count} v-component="AnimatedCounter" v-socket={@socket} /> 139 |
140 | """ 141 | end 142 | ``` 143 | 144 | Now your counter will smoothly animate when the value changes! This showcases how Vue's transition system can add polish to your LiveView apps without any server-side complexity. 145 | 146 | ## Key Concepts 147 | 148 | This example demonstrates several key LiveVue features: 149 | 150 | - **Props Flow**: LiveView sends the `count` value to Vue as a prop 151 | - **Event Handling**: Vue emits an `inc` event with `phx-click` and `phx-value-diff` attributes 152 | - **State Management**: LiveView maintains the source of truth (the counter value) 153 | - **Local UI State**: Vue maintains the slider value locally without server involvement 154 | - **Transitions**: Vue handles smooth animations purely on the client side 155 | 156 | Basic diagram of the flow: 157 | 158 | ![LiveVue flow](./images/lifecycle.png) 159 | 160 | If you want to understand how it works in depth, see [Architecture](architecture.md). 161 | 162 | ### Working with Custom Structs 163 | 164 | When you start passing more complex data structures as props, you'll need to implement the `LiveVue.Encoder` protocol: 165 | 166 | ```elixir 167 | # For any custom structs you want to pass as props 168 | defmodule User do 169 | @derive LiveVue.Encoder 170 | defstruct [:name, :email, :age] 171 | end 172 | 173 | # Use in your LiveView 174 | def render(assigns) do 175 | ~H""" 176 | <.vue user={@current_user} v-component="UserProfile" v-socket={@socket} /> 177 | """ 178 | end 179 | ``` 180 | 181 | This protocol ensures that: 182 | - Only specified fields are sent to the client 183 | - Sensitive data is protected from accidental exposure 184 | - Props can be efficiently diffed for optimal performance 185 | 186 | For more details, see [Component Reference](component_reference.md#custom-structs-with-livevue-encoder). 187 | 188 | 189 | > #### Good to know {: .info} 190 | > 191 | > - Install the [Vue DevTools browser extension](https://devtools.vuejs.org/getting-started/installation) for debugging 192 | > - In development, LiveVue enables Hot Module Replacement for instant component updates 193 | > - Structure your app with LiveView managing application state and Vue handling UI interactions 194 | > - For complex UIs with lots of local state, prefer Vue components over LiveView hooks 195 | 196 | ## Next Steps 197 | 198 | Now that you have your first component working, explore: 199 | - [Live Examples](https://livevue.skalecki.dev) - Interactive demos to see LiveVue in action 200 | - [Basic Usage](basic_usage.md) for more patterns and the ~VUE sigil 201 | - [Component Reference](component_reference.md) for complete syntax documentation 202 | - [FAQ](faq.md) for common questions and troubleshooting 203 | - [Troubleshooting](troubleshooting.md) for common issues -------------------------------------------------------------------------------- /test/live_vue_ssr_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.SSRTest do 2 | use ExUnit.Case 3 | 4 | alias LiveVue.SSR 5 | 6 | defmodule MockSSRRenderer do 7 | @moduledoc false 8 | @behaviour LiveVue.SSR 9 | 10 | @impl true 11 | def render("WithPreload", _props, _slots) do 12 | "
Component HTML
" 13 | end 14 | 15 | @impl true 16 | def render("NoPreload", _props, _slots) do 17 | "
Just HTML
" 18 | end 19 | 20 | @impl true 21 | def render("MapResponse", _props, _slots) do 22 | %{preloadLinks: "", html: "
Direct map
"} 23 | end 24 | 25 | @impl true 26 | def render("Echo", props, slots) do 27 | props_str = Jason.encode!(props) 28 | slots_str = Jason.encode!(slots) 29 | "
Props: #{props_str}, Slots: #{slots_str}
" 30 | end 31 | end 32 | 33 | setup do 34 | # Clean up config after each test 35 | on_exit(fn -> 36 | Application.delete_env(:live_vue, :ssr_module) 37 | end) 38 | 39 | :ok 40 | end 41 | 42 | describe "render/3 when ssr_module is not configured" do 43 | test "returns empty map with empty strings" do 44 | Application.delete_env(:live_vue, :ssr_module) 45 | 46 | result = SSR.render("AnyComponent", %{}, %{}) 47 | 48 | assert result == %{preloadLinks: "", html: ""} 49 | end 50 | 51 | test "ignores props and slots when not configured" do 52 | Application.delete_env(:live_vue, :ssr_module) 53 | 54 | result = SSR.render("Component", %{"key" => "value"}, %{"slot" => "content"}) 55 | 56 | assert result == %{preloadLinks: "", html: ""} 57 | end 58 | end 59 | 60 | describe "render/3 when ssr_module is configured" do 61 | setup do 62 | Application.put_env(:live_vue, :ssr_module, MockSSRRenderer) 63 | :ok 64 | end 65 | 66 | test "splits response on delimiter" do 67 | result = SSR.render("WithPreload", %{}, %{}) 68 | 69 | assert result == %{ 70 | preloadLinks: "", 71 | html: "
Component HTML
" 72 | } 73 | end 74 | 75 | test "handles response without preload delimiter" do 76 | result = SSR.render("NoPreload", %{}, %{}) 77 | 78 | assert result == %{ 79 | preloadLinks: "", 80 | html: "
Just HTML
" 81 | } 82 | end 83 | 84 | test "passes through map response directly" do 85 | result = SSR.render("MapResponse", %{}, %{}) 86 | 87 | assert result == %{ 88 | preloadLinks: "", 89 | html: "
Direct map
" 90 | } 91 | end 92 | 93 | test "forwards component name, props, and slots to renderer" do 94 | props = %{"count" => 42, "title" => "Test"} 95 | slots = %{"default" => "Content"} 96 | 97 | result = SSR.render("Echo", props, slots) 98 | 99 | assert result.html =~ "\"count\":42" 100 | assert result.html =~ "\"title\":\"Test\"" 101 | assert result.html =~ "\"default\":\"Content\"" 102 | end 103 | end 104 | 105 | describe "render/3 telemetry" do 106 | setup do 107 | Application.put_env(:live_vue, :ssr_module, MockSSRRenderer) 108 | 109 | # Attach telemetry handler 110 | handler_id = :telemetry_test_handler 111 | test_pid = self() 112 | 113 | :telemetry.attach( 114 | handler_id, 115 | [:live_vue, :ssr, :start], 116 | fn event, measurements, metadata, _config -> 117 | send(test_pid, {:telemetry_event, event, measurements, metadata}) 118 | end, 119 | nil 120 | ) 121 | 122 | on_exit(fn -> 123 | :telemetry.detach(handler_id) 124 | end) 125 | 126 | :ok 127 | end 128 | 129 | test "emits telemetry span with correct metadata" do 130 | props = %{"key" => "value"} 131 | slots = %{"header" => "Header content"} 132 | 133 | SSR.render("NoPreload", props, slots) 134 | 135 | assert_receive {:telemetry_event, [:live_vue, :ssr, :start], _measurements, metadata} 136 | 137 | assert metadata.component == "NoPreload" 138 | assert metadata.props == %{"key" => "value"} 139 | assert metadata.slots == %{"header" => "Header content"} 140 | end 141 | 142 | test "emits telemetry for each render call" do 143 | SSR.render("WithPreload", %{}, %{}) 144 | SSR.render("NoPreload", %{}, %{}) 145 | 146 | assert_receive {:telemetry_event, [:live_vue, :ssr, :start], _, %{component: "WithPreload"}} 147 | assert_receive {:telemetry_event, [:live_vue, :ssr, :start], _, %{component: "NoPreload"}} 148 | end 149 | end 150 | 151 | describe "render/3 edge cases" do 152 | defmodule EdgeCaseRenderer do 153 | @behaviour LiveVue.SSR 154 | 155 | @impl true 156 | def render("MultipleDelimiters", _props, _slots) do 157 | "
Another one
" 158 | end 159 | 160 | @impl true 161 | def render("EmptyPreload", _props, _slots) do 162 | "
No preload links
" 163 | end 164 | 165 | @impl true 166 | def render("EmptyHTML", _props, _slots) do 167 | "" 168 | end 169 | end 170 | 171 | setup do 172 | Application.put_env(:live_vue, :ssr_module, EdgeCaseRenderer) 173 | :ok 174 | end 175 | 176 | test "splits only on first delimiter with multiple delimiters" do 177 | result = SSR.render("MultipleDelimiters", %{}, %{}) 178 | 179 | assert result == %{ 180 | preloadLinks: "", 181 | html: "
Another one
" 182 | } 183 | end 184 | 185 | test "handles empty preload section" do 186 | result = SSR.render("EmptyPreload", %{}, %{}) 187 | 188 | assert result == %{ 189 | preloadLinks: "", 190 | html: "
No preload links
" 191 | } 192 | end 193 | 194 | test "handles empty HTML section" do 195 | result = SSR.render("EmptyHTML", %{}, %{}) 196 | 197 | assert result == %{ 198 | preloadLinks: "", 199 | html: "" 200 | } 201 | end 202 | end 203 | 204 | describe "LiveVue.SSR.NotConfigured exception" do 205 | test "can be raised with a message" do 206 | assert_raise SSR.NotConfigured, "SSR is not configured", fn -> 207 | raise SSR.NotConfigured, message: "SSR is not configured" 208 | end 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /lib/live_vue/test.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.Test do 2 | @moduledoc """ 3 | Helpers for testing LiveVue components and views. 4 | 5 | ## Overview 6 | 7 | LiveVue testing differs from traditional Phoenix LiveView testing in how components 8 | are rendered and inspected: 9 | 10 | * In Phoenix LiveView testing, you use `Phoenix.LiveViewTest.render_component/2` 11 | to get the final rendered HTML 12 | * In LiveVue testing, `render_component/2` returns an unrendered LiveVue root 13 | element containing the Vue component's configuration 14 | 15 | This module provides helpers to extract and inspect Vue component data from the 16 | LiveVue root element, including: 17 | 18 | * Component name and ID 19 | * Props passed to the component 20 | * Event handlers and their operations 21 | * Server-side rendering (SSR) status 22 | * Slot content 23 | * CSS classes 24 | 25 | ## Examples 26 | 27 | # Render a LiveVue component and inspect its properties 28 | {:ok, view, _html} = live(conn, "/") 29 | vue = LiveVue.Test.get_vue(view) 30 | 31 | # Basic component info 32 | assert vue.component == "MyComponent" 33 | assert vue.props["title"] == "Hello" 34 | 35 | # Event handlers 36 | assert vue.handlers["click"] == JS.push("click") 37 | 38 | # SSR status and styling 39 | assert vue.ssr == true 40 | assert vue.class == "my-custom-class" 41 | 42 | ## Configuration 43 | 44 | ### enable_props_diff 45 | 46 | When set to `false` in your config, LiveVue will always send full props and not send diffs. 47 | This is useful for testing scenarios where you need to inspect the complete props state 48 | rather than just the changes. 49 | 50 | ```elixir 51 | # config/test.exs 52 | config :live_vue, 53 | enable_props_diff: false 54 | ``` 55 | 56 | When disabled, the `props` field returned by `get_vue/2` will always contain 57 | the complete props state, making it easier to write comprehensive tests that verify the 58 | full component state rather than just the incremental changes. 59 | """ 60 | 61 | @compile {:no_warn_undefined, LazyHTML} 62 | 63 | @doc """ 64 | Extracts Vue component information from a LiveView or HTML string. 65 | 66 | When multiple Vue components are present, you can specify which one to extract using 67 | either the `:name` or `:id` option. 68 | 69 | Returns a map containing the component's configuration: 70 | * `:component` - The Vue component name (from `v-component` attribute) 71 | * `:id` - The unique component identifier (auto-generated or explicitly set) 72 | * `:props` - The decoded props passed to the component 73 | * `:handlers` - Map of event handlers (`v-on:*`) and their operations 74 | * `:slots` - Base64 encoded slot content 75 | * `:ssr` - Boolean indicating if server-side rendering was performed 76 | * `:class` - CSS classes applied to the component root element 77 | * `:props_diff` - List of prop diffs 78 | * `:streams_diff` - List of stream diffs 79 | * `:doc` - Parsed HTML element of the component (as tree structure) 80 | 81 | ## Options 82 | * `:name` - Find component by name (from `v-component` attribute) 83 | * `:id` - Find component by ID 84 | 85 | ## Examples 86 | 87 | # From a LiveView, get first Vue component 88 | {:ok, view, _html} = live(conn, "/") 89 | vue = LiveVue.Test.get_vue(view) 90 | 91 | # Get specific component by name 92 | vue = LiveVue.Test.get_vue(view, name: "MyComponent") 93 | 94 | # Get specific component by ID 95 | vue = LiveVue.Test.get_vue(view, id: "my-component-1") 96 | """ 97 | def get_vue(view, opts \\ []) 98 | 99 | def get_vue(view, opts) when is_struct(view, Phoenix.LiveViewTest.View) do 100 | view |> Phoenix.LiveViewTest.render() |> get_vue(opts) 101 | end 102 | 103 | def get_vue(html, opts) when is_binary(html) do 104 | if Code.ensure_loaded?(LazyHTML) do 105 | lazy_html = 106 | html 107 | |> LazyHTML.from_document() 108 | |> LazyHTML.query("[phx-hook='VueHook']") 109 | 110 | vue_tree = find_component!(lazy_html, opts) 111 | 112 | %{ 113 | props: Jason.decode!(attr_from_tree(vue_tree, "data-props")), 114 | component: attr_from_tree(vue_tree, "data-name"), 115 | id: attr_from_tree(vue_tree, "id"), 116 | handlers: extract_handlers(attr_from_tree(vue_tree, "data-handlers")), 117 | slots: extract_base64_slots(attr_from_tree(vue_tree, "data-slots")), 118 | ssr: vue_tree |> attr_from_tree("data-ssr") |> String.to_existing_atom(), 119 | use_diff: vue_tree |> attr_from_tree("data-use-diff") |> String.to_existing_atom(), 120 | class: attr_from_tree(vue_tree, "class"), 121 | props_diff: Jason.decode!(attr_from_tree(vue_tree, "data-props-diff")), 122 | streams_diff: Jason.decode!(attr_from_tree(vue_tree, "data-streams-diff")), 123 | doc: vue_tree 124 | } 125 | else 126 | raise "LazyHTML is not installed. Add {:lazy_html, \">= 0.1.0\", only: :test} to your dependencies to use LiveVue.Test" 127 | end 128 | end 129 | 130 | defp extract_handlers(handlers) do 131 | handlers 132 | |> Jason.decode!() 133 | |> Map.new(fn {k, v} -> {k, extract_js_ops(v)} end) 134 | end 135 | 136 | defp extract_base64_slots(slots) do 137 | slots 138 | |> Jason.decode!() 139 | |> Map.new(fn {key, value} -> {key, Base.decode64!(value)} end) 140 | end 141 | 142 | defp extract_js_ops(ops) do 143 | ops 144 | |> Jason.decode!() 145 | |> Enum.map(fn 146 | [op, map] when is_map(map) -> [op, for({k, v} <- map, do: {String.to_existing_atom(k), v}, into: %{})] 147 | op -> op 148 | end) 149 | |> then(&%Phoenix.LiveView.JS{ops: &1}) 150 | end 151 | 152 | defp find_component!(components, opts) do 153 | components_tree = LazyHTML.to_tree(components) 154 | 155 | available = Enum.map_join(components_tree, ", ", &"#{attr_from_tree(&1, "data-name")}##{attr_from_tree(&1, "id")}") 156 | 157 | matched = 158 | Enum.reduce(opts, components_tree, fn 159 | {:id, id}, result -> 160 | with [] <- Enum.filter(result, &(attr_from_tree(&1, "id") == id)) do 161 | raise "No Vue component found with id=\"#{id}\". Available components: #{available}" 162 | end 163 | 164 | {:name, name}, result -> 165 | with [] <- Enum.filter(result, &(attr_from_tree(&1, "data-name") == name)) do 166 | raise "No Vue component found with name=\"#{name}\". Available components: #{available}" 167 | end 168 | 169 | {key, _}, _result -> 170 | raise ArgumentError, "invalid keyword option for get_vue/2: #{key}" 171 | end) 172 | 173 | case matched do 174 | [vue | _] -> 175 | vue 176 | 177 | [] -> 178 | raise "No Vue components found in the rendered HTML" 179 | end 180 | end 181 | 182 | defp attr_from_tree({_tag, attrs, _children}, name) do 183 | case Enum.find(attrs, fn {k, _v} -> k == name end) do 184 | {^name, value} -> value 185 | nil -> nil 186 | end 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /guides/testing.md: -------------------------------------------------------------------------------- 1 | # Testing Guide 2 | 3 | LiveVue provides a robust testing module `LiveVue.Test` that makes it easy to test Vue components within your Phoenix LiveView tests. 4 | 5 | ## Overview 6 | 7 | Testing LiveVue components differs from traditional Phoenix LiveView testing in a key way: 8 | - Traditional LiveView testing uses `render_component/2` to get final HTML 9 | - LiveVue testing provides helpers to inspect the Vue component configuration before client-side rendering 10 | 11 | ## Dependencies 12 | 13 | The `LiveVue.Test` module requires the `lazy_html` package for parsing HTML. Add it to your test dependencies: 14 | 15 | ```elixir 16 | # mix.exs 17 | defp deps do 18 | [ 19 | # ... other deps 20 | {:lazy_html, ">= 0.1.0", only: :test} 21 | ] 22 | end 23 | ``` 24 | 25 | ## Testing Configuration 26 | 27 | For comprehensive testing, you should disable props diffing in your test environment to ensure `LiveVue.Test.get_vue/2` always returns complete props data: 28 | 29 | ```elixir 30 | # config/test.exs 31 | config :live_vue, 32 | enable_props_diff: false 33 | ``` 34 | 35 | When props diffing is enabled (the default), LiveVue only sends changed properties to optimize performance. However, during testing, you typically want to inspect the complete component state rather than just the incremental changes. Disabling diffing ensures that all props are always available for testing assertions. 36 | 37 | This configuration should be set globally for the test environment rather than per-component, as it affects the behavior of the testing helpers. 38 | 39 | ## Basic Component Testing 40 | 41 | Let's start with a simple component test: 42 | 43 | ```elixir 44 | defmodule MyAppWeb.CounterTest do 45 | use MyAppWeb.ConnCase 46 | alias LiveVue.Test 47 | 48 | test "renders counter component with initial props", %{conn: conn} do 49 | {:ok, view, _html} = live(conn, "/counter") 50 | vue = Test.get_vue(view) 51 | 52 | assert vue.component == "Counter" 53 | assert vue.props == %{"count" => 0} 54 | end 55 | end 56 | ``` 57 | 58 | The `get_vue/2` function returns a map containing: 59 | - `:component` - Vue component name 60 | - `:id` - Unique component identifier 61 | - `:props` - Decoded props 62 | - `:handlers` - Event handlers and operations 63 | - `:slots` - Slot content 64 | - `:ssr` - SSR status 65 | - `:class` - CSS classes 66 | 67 | ## Testing Multiple Components 68 | 69 | When your view contains multiple Vue components, you can specify which one to test: 70 | 71 | ```elixir 72 | # Find by component name 73 | vue = Test.get_vue(view, name: "UserProfile") 74 | 75 | # Find by ID 76 | vue = Test.get_vue(view, id: "profile-1") 77 | ``` 78 | 79 | Example with multiple components: 80 | 81 | ```elixir 82 | def render(assigns) do 83 | ~H""" 84 |
85 | <.vue id="profile-1" name="John" v-component="UserProfile" /> 86 | <.vue id="card-1" name="Jane" v-component="UserCard" /> 87 |
88 | """ 89 | end 90 | 91 | test "finds specific component" do 92 | html = render_component(&my_component/1) 93 | 94 | # Get UserCard component 95 | vue = Test.get_vue(html, name: "UserCard") 96 | assert vue.props == %{"name" => "Jane"} 97 | 98 | # Get by ID 99 | vue = Test.get_vue(html, id: "profile-1") 100 | assert vue.component == "UserProfile" 101 | end 102 | ``` 103 | 104 | ## Testing Event Handlers 105 | 106 | You can verify event handlers are properly configured: 107 | 108 | ```elixir 109 | test "component has correct event handlers" do 110 | vue = Test.get_vue(render_component(&my_component/1)) 111 | 112 | assert vue.handlers == %{ 113 | "click" => JS.push("click", value: %{"abc" => "def"}), 114 | "submit" => JS.push("submit") 115 | } 116 | end 117 | ``` 118 | 119 | ## Testing Slots 120 | 121 | LiveVue provides tools to test both default and named slots: 122 | 123 | ```elixir 124 | def component_with_slots(assigns) do 125 | ~H""" 126 | <.vue v-component="WithSlots"> 127 | Default content 128 | <:header>Header content 129 | <:footer>Footer content 130 | 131 | """ 132 | end 133 | 134 | test "renders slots correctly" do 135 | vue = Test.get_vue(render_component(&component_with_slots/1)) 136 | 137 | assert vue.slots == %{ 138 | "default" => "Default content", 139 | "header" => "Header content", 140 | "footer" => "Footer content" 141 | } 142 | end 143 | ``` 144 | 145 | Important notes about slots: 146 | - Use `<:inner_block>` instead of `<:default>` for default content 147 | - Slots are automatically Base64 encoded in the HTML 148 | - The test helper decodes them for easier assertions 149 | 150 | ## Testing SSR Configuration 151 | 152 | Verify Server-Side Rendering settings: 153 | 154 | ```elixir 155 | test "respects SSR configuration" do 156 | vue = Test.get_vue(render_component(&my_component/1)) 157 | assert vue.ssr == true 158 | 159 | # Or with SSR disabled 160 | vue = Test.get_vue(render_component(&ssr_disabled_component/1)) 161 | assert vue.ssr == false 162 | end 163 | ``` 164 | 165 | ## Testing CSS Classes 166 | 167 | Check applied styling: 168 | 169 | ```elixir 170 | test "applies correct CSS classes" do 171 | vue = Test.get_vue(render_component(&my_component/1)) 172 | assert vue.class == "bg-blue-500 rounded" 173 | end 174 | ``` 175 | 176 | ## Integration Testing 177 | 178 | For full integration tests with client-side Vue rendering, use a headless browser with Playwright. 179 | 180 | ### Playwright Setup 181 | 182 | LiveVue's E2E tests use Playwright. Here's a typical test structure: 183 | 184 | ```javascript 185 | // tests/e2e/example.spec.js 186 | import { test, expect } from "@playwright/test" 187 | 188 | // Helper to wait for LiveView connection 189 | const syncLV = async page => { 190 | await Promise.all([ 191 | expect(page.locator(".phx-connected").first()).toBeVisible(), 192 | expect(page.locator(".phx-change-loading")).toHaveCount(0), 193 | new Promise(resolve => setTimeout(resolve, 50)), 194 | ]) 195 | } 196 | 197 | test("Vue component renders and responds to events", async ({ page }) => { 198 | await page.goto("/counter") 199 | await syncLV(page) 200 | 201 | // Verify Vue component is mounted 202 | await expect(page.locator('[phx-hook="VueHook"]')).toBeVisible() 203 | 204 | // Check initial state 205 | await expect(page.locator("[data-testid='count']")).toHaveText("0") 206 | 207 | // Trigger event and verify update 208 | await page.click("button") 209 | await syncLV(page) 210 | await expect(page.locator("[data-testid='count']")).toHaveText("1") 211 | }) 212 | ``` 213 | 214 | ### Tips for E2E Tests 215 | 216 | 1. **Wait for LiveView**: Always use `syncLV()` after navigation or events 217 | 2. **Use data attributes**: Add `data-testid` or `data-pw-*` attributes for reliable selectors 218 | 3. **Test Vue + LiveView interaction**: Verify props update correctly after server events 219 | 220 | ## Best Practices 221 | 222 | 1. **Component Isolation** 223 | - Test Vue components in isolation when possible 224 | - Use `render_component/1` for focused tests 225 | 226 | 2. **Clear Assertions** 227 | - Test one aspect per test 228 | - Use descriptive test names 229 | - Assert specific properties rather than entire component structure 230 | 231 | 3. **Integration Testing** 232 | - Test full component interaction in LiveView context 233 | - Verify both server and client-side behavior 234 | - Test error cases and edge conditions 235 | 236 | 4. **Maintainable Tests** 237 | - Use helper functions for common assertions 238 | - Keep test setup minimal and clear 239 | - Document complex test scenarios -------------------------------------------------------------------------------- /test/e2e/features/prop-diff/live.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveVue.E2E.PropDiffTestLive do 2 | @moduledoc false 3 | use Phoenix.LiveView 4 | 5 | def mount(_params, _session, socket) do 6 | initial_data = %{ 7 | simple_string: "hello", 8 | simple_number: 42, 9 | simple_boolean: true, 10 | simple_list: ["a", "b", "c"], 11 | simple_map: %{key1: "value1", key2: "value2"}, 12 | nested_data: %{ 13 | user: %{name: "John", age: 30}, 14 | settings: %{theme: "dark", notifications: true} 15 | }, 16 | list_of_maps: [ 17 | %{id: 1, name: "Alice", role: "admin"}, 18 | %{id: 2, name: "Bob", role: "user"} 19 | ] 20 | } 21 | 22 | {:ok, assign(socket, :data, initial_data)} 23 | end 24 | 25 | def handle_event("set_simple_string", %{"new" => value}, socket) do 26 | {:noreply, update(socket, :data, &Map.put(&1, :simple_string, value))} 27 | end 28 | 29 | def handle_event("set_simple_number", %{"new" => value}, socket) do 30 | {:noreply, update(socket, :data, &Map.put(&1, :simple_number, String.to_integer(value)))} 31 | end 32 | 33 | def handle_event("set_simple_boolean", %{"new" => value}, socket) do 34 | {:noreply, update(socket, :data, &Map.put(&1, :simple_boolean, String.to_existing_atom(value)))} 35 | end 36 | 37 | def handle_event("add_to_list", %{"new" => value}, socket) do 38 | {:noreply, update(socket, :data, &Map.put(&1, :simple_list, &1.simple_list ++ [value]))} 39 | end 40 | 41 | def handle_event("remove_from_list", %{"index" => index}, socket) do 42 | {:noreply, 43 | update(socket, :data, &Map.put(&1, :simple_list, List.delete_at(&1.simple_list, String.to_integer(index))))} 44 | end 45 | 46 | def handle_event("replace_in_list", %{"index" => index, "new" => value}, socket) do 47 | {:noreply, 48 | update(socket, :data, &Map.put(&1, :simple_list, List.replace_at(&1.simple_list, String.to_integer(index), value)))} 49 | end 50 | 51 | def handle_event("add_to_map", %{"key" => key, "new" => value}, socket) do 52 | {:noreply, update(socket, :data, &Map.put(&1, :simple_map, Map.put(&1.simple_map, String.to_atom(key), value)))} 53 | end 54 | 55 | def handle_event("remove_from_map", %{"key" => key}, socket) do 56 | {:noreply, 57 | update(socket, :data, &Map.put(&1, :simple_map, Map.delete(&1.simple_map, String.to_existing_atom(key))))} 58 | end 59 | 60 | def handle_event("change_nested_user_name", %{"new" => value}, socket) do 61 | {:noreply, update(socket, :data, &put_in(&1, [:nested_data, :user, :name], value))} 62 | end 63 | 64 | def handle_event("change_nested_user_age", %{"new" => value}, socket) do 65 | {:noreply, update(socket, :data, &put_in(&1, [:nested_data, :user, :age], String.to_integer(value)))} 66 | end 67 | 68 | def handle_event("add_nested_setting", %{"key" => key, "new" => value}, socket) do 69 | {:noreply, update(socket, :data, &put_in(&1, [:nested_data, :settings, String.to_atom(key)], value))} 70 | end 71 | 72 | def handle_event("set_to_nil", %{"key" => key}, socket) do 73 | {:noreply, update(socket, :data, &Map.put(&1, String.to_existing_atom(key), nil))} 74 | end 75 | 76 | def handle_event("add_list_item", %{"name" => name, "role" => role}, socket) do 77 | {:noreply, 78 | update(socket, :data, fn data -> 79 | new_id = Enum.max_by(data.list_of_maps, & &1.id).id + 1 80 | new_item = %{id: new_id, name: name, role: role} 81 | Map.put(data, :list_of_maps, data.list_of_maps ++ [new_item]) 82 | end)} 83 | end 84 | 85 | def handle_event("update_list_item", %{"id" => id, "name" => name}, socket) do 86 | {:noreply, 87 | update(socket, :data, fn data -> 88 | new_list = 89 | Enum.map(data.list_of_maps, fn item -> 90 | if item.id == String.to_integer(id) do 91 | %{item | name: name} 92 | else 93 | item 94 | end 95 | end) 96 | 97 | Map.put(data, :list_of_maps, new_list) 98 | end)} 99 | end 100 | 101 | def handle_event("remove_list_item", %{"id" => id}, socket) do 102 | {:noreply, 103 | update( 104 | socket, 105 | :data, 106 | &Map.put(&1, :list_of_maps, Enum.reject(&1.list_of_maps, fn item -> item.id == String.to_integer(id) end)) 107 | )} 108 | end 109 | 110 | def render(assigns) do 111 | ~H""" 112 |
113 |

Prop Diff Test LiveView

114 | 115 | 116 | 117 |
118 |

Test Controls

119 | 120 |
121 | 124 |
125 | 126 |
127 | 130 |
131 | 132 |
133 | 140 |
141 | 142 |
143 | 146 |
147 | 148 |
149 | 152 |
153 | 154 |
155 | 158 |
159 | 160 |
161 | 164 |
165 | 166 |
167 | 170 |
171 | 172 |
173 | 176 |
177 | 178 |
179 | 182 |
183 | 184 |
185 | 193 |
194 | 195 |
196 | 199 |
200 | 201 |
202 | 205 |
206 | 207 |
208 | 216 |
217 | 218 |
219 | 222 |
223 |
224 |
225 | """ 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /test/e2e/features/upload/upload-test.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 107 | 108 | 313 | -------------------------------------------------------------------------------- /test/live_vue_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveVueTest do 2 | use ExUnit.Case 3 | 4 | import LiveVue 5 | import Phoenix.Component 6 | import Phoenix.LiveViewTest 7 | 8 | alias LiveVue.Test 9 | alias Phoenix.LiveView.JS 10 | 11 | doctest LiveVue 12 | 13 | describe "basic component rendering" do 14 | def simple_component(assigns) do 15 | ~H""" 16 | <.vue name="John" surname="Doe" v-component="MyComponent" /> 17 | """ 18 | end 19 | 20 | test "renders component with correct props" do 21 | html = render_component(&simple_component/1) 22 | vue = Test.get_vue(html) 23 | 24 | assert vue.component == "MyComponent" 25 | assert vue.props == %{"name" => "John", "surname" => "Doe"} 26 | end 27 | 28 | test "generates consistent ID" do 29 | html = render_component(&simple_component/1) 30 | vue = Test.get_vue(html) 31 | 32 | assert vue.id =~ ~r/MyComponent-\d+/ 33 | end 34 | end 35 | 36 | describe "multiple components" do 37 | def multi_component(assigns) do 38 | ~H""" 39 |
40 | <.vue id="profile-1" name="John" v-component="UserProfile" /> 41 | <.vue id="card-1" name="Jane" v-component="UserCard" /> 42 |
43 | """ 44 | end 45 | 46 | test "finds first component by default" do 47 | html = render_component(&multi_component/1) 48 | vue = Test.get_vue(html) 49 | 50 | assert vue.component == "UserProfile" 51 | assert vue.props == %{"name" => "John"} 52 | end 53 | 54 | test "finds specific component by name" do 55 | html = render_component(&multi_component/1) 56 | vue = Test.get_vue(html, name: "UserCard") 57 | 58 | assert vue.component == "UserCard" 59 | assert vue.props == %{"name" => "Jane"} 60 | end 61 | 62 | test "finds specific component by id" do 63 | html = render_component(&multi_component/1) 64 | vue = Test.get_vue(html, id: "card-1") 65 | 66 | assert vue.component == "UserCard" 67 | assert vue.id == "card-1" 68 | end 69 | 70 | test "raises error when component with name not found" do 71 | html = render_component(&multi_component/1) 72 | 73 | assert_raise RuntimeError, 74 | ~r/No Vue component found with name="Unknown".*Available components: UserProfile#profile-1, UserCard#card-1/, 75 | fn -> 76 | Test.get_vue(html, name: "Unknown") 77 | end 78 | end 79 | 80 | test "raises error when component with id not found" do 81 | html = render_component(&multi_component/1) 82 | 83 | assert_raise RuntimeError, 84 | ~r/No Vue component found with id="unknown-id".*Available components: UserProfile#profile-1, UserCard#card-1/, 85 | fn -> 86 | Test.get_vue(html, id: "unknown-id") 87 | end 88 | end 89 | end 90 | 91 | describe "event handlers" do 92 | def component_with_events(assigns) do 93 | ~H""" 94 | <.vue 95 | name="John" 96 | v-component="MyComponent" 97 | v-on:click={JS.push("click", value: %{"abc" => "def"})} 98 | v-on:submit={JS.push("submit")} 99 | /> 100 | """ 101 | end 102 | 103 | test "renders event handlers correctly" do 104 | html = render_component(&component_with_events/1) 105 | vue = Test.get_vue(html) 106 | 107 | assert vue.handlers == %{ 108 | "click" => JS.push("click", value: %{"abc" => "def"}), 109 | "submit" => JS.push("submit") 110 | } 111 | end 112 | end 113 | 114 | describe "styling" do 115 | def styled_component(assigns) do 116 | ~H""" 117 | <.vue name="John" v-component="MyComponent" class="bg-blue-500 rounded" /> 118 | """ 119 | end 120 | 121 | test "applies CSS classes" do 122 | html = render_component(&styled_component/1) 123 | vue = Test.get_vue(html) 124 | 125 | assert vue.class == "bg-blue-500 rounded" 126 | end 127 | end 128 | 129 | describe "SSR behavior" do 130 | def ssr_component(assigns) do 131 | ~H""" 132 | <.vue name="John" v-component="MyComponent" v-ssr={false} /> 133 | """ 134 | end 135 | 136 | test "respects SSR flag" do 137 | html = render_component(&ssr_component/1) 138 | vue = Test.get_vue(html) 139 | 140 | assert vue.ssr == false 141 | end 142 | end 143 | 144 | describe "slots" do 145 | def component_with_slots(assigns) do 146 | ~H""" 147 | <.vue v-component="WithSlots"> 148 | Default content 149 | <:header>Header content 150 | <:footer> 151 |
Footer content
152 | 153 | 154 | 155 | """ 156 | end 157 | 158 | def component_with_default_slot(assigns) do 159 | ~H""" 160 | <.vue v-component="WithSlots"> 161 | <:default>Simple content 162 | 163 | """ 164 | end 165 | 166 | def component_with_inner_block(assigns) do 167 | ~H""" 168 | <.vue v-component="WithSlots"> 169 | Simple content 170 | 171 | """ 172 | end 173 | 174 | test "warns about usage of <:default> slot" do 175 | assert_raise RuntimeError, 176 | "Instead of using <:default> use <:inner_block> slot", 177 | fn -> render_component(&component_with_default_slot/1) end 178 | end 179 | 180 | test "renders multiple slots" do 181 | html = render_component(&component_with_slots/1) 182 | vue = Test.get_vue(html) 183 | 184 | assert vue.slots == %{ 185 | "default" => "Default content", 186 | "header" => "Header content", 187 | "footer" => "
Footer content
\n " 188 | } 189 | end 190 | 191 | test "renders default slot with inner_block" do 192 | html = render_component(&component_with_inner_block/1) 193 | vue = Test.get_vue(html) 194 | 195 | assert vue.slots == %{"default" => "Simple content"} 196 | end 197 | 198 | test "encodes slots as base64" do 199 | html = render_component(&component_with_slots/1) 200 | 201 | # Get raw data-slots attribute to verify base64 encoding 202 | doc = LazyHTML.from_fragment(html) 203 | slots_attr = doc |> LazyHTML.attribute("data-slots") |> hd() 204 | 205 | # JSON encoded map 206 | assert slots_attr =~ ~r/^\{.*\}$/ 207 | 208 | slots = 209 | slots_attr 210 | |> Jason.decode!() 211 | |> Map.new(fn {key, value} -> {key, Base.decode64!(value)} end) 212 | 213 | assert slots == %{ 214 | "default" => "Default content", 215 | "header" => "Header content", 216 | "footer" => "
Footer content
\n " 217 | } 218 | end 219 | 220 | test "handles empty slots" do 221 | html = 222 | render_component(fn assigns -> 223 | ~H""" 224 | <.vue v-component="WithSlots" /> 225 | """ 226 | end) 227 | 228 | vue = Test.get_vue(html) 229 | 230 | assert vue.slots == %{} 231 | end 232 | end 233 | 234 | describe "edge cases" do 235 | def edge_case_component(assigns) do 236 | ~H""" 237 | <.vue name="John" v-component="MyComponent" /> 238 | """ 239 | end 240 | 241 | test "raises ArgumentError with invalid option key" do 242 | html = render_component(&edge_case_component/1) 243 | 244 | assert_raise ArgumentError, 245 | "invalid keyword option for get_vue/2: foo", 246 | fn -> 247 | Test.get_vue(html, foo: "bar") 248 | end 249 | end 250 | 251 | test "raises error when no Vue components found" do 252 | html = "
No Vue components here
" 253 | 254 | assert_raise RuntimeError, 255 | "No Vue components found in the rendered HTML", 256 | fn -> 257 | Test.get_vue(html) 258 | end 259 | end 260 | 261 | test "handles missing attributes gracefully" do 262 | html = render_component(&edge_case_component/1) 263 | vue = Test.get_vue(html) 264 | 265 | # Components without class attribute should return nil or empty string 266 | assert vue.class in [nil, ""] 267 | end 268 | end 269 | end 270 | -------------------------------------------------------------------------------- /test/e2e/features/prop-diff/prop-diff.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test" 2 | import { syncLV } from "../../utils.js" 3 | 4 | // Read and parse props from the page 5 | const getProps = async page => { 6 | await syncLV(page) 7 | 8 | const propsJson = await page.locator('[data-testid="props-json"]').textContent() 9 | return JSON.parse(propsJson) 10 | } 11 | 12 | test.describe("LiveVue Prop Diff E2E Tests", () => { 13 | test.beforeEach(async ({ page }) => { 14 | await page.goto("/prop-diff-test") 15 | await syncLV(page) 16 | 17 | // Verify Vue component is mounted 18 | await expect(page.locator('[phx-hook="VueHook"]')).toBeVisible() 19 | }) 20 | 21 | test("initial render displays all props correctly", async ({ page }) => { 22 | // Check initial props are rendered 23 | const props = await getProps(page) 24 | 25 | expect(props.simple_string).toBe("hello") 26 | expect(props.simple_number).toBe(42) 27 | expect(props.simple_boolean).toBe(true) 28 | expect(props.simple_list).toEqual(["a", "b", "c"]) 29 | expect(props.simple_map).toEqual({ key1: "value1", key2: "value2" }) 30 | expect(props.nested_data.user).toEqual({ name: "John", age: 30 }) 31 | expect(props.nested_data.settings).toEqual({ theme: "dark", notifications: true }) 32 | expect(props.list_of_maps).toEqual([ 33 | { id: 1, name: "Alice", role: "admin" }, 34 | { id: 2, name: "Bob", role: "user" }, 35 | ]) 36 | }) 37 | 38 | test("simple string change is applied via diff", async ({ page }) => { 39 | await page.locator('[data-testid="btn-change-string"]').click() 40 | 41 | const props = await getProps(page) 42 | 43 | expect(props.simple_string).toBe("changed") 44 | // Other props should remain unchanged 45 | expect(props.simple_number).toBe(42) 46 | expect(props.simple_boolean).toBe(true) 47 | }) 48 | 49 | test("simple number change is applied via diff", async ({ page }) => { 50 | await page.locator('[data-testid="btn-change-number"]').click() 51 | 52 | const props = await getProps(page) 53 | 54 | expect(props.simple_number).toBe(99) 55 | // Other props should remain unchanged 56 | expect(props.simple_string).toBe("hello") 57 | expect(props.simple_boolean).toBe(true) 58 | }) 59 | 60 | test("boolean toggle is applied via diff", async ({ page }) => { 61 | await page.locator('[data-testid="btn-toggle-boolean"]').click() 62 | 63 | const props = await getProps(page) 64 | 65 | expect(props.simple_boolean).toBe(false) 66 | 67 | // Toggle back 68 | await page.locator('[data-testid="btn-toggle-boolean"]').click() 69 | 70 | const props2 = await getProps(page) 71 | expect(props2.simple_boolean).toBe(true) 72 | }) 73 | 74 | test("array addition is applied via diff", async ({ page }) => { 75 | await page.locator('[data-testid="btn-add-to-list"]').click() 76 | 77 | const props = await getProps(page) 78 | 79 | expect(props.simple_list).toEqual(["a", "b", "c", "d"]) 80 | }) 81 | 82 | test("array removal is applied via diff", async ({ page }) => { 83 | await page.locator('[data-testid="btn-remove-from-list"]').click() 84 | 85 | const props = await getProps(page) 86 | 87 | expect(props.simple_list).toEqual(["b", "c"]) 88 | }) 89 | 90 | test("array replacement is applied via diff", async ({ page }) => { 91 | await page.locator('[data-testid="btn-replace-in-list"]').click() 92 | 93 | const props = await getProps(page) 94 | 95 | expect(props.simple_list).toEqual(["a", "REPLACED", "c"]) 96 | }) 97 | 98 | test("map addition is applied via diff", async ({ page }) => { 99 | await page.locator('[data-testid="btn-add-to-map"]').click() 100 | 101 | const props = await getProps(page) 102 | 103 | expect(props.simple_map).toEqual({ 104 | key1: "value1", 105 | key2: "value2", 106 | key3: "value3", 107 | }) 108 | }) 109 | 110 | test("map removal is applied via diff", async ({ page }) => { 111 | await page.locator('[data-testid="btn-remove-from-map"]').click() 112 | 113 | const props = await getProps(page) 114 | 115 | expect(props.simple_map).toEqual({ key2: "value2" }) 116 | }) 117 | 118 | test("nested object property change is applied via diff", async ({ page }) => { 119 | await page.locator('[data-testid="btn-change-nested-name"]').click() 120 | 121 | const props = await getProps(page) 122 | 123 | expect(props.nested_data.user.name).toBe("Jane") 124 | // Other nested properties should remain unchanged 125 | expect(props.nested_data.user.age).toBe(30) 126 | expect(props.nested_data.settings).toEqual({ theme: "dark", notifications: true }) 127 | }) 128 | 129 | test("nested object number change is applied via diff", async ({ page }) => { 130 | await page.locator('[data-testid="btn-change-nested-age"]').click() 131 | 132 | const props = await getProps(page) 133 | 134 | expect(props.nested_data.user.age).toBe(25) 135 | // Other properties should remain unchanged 136 | expect(props.nested_data.user.name).toBe("John") 137 | }) 138 | 139 | test("adding nested property is applied via diff", async ({ page }) => { 140 | await page.locator('[data-testid="btn-add-nested-setting"]').click() 141 | 142 | const props = await getProps(page) 143 | 144 | expect(props.nested_data.settings).toEqual({ 145 | theme: "dark", 146 | notifications: true, 147 | language: "en", 148 | }) 149 | }) 150 | 151 | test("setting value to nil is applied via diff", async ({ page }) => { 152 | await page.locator('[data-testid="btn-set-nil"]').click() 153 | 154 | const props = await getProps(page) 155 | 156 | expect(props.simple_string).toBe(null) 157 | // Other props should remain unchanged 158 | expect(props.simple_number).toBe(42) 159 | expect(props.simple_boolean).toBe(true) 160 | }) 161 | 162 | test("adding item to list of maps is applied via diff", async ({ page }) => { 163 | await page.locator('[data-testid="btn-add-list-item"]').click() 164 | 165 | const props = await getProps(page) 166 | 167 | expect(props.list_of_maps).toEqual([ 168 | { id: 1, name: "Alice", role: "admin" }, 169 | { id: 2, name: "Bob", role: "user" }, 170 | { id: 3, name: "Charlie", role: "guest" }, 171 | ]) 172 | }) 173 | 174 | test("updating item in list of maps is applied via diff", async ({ page }) => { 175 | await page.locator('[data-testid="btn-update-list-item"]').click() 176 | 177 | const props = await getProps(page) 178 | 179 | expect(props.list_of_maps).toEqual([ 180 | { id: 1, name: "Alice Updated", role: "admin" }, 181 | { id: 2, name: "Bob", role: "user" }, 182 | ]) 183 | }) 184 | 185 | test("removing item from list of maps is applied via diff", async ({ page }) => { 186 | await page.locator('[data-testid="btn-remove-list-item"]').click() 187 | 188 | const props = await getProps(page) 189 | 190 | expect(props.list_of_maps).toEqual([{ id: 1, name: "Alice", role: "admin" }]) 191 | }) 192 | 193 | test("multiple consecutive changes are applied correctly", async ({ page }) => { 194 | // Make multiple changes in sequence 195 | await page.locator('[data-testid="btn-change-string"]').click() 196 | await page.locator('[data-testid="btn-change-number"]').click() 197 | await page.locator('[data-testid="btn-add-to-list"]').click() 198 | await page.locator('[data-testid="btn-change-nested-name"]').click() 199 | 200 | const props = await getProps(page) 201 | 202 | expect(props.simple_string).toBe("changed") 203 | expect(props.simple_number).toBe(99) 204 | expect(props.simple_list).toEqual(["a", "b", "c", "d"]) 205 | expect(props.nested_data.user.name).toBe("Jane") 206 | 207 | // Unchanged properties should remain the same 208 | expect(props.simple_boolean).toBe(true) 209 | expect(props.nested_data.user.age).toBe(30) 210 | }) 211 | 212 | test("Vue component can access all updated props reactively", async ({ page }) => { 213 | // Verify that Vue component reactively updates when props change 214 | const initial = await getProps(page) 215 | 216 | // Make a change 217 | await page.locator('[data-testid="btn-change-string"]').click() 218 | 219 | // Wait for Vue to update (should be immediate) 220 | const updated = await getProps(page) 221 | 222 | // Verify the change is reflected immediately in the Vue component 223 | expect(updated.simple_string).toBe("changed") 224 | expect(updated.simple_string).not.toBe(initial.simple_string) 225 | }) 226 | }) 227 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Description 3 |
4 | Hex.pm 5 | Hexdocs.pm 6 | GitHub 7 |

8 | Vue inside Phoenix LiveView with seamless end-to-end reactivity. 9 |
10 | 11 | ## Features 12 | 13 | - ⚡ **End-To-End Reactivity** with LiveView 14 | - 🧙‍♂️ **One-line Install** - Automated setup via Igniter installer 15 | - 🔋 **Server-Side Rendered** (SSR) Vue 16 | - 🐌 **Lazy-loading** Vue Components 17 | - 📦 **Efficient Props Diffing** - Only changed data is sent over WebSocket 18 | - 🪄 **~VUE Sigil** as an alternative LiveView DSL with VS Code syntax highlighting 19 | - 🎯 **Phoenix Streams** Support with efficient patches 20 | - 🦄 **Tailwind** Support 21 | - 🦥 **Slot Interoperability** 22 | - 📁 **File Upload Composable** - `useLiveUpload()` for seamless Vue integration with LiveView uploads 23 | - 📝 **Comprehensive Form Handling** - `useLiveForm()` with server-side validation via Ecto changesets 24 | - 🚀 **Amazing DX** with Vite 25 | 26 | 27 | ## Resources 28 | 29 | - [Live Examples](https://livevue.skalecki.dev) - Interactive demos 30 | - [HexDocs](https://hexdocs.pm/live_vue) 31 | - [HexPackage](https://hex.pm/packages/live_vue) 32 | - [Phoenix LiveView](https://github.com/phoenixframework/phoenix_live_view) 33 | 34 | ## Example 35 | 36 | After installation, you can use Vue components in the same way as you'd use functional LiveView components. You can even handle Vue events with `JS` hooks! All the `phx-click`, `phx-change` attributes works inside Vue components as well. 37 | 38 | ```html 39 | 48 | 49 | 58 | ``` 59 | 60 | ```elixir 61 | defmodule MyAppWeb.CounterLive do 62 | use MyAppWeb, :live_view 63 | 64 | def render(assigns) do 65 | ~H""" 66 | <.vue count={@count} v-component="Counter" v-socket={@socket} /> 67 | """ 68 | end 69 | 70 | def mount(_params, _session, socket) do 71 | {:ok, assign(socket, count: 0)} 72 | end 73 | 74 | def handle_event("inc", %{"diff" => value}, socket) do 75 | {:noreply, update(socket, :count, &(&1 + value))} 76 | end 77 | end 78 | ``` 79 | 80 | ## Why? 81 | 82 | Phoenix Live View makes it possible to create rich, interactive web apps without writing JS. 83 | 84 | But once you'll need to do anything even slightly complex on the client-side, you'll end up writing lots of imperative, hard-to-maintain hooks. 85 | 86 | LiveVue allows to create hybrid apps, where part of the session state is on the server and part on the client. 87 | 88 | ### Reasons why you'd like to use LiveVue 89 | 90 | - Your hooks are starting to look like jQuery 91 | - You have a complex local state 92 | - You'd like to use a massive Vue ecosystem 93 | - You want transitions, graphs etc. 94 | - You simply like Vue 😉 95 | 96 | ## Installation 97 | 98 | **New project:** 99 | ```bash 100 | mix archive.install hex igniter_new 101 | mix igniter.new my_app --with phx.new --install live_vue@1.0.0-rc.4 102 | ``` 103 | 104 | **Existing project (Phoenix 1.8+ only):** 105 | ```bash 106 | mix igniter.install live_vue@1.0.0-rc.4 107 | ``` 108 | 109 | Igniter installer works only for Phoenix 1.8+ projects. For detailed installation instructions, see the [Installation Guide](guides/installation.md). 110 | 111 | ## VS Code Extension 112 | 113 | For syntax highlighting of the `~VUE` sigil: 114 | - **VS Code Marketplace**: Install [LiveVue](https://marketplace.visualstudio.com/items?itemName=guilhermepsf23.livevue-sigil-highlighting) extension 115 | - **Manual Installation**: Download VSIX from [releases](https://github.com/GuilhermePSF/live-vue-sigil-highlighting/releases) and install via `Extensions > Install from VSIX...` 116 | 117 | ## Guides 118 | 119 | ### Getting Started 120 | - [Getting Started](guides/getting_started.md) - Create your first Vue component with transitions 121 | 122 | ### Core Usage 123 | - [Basic Usage](guides/basic_usage.md) - Fundamental patterns, ~VUE sigil, and common examples 124 | - [Forms and Validation](guides/forms.md) - Complex forms with server-side validation using useLiveForm 125 | - [Configuration](guides/configuration.md) - Advanced setup, SSR, and customization options 126 | 127 | ### Reference 128 | - [Component Reference](guides/component_reference.md) - Complete syntax documentation 129 | - [Client-Side API](guides/client_api.md) - Vue composables and utilities 130 | 131 | ### Advanced Topics 132 | - [Architecture](guides/architecture.md) - How LiveVue works under the hood 133 | - [Testing](guides/testing.md) - Testing Vue components in LiveView 134 | - [Deployment](guides/deployment.md) - Production deployment guide 135 | 136 | ### Help & Troubleshooting 137 | - [FAQ](guides/faq.md) - Common questions and comparisons 138 | - [Troubleshooting](guides/troubleshooting.md) - Debug common issues 139 | - [Comparison](guides/comparison.md) - LiveVue vs other solutions 140 | 141 | ## Relation to LiveSvelte 142 | 143 | This project is heavily inspired by ✨ [LiveSvelte](https://github.com/woutdp/live_svelte) ✨. Both projects try to solve the same problem. LiveVue was started as a fork of LiveSvelte with adjusted ESbuild settings, and evolved to use Vite and a slightly different syntax. I strongly believe more options are always better, and since I love Vue and it's ecosystem I've decided to give it a go 😉 144 | 145 | You can read more about differences between Vue and Svelte [in FAQ](guides/faq.md#how-does-livevue-compare-to-livesvelte) or [in comparison guide](guides/comparison.md). 146 | 147 | ## LiveVue Development 148 | 149 | ### Local Setup 150 | 151 | Ensure you have Node.js installed. Clone the repo and run `mix setup`. 152 | 153 | No build step is required for the library itself - Vite handles TypeScript transpilation when consumers bundle their app. 154 | 155 | Use `npm run e2e:test` to run the Playwright E2E tests. 156 | 157 | ### Testing Local Changes in Another Project 158 | 159 | To test local LiveVue changes in a separate Phoenix project, use a path dependency in your project's `mix.exs`: 160 | 161 | ```elixir 162 | {:live_vue, path: "../live_vue"} 163 | ``` 164 | 165 | Then run `mix deps.get && npm install`. The installer already configures `package.json` to use `file:./deps/live_vue`, so both Elixir and npm will point to your local copy. 166 | 167 | Elixir changes are reflected immediately. For TypeScript changes, run `npm install` again to pick them up. 168 | 169 | ### Releasing 170 | 171 | Release is done with `expublish` package. 172 | 173 | - Write version changelog in untracked `RELEASE.md` file 174 | - Update version in `INSTALLATION.md` 175 | 176 | Run 177 | 178 | ```bash 179 | git add INSTALLATION.md 180 | git commit -m "INSTALLATION version bump" 181 | 182 | # to ensure everything works fine 183 | mix expublish.minor --dry-run --allow-untracked --branch=main 184 | 185 | # to publish 186 | mix expublish.minor --allow-untracked --branch=main 187 | ``` 188 | 189 | ## Features Implemented 🎯 190 | 191 | - [x] `useLiveEvent` - automatically attaching & detaching [`handleEvent`](https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks-via-phx-hook) 192 | - [x] JSON Patch diffing - send only changed props over the WebSocket 193 | - [x] VS Code extension - syntax highlighting for `~VUE` sigil 194 | - [x] Igniter installer - one-line installation for Phoenix 1.8+ projects 195 | - [x] `useEventReply` - easy handling of `{:reply, data, socket}` responses 196 | - [x] `useLiveForm` - Ecto changesets & server-side validation 197 | - [x] Phoenix Streams - full support for `stream()` operations 198 | 199 | ## Credits 200 | 201 | [LiveSvelte](https://github.com/woutdp/live_svelte) 202 | 203 | ## Star History 204 | 205 | [![Star History Chart](https://api.star-history.com/svg?repos=Valian/live_vue&type=Date)](https://star-history.com/#Valian/live_vue&Date) 206 | -------------------------------------------------------------------------------- /guides/faq.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | ## General Questions 4 | 5 | ### Why LiveVue? 6 | 7 | Phoenix LiveView makes it possible to create rich, interactive web apps without writing JS. However, when you need complex client-side functionality, you might end up writing lots of imperative, hard-to-maintain hooks. 8 | 9 | LiveVue allows you to create hybrid apps where: 10 | - Server maintains the session state 11 | - Vue handles complex client-side interactions 12 | - Both sides communicate seamlessly 13 | 14 | Common use cases: 15 | - Your hooks are starting to look like jQuery 16 | - You have complex local state to manage 17 | - You want to use the Vue ecosystem (transitions, graphs, etc.) 18 | - You need advanced client-side features 19 | - You simply like Vue 😉 20 | 21 | ### What's with the Name? 22 | 23 | Yes, "LiveVue" sounds exactly like "LiveView" - we noticed slightly too late to change! Some helpful Reddit users pointed it out 😉 24 | 25 | We suggest referring to it as "LiveVuejs" in speech to avoid confusion. 26 | 27 | ## Technical Details 28 | 29 | ### How Does LiveVue Work? 30 | 31 | The implementation is straightforward: 32 | 33 | 1. **Rendering**: Phoenix [renders](https://github.com/Valian/live_vue/blob/main/lib/live_vue.ex) a `div` with: 34 | - Props as data attributes 35 | - Slots as child elements 36 | - Event handlers configured 37 | - SSR content (when enabled) 38 | 39 | 2. **Initialization**: The [LiveVue hook](https://github.com/Valian/live_vue/blob/main/assets/hooks.ts): 40 | - Mounts on element creation 41 | - Sets up event handlers 42 | - Injects the hook for `useLiveVue` 43 | - Mounts the Vue component 44 | 45 | 3. **Updates**: 46 | - Phoenix updates only changed data attributes 47 | - Hook updates component props accordingly 48 | 49 | 4. **Cleanup**: 50 | - Vue component unmounts on destroy 51 | - Garbage collection handles cleanup 52 | 53 | Note: Hooks fire only after `app.js` loads, which may cause slight delays in initial render. 54 | 55 | For a deeper dive into the architecture, see [Architecture](architecture.md). 56 | 57 | ### What Optimizations Does LiveVue Use? 58 | 59 | LiveVue implements several performance optimizations: 60 | 61 | 1. **Selective Updates**: 62 | - Only changed props/handlers/slots are sent to client 63 | - Achieved through careful `__changed__` assign modifications 64 | 65 | 2. **Efficient Props Handling**: 66 | ```elixir 67 | data-props={"#{@props |> Jason.encode()}"} 68 | ``` 69 | String interpolation prevents sending `data-props=` on each update 70 | 71 | 3. **Struct Encoding and Diffing**: 72 | - Uses `LiveVue.Encoder` protocol to convert structs to maps 73 | - Enables efficient JSON patch calculations (using [Jsonpatch](https://github.com/corka149/jsonpatch) library) 74 | - Reduces payload sizes by sending only changed fields 75 | 76 | 4. **JSON Patch Diffing**: 77 | - Only changed props are sent over the WebSocket 78 | - Uses JSON Patch format for minimal payloads 79 | 80 | ### What is the LiveVue.Encoder Protocol? 81 | 82 | The `LiveVue.Encoder` protocol is a crucial part of LiveVue's architecture that safely converts Elixir structs to maps before JSON serialization. It serves several important purposes: 83 | 84 | **Why it's needed:** 85 | - **Security**: Prevents accidental exposure of sensitive struct fields 86 | - **Performance**: Enables efficient JSON patch diffing by providing consistent data structures 87 | - **Explicit Control**: Forces developers to be intentional about what data is sent to the client 88 | 89 | **How to use it:** 90 | ```elixir 91 | defmodule User do 92 | @derive LiveVue.Encoder 93 | defstruct [:name, :email, :age] 94 | end 95 | ``` 96 | 97 | For complete implementation details including field selection, custom implementations, and third-party structs, see [Component Reference](component_reference.md#custom-structs-with-livevue-encoder). 98 | 99 | Without implementing this protocol, you'll get a `Protocol.UndefinedError` when trying to pass custom structs as props. This is by design - it's a safety feature to prevent accidental data exposure. 100 | 101 | The protocol is similar to `Jason.Encoder` but converts structs to maps instead of JSON strings, which allows LiveVue to calculate minimal diffs and send only changed data over WebSocket connections. 102 | 103 | ### Why is SSR Useful? 104 | 105 | SSR (Server-Side Rendering) provides several benefits: 106 | 107 | 1. **Initial Render**: Components appear immediately, before JS loads 108 | 2. **SEO**: Search engines see complete content 109 | 3. **Performance**: Reduces client-side computation 110 | 111 | Important notes: 112 | - SSR runs only during "dead" renders (no socket) 113 | - Not needed during live navigation 114 | - Can be disabled per-component with `v-ssr={false}` 115 | 116 | For complete SSR configuration, see [Configuration](configuration.md#server-side-rendering-ssr). 117 | 118 | ### Can I nest LiveVue components inside each other? 119 | 120 | No, it is not possible to nest a `<.vue>` component rendered by LiveView inside another `<.vue>` component's slot. 121 | 122 | **Why?** 123 | 124 | This limitation exists because of how slots are handled. The content you place inside a component's slot in your `.heex` template is rendered into raw HTML on the server *before* being sent to the client. When the parent Vue component is mounted on the client, it receives this HTML as a simple string. 125 | 126 | Since the nested component's HTML is just inert markup at that point, Phoenix LiveView's hooks (including the `VueHook` that powers LiveVue) cannot be attached to it, and the nested Vue component will never be initialized. 127 | 128 | **Workarounds:** 129 | 130 | 1. **Adjacent Components:** The simplest approach is to restructure your UI to use adjacent components instead of nested ones. 131 | 132 | ```elixir 133 | # Instead of this: 134 | <.Card v-socket={@socket}> 135 | <.UserProfile user={@user} v-socket={@socket} /> 136 | 137 | 138 | # Do this: 139 | <.Card v-socket={@socket} /> 140 | <.UserProfile user={@user} v-socket={@socket} /> 141 | ``` 142 | 143 | 2. **Standard Vue Components:** You can nest standard (non-LiveVue) Vue components inside a LiveVue component. These child components are defined entirely within the parent's Vue template and do not have a corresponding `<.vue>` tag in LiveView. They can receive props from their LiveVue parent and manage their own state as usual. 144 | 145 | ```html 146 | 147 | 151 | 157 | ``` 158 | 159 | ## Development 160 | 161 | ### How Do I Use TypeScript? 162 | 163 | LiveVue provides full TypeScript support out of the box. The Igniter installer sets up TypeScript automatically with proper configuration for: 164 | - Vue single-file components with `