├── .formatter.exs ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── config.yml ├── single-file-samples │ ├── main.exs │ └── test.exs └── workflows │ ├── assets.yml │ ├── ci.yml │ └── docs.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── assets ├── .prettierignore ├── .prettierrc ├── js │ └── phoenix_live_view │ │ ├── aria.js │ │ ├── browser.js │ │ ├── constants.js │ │ ├── dom.js │ │ ├── dom_patch.js │ │ ├── dom_post_morph_restorer.js │ │ ├── element_ref.js │ │ ├── entry_uploader.js │ │ ├── global.d.ts │ │ ├── hooks.js │ │ ├── index.ts │ │ ├── js.js │ │ ├── js_commands.ts │ │ ├── live_socket.js │ │ ├── live_uploader.js │ │ ├── rendered.js │ │ ├── upload_entry.js │ │ ├── utils.js │ │ ├── view.js │ │ └── view_hook.ts └── test │ ├── browser_test.ts │ ├── debounce_test.ts │ ├── dom_test.ts │ ├── event_test.ts │ ├── globals.d.ts │ ├── index_test.ts │ ├── integration │ ├── event_test.ts │ └── metadata_test.ts │ ├── js_test.ts │ ├── live_socket_test.ts │ ├── modify_root_test.ts │ ├── rendered_test.ts │ ├── test_helpers.ts │ ├── tsconfig.json │ ├── utils_test.ts │ └── view_test.ts ├── babel.config.json ├── config ├── config.exs ├── dev.exs ├── docs.exs ├── e2e.exs └── test.exs ├── eslint.config.js ├── guides ├── cheatsheets │ └── html-attrs.cheatmd ├── client │ ├── bindings.md │ ├── external-uploads.md │ ├── form-bindings.md │ ├── js-interop.md │ └── syncing-changes.md ├── introduction │ └── welcome.md └── server │ ├── assigns-eex.md │ ├── deployments.md │ ├── error-handling.md │ ├── gettext.md │ ├── live-layouts.md │ ├── live-navigation.md │ ├── security-model.md │ ├── telemetry.md │ └── uploads.md ├── jest.config.js ├── lib ├── mix │ └── tasks │ │ └── compile │ │ └── phoenix_live_view.ex ├── phoenix_component.ex ├── phoenix_component │ ├── declarative.ex │ └── macro_component.ex ├── phoenix_live_component.ex ├── phoenix_live_view.ex └── phoenix_live_view │ ├── application.ex │ ├── async.ex │ ├── async_result.ex │ ├── channel.ex │ ├── colocated_hook.ex │ ├── colocated_js.ex │ ├── controller.ex │ ├── debug.ex │ ├── diff.ex │ ├── engine.ex │ ├── helpers.ex │ ├── html_algebra.ex │ ├── html_engine.ex │ ├── html_formatter.ex │ ├── js.ex │ ├── lifecycle.ex │ ├── live_stream.ex │ ├── logger.ex │ ├── plug.ex │ ├── renderer.ex │ ├── route.ex │ ├── router.ex │ ├── session.ex │ ├── socket.ex │ ├── static.ex │ ├── tag_engine.ex │ ├── test │ ├── client_proxy.ex │ ├── diff.ex │ ├── dom.ex │ ├── live_view_test.ex │ ├── structs.ex │ ├── tree_dom.ex │ └── upload_client.ex │ ├── tokenizer.ex │ ├── upload.ex │ ├── upload_channel.ex │ ├── upload_config.ex │ ├── upload_tmp_file_writer.ex │ ├── upload_writer.ex │ └── utils.ex ├── mix.exs ├── mix.lock ├── package-lock.json ├── package.json ├── priv └── static │ ├── phoenix_live_view.cjs.js │ ├── phoenix_live_view.cjs.js.map │ ├── phoenix_live_view.esm.js │ ├── phoenix_live_view.esm.js.map │ ├── phoenix_live_view.js │ └── phoenix_live_view.min.js ├── setupTests.js ├── setupTestsAfterEnv.js ├── test ├── e2e │ ├── .prettierignore │ ├── README.md │ ├── merge-coverage.js │ ├── playwright.config.js │ ├── support │ │ ├── colocated_live.ex │ │ ├── error_live.ex │ │ ├── form_dynamic_inputs_live.ex │ │ ├── form_feedback.ex │ │ ├── form_live.ex │ │ ├── issues │ │ │ ├── issue_2787.ex │ │ │ ├── issue_2965.ex │ │ │ ├── issue_3026.ex │ │ │ ├── issue_3040.ex │ │ │ ├── issue_3047.ex │ │ │ ├── issue_3083.ex │ │ │ ├── issue_3107.ex │ │ │ ├── issue_3117.ex │ │ │ ├── issue_3169.ex │ │ │ ├── issue_3194.ex │ │ │ ├── issue_3200.ex │ │ │ ├── issue_3378.ex │ │ │ ├── issue_3448.ex │ │ │ ├── issue_3496.ex │ │ │ ├── issue_3529.ex │ │ │ ├── issue_3530.ex │ │ │ ├── issue_3612.ex │ │ │ ├── issue_3647.ex │ │ │ ├── issue_3651.ex │ │ │ ├── issue_3656.ex │ │ │ ├── issue_3658.ex │ │ │ ├── issue_3681.ex │ │ │ ├── issue_3684.ex │ │ │ ├── issue_3686.ex │ │ │ ├── issue_3709.ex │ │ │ ├── issue_3719.ex │ │ │ ├── issue_3814.ex │ │ │ └── issue_3819.ex │ │ ├── js_live.ex │ │ ├── navigation.ex │ │ ├── select_live.ex │ │ └── upload_live.ex │ ├── teardown.js │ ├── test-fixtures.js │ ├── test_helper.exs │ ├── tests │ │ ├── colocated.spec.js │ │ ├── errors.spec.js │ │ ├── forms.spec.js │ │ ├── issues │ │ │ ├── 2787.spec.js │ │ │ ├── 2965.spec.js │ │ │ ├── 3026.spec.js │ │ │ ├── 3040.spec.js │ │ │ ├── 3047.spec.js │ │ │ ├── 3083.spec.js │ │ │ ├── 3107.spec.js │ │ │ ├── 3117.spec.js │ │ │ ├── 3169.spec.js │ │ │ ├── 3194.spec.js │ │ │ ├── 3200.spec.js │ │ │ ├── 3378.spec.js │ │ │ ├── 3448.spec.js │ │ │ ├── 3496.spec.js │ │ │ ├── 3529.spec.js │ │ │ ├── 3530.spec.js │ │ │ ├── 3612.spec.js │ │ │ ├── 3647.spec.js │ │ │ ├── 3651.spec.js │ │ │ ├── 3656.spec.js │ │ │ ├── 3658.spec.js │ │ │ ├── 3681.spec.js │ │ │ ├── 3684.spec.js │ │ │ ├── 3686.spec.js │ │ │ ├── 3709.spec.js │ │ │ ├── 3719.spec.js │ │ │ ├── 3814.spec.js │ │ │ └── 3819.spec.js │ │ ├── js.spec.js │ │ ├── navigation.spec.js │ │ ├── select.spec.js │ │ ├── streams.spec.js │ │ └── uploads.spec.js │ └── utils.js ├── phoenix_component │ ├── components_test.exs │ ├── declarative_assigns_test.exs │ ├── macro_component_integration_test.exs │ ├── macro_component_test.exs │ ├── pages │ │ ├── about_page.html.heex │ │ ├── another_root │ │ │ ├── root.html.heex │ │ │ └── root.text.eex │ │ └── welcome_page.html.heex │ ├── rendering_test.exs │ └── verify_test.exs ├── phoenix_component_test.exs ├── phoenix_live_view │ ├── async_result_test.exs │ ├── async_test.exs │ ├── colocated_hook_test.exs │ ├── colocated_js_test.exs │ ├── controller_test.exs │ ├── debug_test.exs │ ├── diff_test.exs │ ├── engine_test.exs │ ├── heex_extension_test.exs │ ├── hooks_test.exs │ ├── html_engine_test.exs │ ├── html_formatter_test.exs │ ├── integrations │ │ ├── assign_async_test.exs │ │ ├── assigns_test.exs │ │ ├── collocated_test.exs │ │ ├── connect_test.exs │ │ ├── elements_test.exs │ │ ├── event_test.exs │ │ ├── expensive_runtime_checks_test.exs │ │ ├── flash_test.exs │ │ ├── hooks_test.exs │ │ ├── html_formatter_test.exs │ │ ├── layout_test.exs │ │ ├── live_components_test.exs │ │ ├── live_reload_test.exs │ │ ├── live_view_test.exs │ │ ├── live_view_test_warnings_test.exs │ │ ├── navigation_test.exs │ │ ├── nested_test.exs │ │ ├── params_test.exs │ │ ├── start_async_test.exs │ │ ├── stream_test.exs │ │ ├── telemetry_test.exs │ │ └── update_test.exs │ ├── js_test.exs │ ├── live_stream_test.exs │ ├── plug_test.exs │ ├── router_test.exs │ ├── socket_test.exs │ ├── test │ │ ├── diff_test.exs │ │ ├── dom_test.exs │ │ └── tree_dom_test.exs │ ├── tokenizer_test.exs │ ├── upload │ │ ├── channel_test.exs │ │ ├── config_test.exs │ │ └── external_test.exs │ └── utils_test.exs ├── phoenix_live_view_test.exs ├── support │ ├── controller.ex │ ├── endpoint.ex │ ├── layout_view.ex │ ├── live_views │ │ ├── cids_destroyed.ex │ │ ├── collocated.ex │ │ ├── collocated_component.html.heex │ │ ├── collocated_live.html.heex │ │ ├── component_and_nested_in_live.ex │ │ ├── component_in_live.ex │ │ ├── components.ex │ │ ├── connect.ex │ │ ├── debug_anno.exs │ │ ├── duplicates.ex │ │ ├── elements.ex │ │ ├── events.ex │ │ ├── expensive_runtime_checks.ex │ │ ├── flash.ex │ │ ├── general.ex │ │ ├── host.ex │ │ ├── layout.ex │ │ ├── lifecycle.ex │ │ ├── live_in_component.ex │ │ ├── params.ex │ │ ├── reload_live.ex │ │ ├── render_with.ex │ │ ├── streams.ex │ │ ├── update.ex │ │ └── upload_live.ex │ ├── router.ex │ ├── telemetry_test_helpers.ex │ └── templates │ │ ├── heex │ │ ├── dead_with_function_component.html.heex │ │ ├── dead_with_function_component_with_inner_content.html.heex │ │ ├── dead_with_live.html.eex │ │ ├── inner_dead.html.eex │ │ ├── inner_live.html.heex │ │ ├── live_with_comprehension.html.heex │ │ ├── live_with_dead.html.heex │ │ └── live_with_live.html.heex │ │ └── leex │ │ ├── dead_with_live.html.eex │ │ ├── inner_dead.html.eex │ │ ├── inner_live.html.leex │ │ ├── live_with_comprehension.html.leex │ │ ├── live_with_dead.html.leex │ │ └── live_with_live.html.leex └── test_helper.exs └── tsconfig.json /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | plugins: [Phoenix.LiveView.HTMLFormatter], 4 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Environment 11 | 12 | * Elixir version (elixir -v): 13 | * Phoenix version (mix deps): 14 | * Phoenix LiveView version (mix deps): 15 | * Operating system: 16 | * Browsers you attempted to reproduce this bug on (the more the merrier): 17 | * Does the problem persist after removing "assets/node_modules" and trying again? Yes/no: 18 | 19 | ### Actual behavior 20 | 21 | 30 | 31 | ### Expected behavior 32 | 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | blank_issues_enabled: true 3 | 4 | contact_links: 5 | - name: Ask questions, support, and general discussions 6 | url: https://elixirforum.com/c/phoenix-forum 7 | about: Ask questions, provide support, and more on Elixir Forum 8 | 9 | - name: Propose new features 10 | url: https://elixirforum.com/c/phoenix-forum 11 | about: Propose new features on Elixir Forum 12 | -------------------------------------------------------------------------------- /.github/single-file-samples/main.exs: -------------------------------------------------------------------------------- 1 | Application.put_env(:sample, Example.Endpoint, 2 | http: [ip: {127, 0, 0, 1}, port: 5001], 3 | server: true, 4 | live_view: [signing_salt: "aaaaaaaa"], 5 | secret_key_base: String.duplicate("a", 64) 6 | ) 7 | 8 | Mix.install( 9 | [ 10 | {:plug_cowboy, "~> 2.5"}, 11 | {:jason, "~> 1.0"}, 12 | {:phoenix, "~> 1.7"}, 13 | # please test your issue using the latest version of LV from GitHub! 14 | {:phoenix_live_view, 15 | github: "phoenixframework/phoenix_live_view", branch: "main", override: true} 16 | ] 17 | ) 18 | 19 | # if you're trying to test a specific LV commit, it may be necessary to manually build 20 | # the JS assets. To do this, uncomment the following lines: 21 | # this needs mix and npm available in your path! 22 | # 23 | # path = Phoenix.LiveView.__info__(:compile)[:source] |> Path.dirname() |> Path.join("../") 24 | # System.cmd("mix", ["deps.get"], cd: path, into: IO.binstream()) 25 | # System.cmd("npm", ["install"], cd: Path.join(path, "./assets"), into: IO.binstream()) 26 | # System.cmd("mix", ["assets.build"], cd: path, into: IO.binstream()) 27 | 28 | defmodule Example.ErrorView do 29 | def render(template, _), do: Phoenix.Controller.status_message_from_template(template) 30 | end 31 | 32 | defmodule Example.HomeLive do 33 | use Phoenix.LiveView, layout: {__MODULE__, :live} 34 | 35 | def mount(_params, _session, socket) do 36 | {:ok, assign(socket, :count, 0)} 37 | end 38 | 39 | def render("live.html", assigns) do 40 | ~H""" 41 | 43 | 45 | <%!-- uncomment to use enable tailwind --%> 46 | <%!-- --%> 47 | 51 | 54 | {@inner_content} 55 | """ 56 | end 57 | 58 | def render(assigns) do 59 | ~H""" 60 | {@count} 61 | 62 | 63 | """ 64 | end 65 | 66 | def handle_event("inc", _params, socket) do 67 | {:noreply, assign(socket, :count, socket.assigns.count + 1)} 68 | end 69 | 70 | def handle_event("dec", _params, socket) do 71 | {:noreply, assign(socket, :count, socket.assigns.count - 1)} 72 | end 73 | end 74 | 75 | defmodule Example.Router do 76 | use Phoenix.Router 77 | import Phoenix.LiveView.Router 78 | 79 | pipeline :browser do 80 | plug(:accepts, ["html"]) 81 | end 82 | 83 | scope "/", Example do 84 | pipe_through(:browser) 85 | 86 | live("/", HomeLive, :index) 87 | end 88 | end 89 | 90 | defmodule Example.Endpoint do 91 | use Phoenix.Endpoint, otp_app: :sample 92 | socket("/live", Phoenix.LiveView.Socket) 93 | 94 | plug Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix" 95 | plug Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view" 96 | 97 | plug(Example.Router) 98 | end 99 | 100 | {:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one) 101 | Process.sleep(:infinity) 102 | -------------------------------------------------------------------------------- /.github/single-file-samples/test.exs: -------------------------------------------------------------------------------- 1 | Application.put_env(:phoenix, Example.Endpoint, 2 | http: [ip: {127, 0, 0, 1}, port: 5001], 3 | server: true, 4 | live_view: [signing_salt: "aaaaaaaa"], 5 | secret_key_base: String.duplicate("a", 64) 6 | ) 7 | 8 | Mix.install( 9 | [ 10 | {:plug_cowboy, "~> 2.5"}, 11 | {:jason, "~> 1.0"}, 12 | {:phoenix, "~> 1.7"}, 13 | # please test your issue using the latest version of LV from GitHub! 14 | {:phoenix_live_view, 15 | github: "phoenixframework/phoenix_live_view", branch: "main", override: true}, 16 | {:lazy_html, ">= 0.1.0"} 17 | ] 18 | ) 19 | 20 | ExUnit.start() 21 | 22 | defmodule Example.ErrorView do 23 | def render(template, _), do: Phoenix.Controller.status_message_from_template(template) 24 | end 25 | 26 | defmodule Example.HomeLive do 27 | use Phoenix.LiveView, layout: {__MODULE__, :live} 28 | 29 | def mount(_params, _session, socket) do 30 | socket 31 | |> then(&{:ok, &1}) 32 | end 33 | 34 | def render("live.html", assigns) do 35 | ~H""" 36 | 38 | 40 | 44 | 47 | {@inner_content} 48 | """ 49 | end 50 | 51 | def render(assigns) do 52 | ~H""" 53 |

The LiveView content goes here

54 | """ 55 | end 56 | end 57 | 58 | defmodule Example.Router do 59 | use Phoenix.Router 60 | import Phoenix.LiveView.Router 61 | 62 | pipeline :browser do 63 | plug(:accepts, ["html"]) 64 | end 65 | 66 | scope "/", Example do 67 | pipe_through(:browser) 68 | 69 | live("/", HomeLive, :index) 70 | end 71 | end 72 | 73 | defmodule Example.Endpoint do 74 | use Phoenix.Endpoint, otp_app: :phoenix 75 | socket("/live", Phoenix.LiveView.Socket) 76 | plug Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix" 77 | plug Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view" 78 | plug(Example.Router) 79 | end 80 | 81 | defmodule Example.HomeLiveTest do 82 | use ExUnit.Case 83 | 84 | import Phoenix.ConnTest 85 | import Plug.Conn 86 | import Phoenix.LiveViewTest 87 | 88 | @endpoint Example.Endpoint 89 | 90 | test "works properly" do 91 | conn = Phoenix.ConnTest.build_conn() 92 | 93 | {:ok, _view, html} = live(conn, "/") 94 | 95 | assert html =~ "The LiveView content goes here" 96 | end 97 | end 98 | 99 | {:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one) 100 | ExUnit.run() 101 | Process.sleep(:infinity) 102 | -------------------------------------------------------------------------------- /.github/workflows/assets.yml: -------------------------------------------------------------------------------- 1 | name: Assets 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - "v*.*" 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-22.04 12 | env: 13 | elixir: 1.18.1 14 | otp: 27.2 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Elixir 20 | uses: erlef/setup-beam@v1 21 | with: 22 | elixir-version: ${{ env.elixir }} 23 | otp-version: ${{ env.otp }} 24 | 25 | - name: Restore deps and _build cache 26 | uses: actions/cache@v4 27 | with: 28 | path: | 29 | deps 30 | _build 31 | key: ${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-${{ hashFiles('**/mix.lock') }}-dev 32 | restore-keys: | 33 | ${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}- 34 | - name: Install Dependencies 35 | run: mix deps.get --only dev 36 | 37 | - name: Set up Node.js 20.x 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: 20.x 41 | 42 | - name: Restore npm cache 43 | uses: actions/cache@v4 44 | with: 45 | path: ~/.npm 46 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 47 | restore-keys: | 48 | ${{ runner.os }}-node- 49 | 50 | - name: Install npm dependencies 51 | run: npm ci 52 | 53 | - name: Build assets 54 | run: mix assets.build 55 | 56 | - name: Push updated assets 57 | id: push_assets 58 | uses: stefanzweifel/git-auto-commit-action@v5 59 | with: 60 | commit_message: Update assets 61 | file_pattern: priv/static 62 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-22.04 16 | env: 17 | elixir: 1.18.1 18 | otp: 27.2 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Elixir 24 | uses: erlef/setup-beam@v1 25 | with: 26 | elixir-version: ${{ env.elixir }} 27 | otp-version: ${{ env.otp }} 28 | 29 | - name: Restore deps and _build cache 30 | uses: actions/cache@v4 31 | with: 32 | path: | 33 | deps 34 | _build 35 | key: ${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-${{ hashFiles('**/mix.lock') }}-docs 36 | restore-keys: | 37 | ${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}- 38 | - name: Install Dependencies 39 | run: mix deps.get --only docs 40 | 41 | - name: Build docs 42 | run: mix docs 43 | 44 | - name: Upload artifact 45 | uses: actions/upload-pages-artifact@v3 46 | with: 47 | path: doc/ 48 | 49 | # Deployment job 50 | deploy: 51 | environment: 52 | name: github-pages 53 | url: ${{steps.deployment.outputs.page_url}} 54 | runs-on: ubuntu-latest 55 | needs: build 56 | steps: 57 | - name: Deploy to GitHub Pages 58 | id: deployment 59 | uses: actions/deploy-pages@v4 60 | -------------------------------------------------------------------------------- /.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 3rd-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 | phoenix_live_view-*.tar 24 | 25 | node_modules 26 | 27 | /test/e2e/test-results/ 28 | /playwright-report/ 29 | /coverage/ 30 | /assets/js/types/ 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2018 Chris McCord 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /assets/.prettierignore: -------------------------------------------------------------------------------- 1 | js/types/ 2 | -------------------------------------------------------------------------------- /assets/.prettierrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phoenixframework/phoenix_live_view/e29a95ffdbf8e630a2e149b19590036a7c855c5c/assets/.prettierrc -------------------------------------------------------------------------------- /assets/js/phoenix_live_view/aria.js: -------------------------------------------------------------------------------- 1 | const ARIA = { 2 | anyOf(instance, classes) { 3 | return classes.find((name) => instance instanceof name); 4 | }, 5 | 6 | isFocusable(el, interactiveOnly) { 7 | return ( 8 | (el instanceof HTMLAnchorElement && el.rel !== "ignore") || 9 | (el instanceof HTMLAreaElement && el.href !== undefined) || 10 | (!el.disabled && 11 | this.anyOf(el, [ 12 | HTMLInputElement, 13 | HTMLSelectElement, 14 | HTMLTextAreaElement, 15 | HTMLButtonElement, 16 | ])) || 17 | el instanceof HTMLIFrameElement || 18 | el.tabIndex >= 0 || 19 | (!interactiveOnly && 20 | el.getAttribute("tabindex") !== null && 21 | el.getAttribute("aria-hidden") !== "true") 22 | ); 23 | }, 24 | 25 | attemptFocus(el, interactiveOnly) { 26 | if (this.isFocusable(el, interactiveOnly)) { 27 | try { 28 | el.focus(); 29 | } catch { 30 | // that's fine 31 | } 32 | } 33 | return !!document.activeElement && document.activeElement.isSameNode(el); 34 | }, 35 | 36 | focusFirstInteractive(el) { 37 | let child = el.firstElementChild; 38 | while (child) { 39 | if (this.attemptFocus(child, true) || this.focusFirstInteractive(child)) { 40 | return true; 41 | } 42 | child = child.nextElementSibling; 43 | } 44 | }, 45 | 46 | focusFirst(el) { 47 | let child = el.firstElementChild; 48 | while (child) { 49 | if (this.attemptFocus(child) || this.focusFirst(child)) { 50 | return true; 51 | } 52 | child = child.nextElementSibling; 53 | } 54 | }, 55 | 56 | focusLast(el) { 57 | let child = el.lastElementChild; 58 | while (child) { 59 | if (this.attemptFocus(child) || this.focusLast(child)) { 60 | return true; 61 | } 62 | child = child.previousElementSibling; 63 | } 64 | }, 65 | }; 66 | export default ARIA; 67 | -------------------------------------------------------------------------------- /assets/js/phoenix_live_view/dom_post_morph_restorer.js: -------------------------------------------------------------------------------- 1 | import { maybe } from "./utils"; 2 | 3 | import DOM from "./dom"; 4 | 5 | export default class DOMPostMorphRestorer { 6 | constructor(containerBefore, containerAfter, updateType) { 7 | const idsBefore = new Set(); 8 | const idsAfter = new Set( 9 | [...containerAfter.children].map((child) => child.id), 10 | ); 11 | 12 | const elementsToModify = []; 13 | 14 | Array.from(containerBefore.children).forEach((child) => { 15 | if (child.id) { 16 | // all of our children should be elements with ids 17 | idsBefore.add(child.id); 18 | if (idsAfter.has(child.id)) { 19 | const previousElementId = 20 | child.previousElementSibling && child.previousElementSibling.id; 21 | elementsToModify.push({ 22 | elementId: child.id, 23 | previousElementId: previousElementId, 24 | }); 25 | } 26 | } 27 | }); 28 | 29 | this.containerId = containerAfter.id; 30 | this.updateType = updateType; 31 | this.elementsToModify = elementsToModify; 32 | this.elementIdsToAdd = [...idsAfter].filter((id) => !idsBefore.has(id)); 33 | } 34 | 35 | // We do the following to optimize append/prepend operations: 36 | // 1) Track ids of modified elements & of new elements 37 | // 2) All the modified elements are put back in the correct position in the DOM tree 38 | // by storing the id of their previous sibling 39 | // 3) New elements are going to be put in the right place by morphdom during append. 40 | // For prepend, we move them to the first position in the container 41 | perform() { 42 | const container = DOM.byId(this.containerId); 43 | if (!container) { 44 | return; 45 | } 46 | this.elementsToModify.forEach((elementToModify) => { 47 | if (elementToModify.previousElementId) { 48 | maybe( 49 | document.getElementById(elementToModify.previousElementId), 50 | (previousElem) => { 51 | maybe( 52 | document.getElementById(elementToModify.elementId), 53 | (elem) => { 54 | const isInRightPlace = 55 | elem.previousElementSibling && 56 | elem.previousElementSibling.id == previousElem.id; 57 | if (!isInRightPlace) { 58 | previousElem.insertAdjacentElement("afterend", elem); 59 | } 60 | }, 61 | ); 62 | }, 63 | ); 64 | } else { 65 | // This is the first element in the container 66 | maybe(document.getElementById(elementToModify.elementId), (elem) => { 67 | const isInRightPlace = elem.previousElementSibling == null; 68 | if (!isInRightPlace) { 69 | container.insertAdjacentElement("afterbegin", elem); 70 | } 71 | }); 72 | } 73 | }); 74 | 75 | if (this.updateType == "prepend") { 76 | this.elementIdsToAdd.reverse().forEach((elemId) => { 77 | maybe(document.getElementById(elemId), (elem) => 78 | container.insertAdjacentElement("afterbegin", elem), 79 | ); 80 | }); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /assets/js/phoenix_live_view/entry_uploader.js: -------------------------------------------------------------------------------- 1 | import { logError } from "./utils"; 2 | 3 | export default class EntryUploader { 4 | constructor(entry, config, liveSocket) { 5 | const { chunk_size, chunk_timeout } = config; 6 | this.liveSocket = liveSocket; 7 | this.entry = entry; 8 | this.offset = 0; 9 | this.chunkSize = chunk_size; 10 | this.chunkTimeout = chunk_timeout; 11 | this.chunkTimer = null; 12 | this.errored = false; 13 | this.uploadChannel = liveSocket.channel(`lvu:${entry.ref}`, { 14 | token: entry.metadata(), 15 | }); 16 | } 17 | 18 | error(reason) { 19 | if (this.errored) { 20 | return; 21 | } 22 | this.uploadChannel.leave(); 23 | this.errored = true; 24 | clearTimeout(this.chunkTimer); 25 | this.entry.error(reason); 26 | } 27 | 28 | upload() { 29 | this.uploadChannel.onError((reason) => this.error(reason)); 30 | this.uploadChannel 31 | .join() 32 | .receive("ok", (_data) => this.readNextChunk()) 33 | .receive("error", (reason) => this.error(reason)); 34 | } 35 | 36 | isDone() { 37 | return this.offset >= this.entry.file.size; 38 | } 39 | 40 | readNextChunk() { 41 | const reader = new window.FileReader(); 42 | const blob = this.entry.file.slice( 43 | this.offset, 44 | this.chunkSize + this.offset, 45 | ); 46 | reader.onload = (e) => { 47 | if (e.target.error === null) { 48 | this.offset += /** @type {ArrayBuffer} */ (e.target.result).byteLength; 49 | this.pushChunk(/** @type {ArrayBuffer} */ (e.target.result)); 50 | } else { 51 | return logError("Read error: " + e.target.error); 52 | } 53 | }; 54 | reader.readAsArrayBuffer(blob); 55 | } 56 | 57 | pushChunk(chunk) { 58 | if (!this.uploadChannel.isJoined()) { 59 | return; 60 | } 61 | this.uploadChannel 62 | .push("chunk", chunk, this.chunkTimeout) 63 | .receive("ok", () => { 64 | this.entry.progress((this.offset / this.entry.file.size) * 100); 65 | if (!this.isDone()) { 66 | this.chunkTimer = setTimeout( 67 | () => this.readNextChunk(), 68 | this.liveSocket.getLatencySim() || 0, 69 | ); 70 | } 71 | }) 72 | .receive("error", ({ reason }) => this.error(reason)); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /assets/js/phoenix_live_view/global.d.ts: -------------------------------------------------------------------------------- 1 | declare let LV_VSN: string; 2 | -------------------------------------------------------------------------------- /assets/js/phoenix_live_view/utils.js: -------------------------------------------------------------------------------- 1 | import { PHX_VIEW_SELECTOR } from "./constants"; 2 | 3 | import EntryUploader from "./entry_uploader"; 4 | 5 | export const logError = (msg, obj) => console.error && console.error(msg, obj); 6 | 7 | export const isCid = (cid) => { 8 | const type = typeof cid; 9 | return type === "number" || (type === "string" && /^(0|[1-9]\d*)$/.test(cid)); 10 | }; 11 | 12 | export function detectDuplicateIds() { 13 | const ids = new Set(); 14 | const elems = document.querySelectorAll("*[id]"); 15 | for (let i = 0, len = elems.length; i < len; i++) { 16 | if (ids.has(elems[i].id)) { 17 | console.error( 18 | `Multiple IDs detected: ${elems[i].id}. Ensure unique element ids.`, 19 | ); 20 | } else { 21 | ids.add(elems[i].id); 22 | } 23 | } 24 | } 25 | 26 | export function detectInvalidStreamInserts(inserts) { 27 | const errors = new Set(); 28 | Object.keys(inserts).forEach((id) => { 29 | const streamEl = document.getElementById(id); 30 | if ( 31 | streamEl && 32 | streamEl.parentElement && 33 | streamEl.parentElement.getAttribute("phx-update") !== "stream" 34 | ) { 35 | errors.add( 36 | `The stream container with id "${streamEl.parentElement.id}" is missing the phx-update="stream" attribute. Ensure it is set for streams to work properly.`, 37 | ); 38 | } 39 | }); 40 | errors.forEach((error) => console.error(error)); 41 | } 42 | 43 | export const debug = (view, kind, msg, obj) => { 44 | if (view.liveSocket.isDebugEnabled()) { 45 | console.log(`${view.id} ${kind}: ${msg} - `, obj); 46 | } 47 | }; 48 | 49 | // wraps value in closure or returns closure 50 | export const closure = (val) => 51 | typeof val === "function" 52 | ? val 53 | : function () { 54 | return val; 55 | }; 56 | 57 | export const clone = (obj) => { 58 | return JSON.parse(JSON.stringify(obj)); 59 | }; 60 | 61 | export const closestPhxBinding = (el, binding, borderEl) => { 62 | do { 63 | if (el.matches(`[${binding}]`) && !el.disabled) { 64 | return el; 65 | } 66 | el = el.parentElement || el.parentNode; 67 | } while ( 68 | el !== null && 69 | el.nodeType === 1 && 70 | !((borderEl && borderEl.isSameNode(el)) || el.matches(PHX_VIEW_SELECTOR)) 71 | ); 72 | return null; 73 | }; 74 | 75 | export const isObject = (obj) => { 76 | return obj !== null && typeof obj === "object" && !(obj instanceof Array); 77 | }; 78 | 79 | export const isEqualObj = (obj1, obj2) => 80 | JSON.stringify(obj1) === JSON.stringify(obj2); 81 | 82 | export const isEmpty = (obj) => { 83 | for (const x in obj) { 84 | return false; 85 | } 86 | return true; 87 | }; 88 | 89 | export const maybe = (el, callback) => el && callback(el); 90 | 91 | export const channelUploader = function (entries, onError, resp, liveSocket) { 92 | entries.forEach((entry) => { 93 | const entryUploader = new EntryUploader(entry, resp.config, liveSocket); 94 | entryUploader.upload(); 95 | }); 96 | }; 97 | -------------------------------------------------------------------------------- /assets/test/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | function setStartSystemTime(): void; 3 | function advanceTimersToNextFrame(): void; 4 | let LV_VSN: string; 5 | } 6 | 7 | export {}; 8 | -------------------------------------------------------------------------------- /assets/test/index_test.ts: -------------------------------------------------------------------------------- 1 | import { LiveSocket, isUsedInput, ViewHook } from "phoenix_live_view"; 2 | import * as LiveSocket2 from "phoenix_live_view/live_socket"; 3 | import ViewHook2 from "phoenix_live_view/view_hook"; 4 | import DOM from "phoenix_live_view/dom"; 5 | 6 | describe("Named Imports", () => { 7 | test("LiveSocket is equal to the actual LiveSocket", () => { 8 | expect(LiveSocket).toBe(LiveSocket2.default); 9 | }); 10 | 11 | test("ViewHook is equal to the actual ViewHook", () => { 12 | expect(ViewHook).toBe(ViewHook2); 13 | }); 14 | }); 15 | 16 | describe("isUsedInput", () => { 17 | test("returns true if the input is used", () => { 18 | const input = document.createElement("input"); 19 | input.type = "text"; 20 | expect(isUsedInput(input)).toBeFalsy(); 21 | DOM.putPrivate(input, "phx-has-focused", true); 22 | expect(isUsedInput(input)).toBe(true); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /assets/test/integration/event_test.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "phoenix"; 2 | import LiveSocket from "phoenix_live_view/live_socket"; 3 | 4 | const stubViewPushInput = (view, callback) => { 5 | view.pushInput = ( 6 | sourceEl, 7 | targetCtx, 8 | newCid, 9 | event, 10 | pushOpts, 11 | originalCallback, 12 | ) => { 13 | return callback( 14 | sourceEl, 15 | targetCtx, 16 | newCid, 17 | event, 18 | pushOpts, 19 | originalCallback, 20 | ); 21 | }; 22 | }; 23 | 24 | const prepareLiveViewDOM = (document, rootId) => { 25 | document.body.innerHTML = ` 26 |
29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 |
39 | `; 40 | }; 41 | 42 | describe("events", () => { 43 | beforeEach(() => { 44 | prepareLiveViewDOM(global.document, "root"); 45 | }); 46 | 47 | test("send change event to correct target", () => { 48 | const liveSocket = new LiveSocket("/live", Socket); 49 | liveSocket.connect(); 50 | const view = liveSocket.getViewByEl(document.getElementById("root")); 51 | view.isConnected = () => true; 52 | const input = view.el.querySelector("#first_name"); 53 | let meta = { 54 | event: null, 55 | target: null, 56 | changed: null, 57 | }; 58 | 59 | stubViewPushInput( 60 | view, 61 | (sourceEl, targetCtx, newCid, event, pushOpts, _callback) => { 62 | meta = { 63 | event, 64 | target: targetCtx, 65 | changed: pushOpts["_target"], 66 | }; 67 | }, 68 | ); 69 | 70 | input.value = "John Doe"; 71 | input.dispatchEvent(new Event("change", { bubbles: true })); 72 | 73 | expect(meta).toEqual({ 74 | event: "validate", 75 | target: 2, 76 | changed: "user[first_name]", 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /assets/test/integration/metadata_test.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "phoenix"; 2 | import LiveSocket from "phoenix_live_view/live_socket"; 3 | 4 | const stubViewPushEvent = (view, callback) => { 5 | view.pushEvent = (type, el, targetCtx, phxEvent, meta, opts = {}) => { 6 | return callback(type, el, targetCtx, phxEvent, meta, opts); 7 | }; 8 | }; 9 | 10 | const prepareLiveViewDOM = (document, rootId) => { 11 | document.body.innerHTML = ` 12 |
15 | 16 | 17 | 18 |
19 | `; 20 | }; 21 | 22 | describe("metadata", () => { 23 | beforeEach(() => { 24 | prepareLiveViewDOM(global.document, "root"); 25 | }); 26 | 27 | test("is empty by default", () => { 28 | const liveSocket = new LiveSocket("/live", Socket); 29 | liveSocket.connect(); 30 | const view = liveSocket.getViewByEl(document.getElementById("root")); 31 | const btn = view.el.querySelector("button"); 32 | let meta = {}; 33 | stubViewPushEvent( 34 | view, 35 | (type, el, target, targetCtx, phxEvent, metadata) => { 36 | meta = metadata; 37 | }, 38 | ); 39 | btn.dispatchEvent(new Event("click", { bubbles: true })); 40 | 41 | expect(meta).toEqual({}); 42 | }); 43 | 44 | test("can be user defined", () => { 45 | const liveSocket = new LiveSocket("/live", Socket, { 46 | metadata: { 47 | click: (e, el) => { 48 | return { 49 | id: el.id, 50 | altKey: e.altKey, 51 | }; 52 | }, 53 | }, 54 | }); 55 | liveSocket.connect(); 56 | liveSocket.isConnected = () => true; 57 | const view = liveSocket.getViewByEl(document.getElementById("root")); 58 | view.isConnected = () => true; 59 | const btn = view.el.querySelector("button"); 60 | let meta = {}; 61 | stubViewPushEvent(view, (type, el, target, phxEvent, metadata, _opts) => { 62 | meta = metadata; 63 | }); 64 | btn.dispatchEvent(new Event("click", { bubbles: true })); 65 | 66 | expect(meta).toEqual({ 67 | id: "btn", 68 | altKey: undefined, 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /assets/test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "allowJs": true, 6 | "checkJs": false, 7 | "resolveJsonModule": true, 8 | "baseUrl": ".", 9 | "paths": { 10 | "phoenix_live_view": ["../js/phoenix_live_view/index.ts"], 11 | "phoenix_live_view*": ["../js/phoenix_live_view/*"] 12 | } 13 | }, 14 | "include": ["./**/*"], 15 | "exclude": [] 16 | } 17 | -------------------------------------------------------------------------------- /assets/test/utils_test.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "phoenix"; 2 | import { closestPhxBinding } from "phoenix_live_view/utils"; 3 | import LiveSocket from "phoenix_live_view/live_socket"; 4 | import { simulateJoinedView, liveViewDOM } from "./test_helpers"; 5 | 6 | const setupView = (content) => { 7 | const el = liveViewDOM(content); 8 | global.document.body.appendChild(el); 9 | const liveSocket = new LiveSocket("/live", Socket); 10 | return simulateJoinedView(el, liveSocket); 11 | }; 12 | 13 | describe("utils", () => { 14 | describe("closestPhxBinding", () => { 15 | test("if an element's parent has a phx-click binding and is not disabled, return the parent", () => { 16 | const _view = setupView(` 17 | 20 | `); 21 | const element = global.document.querySelector("#innerContent"); 22 | const parent = global.document.querySelector("#button"); 23 | expect(closestPhxBinding(element, "phx-click")).toBe(parent); 24 | }); 25 | 26 | test("if an element's parent is disabled, return null", () => { 27 | const _view = setupView(` 28 | 31 | `); 32 | const element = global.document.querySelector("#innerContent"); 33 | expect(closestPhxBinding(element, "phx-click")).toBe(null); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :phoenix, :json_library, Jason 4 | config :phoenix, :trim_on_html_eex_engine, false 5 | 6 | if Mix.env() == :dev do 7 | esbuild = fn args -> 8 | [ 9 | args: ~w(./js/phoenix_live_view --bundle) ++ args, 10 | cd: Path.expand("../assets", __DIR__), 11 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 12 | ] 13 | end 14 | 15 | lv_vsn = Mix.Project.config()[:version] 16 | 17 | config :esbuild, 18 | version: "0.20.2", 19 | module: 20 | esbuild.( 21 | ~w(--format=esm --sourcemap --define:LV_VSN="#{lv_vsn}" --outfile=../priv/static/phoenix_live_view.esm.js) 22 | ), 23 | main: 24 | esbuild.( 25 | ~w(--format=cjs --sourcemap --define:LV_VSN="#{lv_vsn}" --outfile=../priv/static/phoenix_live_view.cjs.js) 26 | ), 27 | cdn: 28 | esbuild.( 29 | ~w(--format=iife --target=es2016 --global-name=LiveView --define:LV_VSN="#{lv_vsn}" --outfile=../priv/static/phoenix_live_view.js) 30 | ), 31 | cdn_min: 32 | esbuild.( 33 | ~w(--format=iife --target=es2016 --global-name=LiveView --minify --define:LV_VSN="#{lv_vsn}" --outfile=../priv/static/phoenix_live_view.min.js) 34 | ) 35 | end 36 | 37 | import_config "#{config_env()}.exs" 38 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/docs.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/e2e.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :logger, :level, :error 4 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :logger, :level, :debug 4 | config :logger, :default_handler, false 5 | 6 | # we still support 1.14, silence logs in tests 7 | if Version.match?(System.version(), "< 1.15.0") do 8 | config :logger, :backends, [] 9 | end 10 | 11 | config :phoenix_live_view, enable_expensive_runtime_checks: true 12 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import playwright from "eslint-plugin-playwright" 2 | import jest from "eslint-plugin-jest" 3 | import globals from "globals" 4 | import js from "@eslint/js" 5 | import tseslint from "typescript-eslint" 6 | 7 | const sharedRules = { 8 | "@typescript-eslint/no-unused-vars": ["error", { 9 | argsIgnorePattern: "^_", 10 | varsIgnorePattern: "^_", 11 | }], 12 | 13 | "@typescript-eslint/no-unused-expressions": "off", 14 | "@typescript-eslint/no-explicit-any": "off", 15 | 16 | "no-useless-escape": "off", 17 | "no-cond-assign": "off", 18 | "no-case-declarations": "off", 19 | "prefer-const": "off" 20 | } 21 | 22 | export default tseslint.config([ 23 | { 24 | ignores: [ 25 | "_build/", 26 | "assets/js/types/", 27 | "test/e2e/test-results/", 28 | "coverage/", 29 | "cover/", 30 | "priv/", 31 | "deps/", 32 | "doc/" 33 | ] 34 | }, 35 | { 36 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 37 | files: ["*.js", "*.ts", "test/e2e/**"], 38 | ignores: ["assets/**"], 39 | 40 | plugins: { 41 | ...playwright.configs["flat/recommended"].plugins, 42 | }, 43 | 44 | rules: { 45 | ...playwright.configs["flat/recommended"].rules, 46 | ...sharedRules 47 | }, 48 | }, 49 | { 50 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 51 | files: ["assets/**/*.{js,ts}"], 52 | ignores: ["test/e2e/**"], 53 | 54 | plugins: { 55 | jest, 56 | }, 57 | 58 | languageOptions: { 59 | globals: { 60 | ...globals.browser, 61 | ...jest.environments.globals.globals, 62 | global: "writable", 63 | }, 64 | 65 | ecmaVersion: 12, 66 | sourceType: "module", 67 | }, 68 | 69 | rules: { 70 | ...sharedRules, 71 | }, 72 | }]) 73 | -------------------------------------------------------------------------------- /guides/server/deployments.md: -------------------------------------------------------------------------------- 1 | # Deployments and recovery 2 | 3 | One of the questions that arise from LiveView stateful model is what considerations are necessary when deploying a new version of LiveView (or when recovering from an error). 4 | 5 | First off, whenever LiveView disconnects, it will automatically attempt to reconnect to the server using exponential back-off. This means it will try immediately, then wait 2s and try again, then 5s and so on. If you are deploying, this typically means the next reconnection will immediately succeed and your load balancer will automatically redirect to the new servers. 6 | 7 | However, your LiveView _may_ still have state that will be lost in this transition. How to deal with it? The good news is that there are a series of practices you can follow that will not only help with deployments but it will improve your application in general. 8 | 9 | 1. Keep state in the query parameters when appropriate. For example, if your application has tabs and the user clicked a tab, instead of using `phx-click` and `c:Phoenix.LiveView.handle_event/3` to manage it, you should implement it using `<.link patch={...}>` passing the tab name as parameter. You will then receive the new tab name `c:Phoenix.LiveView.handle_params/3` which will set the relevant assign to choose which tab to display. You can even define specific URLs for each tab in your application router. By doing this, you will reduce the amount of server state, make tab navigation shareable via links, improving search engine indexing, and more. 10 | 11 | 2. Consider storing other relevant state in the database. For example, if you are building a chat app and you want to store which messages have been read, you can store so in the database. Once the page is loaded, you retrieve the index of the last read message. This makes the application more robust, allow data to be synchronized across devices, etc. 12 | 13 | 3. If your application uses forms (which is most likely the case), keep in mind that Phoenix performs automatic form recovery: in case of disconnections, Phoenix will collect the form data and resubmit it on reconnection. This mechanism works out of the box for most forms but you may want to customize it or test it for your most complex forms. See the relevant section [in the "Form bindings" document](../client/form-bindings.md) to learn more. 14 | 15 | The idea is that: if you follow the practices above, most of your state is already handled within your app and therefore deployments should not bring additional concerns. Not only that, it will bring overall benefits to your app such as indexing, link sharing, device sharing, and so on. 16 | 17 | If you really have complex state that cannot be immediately handled, then you may need to resort to special strategies. This may be persisting "old" state to Redis/S3/Database and loading the new state on the new connections. Or you may take special care when migrating connections (for example, if you are building a game, you may want to wait for on-going sessions to finish before turning down the old server while routing new sessions to the new ones). Such cases will depend on your requirements (and they would likely exist regardless of which application stack you are using). 18 | -------------------------------------------------------------------------------- /lib/mix/tasks/compile/phoenix_live_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Compile.PhoenixLiveView do 2 | @moduledoc """ 3 | A LiveView compiler for HEEx macro components. 4 | 5 | Right now, only `Phoenix.LiveView.ColocatedHook` and `Phoenix.LiveView.ColocatedJS` 6 | are handled. 7 | 8 | You must add it to your `mix.exs` as: 9 | 10 | compilers: [:phoenix_live_view] ++ Mix.compilers() 11 | 12 | """ 13 | use Mix.Task 14 | 15 | @recursive true 16 | 17 | @doc false 18 | def run(_args) do 19 | Mix.Task.Compiler.after_compiler(:elixir, fn 20 | {:noop, diagnostics} -> 21 | {:noop, diagnostics} 22 | 23 | {status, dignostics} -> 24 | compile() 25 | {status, dignostics} 26 | end) 27 | 28 | :noop 29 | end 30 | 31 | defp compile do 32 | Phoenix.LiveView.ColocatedJS.compile() 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/phoenix_live_view/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | @impl true 7 | def start(_type, _args) do 8 | Phoenix.LiveView.Logger.install() 9 | Supervisor.start_link([], strategy: :one_for_one, name: Phoenix.LiveView.Supervisor) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/phoenix_live_view/controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.Controller do 2 | @moduledoc """ 3 | Helpers for rendering LiveViews from a controller. 4 | """ 5 | 6 | alias Phoenix.LiveView 7 | alias Phoenix.LiveView.Socket 8 | 9 | @doc """ 10 | Renders a live view from a Plug request and sends an HTML response 11 | from within a controller. 12 | 13 | It also automatically sets the `@live_module` assign with the value 14 | of the LiveView to be rendered. 15 | 16 | ## Options 17 | 18 | See `Phoenix.Component.live_render/3` for all supported options. 19 | 20 | ## Examples 21 | 22 | defmodule ThermostatController do 23 | use MyAppWeb, :controller 24 | 25 | # "use MyAppWeb, :controller" should import Phoenix.LiveView.Controller. 26 | # If it does not, you can either import it there or uncomment the line below: 27 | # import Phoenix.LiveView.Controller 28 | 29 | def show(conn, %{"id" => thermostat_id}) do 30 | live_render(conn, ThermostatLive, session: %{ 31 | "thermostat_id" => thermostat_id, 32 | "current_user_id" => get_session(conn, :user_id) 33 | }) 34 | end 35 | end 36 | 37 | """ 38 | def live_render(%Plug.Conn{} = conn, view, opts \\ []) do 39 | case LiveView.Static.render(conn, view, opts) do 40 | {:ok, content, socket_assigns} -> 41 | conn 42 | |> Plug.Conn.fetch_query_params() 43 | |> ensure_format() 44 | |> Phoenix.Controller.put_view(LiveView.Static) 45 | |> Phoenix.Controller.render( 46 | :template, 47 | Map.merge(socket_assigns, %{content: content, live_module: view}) 48 | ) 49 | 50 | {:stop, %Socket{redirected: {:redirect, %{status: status} = opts}} = socket} -> 51 | redirect_opts = Map.delete(opts, :status) |> Map.to_list() 52 | 53 | conn 54 | |> Plug.Conn.put_status(status) 55 | |> put_flash(LiveView.Utils.get_flash(socket)) 56 | |> Phoenix.Controller.redirect(redirect_opts) 57 | 58 | {:stop, %Socket{redirected: {:live, _, %{to: to}}} = socket} -> 59 | conn 60 | |> put_flash(LiveView.Utils.get_flash(socket)) 61 | |> Plug.Conn.put_private(:phoenix_live_redirect, true) 62 | |> Phoenix.Controller.redirect(to: to) 63 | end 64 | end 65 | 66 | defp ensure_format(conn) do 67 | if Phoenix.Controller.get_format(conn) do 68 | conn 69 | else 70 | Phoenix.Controller.put_format(conn, "html") 71 | end 72 | end 73 | 74 | defp put_flash(conn, nil), do: conn 75 | 76 | defp put_flash(conn, flash), 77 | do: Enum.reduce(flash, conn, fn {k, v}, acc -> Phoenix.Controller.put_flash(acc, k, v) end) 78 | end 79 | -------------------------------------------------------------------------------- /lib/phoenix_live_view/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.Plug do 2 | @moduledoc false 3 | 4 | @behaviour Plug 5 | 6 | @impl Plug 7 | def init(view) when is_atom(view), do: view 8 | 9 | @impl Plug 10 | def call(%Plug.Conn{private: %{phoenix_live_view: {view, opts, live_session}}} = conn, _) do 11 | %{extra: live_session_extra} = live_session 12 | session = live_session(live_session_extra, conn) 13 | opts = Keyword.put(opts, :session, session) 14 | 15 | conn 16 | |> Phoenix.Controller.put_layout(false) 17 | |> put_root_layout_from_router(live_session_extra) 18 | |> Phoenix.LiveView.Controller.live_render(view, opts) 19 | end 20 | 21 | defp live_session(opts, conn) do 22 | case opts[:session] do 23 | {mod, fun, args} when is_atom(mod) and is_atom(fun) and is_list(args) -> 24 | apply(mod, fun, [conn | args]) 25 | 26 | %{} = session -> 27 | session 28 | 29 | nil -> 30 | %{} 31 | end 32 | end 33 | 34 | defp put_root_layout_from_router(conn, extra) do 35 | case Map.fetch(extra, :root_layout) do 36 | {:ok, layout} -> Phoenix.Controller.put_root_layout(conn, layout) 37 | :error -> conn 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/phoenix_live_view/route.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.Route do 2 | @moduledoc false 3 | 4 | alias Phoenix.LiveView.{Route, Socket} 5 | 6 | defstruct path: nil, 7 | view: nil, 8 | action: nil, 9 | opts: [], 10 | live_session: %{}, 11 | params: %{}, 12 | uri: nil 13 | 14 | @doc """ 15 | Computes the container from the route options and falls backs to use options. 16 | """ 17 | def container(%Route{} = route) do 18 | route.opts[:container] || route.view.__live__()[:container] 19 | end 20 | 21 | @doc """ 22 | Returns the internal or external matched LiveView route info for the given socket 23 | and uri, raises if none is available. 24 | """ 25 | def live_link_info!(%Socket{router: nil}, view, _uri) do 26 | raise ArgumentError, 27 | "cannot invoke handle_params/3 on #{inspect(view)} " <> 28 | "because it is not mounted nor accessed through the router live/3 macro" 29 | end 30 | 31 | def live_link_info!(%Socket{} = socket, view, uri) do 32 | %{private: %{live_session_name: session_name}} = socket 33 | 34 | case live_link_info_without_checks(socket.endpoint, socket.router, uri) do 35 | {:internal, %Route{view: ^view, live_session: %{name: ^session_name}} = route} -> 36 | {:internal, route} 37 | 38 | {:internal, %Route{} = route} -> 39 | {:external, route.uri} 40 | 41 | {:external, _parsed_uri} = external -> 42 | external 43 | 44 | :error -> 45 | raise ArgumentError, 46 | "cannot invoke handle_params nor navigate/patch to #{inspect(uri)} " <> 47 | "because it isn't defined in #{inspect(socket.router)}" 48 | end 49 | end 50 | 51 | @doc """ 52 | Returns the internal or external matched LiveView route info for the given uri. 53 | """ 54 | def live_link_info_without_checks(endpoint, router, uri) when is_binary(uri) do 55 | live_link_info_without_checks(endpoint, router, URI.parse(uri)) 56 | end 57 | 58 | def live_link_info_without_checks(endpoint, router, %URI{} = parsed_uri) 59 | when is_atom(endpoint) and is_atom(router) do 60 | %URI{host: host, path: path, query: query} = parsed_uri 61 | query_params = if query, do: Plug.Conn.Query.decode(query), else: %{} 62 | 63 | split_path = 64 | for segment <- String.split(path || "", "/"), segment != "", do: URI.decode(segment) 65 | 66 | route_path = strip_segments(endpoint.script_name(), split_path) || split_path 67 | 68 | case Phoenix.Router.route_info(router, "GET", route_path, host) do 69 | %{plug: Phoenix.LiveView.Plug, phoenix_live_view: lv, path_params: path_params} -> 70 | {view, action, opts, live_session} = lv 71 | 72 | route = %Route{ 73 | view: view, 74 | path: route_path, 75 | action: action, 76 | uri: parsed_uri, 77 | opts: opts, 78 | live_session: live_session, 79 | params: Map.merge(query_params, path_params) 80 | } 81 | 82 | {:internal, route} 83 | 84 | %{} -> 85 | {:external, parsed_uri} 86 | 87 | :error -> 88 | :error 89 | end 90 | end 91 | 92 | defp strip_segments([head | tail1], [head | tail2]), do: strip_segments(tail1, tail2) 93 | defp strip_segments([], tail2), do: tail2 94 | defp strip_segments(_, _), do: nil 95 | end 96 | -------------------------------------------------------------------------------- /lib/phoenix_live_view/session.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.Session do 2 | @moduledoc false 3 | alias Phoenix.LiveView.{Session, Route, Static} 4 | 5 | defstruct id: nil, 6 | view: nil, 7 | root_view: nil, 8 | parent_pid: nil, 9 | root_pid: nil, 10 | session: %{}, 11 | redirected?: false, 12 | router: nil, 13 | flash: nil, 14 | live_session_name: nil, 15 | assign_new: [] 16 | 17 | def main?(%Session{} = session), do: session.router != nil and session.parent_pid == nil 18 | 19 | def authorize_root_redirect(%Session{} = session, %Route{} = route) do 20 | %Session{live_session_name: session_name} = session 21 | 22 | case route.live_session do 23 | %{name: ^session_name} -> 24 | {:ok, replace_root(session, route.view, self())} 25 | 26 | %{} -> 27 | :error 28 | end 29 | end 30 | 31 | defp replace_root(%Session{} = session, new_root_view, root_pid) when is_pid(root_pid) do 32 | %{ 33 | session 34 | | view: new_root_view, 35 | root_view: new_root_view, 36 | root_pid: root_pid, 37 | assign_new: [], 38 | redirected?: true 39 | } 40 | end 41 | 42 | @doc """ 43 | Verifies the session token. 44 | 45 | Returns the decoded map of session data or an error. 46 | 47 | ## Examples 48 | 49 | iex> verify_session(AppWeb.Endpoint, "topic", encoded_token, static_token) 50 | {:ok, %Session{} = decoded_session} 51 | 52 | iex> verify_session(AppWeb.Endpoint, "topic", "bad token", "bac static") 53 | {:error, :invalid} 54 | 55 | iex> verify_session(AppWeb.Endpoint, "topic", "expired", "expired static") 56 | {:error, :expired} 57 | """ 58 | def verify_session(endpoint, topic, session_token, static_token) do 59 | with {:ok, %{id: id} = session} <- Static.verify_token(endpoint, session_token), 60 | :ok <- verify_topic(topic, id), 61 | {:ok, static} <- verify_static_token(endpoint, id, static_token) do 62 | merged_session = Map.merge(session, static) 63 | live_session_name = merged_session[:live_session_name] 64 | 65 | session = %Session{ 66 | id: id, 67 | view: merged_session.view, 68 | root_view: merged_session.root_view, 69 | parent_pid: merged_session.parent_pid, 70 | root_pid: merged_session.root_pid, 71 | session: merged_session.session, 72 | assign_new: merged_session.assign_new, 73 | live_session_name: live_session_name, 74 | # optional keys 75 | router: merged_session[:router], 76 | flash: merged_session[:flash] 77 | } 78 | 79 | {:ok, session} 80 | end 81 | end 82 | 83 | defp verify_topic("lv:" <> session_id, session_id), do: :ok 84 | defp verify_topic(_topic, _session_id), do: {:error, :invalid} 85 | 86 | defp verify_static_token(_endpoint, _id, nil), do: {:ok, %{assign_new: []}} 87 | 88 | defp verify_static_token(endpoint, id, token) do 89 | case Static.verify_token(endpoint, token) do 90 | {:ok, %{id: ^id}} = ok -> ok 91 | {:ok, _} -> {:error, :invalid} 92 | {:error, _} = error -> error 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/phoenix_live_view/upload_tmp_file_writer.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.UploadTmpFileWriter do 2 | @moduledoc false 3 | 4 | @behaviour Phoenix.LiveView.UploadWriter 5 | 6 | @impl true 7 | def init(_opts) do 8 | with {:ok, path} <- Plug.Upload.random_file("live_view_upload"), 9 | {:ok, file} <- File.open(path, [:binary, :write]) do 10 | {:ok, %{path: path, file: file}} 11 | end 12 | end 13 | 14 | @impl true 15 | def meta(state) do 16 | %{path: state.path} 17 | end 18 | 19 | @impl true 20 | def write_chunk(data, state) do 21 | case IO.binwrite(state.file, data) do 22 | :ok -> {:ok, state} 23 | {:error, reason} -> {:error, reason, state} 24 | end 25 | end 26 | 27 | @impl true 28 | def close(state, _reason) do 29 | case File.close(state.file) do 30 | :ok -> {:ok, state} 31 | {:error, reason} -> {:error, reason} 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phoenix_live_view", 3 | "version": "1.1.0-dev", 4 | "description": "The Phoenix LiveView JavaScript client.", 5 | "license": "MIT", 6 | "type": "module", 7 | "module": "./priv/static/phoenix_live_view.esm.js", 8 | "main": "./priv/static/phoenix_live_view.cjs.js", 9 | "unpkg": "./priv/static/phoenix_live_view.min.js", 10 | "jsdelivr": "./priv/static/phoenix_live_view.min.js", 11 | "exports": { 12 | "import": "./priv/static/phoenix_live_view.esm.js", 13 | "require": "./priv/static/phoenix_live_view.cjs.js" 14 | }, 15 | "author": "Chris McCord (http://www.phoenixframework.org)", 16 | "repository": { 17 | "type": "git", 18 | "url": "git://github.com/phoenixframework/phoenix_live_view.git" 19 | }, 20 | "files": [ 21 | "README.md", 22 | "LICENSE.md", 23 | "package.json", 24 | "priv/static/*", 25 | "assets/js/**" 26 | ], 27 | "types": "./assets/js/types/index.d.ts", 28 | "dependencies": { 29 | "morphdom": "git+https://github.com/SteffenDE/morphdom.git#sd-fix-ts" 30 | }, 31 | "devDependencies": { 32 | "@babel/cli": "7.27.0", 33 | "@babel/core": "7.26.10", 34 | "@babel/preset-env": "7.26.9", 35 | "@babel/preset-typescript": "^7.27.1", 36 | "@eslint/js": "^9.24.0", 37 | "@playwright/test": "^1.51.1", 38 | "@types/jest": "^29.5.14", 39 | "@types/phoenix": "^1.6.6", 40 | "css.escape": "^1.5.1", 41 | "eslint": "9.24.0", 42 | "eslint-plugin-jest": "28.11.0", 43 | "eslint-plugin-playwright": "^2.2.0", 44 | "globals": "^16.0.0", 45 | "jest": "^29.7.0", 46 | "jest-environment-jsdom": "^29.7.0", 47 | "jest-monocart-coverage": "^1.1.1", 48 | "monocart-reporter": "^2.9.17", 49 | "phoenix": "1.7.21", 50 | "prettier": "3.5.3", 51 | "ts-jest": "^29.3.2", 52 | "typescript": "^5.8.3", 53 | "typescript-eslint": "^8.32.0" 54 | }, 55 | "scripts": { 56 | "setup": "mix deps.get && npm install", 57 | "build": "tsc", 58 | "e2e:server": "MIX_ENV=e2e mix test --cover --export-coverage e2e test/e2e/test_helper.exs", 59 | "e2e:test": "mix assets.build && cd test/e2e && npx playwright install && npx playwright test", 60 | "js:test": "npm run build && jest", 61 | "js:test.coverage": "npm run build && jest --coverage", 62 | "js:test.watch": "npm run build && jest --watch", 63 | "js:lint": "eslint", 64 | "js:format": "prettier --write assets --log-level warn && prettier --write test/e2e --log-level warn", 65 | "js:format.check": "prettier --check assets --log-level warn && prettier --check test/e2e --log-level warn", 66 | "test": "npm run js:test && npm run e2e:test", 67 | "typecheck:tests": "tsc -p assets/test/tsconfig.json", 68 | "cover:merge": "node test/e2e/merge-coverage.js", 69 | "cover": "npm run test && npm run cover:merge", 70 | "cover:report": "npx monocart show-report cover/merged-js/index.html" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /setupTests.js: -------------------------------------------------------------------------------- 1 | import "regenerator-runtime/runtime" 2 | import "css.escape" 3 | -------------------------------------------------------------------------------- /setupTestsAfterEnv.js: -------------------------------------------------------------------------------- 1 | // https://github.com/jestjs/jest/pull/14598#issuecomment-1748047560 2 | // TODO: remove this once jest.advanceTimersToNextFrame() is available 3 | // ensure you are using "modern" fake timers 4 | // 1. before doing anything, grab the start time `setStartSystemTime()` 5 | // 2. step through frames by using `advanceTimersToNextFrame()` 6 | 7 | let startTime = null 8 | 9 | /** Record the initial (mocked) system start time 10 | * 11 | * This is no longer needed once `jest.advanceTimersToNextFrame()` is available 12 | * https://github.com/jestjs/jest/pull/14598 13 | */ 14 | global.setStartSystemTime = () => { 15 | startTime = Date.now() 16 | } 17 | 18 | /** Step forward a single animation frame 19 | * 20 | * This is no longer needed once `jest.advanceTimersToNextFrame()` is available 21 | * https://github.com/jestjs/jest/pull/14598 22 | */ 23 | global.advanceTimersToNextFrame = () => { 24 | if(startTime == null){ 25 | throw new Error("Must call `setStartSystemTime` before using `advanceTimersToNextFrame()`") 26 | } 27 | 28 | // Stealing logic from sinon fake timers 29 | // https://github.com/sinonjs/fake-timers/blob/fc312b9ce96a4ea2c7e60bb0cccd2c604b75cdbd/src/fake-timers-src.js#L1102-L1105 30 | const timePassedInFrame = (Date.now() - startTime) % 16 31 | const timeToNextFrame = 16 - timePassedInFrame 32 | jest.advanceTimersByTime(timeToNextFrame) 33 | } 34 | -------------------------------------------------------------------------------- /test/e2e/.prettierignore: -------------------------------------------------------------------------------- 1 | test-results/ 2 | -------------------------------------------------------------------------------- /test/e2e/README.md: -------------------------------------------------------------------------------- 1 | # End-to-end tests 2 | 3 | This directory contains end-to-end tests that use the [Playwright](https://playwright.dev/) 4 | test framework. 5 | These tests use all three web engines (Chromium, Firefox, Webkit) and test the interaction 6 | with an actual LiveView server. 7 | 8 | ## Running the tests 9 | 10 | To run the tests, ensure that the npm dependencies are installed by running `npm install`, followed by `npx playwright install` in 11 | the root of the repository. Then, run `npm run e2e:test` to run the tests. 12 | 13 | This will execute the `npx playwright test` command in the `test/e2e` directory. Playwright 14 | will start a LiveView server using the `MIX_ENV=e2e mix run test/e2e/test_helper.exs` command. 15 | 16 | Playwright supports an [interactive UI mode](https://playwright.dev/docs/test-ui-mode) that 17 | can be used to debug the tests. To run the tests in this mode, run `npm run e2e:test -- --ui`. 18 | 19 | Tests can also be run in headed mode by passing the `--headed` flag. This is especially useful 20 | in combination with running only specific tests, for example: 21 | 22 | ```bash 23 | npm run e2e:test -- tests/streams.spec.js:9 --project chromium --headed 24 | ``` 25 | 26 | To step through a single test, pass `--debug`, which will automatically run the test in headed 27 | mode: 28 | 29 | ```bash 30 | npm run e2e:test -- tests/streams.spec.js:9 --project chromium --debug 31 | ``` 32 | -------------------------------------------------------------------------------- /test/e2e/merge-coverage.js: -------------------------------------------------------------------------------- 1 | import { CoverageReport } from "monocart-coverage-reports"; 2 | 3 | const coverageOptions = { 4 | name: "Phoenix LiveView JS Coverage", 5 | inputDir: ["./coverage/raw", "./test/e2e/test-results/coverage/raw"], 6 | outputDir: "./cover/merged-js", 7 | reports: [["v8"], ["console-summary"]], 8 | sourcePath: (filePath) => { 9 | if (!filePath.startsWith("assets")) { 10 | return "assets/js/phoenix_live_view/" + filePath; 11 | } else { 12 | return filePath; 13 | } 14 | }, 15 | }; 16 | await new CoverageReport(coverageOptions).generate(); 17 | -------------------------------------------------------------------------------- /test/e2e/playwright.config.js: -------------------------------------------------------------------------------- 1 | // playwright.config.js 2 | // @ts-check 3 | import { devices } from "@playwright/test"; 4 | import { dirname, resolve } from "node:path"; 5 | import { fileURLToPath } from "node:url"; 6 | 7 | const __dirname = dirname(fileURLToPath(import.meta.url)); 8 | 9 | /** @type {import("@playwright/test").ReporterDescription} */ 10 | const monocartReporter = [ 11 | "monocart-reporter", 12 | { 13 | name: "Phoenix LiveView", 14 | outputFile: "./test-results/report.html", 15 | coverage: { 16 | reports: [["raw", { outputDir: "./raw" }], ["v8"]], 17 | entryFilter: (entry) => 18 | entry.url.indexOf("phoenix_live_view.esm.js") !== -1, 19 | }, 20 | }, 21 | ]; 22 | 23 | /** @type {import("@playwright/test").PlaywrightTestConfig} */ 24 | const config = { 25 | forbidOnly: !!process.env.CI, 26 | retries: process.env.CI ? 2 : 0, 27 | reporter: process.env.CI 28 | ? [["github"], ["html"], ["dot"], monocartReporter] 29 | : [["list"], monocartReporter], 30 | use: { 31 | trace: "retain-on-failure", 32 | screenshot: "only-on-failure", 33 | baseURL: "http://localhost:4004/", 34 | ignoreHTTPSErrors: true, 35 | }, 36 | webServer: { 37 | command: "npm run e2e:server", 38 | url: "http://127.0.0.1:4004/health", 39 | reuseExistingServer: !process.env.CI, 40 | stdout: "pipe", 41 | stderr: "pipe", 42 | }, 43 | projects: [ 44 | { 45 | name: "chromium", 46 | use: { ...devices["Desktop Chrome"] }, 47 | }, 48 | { 49 | name: "firefox", 50 | use: { ...devices["Desktop Firefox"] }, 51 | }, 52 | { 53 | name: "webkit", 54 | use: { ...devices["Desktop Safari"] }, 55 | }, 56 | ], 57 | outputDir: "test-results", 58 | globalTeardown: resolve(__dirname, "./teardown.js"), 59 | }; 60 | 61 | export default config; 62 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3026.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3026Live do 2 | use Phoenix.LiveView 3 | 4 | # https://github.com/phoenixframework/phoenix_live_view/issues/3026 5 | 6 | defmodule Form do 7 | use Phoenix.LiveComponent 8 | 9 | def render(assigns) do 10 | ~H""" 11 |
12 | Example form 13 | <.form for={to_form(%{})} phx-change="validate" phx-submit="submit"> 14 | 15 | 16 | 17 | 18 |
19 | """ 20 | end 21 | end 22 | 23 | @impl Phoenix.LiveView 24 | def mount(_params, _session, socket) do 25 | if connected?(socket) do 26 | send(self(), :load) 27 | end 28 | 29 | status = if connected?(socket), do: :loading, else: :connecting 30 | 31 | {:ok, assign(socket, :status, status)} 32 | end 33 | 34 | @impl Phoenix.LiveView 35 | def handle_info(:load, socket) do 36 | Process.sleep(200) 37 | 38 | {:noreply, assign(socket, %{status: :loaded, name: "John", email: ""})} 39 | end 40 | 41 | @impl Phoenix.LiveView 42 | def handle_event("change_status", %{"status" => status}, socket) do 43 | {:noreply, assign(socket, :status, String.to_existing_atom(status))} 44 | end 45 | 46 | def handle_event("validate", params, socket) do 47 | {:noreply, assign(socket, %{name: params["name"], email: params["email"]})} 48 | end 49 | 50 | def handle_event("submit", _params, socket) do 51 | send(self(), :load) 52 | {:noreply, assign(socket, %{status: :loading})} 53 | end 54 | 55 | @impl Phoenix.LiveView 56 | def render(assigns) do 57 | ~H""" 58 | <.form for={to_form(%{})} phx-change="change_status"> 59 | 62 | 63 | 64 | <%= case @status do %> 65 | <% :connecting -> %> 66 | <.status status={@status} /> 67 | <% :loading -> %> 68 | <.status status={@status} /> 69 | <% :connected -> %> 70 | <.status status={@status} /> 71 | <% :loaded -> %> 72 | <.live_component module={__MODULE__.Form} id="my-form" name={@name} email={@email} /> 73 | <% end %> 74 | """ 75 | end 76 | 77 | defp status(assigns) do 78 | ~H""" 79 |
80 | {@status} 81 |
82 | """ 83 | end 84 | 85 | defp options do 86 | ~w(connecting loading connected loaded) 87 | |> Enum.map(fn status -> {String.capitalize(status), status} end) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3047.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3047ALive do 2 | use Phoenix.LiveView, layout: {__MODULE__, :live} 3 | 4 | def render("live.html", assigns) do 5 | ~H""" 6 | {apply(Phoenix.LiveViewTest.E2E.Layout, :render, [ 7 | "live.html", 8 | Map.put(assigns, :inner_content, []) 9 | ])} 10 | 11 |
12 |
13 | <.link class="border rounded bg-blue-700 w-fit px-2 text-white" navigate="/issues/3047/a"> 14 | Page A 15 | 16 | <.link class="border rounded bg-blue-700 w-fit px-2 text-white" navigate="/issues/3047/b"> 17 | Page B 18 | 19 |
20 | 21 | {@inner_content} 22 | 23 | {live_render(@socket, Phoenix.LiveViewTest.E2E.Issue3047.Sticky, id: "test", sticky: true)} 24 |
25 | """ 26 | end 27 | 28 | def render(assigns) do 29 | ~H""" 30 | Page A 31 | """ 32 | end 33 | end 34 | 35 | defmodule Phoenix.LiveViewTest.E2E.Issue3047BLive do 36 | use Phoenix.LiveView, layout: {Phoenix.LiveViewTest.E2E.Issue3047ALive, :live} 37 | 38 | def render(assigns) do 39 | ~H""" 40 | Page B 41 | """ 42 | end 43 | end 44 | 45 | defmodule Phoenix.LiveViewTest.E2E.Issue3047.Sticky do 46 | use Phoenix.LiveView 47 | 48 | def mount(:not_mounted_at_router, _session, socket) do 49 | items = 50 | Enum.map(1..10, fn x -> 51 | %{id: x, name: "item-#{x}"} 52 | end) 53 | 54 | {:ok, socket |> stream(:items, items), layout: false} 55 | end 56 | 57 | def handle_event("reset", _, socket) do 58 | items = 59 | Enum.map(5..15, fn x -> 60 | %{id: x, name: "item-#{x}"} 61 | end) 62 | 63 | {:noreply, socket |> stream(:items, items, reset: true)} 64 | end 65 | 66 | def render(assigns) do 67 | ~H""" 68 |
69 |

This is the sticky liveview

70 |
71 | {item.name} 72 |
73 | 74 | 75 |
76 | """ 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3083.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3083Live do 2 | use Phoenix.LiveView 3 | 4 | # https://github.com/phoenixframework/phoenix_live_view/issues/3083 5 | 6 | @impl Phoenix.LiveView 7 | def mount(params, _session, socket) do 8 | if connected?(socket) and not (params["auto"] == "false") do 9 | :timer.send_interval(1000, self(), :tick) 10 | end 11 | 12 | {:ok, socket |> assign(options: [1, 2, 3, 4, 5], form: to_form(%{"ids" => []}))} 13 | end 14 | 15 | @impl Phoenix.LiveView 16 | def handle_info(:tick, socket) do 17 | selected = Enum.take_random([1, 2, 3, 4, 5], 2) 18 | params = %{"ids" => selected} 19 | 20 | {:noreply, socket |> assign(form: to_form(params))} 21 | end 22 | 23 | def handle_info({:select, values}, socket) do 24 | params = %{"ids" => values} 25 | 26 | {:noreply, socket |> assign(form: to_form(params))} 27 | end 28 | 29 | @impl Phoenix.LiveView 30 | def handle_event("validate", _params, socket) do 31 | {:noreply, socket} 32 | end 33 | 34 | @impl Phoenix.LiveView 35 | def render(assigns) do 36 | ~H""" 37 | <.form id="form" for={@form} phx-change="validate"> 38 | 41 | 42 | 43 | """ 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3107.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3107Live do 2 | use Phoenix.LiveView 3 | 4 | # https://github.com/phoenixframework/phoenix_live_view/issues/3107 5 | 6 | @impl Phoenix.LiveView 7 | def mount(_params, _session, socket) do 8 | {:ok, 9 | socket 10 | |> assign(:form, Phoenix.Component.to_form(%{})) 11 | |> assign(:disabled, true)} 12 | end 13 | 14 | @impl Phoenix.LiveView 15 | def handle_event("validate", _, socket) do 16 | {:noreply, assign(socket, :disabled, false)} 17 | end 18 | 19 | @impl Phoenix.LiveView 20 | def render(assigns) do 21 | ~H""" 22 | <.form for={@form} phx-change="validate" style="display: flex;"> 23 | 27 | 28 | 29 | 30 | """ 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3117.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3117Live do 2 | use Phoenix.LiveView 3 | 4 | # https://github.com/phoenixframework/phoenix_live_view/issues/3117 5 | 6 | defmodule Row do 7 | use Phoenix.LiveComponent 8 | 9 | def update(assigns, socket) do 10 | {:ok, assign(socket, assigns) |> assign_async(:foo, fn -> {:ok, %{foo: :bar}} end)} 11 | end 12 | 13 | def render(assigns) do 14 | ~H""" 15 |
16 | Example LC Row {inspect(@foo.result)} 17 | <.fc /> 18 |
19 | """ 20 | end 21 | 22 | defp fc(assigns) do 23 | ~H""" 24 |
static content
25 | """ 26 | end 27 | end 28 | 29 | @impl Phoenix.LiveView 30 | def render(assigns) do 31 | ~H""" 32 | <.link id="navigate" navigate="/issues/3117?nav">Navigate 33 |
34 | <.live_component module={__MODULE__.Row} id={"row-#{i}"} /> 35 |
36 | """ 37 | end 38 | 39 | @impl Phoenix.LiveView 40 | def mount(_params, _session, socket) do 41 | {:ok, socket} 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3194.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3194Live do 2 | use Phoenix.LiveView 3 | 4 | # https://github.com/phoenixframework/phoenix_live_view/issues/3194 5 | 6 | @impl Phoenix.LiveView 7 | def mount(_params, _session, socket) do 8 | {:ok, assign(socket, :form, to_form(%{}, as: :foo))} 9 | end 10 | 11 | @impl Phoenix.LiveView 12 | def render(assigns) do 13 | ~H""" 14 | <.form for={@form} phx-change="validate" phx-submit="submit"> 15 | 22 | 23 | """ 24 | end 25 | 26 | @impl Phoenix.LiveView 27 | def handle_event("submit", _params, socket) do 28 | {:noreply, push_navigate(socket, to: "/issues/3194/other")} 29 | end 30 | 31 | @impl Phoenix.LiveView 32 | def handle_event("validate", _params, socket) do 33 | {:noreply, socket} 34 | end 35 | 36 | defmodule OtherLive do 37 | use Phoenix.LiveView 38 | 39 | @impl Phoenix.LiveView 40 | def render(assigns) do 41 | ~H""" 42 |

Another LiveView

43 | """ 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3200.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3200 do 2 | # https://github.com/phoenixframework/phoenix_live_view/issues/3200 3 | 4 | defmodule PanelLive do 5 | use Phoenix.LiveView 6 | 7 | alias Phoenix.LiveView.JS 8 | 9 | def render(assigns) do 10 | ~H""" 11 |
12 |
13 |
14 | <.tab_button text="Messages tab" route="/issues/3200/messages" /> 15 | <.tab_button text="Settings tab" route="/issues/3200/settings" /> 16 |
17 | 18 | 34 |
35 |
36 | """ 37 | end 38 | 39 | def handle_params(_params, _uri, socket), do: {:noreply, socket} 40 | 41 | defp tab_button(assigns) do 42 | ~H""" 43 | 46 | """ 47 | end 48 | end 49 | 50 | defmodule SettingsTab do 51 | use Phoenix.LiveComponent 52 | 53 | @impl Phoenix.LiveComponent 54 | def render(assigns) do 55 | ~H""" 56 |
Settings
57 | """ 58 | end 59 | end 60 | 61 | defmodule MessagesTab do 62 | use Phoenix.LiveComponent 63 | 64 | def update(assigns, socket) do 65 | { 66 | :ok, 67 | assign(socket, id: assigns.id, value: "") 68 | } 69 | end 70 | 71 | def render(assigns) do 72 | ~H""" 73 |
74 | <.live_component 75 | module={Phoenix.LiveViewTest.E2E.Issue3200.MessageComponent} 76 | id="some_unique_message_id" 77 | message="Example message" 78 | /> 79 |
85 | <.input id="new_message_input" name="new_message" value={@value} /> 86 |
87 |
88 | """ 89 | end 90 | 91 | def input(assigns) do 92 | ~H""" 93 |
94 | 95 |
96 | """ 97 | end 98 | 99 | def handle_event("add_message_change", %{"new_message" => value}, socket) do 100 | {:noreply, assign(socket, :value, value)} 101 | end 102 | end 103 | 104 | defmodule MessageComponent do 105 | use Phoenix.LiveComponent 106 | 107 | def render(assigns) do 108 | ~H""" 109 |
{@message}
110 | """ 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3378.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3378.NotificationsLive do 2 | use Phoenix.LiveView, layout: {__MODULE__, :live} 3 | 4 | def mount(_params, _session, socket) do 5 | {:ok, 6 | socket 7 | |> stream(:notifications, [%{id: 1, message: "Hello"}])} 8 | end 9 | 10 | def render(assigns) do 11 | ~H""" 12 |
13 | 18 |
19 | """ 20 | end 21 | end 22 | 23 | defmodule Phoenix.LiveViewTest.E2E.Issue3378.AppBarLive do 24 | use Phoenix.LiveView, layout: {__MODULE__, :live} 25 | 26 | def mount(_params, _session, socket) do 27 | {:ok, socket} 28 | end 29 | 30 | def render(assigns) do 31 | ~H""" 32 |
33 | {live_render( 34 | @socket, 35 | Phoenix.LiveViewTest.E2E.Issue3378.NotificationsLive, 36 | session: %{}, 37 | id: :notifications 38 | )} 39 |
40 | """ 41 | end 42 | end 43 | 44 | defmodule Phoenix.LiveViewTest.E2E.Issue3378.HomeLive do 45 | use Phoenix.LiveView, layout: {__MODULE__, :live} 46 | 47 | def mount(_params, _session, socket) do 48 | {:ok, socket} 49 | end 50 | 51 | def render(assigns) do 52 | ~H""" 53 | {live_render( 54 | @socket, 55 | Phoenix.LiveViewTest.E2E.Issue3378.AppBarLive, 56 | session: %{}, 57 | id: :appbar 58 | )} 59 | """ 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3448.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3448Live do 2 | # https://github.com/phoenixframework/phoenix_live_view/issues/3448 3 | 4 | use Phoenix.LiveView 5 | 6 | alias Phoenix.LiveView.JS 7 | 8 | def mount(_params, _session, socket) do 9 | form = to_form(%{"a" => []}) 10 | 11 | {:ok, assign_new(socket, :form, fn -> form end)} 12 | end 13 | 14 | def render(assigns) do 15 | ~H""" 16 | <.form for={@form} id="my_form" phx-change="validate" class="flex flex-col gap-2"> 17 | <.my_component> 18 | <:left_content :for={value <- @form[:a].value || []}> 19 |
{value}
20 | 21 | 22 | 23 |
24 | "[]"} 27 | value="settings" 28 | checked={"settings" in (@form[:a].value || [])} 29 | phx-click={JS.dispatch("input") |> JS.focus(to: "#search")} 30 | /> 31 | 32 | "[]"} 35 | value="content" 36 | checked={"content" in (@form[:a].value || [])} 37 | phx-click={JS.dispatch("input") |> JS.focus(to: "#search")} 38 | /> 39 |
40 | 41 | """ 42 | end 43 | 44 | def handle_event("validate", params, socket) do 45 | {:noreply, assign(socket, form: to_form(params))} 46 | end 47 | 48 | def handle_event("search", _params, socket) do 49 | {:noreply, socket} 50 | end 51 | 52 | slot :left_content 53 | 54 | defp my_component(assigns) do 55 | ~H""" 56 |
57 |
58 | {render_slot(left_content)} 59 |
60 | 61 | 62 |
63 | """ 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3496.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3496.ALive do 2 | # https://github.com/phoenixframework/phoenix_live_view/issues/3496 3 | 4 | use Phoenix.LiveView 5 | 6 | def base(assigns) do 7 | ~H""" 8 | 9 | 11 | 25 | 28 | """ 29 | end 30 | 31 | def with_sticky(assigns) do 32 | ~H""" 33 | <.base /> 34 | 35 |
36 | {@inner_content} 37 |
38 | 39 | {live_render(@socket, Phoenix.LiveViewTest.E2E.Issue3496.StickyLive, 40 | id: "sticky", 41 | sticky: true 42 | )} 43 | """ 44 | end 45 | 46 | def without_sticky(assigns) do 47 | ~H""" 48 | <.base /> 49 | 50 |
51 | {@inner_content} 52 |
53 | """ 54 | end 55 | 56 | def mount(_params, _session, socket) do 57 | {:ok, socket, layout: {__MODULE__, :with_sticky}} 58 | end 59 | 60 | def render(assigns) do 61 | ~H""" 62 |

Page A

63 | <.link navigate="/issues/3496/b">Go to page B 64 | """ 65 | end 66 | end 67 | 68 | defmodule Phoenix.LiveViewTest.E2E.Issue3496.BLive do 69 | use Phoenix.LiveView 70 | 71 | def mount(_params, _session, socket) do 72 | {:ok, socket, layout: {Phoenix.LiveViewTest.E2E.Issue3496.ALive, :without_sticky}} 73 | end 74 | 75 | def render(assigns) do 76 | ~H""" 77 |

Page B

78 | 79 | """ 80 | end 81 | end 82 | 83 | defmodule Phoenix.LiveViewTest.E2E.Issue3496.StickyLive do 84 | use Phoenix.LiveView 85 | 86 | def mount(:not_mounted_at_router, _session, socket) do 87 | {:ok, socket, layout: false} 88 | end 89 | 90 | def render(assigns) do 91 | ~H""" 92 |
93 | 94 |
95 | """ 96 | end 97 | end 98 | 99 | defmodule Phoenix.LiveViewTest.E2E.Issue3496.MyComponent do 100 | use Phoenix.Component 101 | 102 | def my_component(assigns) do 103 | ~H""" 104 |
105 | """ 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3529.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3529Live do 2 | # https://github.com/phoenixframework/phoenix_live_view/issues/3529 3 | 4 | use Phoenix.LiveView 5 | 6 | def mount(_params, _session, socket) do 7 | {:ok, assign(socket, :mounted, DateTime.utc_now())} 8 | end 9 | 10 | def handle_params(_params, _uri, socket) do 11 | {:noreply, assign(socket, :next, :rand.uniform())} 12 | end 13 | 14 | def render(assigns) do 15 | ~H""" 16 |

Mounted at {@mounted}

17 | <.link navigate={"/issues/3529?param=#{@next}"}>Navigate 18 | <.link patch={"/issues/3529?param=#{@next}"}>Patch 19 | """ 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3530.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3530Live do 2 | use Phoenix.LiveView, layout: {__MODULE__, :live} 3 | 4 | defmodule NestedLive do 5 | use Phoenix.LiveView 6 | 7 | def mount(_params, session, socket) do 8 | {:ok, assign(socket, :item_id, session["item_id"])} 9 | end 10 | 11 | def render(assigns) do 12 | ~H""" 13 |
14 | test hook with nested liveview 15 |
16 |
17 | """ 18 | end 19 | end 20 | 21 | def render("live.html", assigns) do 22 | ~H""" 23 | 24 | 26 | 38 | 39 | {@inner_content} 40 | """ 41 | end 42 | 43 | def render(assigns) do 44 | ~H""" 45 | 50 | <.link patch="/issues/3530?q=a">patch a 51 | <.link patch="/issues/3530?q=b">patch b 52 |
+
53 | """ 54 | end 55 | 56 | def mount(_params, _session, socket) do 57 | socket = 58 | socket 59 | |> assign(:count, 3) 60 | |> stream_configure(:items, dom_id: &"item-#{&1.id}") 61 | 62 | {:ok, socket} 63 | end 64 | 65 | def handle_params(%{"q" => "a"}, _uri, socket) do 66 | socket = 67 | socket 68 | |> stream(:items, [%{id: 1}, %{id: 3}], reset: true) 69 | 70 | {:noreply, socket} 71 | end 72 | 73 | def handle_params(%{"q" => "b"}, _uri, socket) do 74 | socket = 75 | socket 76 | |> stream(:items, [%{id: 2}, %{id: 3}], reset: true) 77 | 78 | {:noreply, socket} 79 | end 80 | 81 | def handle_params(_params, _uri, socket) do 82 | socket = 83 | socket 84 | |> stream(:items, [%{id: 1}, %{id: 2}, %{id: 3}], reset: true) 85 | 86 | {:noreply, socket} 87 | end 88 | 89 | def handle_event("inc", _params, socket) do 90 | socket = 91 | socket 92 | |> update(:count, &(&1 + 1)) 93 | |> then(&stream_insert(&1, :items, %{id: &1.assigns.count})) 94 | 95 | {:noreply, socket} 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3612.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3612.ALive do 2 | # https://github.com/phoenixframework/phoenix_live_view/issues/3612 3 | 4 | use Phoenix.LiveView 5 | 6 | def mount(_params, _session, socket) do 7 | {:ok, socket} 8 | end 9 | 10 | def render(assigns) do 11 | ~H""" 12 | {live_render(@socket, Phoenix.LiveViewTest.E2E.Issue3612.StickyLive, 13 | id: "sticky", 14 | sticky: true 15 | )} 16 | 17 |

Page A

18 | """ 19 | end 20 | end 21 | 22 | defmodule Phoenix.LiveViewTest.E2E.Issue3612.BLive do 23 | use Phoenix.LiveView 24 | 25 | def mount(_params, _session, socket) do 26 | {:ok, socket} 27 | end 28 | 29 | def render(assigns) do 30 | ~H""" 31 | {live_render(@socket, Phoenix.LiveViewTest.E2E.Issue3612.StickyLive, 32 | id: "sticky", 33 | sticky: true 34 | )} 35 | 36 |

Page B

37 | """ 38 | end 39 | end 40 | 41 | defmodule Phoenix.LiveViewTest.E2E.Issue3612.StickyLive do 42 | use Phoenix.LiveView 43 | 44 | def mount(:not_mounted_at_router, _session, socket) do 45 | {:ok, socket, layout: false} 46 | end 47 | 48 | def render(assigns) do 49 | ~H""" 50 |
51 | <.link phx-click="navigate_to_a">Go to page A 52 | <.link phx-click="navigate_to_b">Go to page B 53 |
54 | """ 55 | end 56 | 57 | def handle_event("navigate_to_a", _params, socket) do 58 | {:noreply, push_navigate(socket, to: "/issues/3612/a")} 59 | end 60 | 61 | def handle_event("navigate_to_b", _params, socket) do 62 | {:noreply, push_navigate(socket, to: "/issues/3612/b")} 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3651.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3651Live do 2 | # https://github.com/phoenixframework/phoenix_live_view/issues/3651 3 | use Phoenix.LiveView 4 | 5 | def mount(_params, _session, socket) do 6 | if connected?(socket) do 7 | send(self(), :change_id) 8 | end 9 | 10 | # assigns for pre_script 11 | assigns = %{} 12 | 13 | socket = 14 | socket 15 | |> assign(id: 1, counter: 0) 16 | |> assign( 17 | :pre_script, 18 | ~H""" 19 | 44 | """ 45 | ) 46 | |> push_event("myevent", %{}) 47 | 48 | {:ok, socket} 49 | end 50 | 51 | def handle_info(:change_id, socket) do 52 | {:noreply, assign(socket, id: 2)} 53 | end 54 | 55 | def handle_event("lol", _params, socket) do 56 | {:noreply, socket} 57 | end 58 | 59 | def handle_event("reload", _params, socket) do 60 | counter = socket.assigns.counter + 1 61 | 62 | socket = 63 | socket 64 | |> push_event("myevent", %{}) 65 | |> assign(counter: counter) 66 | 67 | socket = 68 | if counter > 4096 do 69 | raise "that's enough, bye!" 70 | else 71 | socket 72 | end 73 | 74 | {:noreply, socket} 75 | end 76 | 77 | def render(assigns) do 78 | ~H""" 79 |
80 |
81 | This is an example of nested hooks resulting in a "ghost" element 82 | that isn't on the DOM, and is never cleaned up. In this specific example 83 | a timeout is used to show how the number of events being sent to the server 84 | grows exponentially. 85 |

Doing any of the following things fixes it:

86 |
    87 |
  1. Setting the `phx-hook` to use a fixed id.
  2. 88 |
  3. Removing the `pushEvent` from the OuterHook `mounted` callback.
  4. 89 |
  5. Deferring the pushEvent by wrapping it in a setTimeout.
  6. 90 |
91 |
92 |
93 | To prevent blowing up your computer, the page will reload after 4096 events, which takes ~12 seconds 94 |
95 |
96 | Total Event Calls: {@counter} 97 |
98 |
99 | I will disappear if the bug is not present. 100 |
101 | """ 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3656.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3656Live do 2 | # https://github.com/phoenixframework/phoenix_live_view/issues/3656 3 | 4 | use Phoenix.LiveView 5 | 6 | def mount(_params, _session, socket) do 7 | {:ok, socket} 8 | end 9 | 10 | def render(assigns) do 11 | ~H""" 12 | 31 | 32 | {live_render(@socket, Phoenix.LiveViewTest.E2E.Issue3656Live.Sticky, 33 | id: "sticky", 34 | sticky: true 35 | )} 36 | """ 37 | end 38 | end 39 | 40 | defmodule Phoenix.LiveViewTest.E2E.Issue3656Live.Sticky do 41 | use Phoenix.LiveView 42 | 43 | def mount(:not_mounted_at_router, _session, socket) do 44 | {:ok, socket, layout: false} 45 | end 46 | 47 | def render(assigns) do 48 | ~H""" 49 | 52 | """ 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3658.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3658Live do 2 | # https://github.com/phoenixframework/phoenix_live_view/issues/3658 3 | 4 | use Phoenix.LiveView 5 | 6 | def mount(_params, _session, socket) do 7 | {:ok, socket} 8 | end 9 | 10 | def render(assigns) do 11 | ~H""" 12 | <.link navigate="/issues/3658?navigated=true">Link 1 13 | 14 | {live_render(@socket, Phoenix.LiveViewTest.E2E.Issue3658Live.Sticky, 15 | id: "sticky", 16 | sticky: true 17 | )} 18 | """ 19 | end 20 | end 21 | 22 | defmodule Phoenix.LiveViewTest.E2E.Issue3658Live.Sticky do 23 | use Phoenix.LiveView 24 | 25 | def mount(:not_mounted_at_router, _session, socket) do 26 | {:ok, socket, layout: false} 27 | end 28 | 29 | def render(assigns) do 30 | ~H""" 31 |
32 |
Hi
33 |
34 | """ 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3681.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3681Live do 2 | # https://github.com/phoenixframework/phoenix_live_view/issues/3681 3 | 4 | use Phoenix.LiveView, layout: {__MODULE__, :live} 5 | 6 | def mount(_params, _session, socket) do 7 | {:ok, socket} 8 | end 9 | 10 | def render("live.html", assigns) do 11 | ~H""" 12 | {apply(Phoenix.LiveViewTest.E2E.Layout, :render, [ 13 | "live.html", 14 | Map.put(assigns, :inner_content, []) 15 | ])} 16 | 17 | {live_render( 18 | @socket, 19 | Phoenix.LiveViewTest.E2E.Issue3681.StickyLive, 20 | id: "sticky", 21 | sticky: true 22 | )} 23 | 24 |
25 | {@inner_content} 26 |
27 | """ 28 | end 29 | 30 | def render(assigns) do 31 | ~H""" 32 |

A LiveView that does nothing but render it's layout.

33 | <.link navigate="/issues/3681/away">Go to a different LV with a (funcky) stream 34 | """ 35 | end 36 | end 37 | 38 | defmodule Phoenix.LiveViewTest.E2E.Issue3681.AwayLive do 39 | use Phoenix.LiveView, layout: {Phoenix.LiveViewTest.E2E.Issue3681Live, :live} 40 | 41 | def mount(_params, _session, socket) do 42 | socket = 43 | socket 44 | |> stream(:messages, []) 45 | # <--- This is the root cause 46 | |> stream(:messages, [msg(4)], reset: true) 47 | 48 | {:ok, socket} 49 | end 50 | 51 | def render(assigns) do 52 | ~H""" 53 |

A liveview with a stream configured twice

54 |

This causes the nested liveview in the layout above to be reset by the client.

55 | 56 | <.link navigate="/issues/3681">Go back to (the now borked) LV without a stream 57 |

Normal Stream

58 |
59 |
60 |
{msg.msg}
61 |
62 |
63 | """ 64 | end 65 | 66 | defp msg(num) do 67 | %{id: num, msg: num} 68 | end 69 | end 70 | 71 | defmodule Phoenix.LiveViewTest.E2E.Issue3681.StickyLive do 72 | use Phoenix.LiveView, layout: false 73 | 74 | def mount(_params, _session, socket) do 75 | {:ok, stream(socket, :messages, [msg(1), msg(2), msg(3)])} 76 | end 77 | 78 | def render(assigns) do 79 | ~H""" 80 |
81 |
82 |
{msg.msg}
83 |
84 |
85 | """ 86 | end 87 | 88 | defp msg(num) do 89 | %{id: num, msg: num} 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3684.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3684Live do 2 | # https://github.com/phoenixframework/phoenix_live_view/issues/3684 3 | use Phoenix.LiveView 4 | 5 | defmodule BadgeForm do 6 | use Phoenix.LiveComponent 7 | 8 | def mount(socket) do 9 | socket = 10 | socket 11 | |> assign(:type, :huey) 12 | 13 | {:ok, socket} 14 | end 15 | 16 | def update(assigns, socket) do 17 | socket = 18 | socket 19 | |> assign(:form, assigns.form) 20 | 21 | {:ok, socket} 22 | end 23 | 24 | def render(assigns) do 25 | ~H""" 26 |
27 | <.form 28 | for={@form} 29 | id="foo" 30 | class="max-w-lg p-8 flex flex-col gap-4" 31 | phx-change="change" 32 | phx-submit="submit" 33 | > 34 | <.radios type={@type} form={@form} myself={@myself} /> 35 | 36 |
37 | """ 38 | end 39 | 40 | defp radios(assigns) do 41 | ~H""" 42 |
43 | Radio example: 44 | <%= for type <- [:huey, :dewey] do %> 45 |
46 | 47 | 48 |
49 | <% end %> 50 |
51 | """ 52 | end 53 | 54 | def handle_event("change-type", %{"type" => type}, socket) do 55 | type = String.to_existing_atom(type) 56 | socket = assign(socket, :type, type) 57 | {:noreply, socket} 58 | end 59 | end 60 | 61 | defp changeset(params) do 62 | data = %{} 63 | 64 | types = %{ 65 | type: :string 66 | } 67 | 68 | {data, types} 69 | |> Ecto.Changeset.cast(params, Map.keys(types)) 70 | |> Ecto.Changeset.validate_required(:type) 71 | end 72 | 73 | def mount(_params, _session, socket) do 74 | {:ok, assign(socket, form: to_form(changeset(%{}), as: :foo), payload: nil)} 75 | end 76 | 77 | def render(assigns) do 78 | ~H""" 79 | <.live_component id="badge_form" module={__MODULE__.BadgeForm} action={@live_action} form={@form} /> 80 | """ 81 | end 82 | 83 | def handle_event("change", _params, socket) do 84 | {:noreply, socket} 85 | end 86 | 87 | def handle_event("submit", _params, socket) do 88 | {:noreply, socket} 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3686.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3686.ALive do 2 | use Phoenix.LiveView 3 | 4 | def render(assigns) do 5 | ~H""" 6 |

A

7 | 8 | 9 |
10 | {inspect(@flash)} 11 |
12 | """ 13 | end 14 | 15 | def handle_event("go", _unsigned_params, socket) do 16 | {:noreply, socket |> put_flash(:info, "Flash from A") |> push_navigate(to: "/issues/3686/b")} 17 | end 18 | end 19 | 20 | defmodule Phoenix.LiveViewTest.E2E.Issue3686.BLive do 21 | use Phoenix.LiveView 22 | 23 | def render(assigns) do 24 | ~H""" 25 |

B

26 | 27 | 28 |
29 | {inspect(@flash)} 30 |
31 | """ 32 | end 33 | 34 | def handle_event("go", _unsigned_params, socket) do 35 | {:noreply, socket |> put_flash(:info, "Flash from B") |> redirect(to: "/issues/3686/c")} 36 | end 37 | end 38 | 39 | defmodule Phoenix.LiveViewTest.E2E.Issue3686.CLive do 40 | use Phoenix.LiveView 41 | 42 | def render(assigns) do 43 | ~H""" 44 |

C

45 | 46 | 47 |
48 | {inspect(@flash)} 49 |
50 | """ 51 | end 52 | 53 | def handle_event("go", _unsigned_params, socket) do 54 | {:noreply, socket |> put_flash(:info, "Flash from C") |> push_navigate(to: "/issues/3686/a")} 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3709.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3709Live do 2 | # https://github.com/phoenixframework/phoenix_live_view/issues/3709 3 | use Phoenix.LiveView 4 | 5 | defmodule SomeComponent do 6 | use Phoenix.LiveComponent 7 | 8 | def render(assigns) do 9 | ~H""" 10 |
11 | Hello 12 |
13 | """ 14 | end 15 | end 16 | 17 | def mount(_params, _session, socket) do 18 | {:ok, assign(socket, id: nil)} 19 | end 20 | 21 | def handle_params(params, _, socket) do 22 | {:noreply, assign(socket, :id, params["id"])} 23 | end 24 | 25 | def render(assigns) do 26 | ~H""" 27 |
    28 |
  • 29 | <.link patch={"/issues/3709/#{i}"}>Link {i} 30 |
  • 31 |
32 |
33 | <.live_component module={SomeComponent} id={"user-#{@id}"} /> id: {@id} 34 |
35 | Click the button, then click any link. 36 | 39 |
40 |
41 | """ 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3719.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3719Live do 2 | # https://github.com/phoenixframework/phoenix_live_view/issues/3719 3 | use Phoenix.LiveView 4 | 5 | def mount(_params, _session, socket) do 6 | {:ok, assign(socket, :target, nil)} 7 | end 8 | 9 | def handle_event("inc", %{"_target" => target}, socket) do 10 | {:noreply, assign(socket, :target, target)} 11 | end 12 | 13 | def render(assigns) do 14 | ~H""" 15 |
16 | 17 | 18 |
19 | {inspect(@target)} 20 | """ 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3814.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3814Live do 2 | use Phoenix.LiveView 3 | 4 | def mount(_params, _session, socket) do 5 | {:ok, assign(socket, :trigger_submit, false)} 6 | end 7 | 8 | def handle_event("submit", _params, socket) do 9 | {:noreply, assign(socket, :trigger_submit, true)} 10 | end 11 | 12 | def render(assigns) do 13 | ~H""" 14 | <.form phx-submit="submit" phx-trigger-action={@trigger_submit} action="/submit" method="post"> 15 | 16 | 17 | 18 | """ 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/e2e/support/issues/issue_3819.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.Issue3819Live do 2 | use Phoenix.LiveView 3 | 4 | def mount(_params, _session, socket) do 5 | {:ok, assign(socket, :reconnected, false)} 6 | end 7 | 8 | def handle_event("validate", _params, socket) do 9 | {:noreply, socket} 10 | end 11 | 12 | def handle_event("save", _params, socket) do 13 | {:noreply, socket} 14 | end 15 | 16 | def handle_event("reconnected", _params, socket) do 17 | {:noreply, assign(socket, :reconnected, true)} 18 | end 19 | 20 | def render(assigns) do 21 | ~H""" 22 | <.form id="recover" phx-change="validate" phx-submit="save"> 23 | 24 | 25 | 26 |

Reconnected!

27 | """ 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/e2e/support/js_live.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.JsLive do 2 | use Phoenix.LiveView 3 | 4 | alias Phoenix.LiveView.JS 5 | 6 | @impl Phoenix.LiveView 7 | def mount(_params, _session, socket) do 8 | {:ok, assign(socket, count: 0)} 9 | end 10 | 11 | @impl Phoenix.LiveView 12 | def render(assigns) do 13 | ~H""" 14 | 15 | 16 | 23 | 24 | 31 | 32 | 39 | 40 |
41 | Details 42 | 43 |
44 | """ 45 | end 46 | 47 | @impl Phoenix.LiveView 48 | def handle_event("increment", _params, socket) do 49 | {:noreply, update(socket, :count, &(&1 + 1))} 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/e2e/support/upload_live.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.E2E.UploadLive do 2 | use Phoenix.LiveView 3 | 4 | # for end-to-end testing https://hexdocs.pm/phoenix_live_view/uploads.html 5 | 6 | @impl Phoenix.LiveView 7 | def mount(_params, _session, socket) do 8 | {:ok, 9 | socket 10 | |> assign(:uploaded_files, []) 11 | |> assign(:auto_upload, false) 12 | |> allow_upload(:avatar, accept: ~w(.txt .md), max_entries: 2)} 13 | end 14 | 15 | @impl Phoenix.LiveView 16 | def handle_params(%{"auto_upload" => _}, _uri, socket) do 17 | socket 18 | |> allow_upload(:avatar, accept: ~w(.txt .md), max_entries: 2, auto_upload: true) 19 | |> then(&{:noreply, &1}) 20 | end 21 | 22 | def handle_params(_params, _uri, socket) do 23 | {:noreply, socket} 24 | end 25 | 26 | @impl Phoenix.LiveView 27 | def handle_event("validate", _params, socket) do 28 | {:noreply, socket} 29 | end 30 | 31 | @impl Phoenix.LiveView 32 | def handle_event("cancel-upload", %{"ref" => ref}, socket) do 33 | {:noreply, cancel_upload(socket, :avatar, ref)} 34 | end 35 | 36 | @impl Phoenix.LiveView 37 | def handle_event("save", _params, socket) do 38 | uploaded_files = 39 | consume_uploaded_entries(socket, :avatar, fn %{path: path}, _entry -> 40 | dir = Path.join([System.tmp_dir!(), "lvupload"]) 41 | _ = File.mkdir_p(dir) 42 | dest = Path.join([dir, Path.basename(path)]) 43 | File.cp!(path, dest) 44 | {:ok, "/tmp/lvupload/#{Path.basename(dest)}"} 45 | end) 46 | 47 | {:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded_files))} 48 | end 49 | 50 | @impl Phoenix.LiveView 51 | def render(assigns) do 52 | ~H""" 53 |
54 | <.live_file_input upload={@uploads.avatar} /> 55 | 56 | 57 |
58 |
59 |
60 | <.live_img_preview entry={entry} style="width: 500px" /> 61 |
{entry.client_name}
62 |
63 | {entry.progress}% 64 | 72 |

73 | {error_to_string(err)} 74 |

75 |
76 |

77 | {error_to_string(err)} 78 |

79 |
80 | 81 | 84 |
85 | """ 86 | end 87 | 88 | defp error_to_string(:too_large), do: "Too large" 89 | defp error_to_string(:too_many_files), do: "You have selected too many files" 90 | defp error_to_string(:not_accepted), do: "You have selected an unacceptable file type" 91 | end 92 | -------------------------------------------------------------------------------- /test/e2e/teardown.js: -------------------------------------------------------------------------------- 1 | import { request } from "@playwright/test"; 2 | 3 | export default async () => { 4 | try { 5 | const context = await request.newContext({ 6 | baseURL: "http://localhost:4004", 7 | }); 8 | // gracefully stops the e2e script to export coverage 9 | await context.post("/halt"); 10 | } catch { 11 | // we expect the request to fail because the request 12 | // actually stops the server 13 | return; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /test/e2e/test-fixtures.js: -------------------------------------------------------------------------------- 1 | // see https://github.com/cenfun/monocart-reporter?tab=readme-ov-file#global-coverage-report 2 | import { test as testBase, expect } from "@playwright/test"; 3 | import { addCoverageReport } from "monocart-reporter"; 4 | 5 | import fs from "node:fs"; 6 | import path from "node:path"; 7 | import { fileURLToPath } from "node:url"; 8 | 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 10 | const liveViewSourceMap = JSON.parse( 11 | fs 12 | .readFileSync( 13 | path.resolve( 14 | __dirname + "../../../priv/static/phoenix_live_view.esm.js.map", 15 | ), 16 | ) 17 | .toString("utf-8"), 18 | ); 19 | 20 | const test = testBase.extend({ 21 | autoTestFixture: [ 22 | async ({ page, browserName }, use) => { 23 | // NOTE: it depends on your project name 24 | const isChromium = browserName === "chromium"; 25 | 26 | // console.log("autoTestFixture setup..."); 27 | // coverage API is chromium only 28 | if (isChromium) { 29 | await Promise.all([ 30 | page.coverage.startJSCoverage({ 31 | resetOnNavigation: false, 32 | }), 33 | page.coverage.startCSSCoverage({ 34 | resetOnNavigation: false, 35 | }), 36 | ]); 37 | } 38 | 39 | await use("autoTestFixture"); 40 | 41 | // console.log("autoTestFixture teardown..."); 42 | if (isChromium) { 43 | const [jsCoverage, cssCoverage] = await Promise.all([ 44 | page.coverage.stopJSCoverage(), 45 | page.coverage.stopCSSCoverage(), 46 | ]); 47 | jsCoverage.forEach((entry) => { 48 | // read sourcemap for the phoenix_live_view.esm.js manually 49 | if (entry.url.endsWith("phoenix_live_view.esm.js")) { 50 | entry.sourceMap = liveViewSourceMap; 51 | } 52 | }); 53 | const coverageList = [...jsCoverage, ...cssCoverage]; 54 | // console.log(coverageList.map((item) => item.url)); 55 | await addCoverageReport(coverageList, test.info()); 56 | } 57 | }, 58 | { 59 | scope: "test", 60 | auto: true, 61 | }, 62 | ], 63 | }); 64 | export { test, expect }; 65 | -------------------------------------------------------------------------------- /test/e2e/tests/colocated.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../test-fixtures"; 2 | import { syncLV } from "../utils"; 3 | 4 | test("colocated hooks works", async ({ page }) => { 5 | await page.goto("/colocated"); 6 | await syncLV(page); 7 | 8 | await page.locator("input").fill("1234567890"); 9 | await page.keyboard.press("Enter"); 10 | // the hook formats the phone number with dashes, so if the dashes 11 | // are there, the hook works! 12 | await expect(page.locator("#phone")).toHaveText("123-456-7890"); 13 | 14 | // test runtime hook 15 | await expect(page.locator("#runtime")).toBeVisible(); 16 | }); 17 | 18 | test("colocated JS works", async ({ page }) => { 19 | // our colocated JS provides a window event handler for executing JS commands 20 | // from the server; we have a button that triggers a toggle server side 21 | await page.goto("/colocated"); 22 | await syncLV(page); 23 | 24 | await expect(page.locator("#hello")).toBeVisible(); 25 | 26 | await page.locator("button").click(); 27 | await expect(page.locator("#hello")).toBeHidden(); 28 | 29 | await page.locator("button").click(); 30 | await expect(page.locator("#hello")).toBeVisible(); 31 | }); 32 | 33 | test("custom macro component works (syntax highlighting)", async ({ page }) => { 34 | await page.goto("/colocated"); 35 | await syncLV(page); 36 | // we check if the code has the makeup classes 37 | await expect( 38 | page.locator("pre").nth(1).getByText("button").first(), 39 | ).toHaveClass("nt"); 40 | await expect( 41 | page.locator("pre").nth(1).getByText("@temperature"), 42 | ).toHaveClass("na"); 43 | }); 44 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/2787.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | const selectOptions = (locator) => 5 | locator.evaluateAll((list) => list.map((option) => option.value)); 6 | 7 | test("select is properly cleared on submit", async ({ page }) => { 8 | await page.goto("/issues/2787"); 9 | await syncLV(page); 10 | 11 | const select1 = page.locator("#demo_select1"); 12 | const select2 = page.locator("#demo_select2"); 13 | 14 | // at the beginning, both selects are empty 15 | await expect(select1).toHaveValue(""); 16 | expect(await selectOptions(select1.locator("option"))).toEqual([ 17 | "", 18 | "greetings", 19 | "goodbyes", 20 | ]); 21 | await expect(select2).toHaveValue(""); 22 | expect(await selectOptions(select2.locator("option"))).toEqual([""]); 23 | 24 | // now we select greetings in the first select 25 | await select1.selectOption("greetings"); 26 | await syncLV(page); 27 | // now the second select should have some greeting options 28 | expect(await selectOptions(select2.locator("option"))).toEqual([ 29 | "", 30 | "hello", 31 | "hallo", 32 | "hei", 33 | ]); 34 | await select2.selectOption("hei"); 35 | await syncLV(page); 36 | 37 | // now we submit the form 38 | await page.locator("button").click(); 39 | 40 | // now, both selects should be empty again (this was the bug in #2787) 41 | await expect(select1).toHaveValue(""); 42 | await expect(select2).toHaveValue(""); 43 | 44 | // now we select goodbyes in the first select 45 | await select1.selectOption("goodbyes"); 46 | await syncLV(page); 47 | expect(await selectOptions(select2.locator("option"))).toEqual([ 48 | "", 49 | "goodbye", 50 | "auf wiedersehen", 51 | "ha det bra", 52 | ]); 53 | }); 54 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/2965.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | import { randomBytes } from "node:crypto"; 4 | 5 | test("can upload files with custom chunk hook", async ({ page }) => { 6 | await page.goto("/issues/2965"); 7 | await syncLV(page); 8 | 9 | const files = []; 10 | for (let i = 1; i <= 20; i++) { 11 | files.push({ 12 | name: `file${i}.txt`, 13 | mimeType: "text/plain", 14 | // random 100 kb 15 | buffer: randomBytes(100 * 1024), 16 | }); 17 | } 18 | 19 | await page.locator("#fileinput").setInputFiles(files); 20 | await syncLV(page); 21 | 22 | // wait for uploads to finish 23 | for (let i = 0; i < 20; i++) { 24 | const row = page.locator("tbody tr").nth(i); 25 | await expect(row).toContainText(`file${i + 1}.txt`); 26 | await expect(row.locator("progress")).toHaveAttribute("value", "100"); 27 | } 28 | 29 | // all uploads are finished! 30 | }); 31 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3026.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | test("LiveComponent is re-rendered when racing destory", async ({ page }) => { 5 | const errors = []; 6 | page.on("pageerror", (err) => { 7 | errors.push(err); 8 | }); 9 | 10 | await page.goto("/issues/3026"); 11 | await syncLV(page); 12 | 13 | await expect(page.locator("input[name='name']")).toHaveValue("John"); 14 | 15 | // submitting the form unloads the LiveComponent, but it is re-added shortly after 16 | await page.locator("button").click(); 17 | await syncLV(page); 18 | 19 | // the form elements inside the LC should still be visible 20 | await expect(page.locator("input[name='name']")).toBeVisible(); 21 | await expect(page.locator("input[name='name']")).toHaveValue("John"); 22 | 23 | // quickly toggle status 24 | for (let i = 0; i < 5; i++) { 25 | await page.locator("select[name='status']").selectOption("connecting"); 26 | await syncLV(page); 27 | // now the form is not rendered as status is connecting 28 | await expect(page.locator("input[name='name']")).toBeHidden(); 29 | 30 | // set back to loading 31 | await page.locator("select[name='status']").selectOption("loaded"); 32 | await syncLV(page); 33 | // now the form is not rendered as status is connecting 34 | await expect(page.locator("input[name='name']")).toBeVisible(); 35 | } 36 | 37 | // no js errors should be thrown 38 | expect(errors).toEqual([]); 39 | }); 40 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3040.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | test("click-away does not fire when triggering form submit", async ({ 5 | page, 6 | }) => { 7 | await page.goto("/issues/3040"); 8 | await syncLV(page); 9 | 10 | await page.getByRole("link", { name: "Add new" }).click(); 11 | await syncLV(page); 12 | 13 | const modal = page.locator("#my-modal-container"); 14 | await expect(modal).toBeVisible(); 15 | 16 | // focusFirst should have focused the input 17 | await expect(page.locator("input[name='name']")).toBeFocused(); 18 | 19 | // submit the form 20 | await page.keyboard.press("Enter"); 21 | await syncLV(page); 22 | 23 | await expect(page.locator("form")).toHaveText("Form was submitted!"); 24 | await expect(modal).toBeVisible(); 25 | 26 | // now click outside 27 | await page.mouse.click(0, 0); 28 | await syncLV(page); 29 | 30 | await expect(modal).toBeHidden(); 31 | }); 32 | 33 | // see also https://github.com/phoenixframework/phoenix_live_view/issues/1920 34 | test("does not close modal when moving mouse outside while held down", async ({ 35 | page, 36 | }) => { 37 | await page.goto("/issues/3040"); 38 | await syncLV(page); 39 | 40 | await page.getByRole("link", { name: "Add new" }).click(); 41 | await syncLV(page); 42 | 43 | const modal = page.locator("#my-modal-container"); 44 | await expect(modal).toBeVisible(); 45 | 46 | await expect(page.locator("input[name='name']")).toBeFocused(); 47 | await page.locator("input[name='name']").fill("test"); 48 | 49 | // we move the mouse inside the input field and then drag it outside 50 | // while holding the mouse button down 51 | await page.mouse.move(434, 350); 52 | await page.mouse.down(); 53 | await page.mouse.move(143, 350); 54 | await page.mouse.up(); 55 | 56 | // we expect the modal to still be visible because the mousedown happened 57 | // inside, not triggering phx-click-away 58 | await expect(modal).toBeVisible(); 59 | await page.keyboard.press("Backspace"); 60 | 61 | await expect(page.locator("input[name='name']")).toHaveValue(""); 62 | await expect(modal).toBeVisible(); 63 | 64 | // close modal with escape 65 | await page.keyboard.press("Escape"); 66 | await expect(modal).toBeHidden(); 67 | }); 68 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3047.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | const listItems = async (page) => 5 | page 6 | .locator('[phx-update="stream"] > span') 7 | .evaluateAll((list) => list.map((el) => el.id)); 8 | 9 | test("streams are not cleared in sticky live views", async ({ page }) => { 10 | await page.goto("/issues/3047/a"); 11 | await syncLV(page); 12 | await expect(page.locator("#page")).toContainText("Page A"); 13 | 14 | expect(await listItems(page)).toEqual([ 15 | "items-1", 16 | "items-2", 17 | "items-3", 18 | "items-4", 19 | "items-5", 20 | "items-6", 21 | "items-7", 22 | "items-8", 23 | "items-9", 24 | "items-10", 25 | ]); 26 | 27 | await page.getByRole("button", { name: "Reset" }).click(); 28 | expect(await listItems(page)).toEqual([ 29 | "items-5", 30 | "items-6", 31 | "items-7", 32 | "items-8", 33 | "items-9", 34 | "items-10", 35 | "items-11", 36 | "items-12", 37 | "items-13", 38 | "items-14", 39 | "items-15", 40 | ]); 41 | 42 | await page.getByRole("link", { name: "Page B" }).click(); 43 | await syncLV(page); 44 | 45 | // stream items should still be visible 46 | await expect(page.locator("#page")).toContainText("Page B"); 47 | expect(await listItems(page)).toEqual([ 48 | "items-5", 49 | "items-6", 50 | "items-7", 51 | "items-8", 52 | "items-9", 53 | "items-10", 54 | "items-11", 55 | "items-12", 56 | "items-13", 57 | "items-14", 58 | "items-15", 59 | ]); 60 | }); 61 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3083.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV, evalLV } from "../../utils"; 3 | 4 | test("select multiple handles option updates properly", async ({ page }) => { 5 | await page.goto("/issues/3083?auto=false"); 6 | await syncLV(page); 7 | 8 | await expect(page.locator("select")).toHaveValues([]); 9 | 10 | await evalLV(page, "send(self(), {:select, [1,2]}); nil"); 11 | await expect(page.locator("select")).toHaveValues(["1", "2"]); 12 | await evalLV(page, "send(self(), {:select, [2,3]}); nil"); 13 | await expect(page.locator("select")).toHaveValues(["2", "3"]); 14 | 15 | // now focus the select by interacting with it 16 | await page.locator("select").click({ position: { x: 1, y: 1 } }); 17 | await expect(page.locator("select")).toHaveValues(["1"]); 18 | await evalLV(page, "send(self(), {:select, [1,2]}); nil"); 19 | // because the select is focused, we do not expect the values to change 20 | await expect(page.locator("select")).toHaveValues(["1"]); 21 | // now blur the select by clicking on the body 22 | await page.locator("body").click(); 23 | await expect(page.locator("select")).toHaveValues(["1"]); 24 | // now update the selected values again 25 | await evalLV(page, "send(self(), {:select, [3,4]}); nil"); 26 | // we had a bug here, where the select was focused, despite the blur 27 | await expect(page.locator("select")).not.toBeFocused(); 28 | await expect(page.locator("select")).toHaveValues(["3", "4"]); 29 | await page.waitForTimeout(1000); 30 | }); 31 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3107.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | test("keeps value when updating select", async ({ page }) => { 5 | await page.goto("/issues/3107"); 6 | await syncLV(page); 7 | 8 | await expect(page.locator("select")).toHaveValue("ONE"); 9 | // focus the element and change the value, like a user would 10 | await page.locator("select").focus(); 11 | await page.locator("select").selectOption("TWO"); 12 | await syncLV(page); 13 | await expect(page.locator("select")).toHaveValue("TWO"); 14 | }); 15 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3117.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | test("LiveComponent with static FC root is not reset", async ({ page }) => { 5 | const errors = []; 6 | page.on("pageerror", (err) => errors.push(err)); 7 | 8 | await page.goto("/issues/3117"); 9 | await syncLV(page); 10 | 11 | // clicking the button performs a live navigation 12 | await page.locator("#navigate").click(); 13 | await syncLV(page); 14 | 15 | // the FC root should still be visible and not empty/skipped 16 | await expect(page.locator("#row-1 .static")).toBeVisible(); 17 | await expect(page.locator("#row-2 .static")).toBeVisible(); 18 | await expect(page.locator("#row-1 .static")).toHaveText("static content"); 19 | await expect(page.locator("#row-2 .static")).toHaveText("static content"); 20 | 21 | // no js errors should be thrown 22 | expect(errors).toEqual([]); 23 | }); 24 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3169.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | const inputVals = async (page) => { 5 | return page 6 | .locator('input[type="text"]') 7 | .evaluateAll((list) => list.map((i) => i.value)); 8 | }; 9 | 10 | test("updates which add cids back on page are properly magic id change tracked", async ({ 11 | page, 12 | }) => { 13 | await page.goto("/issues/3169"); 14 | await syncLV(page); 15 | 16 | await page.locator("#select-a").click(); 17 | await syncLV(page); 18 | await expect(page.locator("body")).toContainText("FormColumn (c3)"); 19 | expect(await inputVals(page)).toEqual(["Record a", "Record a", "Record a"]); 20 | 21 | await page.locator("#select-b").click(); 22 | await syncLV(page); 23 | await expect(page.locator("body")).toContainText("FormColumn (c3)"); 24 | expect(await inputVals(page)).toEqual(["Record b", "Record b", "Record b"]); 25 | 26 | await page.locator("#select-z").click(); 27 | await syncLV(page); 28 | await expect(page.locator("body")).toContainText("FormColumn (c3)"); 29 | expect(await inputVals(page)).toEqual(["Record z", "Record z", "Record z"]); 30 | }); 31 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3194.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | test("does not send event to wrong LV when submitting form with debounce blur", async ({ 5 | page, 6 | }) => { 7 | const logs = []; 8 | page.on("console", (e) => logs.push(e.text())); 9 | 10 | await page.goto("/issues/3194"); 11 | await syncLV(page); 12 | 13 | await page.locator("input").focus(); 14 | await page.keyboard.type("hello"); 15 | await page.keyboard.press("Enter"); 16 | await expect(page).toHaveURL("/issues/3194/other"); 17 | 18 | // give it some time for old events to reach the new LV 19 | // (this is the failure case!) 20 | await page.waitForTimeout(50); 21 | 22 | // we navigated to another LV 23 | expect(logs).toEqual( 24 | expect.arrayContaining([ 25 | expect.stringMatching( 26 | "destroyed: the child has been removed from the parent", 27 | ), 28 | ]), 29 | ); 30 | // it should not have crashed 31 | expect(logs).not.toEqual( 32 | expect.arrayContaining([expect.stringMatching("view crashed")]), 33 | ); 34 | }); 35 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3200.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3200 5 | test("phx-target='selector' is used correctly for form recovery", async ({ 6 | page, 7 | }) => { 8 | const errors = []; 9 | page.on("pageerror", (err) => errors.push(err)); 10 | 11 | await page.goto("/issues/3200/settings"); 12 | await syncLV(page); 13 | 14 | await page.getByRole("button", { name: "Messages" }).click(); 15 | await syncLV(page); 16 | await expect(page).toHaveURL("/issues/3200/messages"); 17 | 18 | await page.locator("#new_message_input").fill("Hello"); 19 | await syncLV(page); 20 | 21 | await page.evaluate( 22 | () => new Promise((resolve) => window.liveSocket.disconnect(resolve)), 23 | ); 24 | await expect(page.locator(".phx-loading")).toHaveCount(1); 25 | 26 | await page.evaluate(() => window.liveSocket.connect()); 27 | await syncLV(page); 28 | 29 | await expect(page.locator("#new_message_input")).toHaveValue("Hello"); 30 | expect(errors).toEqual([]); 31 | }); 32 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3378.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | test("can rejoin with nested streams without errors", async ({ page }) => { 5 | const errors = []; 6 | page.on("pageerror", (err) => { 7 | errors.push(err); 8 | }); 9 | 10 | await page.goto("/issues/3378"); 11 | await syncLV(page); 12 | 13 | await expect(page.locator("#notifications")).toContainText("big"); 14 | await page.evaluate( 15 | () => new Promise((resolve) => window.liveSocket.disconnect(resolve)), 16 | ); 17 | 18 | await page.evaluate(() => window.liveSocket.connect()); 19 | await syncLV(page); 20 | 21 | // no js errors should be thrown 22 | expect(errors).toEqual([]); 23 | }); 24 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3448.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3448 5 | test("focus is handled correctly when patching locked form", async ({ 6 | page, 7 | }) => { 8 | await page.goto("/issues/3448"); 9 | await syncLV(page); 10 | 11 | await page.evaluate(() => window.liveSocket.enableLatencySim(500)); 12 | 13 | await page.locator("input[type=checkbox]").first().check(); 14 | await expect(page.locator("input#search")).toBeFocused(); 15 | await syncLV(page); 16 | 17 | // after the patch is applied, the input should still be focused 18 | await expect(page.locator("input#search")).toBeFocused(); 19 | }); 20 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3496.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3496 5 | test("hook is initialized properly when reusing id between sticky and non sticky LiveViews", async ({ 6 | page, 7 | }) => { 8 | const logs = []; 9 | page.on("console", (e) => logs.push(e.text())); 10 | const errors = []; 11 | page.on("pageerror", (err) => errors.push(err)); 12 | 13 | await page.goto("/issues/3496/a"); 14 | await syncLV(page); 15 | 16 | await page.getByRole("link", { name: "Go to page B" }).click(); 17 | await syncLV(page); 18 | 19 | expect(logs.filter((e) => e.includes("Hook mounted!"))).toHaveLength(2); 20 | expect(logs).not.toEqual( 21 | expect.arrayContaining([ 22 | expect.stringMatching("no hook found for custom element"), 23 | ]), 24 | ); 25 | // no uncaught exceptions 26 | expect(errors).toEqual([]); 27 | }); 28 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3529.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | const pageText = async (page) => 5 | await page.evaluate(() => document.querySelector("h1").innerText); 6 | 7 | // https://github.com/phoenixframework/phoenix_live_view/issues/3529 8 | // https://github.com/phoenixframework/phoenix_live_view/pull/3625 9 | test("forward and backward navigation is handled properly (replaceRootHistory)", async ({ 10 | page, 11 | }) => { 12 | await page.goto("/issues/3529"); 13 | await syncLV(page); 14 | 15 | let text = await pageText(page); 16 | await page.getByRole("link", { name: "Navigate" }).click(); 17 | await syncLV(page); 18 | 19 | // navigate remounts and changes the text 20 | expect(await pageText(page)).not.toBe(text); 21 | text = await pageText(page); 22 | 23 | await page.getByRole("link", { name: "Patch" }).click(); 24 | await syncLV(page); 25 | // patch does not remount 26 | expect(await pageText(page)).toBe(text); 27 | 28 | // now we go back (should be patch again) 29 | await page.goBack(); 30 | await syncLV(page); 31 | expect(await pageText(page)).toBe(text); 32 | 33 | // and then we back to the initial page and use back/forward 34 | // this should be a navigate -> remount! 35 | await page.goBack(); 36 | await syncLV(page); 37 | expect(await pageText(page)).not.toBe(text); 38 | 39 | // navigate 40 | await page.goForward(); 41 | await syncLV(page); 42 | text = await pageText(page); 43 | 44 | // now back again (navigate) 45 | await page.goBack(); 46 | await syncLV(page); 47 | expect(await pageText(page)).not.toBe(text); 48 | }); 49 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3530.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3530 5 | test("hook is initialized properly when using a stream of nested LiveViews", async ({ 6 | page, 7 | }) => { 8 | let logs = []; 9 | page.on("console", (e) => logs.push(e.text())); 10 | const errors = []; 11 | page.on("pageerror", (err) => errors.push(err)); 12 | 13 | await page.goto("/issues/3530"); 14 | await syncLV(page); 15 | 16 | expect(errors).toEqual([]); 17 | expect(logs.filter((e) => e.includes("item-1 mounted"))).toHaveLength(1); 18 | expect(logs.filter((e) => e.includes("item-2 mounted"))).toHaveLength(1); 19 | expect(logs.filter((e) => e.includes("item-3 mounted"))).toHaveLength(1); 20 | logs = []; 21 | 22 | await page.getByRole("link", { name: "patch a" }).click(); 23 | await syncLV(page); 24 | 25 | expect(errors).toEqual([]); 26 | expect(logs.filter((e) => e.includes("item-2 destroyed"))).toHaveLength(1); 27 | expect(logs.filter((e) => e.includes("item-1 destroyed"))).toHaveLength(0); 28 | expect(logs.filter((e) => e.includes("item-3 destroyed"))).toHaveLength(0); 29 | logs = []; 30 | 31 | await page.getByRole("link", { name: "patch b" }).click(); 32 | await syncLV(page); 33 | 34 | expect(errors).toEqual([]); 35 | expect(logs.filter((e) => e.includes("item-1 destroyed"))).toHaveLength(1); 36 | expect(logs.filter((e) => e.includes("item-2 destroyed"))).toHaveLength(0); 37 | expect(logs.filter((e) => e.includes("item-3 destroyed"))).toHaveLength(0); 38 | expect(logs.filter((e) => e.includes("item-2 mounted"))).toHaveLength(1); 39 | logs = []; 40 | 41 | await page.locator("div[phx-click=inc]").click(); 42 | await syncLV(page); 43 | expect(logs.filter((e) => e.includes("item-4 mounted"))).toHaveLength(1); 44 | 45 | expect(logs).not.toEqual( 46 | expect.arrayContaining([ 47 | expect.stringMatching("no hook found for custom element"), 48 | ]), 49 | ); 50 | // no uncaught exceptions 51 | expect(errors).toEqual([]); 52 | }); 53 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3612.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3612 5 | test("sticky LiveView stays connected when using push_navigate", async ({ 6 | page, 7 | }) => { 8 | await page.goto("/issues/3612/a"); 9 | await syncLV(page); 10 | await expect(page.locator("h1")).toHaveText("Page A"); 11 | await page.getByRole("link", { name: "Go to page B" }).click(); 12 | await syncLV(page); 13 | await expect(page.locator("h1")).toHaveText("Page B"); 14 | await page.getByRole("link", { name: "Go to page A" }).click(); 15 | await syncLV(page); 16 | await expect(page.locator("h1")).toHaveText("Page A"); 17 | }); 18 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3647.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3647 5 | test("upload works when input event follows immediately afterwards", async ({ 6 | page, 7 | }) => { 8 | await page.goto("/issues/3647"); 9 | await syncLV(page); 10 | 11 | await expect(page.locator("ul li")).toHaveCount(0); 12 | await expect(page.locator('input[name="user[name]"]')).toHaveValue(""); 13 | 14 | await page.getByRole("button", { name: "Upload then Input" }).click(); 15 | await syncLV(page); 16 | 17 | await expect(page.locator("ul li")).toHaveCount(1); 18 | await expect(page.locator('input[name="user[name]"]')).toHaveValue("0"); 19 | }); 20 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3651.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3651 5 | test("locked hook with dynamic id is properly cleared", async ({ page }) => { 6 | await page.goto("/issues/3651"); 7 | await syncLV(page); 8 | 9 | await expect(page.locator("#notice")).toBeHidden(); 10 | 11 | // we want to wait for some events to have been pushed 12 | await page.waitForTimeout(100); 13 | expect( 14 | await page.evaluate(() => 15 | parseInt(document.querySelector("#total").textContent), 16 | ), 17 | ).toBeLessThanOrEqual(50); 18 | }); 19 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3656.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV, attributeMutations } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3656 5 | test("phx-click-loading is removed from links in sticky LiveViews", async ({ 6 | page, 7 | }) => { 8 | await page.goto("/issues/3656"); 9 | await syncLV(page); 10 | 11 | const changes = attributeMutations(page, "nav a"); 12 | 13 | const link = page.getByRole("link", { name: "Link 1" }); 14 | await link.click(); 15 | 16 | await syncLV(page); 17 | await expect(link).not.toHaveClass("phx-click-loading"); 18 | 19 | expect(await changes()).toEqual( 20 | expect.arrayContaining([ 21 | { attr: "class", oldValue: null, newValue: "phx-click-loading" }, 22 | { attr: "class", oldValue: "phx-click-loading", newValue: "" }, 23 | ]), 24 | ); 25 | }); 26 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3658.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3658 5 | test("phx-remove elements inside sticky LiveViews are not removed when navigating", async ({ 6 | page, 7 | }) => { 8 | await page.goto("/issues/3658"); 9 | await syncLV(page); 10 | 11 | await expect(page.locator("#foo")).toBeVisible(); 12 | await page.getByRole("link", { name: "Link 1" }).click(); 13 | 14 | await syncLV(page); 15 | // the bug would remove the element 16 | await expect(page.locator("#foo")).toBeVisible(); 17 | }); 18 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3681.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3681 5 | test("streams in nested LiveViews are not reset when they share the same stream ref", async ({ 6 | page, 7 | request, 8 | }) => { 9 | // this was a separate bug where child LiveViews accidentally shared the parent streams 10 | // check that the initial render does not contain the messages-4 element twice 11 | expect( 12 | (await (await request.get("/issues/3681/away")).text()).match(/messages-4/g) 13 | .length, 14 | ).toBe(1); 15 | 16 | await page.goto("/issues/3681"); 17 | await syncLV(page); 18 | 19 | await expect(page.locator("#msgs-sticky > div")).toHaveCount(3); 20 | 21 | await page 22 | .getByRole("link", { name: "Go to a different LV with a (funcky) stream" }) 23 | .click(); 24 | await syncLV(page); 25 | await expect(page.locator("#msgs-sticky > div")).toHaveCount(3); 26 | 27 | await page 28 | .getByRole("link", { 29 | name: "Go back to (the now borked) LV without a stream", 30 | }) 31 | .click(); 32 | await syncLV(page); 33 | await expect(page.locator("#msgs-sticky > div")).toHaveCount(3); 34 | }); 35 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3684.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3684 5 | test("nested clones are correctly applied", async ({ page }) => { 6 | await page.goto("/issues/3684"); 7 | await syncLV(page); 8 | 9 | await expect(page.locator("#dewey")).not.toHaveAttribute("checked"); 10 | 11 | await page.locator("#dewey").click(); 12 | await syncLV(page); 13 | 14 | await expect(page.locator("#dewey")).toHaveAttribute("checked"); 15 | }); 16 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3686.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3686 5 | test("flash is copied across fallback redirect", async ({ page }) => { 6 | await page.goto("/issues/3686/a"); 7 | await syncLV(page); 8 | await expect(page.locator("#flash")).toHaveText("%{}"); 9 | 10 | await page.getByRole("button", { name: "To B" }).click(); 11 | await syncLV(page); 12 | await expect(page.locator("#flash")).toContainText("Flash from A"); 13 | 14 | await page.getByRole("button", { name: "To C" }).click(); 15 | await syncLV(page); 16 | await expect(page.locator("#flash")).toContainText("Flash from B"); 17 | 18 | await page.getByRole("button", { name: "To A" }).click(); 19 | await syncLV(page); 20 | await expect(page.locator("#flash")).toContainText("Flash from C"); 21 | }); 22 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3709.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3709 5 | test("pendingDiffs don't race with navigation", async ({ page }) => { 6 | const logs = []; 7 | page.on("console", (e) => logs.push(e.text())); 8 | const errors = []; 9 | page.on("pageerror", (err) => errors.push(err)); 10 | 11 | await page.goto("/issues/3709/1"); 12 | await syncLV(page); 13 | await expect(page.locator("body")).toContainText("id: 1"); 14 | 15 | await page.getByRole("button", { name: "Break Stuff" }).click(); 16 | await syncLV(page); 17 | 18 | expect(logs).not.toEqual( 19 | expect.arrayContaining([ 20 | expect.stringMatching( 21 | "Cannot read properties of undefined (reading 's')", 22 | ), 23 | ]), 24 | ); 25 | 26 | await page.getByRole("link", { name: "Link 5" }).click(); 27 | await syncLV(page); 28 | await expect(page.locator("body")).toContainText("id: 5"); 29 | 30 | expect(logs).not.toEqual( 31 | expect.arrayContaining([ 32 | expect.stringMatching( 33 | "Cannot set properties of undefined (setting 'newRender')", 34 | ), 35 | ]), 36 | ); 37 | 38 | // no uncaught exceptions 39 | expect(errors).toEqual([]); 40 | }); 41 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3719.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3719 5 | test("target is properly decoded", async ({ page }) => { 6 | const logs = []; 7 | page.on("console", (e) => logs.push(e.text())); 8 | 9 | await page.goto("/issues/3719"); 10 | await syncLV(page); 11 | await page.locator("#a").fill("foo"); 12 | await syncLV(page); 13 | await expect(page.locator("#target")).toHaveText('["foo"]'); 14 | 15 | await page.locator("#b").fill("foo"); 16 | await syncLV(page); 17 | await expect(page.locator("#target")).toHaveText('["foo", "bar"]'); 18 | 19 | expect(logs).not.toEqual( 20 | expect.arrayContaining([expect.stringMatching("view crashed")]), 21 | ); 22 | }); 23 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3814.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3814 5 | test("submitter is sent when using phx-trigger-action", async ({ page }) => { 6 | await page.goto("/issues/3814"); 7 | await syncLV(page); 8 | 9 | await page.locator("button").click(); 10 | await expect(page.locator("body")).toContainText( 11 | '"i-am-the-submitter":"submitter-value"', 12 | ); 13 | }); 14 | -------------------------------------------------------------------------------- /test/e2e/tests/issues/3819.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../../test-fixtures"; 2 | import { syncLV } from "../../utils"; 3 | 4 | // https://github.com/phoenixframework/phoenix_live_view/issues/3819 5 | test("form recovery aborts early when form is empty", async ({ page }) => { 6 | await page.goto("/issues/3819"); 7 | await syncLV(page); 8 | 9 | await page.evaluate( 10 | () => new Promise((resolve) => window.liveSocket.disconnect(resolve)), 11 | ); 12 | await expect(page.locator(".phx-loading")).toHaveCount(1); 13 | await page.evaluate(() => { 14 | window.addEventListener("phx:page-loading-stop", () => { 15 | window.liveSocket.js().push(window.liveSocket.main.el, "reconnected"); 16 | }); 17 | window.liveSocket.connect(); 18 | }); 19 | 20 | await expect(page.locator(".phx-loading")).toHaveCount(0); 21 | await expect(page.locator("#reconnected")).toBeVisible(); 22 | }); 23 | -------------------------------------------------------------------------------- /test/e2e/tests/select.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../test-fixtures"; 2 | import { syncLV } from "../utils"; 3 | 4 | // this tests issue #2659 5 | // https://github.com/phoenixframework/phoenix_live_view/pull/2659 6 | test("select shows error when invalid option is selected", async ({ page }) => { 7 | await page.goto("/select"); 8 | await syncLV(page); 9 | 10 | const select3 = page.locator("#select_form_select3"); 11 | await expect(select3).toHaveValue("2"); 12 | await expect(select3).not.toHaveClass("has-error"); 13 | 14 | // 5 or below should be invalid 15 | await select3.selectOption("3"); 16 | await syncLV(page); 17 | await expect(select3).toHaveClass("has-error"); 18 | 19 | // 6 or above should be valid 20 | await select3.selectOption("6"); 21 | await syncLV(page); 22 | await expect(select3).not.toHaveClass("has-error"); 23 | }); 24 | -------------------------------------------------------------------------------- /test/phoenix_component/macro_component_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.Component.MacroComponentTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Phoenix.Component.MacroComponent 5 | 6 | setup_all do 7 | defmodule MyMacroComponent do 8 | @behaviour Phoenix.Component.MacroComponent 9 | 10 | @impl true 11 | def transform(ast, _meta), do: {:ok, ast, %{}} 12 | end 13 | 14 | :ok 15 | end 16 | 17 | describe "ast_to_string/1" do 18 | test "simple cases" do 19 | assert MacroComponent.ast_to_string({"div", [{"id", "1"}], ["Hello"], %{}}) == 20 | "
Hello
" 21 | 22 | assert MacroComponent.ast_to_string({"div", [{"id", ""}], ["Hello"], %{}}) == 23 | "
\">Hello
" 24 | end 25 | 26 | test "handles self closing and void tags" do 27 | assert MacroComponent.ast_to_string( 28 | {"div", [{"id", ""}], [{"hr", [], [], %{closing: :void}}], %{}} 29 | ) == 30 | "
\">
" 31 | 32 | assert MacroComponent.ast_to_string({"circle", [{"id", "1"}], [], %{closing: :self}}) == 33 | "" 34 | end 35 | 36 | test "attribute without value" do 37 | assert MacroComponent.ast_to_string( 38 | {"div", [{"foo", nil}, {"bar", "baz"}], [], %{closing: :self}} 39 | ) == 40 | "
" 41 | end 42 | 43 | test "handles quotes" do 44 | assert MacroComponent.ast_to_string({"div", [{"foo", ~s['bar']}], [], %{}}) == 45 | ~s[
] 46 | 47 | assert MacroComponent.ast_to_string({"div", [{"foo", ~s["bar"]}], [], %{}}) == 48 | ~s[
] 49 | 50 | assert_raise ArgumentError, ~r/invalid attribute value for "foo"/, fn -> 51 | MacroComponent.ast_to_string({"div", [{"foo", ~s["'bar'"]}], [], %{}}) 52 | end 53 | end 54 | 55 | test "invalid attribute" do 56 | assert_raise ArgumentError, 57 | ~r/cannot convert AST with non-string attribute "id" to string. Got: @bar/, 58 | fn -> 59 | MacroComponent.ast_to_string( 60 | {"div", [{"id", quote(do: @bar)}], ["Hello"], %{}} 61 | ) 62 | end 63 | end 64 | end 65 | 66 | describe "get_data/2" do 67 | test "returns an empty list if the component module does not exist" do 68 | assert MacroComponent.get_data(IDoNotExist, MyMacroComponent) == [] 69 | end 70 | 71 | test "returns an empty list if the component does not define any macro components" do 72 | defmodule MyComponent do 73 | use Phoenix.Component 74 | 75 | def render(assigns), do: ~H"" 76 | end 77 | 78 | assert MacroComponent.get_data(MyComponent, MyMacroComponent) == [] 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/phoenix_component/pages/about_page.html.heex: -------------------------------------------------------------------------------- 1 | About us -------------------------------------------------------------------------------- /test/phoenix_component/pages/another_root/root.html.heex: -------------------------------------------------------------------------------- 1 | root! -------------------------------------------------------------------------------- /test/phoenix_component/pages/another_root/root.text.eex: -------------------------------------------------------------------------------- 1 | root plain text! 2 | -------------------------------------------------------------------------------- /test/phoenix_component/pages/welcome_page.html.heex: -------------------------------------------------------------------------------- 1 | Welcome <%= @name %> -------------------------------------------------------------------------------- /test/phoenix_live_view/async_result_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.AsyncResultTest do 2 | use ExUnit.Case, async: true 3 | alias Phoenix.LiveView.AsyncResult 4 | doctest Phoenix.LiveView.AsyncResult 5 | end 6 | -------------------------------------------------------------------------------- /test/phoenix_live_view/colocated_hook_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.ColocatedHookTest do 2 | # we set async: false because we call the colocated JS compiler 3 | # and it reads / writes to a shared folder 4 | use ExUnit.Case, async: false 5 | 6 | test "can use a hook" do 7 | defmodule TestComponent do 8 | use Phoenix.Component 9 | alias Phoenix.LiveView.ColocatedHook, as: Hook 10 | 11 | def fun(assigns) do 12 | ~H""" 13 | 20 | 21 |
22 | """ 23 | end 24 | end 25 | 26 | assert module_folders = 27 | File.ls!(Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view")) 28 | 29 | assert folder = 30 | Enum.find(module_folders, fn folder -> 31 | folder =~ ~r/#{inspect(__MODULE__)}\.TestComponent/ 32 | end) 33 | 34 | assert [script] = 35 | Path.wildcard( 36 | Path.join( 37 | Mix.Project.build_path(), 38 | "phoenix-colocated/phoenix_live_view/#{folder}/*.js" 39 | ) 40 | ) 41 | 42 | assert File.read!(script) =~ "Hello, world!" 43 | 44 | # now write the manifest manually as we are in a test 45 | Phoenix.LiveView.ColocatedJS.compile() 46 | 47 | assert manifest = 48 | File.read!( 49 | Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view/index.js") 50 | ) 51 | 52 | assert manifest =~ ~r/export \{ imp_.* as hooks \}/ 53 | 54 | # script is in manifest 55 | assert manifest =~ 56 | Path.relative_to( 57 | script, 58 | Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view/") 59 | ) 60 | end 61 | 62 | test "raises for invalid name" do 63 | assert_raise Phoenix.LiveView.Tokenizer.ParseError, 64 | ~r/the name attribute of a colocated hook must be a compile-time string\. Got: @foo/, 65 | fn -> 66 | defmodule TestComponentInvalidName do 67 | use Phoenix.Component 68 | alias Phoenix.LiveView.ColocatedHook, as: Hook 69 | 70 | def fun(assigns) do 71 | ~H""" 72 | 79 | 80 |
81 | """ 82 | end 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/phoenix_live_view/controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.ControllerTest do 2 | use ExUnit.Case, async: true 3 | import Phoenix.ConnTest 4 | 5 | alias Phoenix.LiveViewTest.Support.Endpoint 6 | 7 | @endpoint Endpoint 8 | 9 | setup do 10 | {:ok, conn: Phoenix.ConnTest.build_conn()} 11 | end 12 | 13 | test "live renders from controller without session", %{conn: conn} do 14 | conn = get(conn, "/controller/live-render-2") 15 | assert html_response(conn, 200) =~ "session: %{}" 16 | end 17 | 18 | test "live renders from controller with session", %{conn: conn} do 19 | conn = get(conn, "/controller/live-render-3") 20 | assert html_response(conn, 200) =~ "session: %{\"custom\" => :session}" 21 | end 22 | 23 | test "live renders from controller with merged assigns", %{conn: conn} do 24 | conn = get(conn, "/controller/live-render-4") 25 | assert html_response(conn, 200) =~ "title: Dashboard" 26 | end 27 | 28 | test "renders function components from dead view", %{conn: conn} do 29 | conn = get(conn, "/controller/render-with-function-component") 30 | assert html_response(conn, 200) =~ "RENDER:COMPONENT:from component" 31 | end 32 | 33 | test "renders function components from dead layout", %{conn: conn} do 34 | conn = get(conn, "/controller/render-layout-with-function-component") 35 | 36 | assert html_response(conn, 200) =~ """ 37 | LAYOUT:COMPONENT:from layout 38 | Hello\ 39 | """ 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/phoenix_live_view/debug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.DebugTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Phoenix.LiveView.Debug 5 | import Phoenix.LiveViewTest 6 | 7 | @endpoint Phoenix.LiveViewTest.Support.Endpoint 8 | 9 | defmodule TestLV do 10 | use Phoenix.LiveView 11 | 12 | defmodule Component do 13 | use Phoenix.LiveComponent 14 | 15 | def render(assigns) do 16 | ~H""" 17 |

Hello

18 | """ 19 | end 20 | end 21 | 22 | def mount(_params, _session, socket) do 23 | {:ok, assign(socket, :hello, :world)} 24 | end 25 | 26 | def render(assigns) do 27 | ~H""" 28 |
29 |

Hello

30 | <.live_component id="component-1" module={Component} /> 31 |
32 | """ 33 | end 34 | end 35 | 36 | describe "list_liveviews/0" do 37 | test "returns a list of all currently connected LiveView processes" do 38 | conn = Plug.Test.conn(:get, "/") 39 | {:ok, view, _} = live_isolated(conn, TestLV) 40 | live_views = Debug.list_liveviews() 41 | 42 | assert is_list(live_views) 43 | assert lv = Enum.find(live_views, fn lv -> lv.pid == view.pid end) 44 | assert lv.view == TestLV 45 | assert lv.transport_pid 46 | assert lv.topic 47 | end 48 | end 49 | 50 | describe "liveview_process?/1" do 51 | test "returns true if the given pid is a LiveView process" do 52 | conn = Plug.Test.conn(:get, "/") 53 | {:ok, view, _} = live_isolated(conn, TestLV) 54 | assert Debug.liveview_process?(view.pid) 55 | end 56 | end 57 | 58 | describe "socket/1" do 59 | test "returns the socket of the given LiveView process" do 60 | conn = Plug.Test.conn(:get, "/") 61 | {:ok, view, _} = live_isolated(conn, TestLV) 62 | assert {:ok, socket} = Debug.socket(view.pid) 63 | assert socket.assigns.hello == :world 64 | end 65 | 66 | test "returns an error if the given pid is not a LiveView process" do 67 | defmodule NotALiveView do 68 | use GenServer 69 | 70 | def start_link(opts) do 71 | GenServer.start_link(__MODULE__, opts) 72 | end 73 | 74 | def init(opts) do 75 | {:ok, opts} 76 | end 77 | end 78 | 79 | pid = start_supervised!(NotALiveView) 80 | assert {:error, :not_alive_or_not_a_liveview} = Debug.socket(pid) 81 | end 82 | end 83 | 84 | describe "live_components/1" do 85 | test "returns a list of all LiveComponents rendered in the given LiveView" do 86 | conn = Plug.Test.conn(:get, "/") 87 | {:ok, view, _} = live_isolated(conn, TestLV) 88 | 89 | assert {:ok, [%{id: "component-1", module: TestLV.Component}]} = 90 | Debug.live_components(view.pid) 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/phoenix_live_view/integrations/assigns_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.AssignsTest do 2 | use ExUnit.Case, async: true 3 | import Plug.Conn 4 | import Phoenix.ConnTest 5 | 6 | import Phoenix.LiveViewTest 7 | alias Phoenix.LiveViewTest.Support.Endpoint 8 | 9 | @endpoint Endpoint 10 | 11 | setup do 12 | {:ok, conn: Plug.Test.init_test_session(Phoenix.ConnTest.build_conn(), %{})} 13 | end 14 | 15 | describe "assign_new" do 16 | test "uses conn.assigns on static render then fetches on connected mount", %{conn: conn} do 17 | user = %{name: "user-from-conn", id: 123} 18 | 19 | conn = 20 | conn 21 | |> Plug.Conn.assign(:current_user, user) 22 | |> Plug.Conn.put_session(:user_id, user.id) 23 | |> get("/root") 24 | 25 | assert html_response(conn, 200) =~ "root name: user-from-conn" 26 | assert html_response(conn, 200) =~ "child static name: user-from-conn" 27 | 28 | {:ok, _, connected_html} = live(conn) 29 | assert connected_html =~ "root name: user-from-root" 30 | assert connected_html =~ "child static name: user-from-root" 31 | end 32 | 33 | test "uses assign_new from parent on dynamically added child", %{conn: conn} do 34 | user = %{name: "user-from-conn", id: 123} 35 | 36 | {:ok, view, _html} = 37 | conn 38 | |> Plug.Conn.assign(:current_user, user) 39 | |> Plug.Conn.put_session(:user_id, user.id) 40 | |> live("/root") 41 | 42 | assert render(view) =~ "child static name: user-from-root" 43 | refute render(view) =~ "child dynamic name" 44 | 45 | :ok = GenServer.call(view.pid, {:dynamic_child, :dynamic}) 46 | 47 | html = render(view) 48 | assert html =~ "child static name: user-from-root" 49 | assert html =~ "child dynamic name: user-from-child" 50 | end 51 | end 52 | 53 | describe "temporary assigns" do 54 | test "can be configured with mount options", %{conn: conn} do 55 | {:ok, conf_live, html} = 56 | conn 57 | |> put_session(:opts, temporary_assigns: [description: nil]) 58 | |> live("/opts") 59 | 60 | assert html =~ "long description. canary" 61 | assert render(conf_live) =~ "long description. canary" 62 | socket = GenServer.call(conf_live.pid, {:exec, fn socket -> {:reply, socket, socket} end}) 63 | 64 | assert socket.assigns.description == nil 65 | assert socket.assigns.canary == "canary" 66 | end 67 | 68 | test "raises with invalid options", %{conn: conn} do 69 | assert_raise ArgumentError, 70 | ~r/invalid option returned from Phoenix.LiveViewTest.Support.OptsLive.mount\/3/, 71 | fn -> 72 | conn 73 | |> put_session(:opts, oops: [:description]) 74 | |> live("/opts") 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/phoenix_live_view/integrations/collocated_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.CollocatedTest do 2 | use ExUnit.Case, async: true 3 | import Phoenix.ConnTest 4 | 5 | import Phoenix.LiveViewTest 6 | alias Phoenix.LiveViewTest.Support.{Endpoint, CollocatedLive, CollocatedComponent} 7 | 8 | @endpoint Endpoint 9 | 10 | test "supports collocated views" do 11 | {:ok, view, html} = live_isolated(build_conn(), CollocatedLive) 12 | assert html =~ "Hello collocated world from live!\n
" 13 | assert render(view) =~ "Hello collocated world from live!\n
" 14 | end 15 | 16 | test "supports collocated components" do 17 | assert render_component(CollocatedComponent, world: "world") =~ 18 | "Hello collocated world from component!\n" 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/phoenix_live_view/integrations/connect_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.ConnectTest do 2 | use ExUnit.Case, async: true 3 | import Phoenix.LiveViewTest 4 | import Phoenix.ConnTest 5 | 6 | @endpoint Phoenix.LiveViewTest.Support.Endpoint 7 | 8 | describe "connect_params" do 9 | test "can be read on mount" do 10 | {:ok, live, _html} = 11 | Phoenix.ConnTest.build_conn() 12 | |> put_connect_params(%{"connect1" => "1"}) 13 | |> live("/connect") 14 | 15 | assert render(live) =~ rendered_to_string(~s|params: %{"_mounts" => 0, "connect1" => "1"}|) 16 | end 17 | end 18 | 19 | describe "connect_info" do 20 | test "can be read on mount" do 21 | {:ok, live, html} = 22 | Phoenix.ConnTest.build_conn() 23 | |> Plug.Conn.put_req_header("user-agent", "custom-client") 24 | |> Plug.Conn.put_req_header("x-foo", "bar") 25 | |> Plug.Conn.put_req_header("x-bar", "baz") 26 | |> Plug.Conn.put_req_header("tracestate", "one") 27 | |> Plug.Conn.put_req_header("traceparent", "two") 28 | |> live("/connect") 29 | 30 | assert_html = fn html -> 31 | html = String.replace(html, """, "\"") 32 | assert html =~ ~S 33 | assert html =~ ~S 34 | assert html =~ ~S 35 | assert html =~ ~S 36 | assert html =~ ~S 37 | end 38 | 39 | assert_html.(html) 40 | assert_html.(render(live)) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/phoenix_live_view/integrations/expensive_runtime_checks_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.ExpensiveRuntimeChecksTest do 2 | # this is intentionally async: false as we change the application 3 | # environment and recompile files! 4 | use ExUnit.Case, async: false 5 | 6 | import ExUnit.CaptureIO 7 | 8 | import Phoenix.ConnTest 9 | 10 | import Phoenix.LiveViewTest 11 | alias Phoenix.LiveViewTest.Support.Endpoint 12 | 13 | @endpoint Endpoint 14 | 15 | setup do 16 | {:ok, conn: Plug.Test.init_test_session(Phoenix.ConnTest.build_conn(), %{})} 17 | end 18 | 19 | describe "async" do 20 | for fun <- [:start_async, :assign_async] do 21 | test "#{fun} warns when accessing socket in function at runtime", %{conn: conn} do 22 | _ = 23 | capture_io(:stderr, fn -> 24 | {:ok, lv, _html} = live(conn, "/expensive-runtime-checks") 25 | render_async(lv) 26 | 27 | send(self(), {:lv, lv}) 28 | end) 29 | 30 | lv = 31 | receive do 32 | {:lv, lv} -> lv 33 | end 34 | 35 | warnings = 36 | capture_io(:stderr, fn -> 37 | render_hook(lv, "expensive_#{unquote(fun)}_socket") 38 | end) 39 | 40 | assert warnings =~ 41 | "you are accessing the LiveView Socket inside a function given to #{unquote(fun)}" 42 | end 43 | 44 | test "#{fun} warns when accessing assigns in function at runtime", %{conn: conn} do 45 | _ = 46 | capture_io(:stderr, fn -> 47 | {:ok, lv, _html} = live(conn, "/expensive-runtime-checks") 48 | render_async(lv) 49 | 50 | send(self(), {:lv, lv}) 51 | end) 52 | 53 | lv = 54 | receive do 55 | {:lv, lv} -> lv 56 | end 57 | 58 | warnings = 59 | capture_io(:stderr, fn -> 60 | render_hook(lv, "expensive_#{unquote(fun)}_assigns") 61 | end) 62 | 63 | assert warnings =~ 64 | "you are accessing an assigns map inside a function given to #{unquote(fun)}" 65 | end 66 | 67 | test "#{fun} does not warns when doing it the right way", %{conn: conn} do 68 | _ = 69 | capture_io(:stderr, fn -> 70 | {:ok, lv, _html} = live(conn, "/expensive-runtime-checks") 71 | render_async(lv) 72 | 73 | send(self(), {:lv, lv}) 74 | end) 75 | 76 | lv = 77 | receive do 78 | {:lv, lv} -> lv 79 | end 80 | 81 | warnings = 82 | capture_io(:stderr, fn -> 83 | render_hook(lv, "good_#{unquote(fun)}") 84 | end) 85 | 86 | assert warnings == "" 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /test/phoenix_live_view/integrations/layout_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.LayoutTest do 2 | use ExUnit.Case, async: true 3 | import Phoenix.ConnTest 4 | 5 | import Phoenix.LiveViewTest 6 | alias Phoenix.LiveViewTest.Support.{Endpoint, LayoutView} 7 | 8 | @endpoint Endpoint 9 | 10 | setup config do 11 | {:ok, 12 | conn: Plug.Test.init_test_session(Phoenix.ConnTest.build_conn(), config[:session] || %{})} 13 | end 14 | 15 | test "uses dead layout from router", %{conn: conn} do 16 | assert_raise ArgumentError, 17 | ~r"no \"unknown_template\" html template defined for UnknownView", 18 | fn -> live(conn, "/bad_layout") end 19 | 20 | {:ok, _, _} = live(conn, "/layout") 21 | end 22 | 23 | test "is picked from config on use", %{conn: conn} do 24 | {:ok, view, html} = live(conn, "/layout") 25 | assert html =~ ~r|LAYOUT]+>LIVELAYOUTSTART\-123\-The value is: 123\-LIVELAYOUTEND| 26 | 27 | assert render_click(view, :double) == 28 | "LIVELAYOUTSTART-246-The value is: 246-LIVELAYOUTEND\n" 29 | end 30 | 31 | test "is picked from config on use on first render", %{conn: conn} do 32 | conn = get(conn, "/layout") 33 | 34 | assert html_response(conn, 200) =~ 35 | ~r|LAYOUT]+>LIVELAYOUTSTART\-123\-The value is: 123\-LIVELAYOUTEND| 36 | end 37 | 38 | @tag session: %{live_layout: {LayoutView, :live_override}} 39 | test "is picked from config on mount when given a layout", %{conn: conn} do 40 | {:ok, view, html} = live(conn, "/layout") 41 | 42 | assert html =~ 43 | ~r|LAYOUT]+>LIVEOVERRIDESTART\-123\-The value is: 123\-LIVEOVERRIDEEND| 44 | 45 | assert render_click(view, :double) == 46 | "LIVEOVERRIDESTART-246-The value is: 246-LIVEOVERRIDEEND\n" 47 | end 48 | 49 | @tag session: %{live_layout: false} 50 | test "is picked from config on mount when given false", %{conn: conn} do 51 | {:ok, view, html} = live(conn, "/layout") 52 | assert html =~ "The value is: 123" 53 | assert render_click(view, :double) == "The value is: 246" 54 | end 55 | 56 | test "is not picked from config on use for child live views", %{conn: conn} do 57 | assert get(conn, "/parent_layout") |> html_response(200) =~ 58 | "The value is: 123" 59 | 60 | {:ok, _view, html} = live(conn, "/parent_layout") 61 | assert html =~ "The value is: 123" 62 | end 63 | 64 | @tag session: %{live_layout: {LayoutView, :live_override}} 65 | test "is picked from config on mount even on child live views", %{conn: conn} do 66 | assert get(conn, "/parent_layout") |> html_response(200) =~ 67 | ~r|]+>LIVEOVERRIDESTART\-123\-The value is: 123\-LIVEOVERRIDEEND| 68 | 69 | {:ok, _view, html} = live(conn, "/parent_layout") 70 | 71 | assert html =~ 72 | ~r|]+>LIVEOVERRIDESTART\-123\-The value is: 123\-LIVEOVERRIDEEND| 73 | end 74 | 75 | test "uses root page title on first render", %{conn: conn} do 76 | {:ok, view, _html} = live(conn, "/styled-elements") 77 | assert page_title(view) == "Styled" 78 | 79 | {:ok, view, _html} = live(conn, "/styled-elements") 80 | render_click(view, "#live-push-patch-button") 81 | assert page_title(view) == "Styled" 82 | 83 | {:ok, no_title_tag_view, _html} = live(conn, "/parent_layout") 84 | assert page_title(no_title_tag_view) == nil 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/phoenix_live_view/integrations/live_reload_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.LiveReloadTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule Endpoint do 5 | use Phoenix.Endpoint, otp_app: :phoenix_live_view 6 | 7 | socket "/live", Phoenix.LiveView.Socket 8 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 9 | plug Phoenix.CodeReloader 10 | plug Phoenix.LiveViewTest.Support.Router 11 | end 12 | 13 | import Phoenix.ConnTest 14 | import Phoenix.ChannelTest 15 | import Phoenix.LiveViewTest 16 | 17 | @endpoint Endpoint 18 | @pubsub PubSub 19 | 20 | defp live_reload_config, 21 | do: [ 22 | url: "ws://localhost:4004", 23 | patterns: [ 24 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$" 25 | ], 26 | notify: [ 27 | live_view: [ 28 | ~r"lib/test_auth_web/live/.*(ex)$" 29 | ] 30 | ] 31 | ] 32 | 33 | test "LiveView renders again when the phoenix_live_reload is received" do 34 | %{conn: conn, socket: socket} = start(live_reload_config()) 35 | 36 | Application.put_env(:phoenix_live_view, :vsn, 1) 37 | {:ok, lv, _html} = live(conn, "/live-reload") 38 | assert render(lv) =~ "
Version 1
" 39 | 40 | send( 41 | socket.channel_pid, 42 | {:file_event, self(), {"lib/test_auth_web/live/user_live.ex", :created}} 43 | ) 44 | 45 | Application.put_env(:phoenix_live_view, :vsn, 2) 46 | 47 | assert_receive {:phoenix_live_reload, :live_view, "lib/test_auth_web/live/user_live.ex"} 48 | assert render(lv) =~ "
Version 2
" 49 | end 50 | 51 | def reload(endpoint, caller) do 52 | Phoenix.CodeReloader.reload(endpoint) 53 | send(caller, :reloaded) 54 | end 55 | 56 | test "custom reloader" do 57 | reloader = {__MODULE__, :reload, [self()]} 58 | %{conn: conn, socket: socket} = start([reloader: reloader] ++ live_reload_config()) 59 | 60 | Application.put_env(:phoenix_live_view, :vsn, 1) 61 | {:ok, lv, _html} = live(conn, "/live-reload") 62 | assert render(lv) =~ "
Version 1
" 63 | 64 | send( 65 | socket.channel_pid, 66 | {:file_event, self(), {"lib/test_auth_web/live/user_live.ex", :created}} 67 | ) 68 | 69 | Application.put_env(:phoenix_live_view, :vsn, 2) 70 | 71 | assert_receive {:phoenix_live_reload, :live_view, "lib/test_auth_web/live/user_live.ex"} 72 | assert_receive :reloaded, 1000 73 | assert render(lv) =~ "
Version 2
" 74 | end 75 | 76 | def start(live_reload_config) do 77 | start_supervised!( 78 | {@endpoint, 79 | secret_key_base: String.duplicate("1", 50), 80 | live_view: [signing_salt: "0123456789"], 81 | pubsub_server: @pubsub, 82 | live_reload: live_reload_config} 83 | ) 84 | 85 | conn = Plug.Test.init_test_session(build_conn(), %{}) 86 | start_supervised!({Phoenix.PubSub, name: @pubsub}) 87 | Phoenix.PubSub.subscribe(@pubsub, "live_view") 88 | 89 | {:ok, _, socket} = 90 | subscribe_and_join( 91 | socket(Phoenix.LiveReloader.Socket), 92 | Phoenix.LiveReloader.Channel, 93 | "phoenix:live_reload", 94 | %{} 95 | ) 96 | 97 | %{conn: conn, socket: socket} 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/phoenix_live_view/integrations/live_view_test_warnings_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.LiveViewTestWarningsTest do 2 | use ExUnit.Case, async: false 3 | 4 | import ExUnit.CaptureIO 5 | 6 | import Phoenix.ConnTest 7 | import Phoenix.LiveViewTest 8 | 9 | alias Phoenix.LiveViewTest.Support.Endpoint 10 | 11 | @endpoint Endpoint 12 | 13 | describe "live" do 14 | test "warns for duplicate ids when on_error: warn" do 15 | conn = Plug.Test.init_test_session(Phoenix.ConnTest.build_conn(), %{}) 16 | conn = get(conn, "/duplicate-id") 17 | 18 | Process.flag(:trap_exit, true) 19 | 20 | assert capture_io(:stderr, fn -> 21 | {:ok, view, _html} = live(conn, nil, on_error: :warn) 22 | render(view) 23 | end) =~ 24 | "Duplicate id found while testing LiveView: a" 25 | 26 | refute_receive {:EXIT, _, _} 27 | end 28 | 29 | test "warns for duplicate component when on_error: warn" do 30 | conn = Plug.Test.init_test_session(Phoenix.ConnTest.build_conn(), %{}) 31 | conn = get(conn, "/dynamic-duplicate-component") 32 | 33 | Process.flag(:trap_exit, true) 34 | 35 | warning = 36 | capture_io(:stderr, fn -> 37 | {:ok, view, _html} = live(conn, nil, on_error: :warn) 38 | 39 | view |> element("button", "Toggle duplicate LC") |> render_click() =~ 40 | "I am LiveComponent2" 41 | 42 | render(view) 43 | end) 44 | 45 | assert warning =~ "Duplicate live component found while testing LiveView:" 46 | assert warning =~ "I am LiveComponent2" 47 | refute warning =~ "I am a LC inside nested LV" 48 | 49 | refute_receive {:EXIT, _, _} 50 | end 51 | end 52 | 53 | describe "live_isolated" do 54 | test "warns for duplicate ids when on_error: warn" do 55 | Process.flag(:trap_exit, true) 56 | 57 | assert capture_io(:stderr, fn -> 58 | {:ok, view, _html} = 59 | live_isolated( 60 | Phoenix.ConnTest.build_conn(), 61 | Phoenix.LiveViewTest.Support.DuplicateIdLive, 62 | on_error: :warn 63 | ) 64 | 65 | render(view) 66 | end) =~ 67 | "Duplicate id found while testing LiveView: a" 68 | 69 | refute_receive {:EXIT, _, _} 70 | end 71 | 72 | test "warns for duplicate component when on_error: warn" do 73 | Process.flag(:trap_exit, true) 74 | 75 | warning = 76 | capture_io(:stderr, fn -> 77 | {:ok, view, _html} = 78 | live_isolated( 79 | Phoenix.ConnTest.build_conn(), 80 | Phoenix.LiveViewTest.Support.DynamicDuplicateComponentLive, 81 | on_error: :warn 82 | ) 83 | 84 | view |> element("button", "Toggle duplicate LC") |> render_click() =~ 85 | "I am LiveComponent2" 86 | 87 | render(view) 88 | end) 89 | 90 | assert warning =~ "Duplicate live component found while testing LiveView:" 91 | assert warning =~ "I am LiveComponent2" 92 | refute warning =~ "I am a LC inside nested LV" 93 | 94 | refute_receive {:EXIT, _, _} 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/phoenix_live_view/integrations/update_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.UpdateTest do 2 | use ExUnit.Case, async: true 3 | import Phoenix.ConnTest 4 | 5 | import Phoenix.LiveViewTest 6 | alias Phoenix.LiveViewTest.TreeDOM 7 | alias Phoenix.LiveViewTest.Support.Endpoint 8 | 9 | @endpoint Endpoint 10 | 11 | setup config do 12 | {:ok, 13 | conn: Plug.Test.init_test_session(Phoenix.ConnTest.build_conn(), config[:session] || %{})} 14 | end 15 | 16 | describe "regular updates" do 17 | @tag session: %{ 18 | time_zones: [%{"id" => "ny", "name" => "NY"}, %{"id" => "sf", "name" => "SF"}] 19 | } 20 | test "existing ids are replaced when patched without respawning children", %{conn: conn} do 21 | {:ok, view, html} = live(conn, "/shuffle") 22 | 23 | assert [ 24 | {"div", _, ["time: 12:00 NY" | _]}, 25 | {"div", _, ["time: 12:00 SF" | _]} 26 | ] = find_time_zones(html, ["ny", "sf"]) 27 | 28 | children_pids_before = for child <- live_children(view), do: child.pid 29 | html = render_click(view, :reverse) 30 | children_pids_after = for child <- live_children(view), do: child.pid 31 | 32 | assert [ 33 | {"div", _, ["time: 12:00 SF" | _]}, 34 | {"div", _, ["time: 12:00 NY" | _]} 35 | ] = find_time_zones(html, ["ny", "sf"]) 36 | 37 | assert children_pids_after == children_pids_before 38 | end 39 | end 40 | 41 | defp find_time_zones(html, zones) do 42 | ids = Enum.map(zones, fn zone -> "tz-" <> zone end) 43 | 44 | html 45 | |> TreeDOM.normalize_to_tree(sort_attributes: true) 46 | |> TreeDOM.all(fn node -> TreeDOM.attribute(node, "id") in ids end) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/phoenix_live_view/live_stream_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.LiveStreamTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Phoenix.LiveView.LiveStream 5 | 6 | test "new raises on invalid options" do 7 | msg = ~r/stream :dom_id must return a function which accepts each item, got: false/ 8 | 9 | assert_raise ArgumentError, msg, fn -> 10 | LiveStream.new(:numbers, 0, [1, 2, 3], dom_id: false) 11 | end 12 | end 13 | 14 | test "default dom_id" do 15 | stream = LiveStream.new(:users, 0, [%{id: 1}, %{id: 2}], []) 16 | 17 | assert stream.inserts == [ 18 | {"users-2", -1, %{id: 2}, nil, nil}, 19 | {"users-1", -1, %{id: 1}, nil, nil} 20 | ] 21 | end 22 | 23 | test "custom dom_id" do 24 | stream = LiveStream.new(:users, 0, [%{name: "u1"}, %{name: "u2"}], dom_id: &"u-#{&1.name}") 25 | 26 | assert stream.inserts == [ 27 | {"u-u2", -1, %{name: "u2"}, nil, nil}, 28 | {"u-u1", -1, %{name: "u1"}, nil, nil} 29 | ] 30 | end 31 | 32 | test "default dom_id without struct or map with :id" do 33 | msg = ~r/expected stream :users to be a struct or map with :id key/ 34 | 35 | assert_raise ArgumentError, msg, fn -> 36 | LiveStream.new(:users, 0, [%{user_id: 1}, %{user_id: 2}], []) 37 | end 38 | end 39 | 40 | test "inserts are deduplicated (last insert wins)" do 41 | assert stream = LiveStream.new(:users, 0, [%{id: 1}, %{id: 2}], []) 42 | stream = LiveStream.insert_item(stream, %{id: 2, updated: true}, -1, nil, nil) 43 | stream = %{stream | consumable?: true} 44 | assert Enum.to_list(stream) == [{"users-1", %{id: 1}}, {"users-2", %{id: 2, updated: true}}] 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/phoenix_live_view/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.PlugTest do 2 | use ExUnit.Case, async: true 3 | import Plug.Conn 4 | import Phoenix.ConnTest 5 | 6 | alias Phoenix.LiveView.Plug, as: LiveViewPlug 7 | alias Phoenix.LiveViewTest.Support.{ThermostatLive, DashboardLive, Endpoint} 8 | 9 | defp call(conn, view, opts \\ []) do 10 | opts = Keyword.merge([router: Phoenix.LiveViewTest.Support.Router, layout: false], opts) 11 | 12 | conn 13 | |> Plug.Test.init_test_session(%{}) 14 | |> Phoenix.LiveView.Router.fetch_live_flash([]) 15 | |> put_private(:phoenix_live_view, {view, opts, %{name: :default, extra: %{}, vsn: 0}}) 16 | |> LiveViewPlug.call(view) 17 | end 18 | 19 | setup config do 20 | conn = 21 | build_conn() 22 | |> fetch_query_params() 23 | |> Plug.Test.init_test_session(config[:plug_session] || %{}) 24 | |> Plug.Conn.put_private(:phoenix_router, Router) 25 | |> Plug.Conn.put_private(:phoenix_endpoint, Endpoint) 26 | 27 | {:ok, conn: conn} 28 | end 29 | 30 | def with_session(%Plug.Conn{}, key, value) do 31 | %{key => value} 32 | end 33 | 34 | test "without session opts", %{conn: conn} do 35 | conn = call(conn, DashboardLive) 36 | assert conn.resp_body =~ ~s(session: %{}) 37 | end 38 | 39 | @tag plug_session: %{user_id: "alex"} 40 | test "with user session", %{conn: conn} do 41 | conn = call(conn, DashboardLive) 42 | assert conn.resp_body =~ ~s(session: %{"user_id" => "alex"}) 43 | end 44 | 45 | test "with a module container", %{conn: conn} do 46 | conn = call(conn, ThermostatLive) 47 | 48 | assert conn.resp_body =~ 49 | ~r/]*class="thermo"[^>]*>/ 50 | end 51 | 52 | test "with container options", %{conn: conn} do 53 | conn = call(conn, DashboardLive, container: {:span, style: "phx-flex"}) 54 | 55 | assert conn.resp_body =~ 56 | ~r/]*class="Phoenix.LiveViewTest.Support.DashboardLive"[^>]*style="phx-flex">/ 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/phoenix_live_view/socket_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.SocketTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "use with no override" do 5 | defmodule MySocket do 6 | use Phoenix.LiveView.Socket 7 | end 8 | 9 | info = %{peer_data: %{}} 10 | assert {:ok, %Phoenix.Socket{} = socket} = MySocket.connect(%{}, %Phoenix.Socket{}, info) 11 | assert socket.private.connect_info == info 12 | assert MySocket.id(socket) == nil 13 | end 14 | 15 | test "use with overrides" do 16 | defmodule MyOverrides do 17 | use Phoenix.LiveView.Socket 18 | 19 | def connect(%{"error" => "true"}, _socket, _info) do 20 | :error 21 | end 22 | 23 | def connect(_params, socket, info) do 24 | {:ok, assign(socket, :info, info)} 25 | end 26 | 27 | def id(_socket), do: "my-id" 28 | end 29 | 30 | info = %{peer_data: %{}} 31 | assert :error = MyOverrides.connect(%{"error" => "true"}, %Phoenix.Socket{}, info) 32 | assert {:ok, %Phoenix.Socket{} = socket} = MyOverrides.connect(%{}, %Phoenix.Socket{}, info) 33 | assert socket.private.connect_info == info 34 | assert socket.assigns.info == info 35 | assert MyOverrides.id(socket) == "my-id" 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/phoenix_live_view/test/diff_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.DiffTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Phoenix.LiveViewTest.Diff 5 | 6 | describe "merge_diff" do 7 | test "merges unless static" do 8 | assert Diff.merge_diff(%{0 => "bar", s: "foo"}, %{0 => "baz"}) == 9 | %{0 => "baz", s: "foo", streams: []} 10 | 11 | assert Diff.merge_diff(%{s: "foo", d: []}, %{s: "bar"}) == 12 | %{s: "bar", streams: []} 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/phoenix_live_view/test/dom_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.DOMTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Phoenix.LiveViewTest.DOM 5 | 6 | describe "parse_fragment" do 7 | test "detects duplicate ids" do 8 | assert DOM.parse_fragment( 9 | """ 10 |
11 |
12 |
13 | """, 14 | fn msg -> send(self(), {:error, msg}) end 15 | ) 16 | 17 | assert_receive {:error, msg} 18 | assert msg =~ "Duplicate id found while testing LiveView" 19 | end 20 | 21 | test "handles declarations (issue #3594)" do 22 | assert DOM.parse_fragment( 23 | """ 24 |
25 | 26 |
27 | """, 28 | fn msg -> send(self(), {:error, msg}) end 29 | ) 30 | 31 | refute_receive {:error, _} 32 | end 33 | end 34 | 35 | describe "parse_document" do 36 | test "detects duplicate ids" do 37 | assert DOM.parse_document( 38 | """ 39 | 40 | 41 |
42 |
43 |
44 | 45 | 46 | """, 47 | fn msg -> send(self(), {:error, msg}) end 48 | ) 49 | 50 | assert_receive {:error, msg} 51 | assert msg =~ "Duplicate id found while testing LiveView" 52 | end 53 | 54 | test "handles declarations (issue #3594)" do 55 | assert DOM.parse_document( 56 | """ 57 | 58 | 59 |
60 | 61 |
62 | 63 | 64 | """, 65 | fn msg -> send(self(), {:error, msg}) end 66 | ) 67 | 68 | refute_receive {:error, _} 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/phoenix_live_view/utils_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.UtilsTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Phoenix.LiveView.Utils 5 | alias Phoenix.LiveViewTest.Support.Endpoint 6 | 7 | test "sign" do 8 | assert is_binary(Utils.sign_flash(Endpoint, %{"info" => "hi"})) 9 | end 10 | 11 | test "verify with valid flash token" do 12 | token = Utils.sign_flash(Endpoint, %{"info" => "hi"}) 13 | assert Utils.verify_flash(Endpoint, token) == %{"info" => "hi"} 14 | end 15 | 16 | test "verify with invalid flash token" do 17 | assert Utils.verify_flash(Endpoint, "bad") == %{} 18 | assert Utils.verify_flash(Endpoint, nil) == %{} 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/support/controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.Controller do 2 | use Phoenix.Controller 3 | import Phoenix.LiveView.Controller 4 | 5 | plug :put_layout, false 6 | 7 | def widget(conn, _) do 8 | conn 9 | |> put_view(Phoenix.LiveViewTest.Support.LayoutView) 10 | |> render("widget.html") 11 | end 12 | 13 | def incoming(conn, %{"type" => "live-render-2"}) do 14 | live_render(conn, Phoenix.LiveViewTest.Support.DashboardLive) 15 | end 16 | 17 | def incoming(conn, %{"type" => "live-render-3"}) do 18 | live_render(conn, Phoenix.LiveViewTest.Support.DashboardLive, 19 | session: %{"custom" => :session} 20 | ) 21 | end 22 | 23 | def incoming(conn, %{"type" => "live-render-4"}) do 24 | conn 25 | |> put_layout({Phoenix.LiveViewTest.Support.AssignsLayoutView, :app}) 26 | |> live_render(Phoenix.LiveViewTest.Support.DashboardLive) 27 | end 28 | 29 | def incoming(conn, %{"type" => "render-with-function-component"}) do 30 | conn 31 | |> put_view(Phoenix.LiveViewTest.Support.LayoutView) 32 | |> render("with-function-component.html") 33 | end 34 | 35 | def incoming(conn, %{"type" => "render-layout-with-function-component"}) do 36 | conn 37 | |> put_view(Phoenix.LiveViewTest.Support.LayoutView) 38 | |> put_root_layout( 39 | {Phoenix.LiveViewTest.Support.LayoutView, "layout-with-function-component.html"} 40 | ) 41 | |> render("hello.html") 42 | end 43 | 44 | def not_found(conn, _) do 45 | conn 46 | |> put_status(:not_found) 47 | |> text("404") 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/support/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.EndpointOverridable do 2 | defmacro __before_compile__(_env) do 3 | quote do 4 | @parsers Plug.Parsers.init( 5 | parsers: [:urlencoded, :multipart, :json], 6 | pass: ["*/*"], 7 | json_decoder: Phoenix.json_library() 8 | ) 9 | 10 | defoverridable call: 2 11 | 12 | def call(conn, opts) do 13 | %{conn | secret_key_base: config(:secret_key_base)} 14 | |> Plug.Parsers.call(@parsers) 15 | |> Plug.Conn.put_private(:phoenix_endpoint, __MODULE__) 16 | |> super(opts) 17 | end 18 | end 19 | end 20 | end 21 | 22 | defmodule Phoenix.LiveViewTest.Support.Endpoint do 23 | use Phoenix.Endpoint, otp_app: :phoenix_live_view 24 | 25 | @before_compile Phoenix.LiveViewTest.Support.EndpointOverridable 26 | 27 | socket "/live", Phoenix.LiveView.Socket 28 | 29 | defoverridable url: 0, script_name: 0, config: 1, config: 2, static_path: 1 30 | def url(), do: "http://localhost:4004" 31 | def script_name(), do: [] 32 | def static_path(path), do: "/static" <> path 33 | def config(:live_view), do: [signing_salt: "112345678212345678312345678412"] 34 | def config(:secret_key_base), do: String.duplicate("57689", 50) 35 | def config(:cache_static_manifest_latest), do: Process.get(:cache_static_manifest_latest) 36 | def config(:otp_app), do: :phoenix_live_view 37 | def config(:pubsub_server), do: Phoenix.LiveView.PubSub 38 | def config(:render_errors), do: [formats: [html: __MODULE__]] 39 | def config(:static_url), do: [path: "/static"] 40 | def config(which), do: super(which) 41 | def config(which, default), do: super(which, default) 42 | 43 | plug Phoenix.LiveViewTest.Support.Router 44 | 45 | def render(template, _assigns) do 46 | Phoenix.Controller.status_message_from_template(template) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/support/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.LayoutView do 2 | use Phoenix.View, root: "" 3 | use Phoenix.Component 4 | 5 | use Phoenix.VerifiedRoutes, 6 | router: Phoenix.LiveViewTest.Support.Router, 7 | endpoint: Phoenix.LiveViewTest.Support.Endpoint, 8 | statics: ~w(css) 9 | 10 | def render("app.html", assigns) do 11 | # Assert those assigns are always available 12 | _ = assigns.live_module 13 | _ = assigns.live_action 14 | 15 | ["LAYOUT", assigns.inner_content] 16 | end 17 | 18 | def render("live.html", assigns) do 19 | ~H""" 20 | LIVELAYOUTSTART-{@val}-{@inner_content}-LIVELAYOUTEND 21 | """ 22 | end 23 | 24 | def render("live_override.html", assigns) do 25 | ~H""" 26 | LIVEOVERRIDESTART-{@val}-{@inner_content}-LIVEOVERRIDEEND 27 | """ 28 | end 29 | 30 | def render("widget.html", assigns) do 31 | ~H""" 32 | WIDGET:{live_render(@conn, Phoenix.LiveViewTest.Support.ClockLive)} 33 | """ 34 | end 35 | 36 | def render("with-function-component.html", assigns) do 37 | ~H""" 38 | RENDER: 39 | """ 40 | end 41 | 42 | def render("layout-with-function-component.html", assigns) do 43 | ~H""" 44 | LAYOUT: 45 | {@inner_content} 46 | """ 47 | end 48 | 49 | def render("hello.html", assigns) do 50 | ~H""" 51 | Hello 52 | """ 53 | end 54 | 55 | def render("styled.html", assigns) do 56 | ~H""" 57 | 58 | 59 | Styled 60 | 61 | 62 | 63 | 64 | 67 | 70 | 71 | 72 | {@inner_content} 73 | 74 | 75 | """ 76 | end 77 | 78 | def on_mount_layout(assigns) do 79 | ~H""" 80 |
81 | {@inner_content} 82 |
83 | """ 84 | end 85 | end 86 | 87 | defmodule Phoenix.LiveViewTest.Support.AssignsLayoutView do 88 | use Phoenix.View, root: "" 89 | 90 | def render("app.html", assigns) do 91 | ["title: #{assigns.title}", assigns.inner_content] 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/support/live_views/cids_destroyed.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.CidsDestroyedLive do 2 | use Phoenix.LiveView 3 | 4 | defmodule Button do 5 | use Phoenix.LiveComponent 6 | 7 | def mount(socket) do 8 | {:ok, assign(socket, counter: 0)} 9 | end 10 | 11 | def render(assigns) do 12 | ~H""" 13 |
14 | 15 |
Bump: {@counter}
16 |
17 | """ 18 | end 19 | 20 | def handle_event("bump", _, socket) do 21 | {:noreply, update(socket, :counter, &(&1 + 1))} 22 | end 23 | end 24 | 25 | def render(assigns) do 26 | ~H""" 27 | <%= if @form do %> 28 |
29 | <.live_component module={Button} id="button" text="Hello World" /> 30 |
31 | <% else %> 32 |
loading...
33 | <% end %> 34 | """ 35 | end 36 | 37 | def mount(_params, _session, socket) do 38 | {:ok, assign(socket, form: true)} 39 | end 40 | 41 | def handle_event("event_1", _params, socket) do 42 | send(self(), :event_2) 43 | {:noreply, assign(socket, form: false)} 44 | end 45 | 46 | def handle_info(:event_2, socket) do 47 | {:noreply, assign(socket, form: true)} 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/support/live_views/collocated.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.CollocatedLive do 2 | use Phoenix.LiveView 3 | 4 | def mount(_params, _session, socket) do 5 | {:ok, assign(socket, world: "world")} 6 | end 7 | end 8 | 9 | defmodule Phoenix.LiveViewTest.Support.CollocatedComponent do 10 | use Phoenix.LiveComponent 11 | end 12 | -------------------------------------------------------------------------------- /test/support/live_views/collocated_component.html.heex: -------------------------------------------------------------------------------- 1 | Hello collocated <%= @world %> from component! 2 | -------------------------------------------------------------------------------- /test/support/live_views/collocated_live.html.heex: -------------------------------------------------------------------------------- 1 | Hello collocated <%= @world %> from live! 2 | -------------------------------------------------------------------------------- /test/support/live_views/component_and_nested_in_live.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.ComponentAndNestedInLive do 2 | use Phoenix.LiveView 3 | 4 | defmodule NestedLive do 5 | use Phoenix.LiveView 6 | 7 | def mount(_params, _session, socket) do 8 | {:ok, assign(socket, :hello, "hello")} 9 | end 10 | 11 | def render(assigns) do 12 | ~H"
{@hello}
" 13 | end 14 | 15 | def handle_event("disable", _params, socket) do 16 | send(socket.parent_pid, :disable) 17 | {:noreply, socket} 18 | end 19 | end 20 | 21 | defmodule NestedComponent do 22 | use Phoenix.LiveComponent 23 | 24 | def mount(socket) do 25 | {:ok, assign(socket, :world, "world")} 26 | end 27 | 28 | def render(assigns) do 29 | ~H"
{@world}
" 30 | end 31 | end 32 | 33 | def mount(_params, _session, socket) do 34 | {:ok, assign(socket, :enabled, true)} 35 | end 36 | 37 | def render(assigns) do 38 | ~H""" 39 | <%= if @enabled do %> 40 | {live_render(@socket, NestedLive, id: :nested_live)} 41 | <.live_component module={NestedComponent} id={:_component} /> 42 | <% end %> 43 | """ 44 | end 45 | 46 | def handle_event("disable", _, socket) do 47 | {:noreply, assign(socket, :enabled, false)} 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/support/live_views/component_in_live.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.ComponentInLive.Root do 2 | use Phoenix.LiveView 3 | 4 | def mount(_params, _session, socket) do 5 | {:ok, assign(socket, :enabled, true)} 6 | end 7 | 8 | def render(assigns) do 9 | ~H"{@enabled && 10 | live_render(@socket, Phoenix.LiveViewTest.Support.ComponentInLive.Live, id: :nested_live)}" 11 | end 12 | 13 | def handle_info(:disable, socket) do 14 | {:noreply, assign(socket, :enabled, false)} 15 | end 16 | end 17 | 18 | defmodule Phoenix.LiveViewTest.Support.ComponentInLive.Live do 19 | use Phoenix.LiveView 20 | 21 | def mount(_params, _session, socket) do 22 | {:ok, socket} 23 | end 24 | 25 | def render(assigns) do 26 | ~H"<.live_component 27 | module={Phoenix.LiveViewTest.Support.ComponentInLive.Component} 28 | id={:nested_component} 29 | />" 30 | end 31 | 32 | def handle_event("disable", _params, socket) do 33 | send(socket.parent_pid, :disable) 34 | {:noreply, socket} 35 | end 36 | end 37 | 38 | defmodule Phoenix.LiveViewTest.Support.ComponentInLive.Component do 39 | use Phoenix.LiveComponent 40 | 41 | # Make sure mount is calling by setting assigns in them. 42 | def mount(socket) do 43 | {:ok, assign(socket, world: "World")} 44 | end 45 | 46 | def update(_assigns, socket) do 47 | {:ok, assign(socket, hello: "Hello")} 48 | end 49 | 50 | def render(assigns) do 51 | ~H"
{@hello} {@world}
" 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/support/live_views/connect.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.ConnectLive do 2 | use Phoenix.LiveView 3 | 4 | def render(assigns) do 5 | ~H""" 6 |

params: {inspect(@params)}

7 |

uri: {URI.to_string(@uri)}

8 |

trace: {inspect(@trace)}

9 |

peer: {inspect(@peer, custom_options: [sort_maps: true])}

10 |

x-headers: {inspect(@x_headers)}

11 |

user-agent: {inspect(@user_agent)}

12 | """ 13 | end 14 | 15 | def mount(_params, _session, socket) do 16 | {:ok, 17 | assign( 18 | socket, 19 | params: get_connect_params(socket), 20 | uri: get_connect_info(socket, :uri), 21 | trace: get_connect_info(socket, :trace_context_headers), 22 | peer: get_connect_info(socket, :peer_data), 23 | x_headers: get_connect_info(socket, :x_headers), 24 | user_agent: get_connect_info(socket, :user_agent) 25 | )} 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/live_views/debug_anno.exs: -------------------------------------------------------------------------------- 1 | # Note this file is intentionally a .exs file because it is loaded 2 | # in the test helper with debug_heex_annotations turned on. 3 | defmodule Phoenix.LiveViewTest.Support.DebugAnno do 4 | use Phoenix.Component 5 | 6 | def remote(assigns) do 7 | ~H"REMOTE COMPONENT: Value: {@value}" 8 | end 9 | 10 | def remote_with_tags(assigns) do 11 | ~H"
REMOTE COMPONENT: Value: {@value}
" 12 | end 13 | 14 | def local(assigns) do 15 | ~H"LOCAL COMPONENT: Value: {@value}" 16 | end 17 | 18 | def local_with_tags(assigns) do 19 | ~H"
LOCAL COMPONENT: Value: {@value}
" 20 | end 21 | 22 | def nested(assigns) do 23 | ~H""" 24 |
25 | <.local_with_tags value="local" /> 26 |
27 | """ 28 | end 29 | 30 | def slot(assigns) do 31 | ~H""" 32 | <.intersperse :let={num} enum={[1, 2]}> 33 | <:separator>, 34 | {num} 35 | 36 | """ 37 | end 38 | 39 | def slot_with_tags(assigns) do 40 | ~H""" 41 | <.intersperse :let={num} enum={[1, 2]}> 42 | <:separator>
43 |
{num}
44 | 45 | """ 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/support/live_views/duplicates.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.DuplicateIdLive do 2 | use Phoenix.LiveView 3 | 4 | def render(assigns) do 5 | ~H""" 6 |
7 |
8 |
9 |
10 |
11 | """ 12 | end 13 | end 14 | 15 | defmodule Phoenix.LiveViewTest.Support.DynamicDuplicateComponentLive do 16 | use Phoenix.LiveView 17 | 18 | defmodule LiveComponent do 19 | use Phoenix.LiveComponent 20 | 21 | def mount(socket) do 22 | {:ok, socket} 23 | end 24 | 25 | def render(assigns) do 26 | ~H""" 27 |
28 | <.live_component 29 | :if={@render_child} 30 | module={Phoenix.LiveViewTest.Support.DynamicDuplicateComponentLive.LiveComponent2} 31 | id="duplicate" 32 | /> Other content of LiveComponent {@id} 33 |
34 | """ 35 | end 36 | end 37 | 38 | defmodule LiveComponent2 do 39 | use Phoenix.LiveComponent 40 | 41 | def render(assigns) do 42 | ~H""" 43 |
I am LiveComponent2
44 | """ 45 | end 46 | end 47 | 48 | defmodule NestedLive do 49 | use Phoenix.LiveView 50 | 51 | def render(assigns) do 52 | ~H""" 53 | <.live_component 54 | module={Phoenix.LiveViewTest.Support.DynamicDuplicateComponentLive.LiveComponent3} 55 | id="inside-nested" 56 | /> 57 | """ 58 | end 59 | end 60 | 61 | defmodule LiveComponent3 do 62 | use Phoenix.LiveComponent 63 | 64 | def render(assigns) do 65 | ~H""" 66 |
I am a LC inside nested LV
67 | """ 68 | end 69 | end 70 | 71 | def mount(_params, _session, socket) do 72 | {:ok, assign(socket, render_first: true, render_second: true, render_duplicate: false)} 73 | end 74 | 75 | def render(assigns) do 76 | ~H""" 77 | <.live_component 78 | id="First" 79 | module={Phoenix.LiveViewTest.Support.DynamicDuplicateComponentLive.LiveComponent} 80 | render_child={true} 81 | /> 82 | <.live_component 83 | id="Second" 84 | module={Phoenix.LiveViewTest.Support.DynamicDuplicateComponentLive.LiveComponent} 85 | render_child={@render_duplicate} 86 | /> 87 | 88 | {live_render(@socket, Phoenix.LiveViewTest.Support.DynamicDuplicateComponentLive.NestedLive, 89 | id: "nested" 90 | )} 91 | 92 | 93 | """ 94 | end 95 | 96 | def handle_event("toggle_duplicate", _, socket) do 97 | {:noreply, assign(socket, :render_duplicate, !socket.assigns.render_duplicate)} 98 | end 99 | 100 | def handle_event("toggle_first", _, socket) do 101 | {:noreply, assign(socket, :render_first, !socket.assigns.render_first)} 102 | end 103 | 104 | def handle_event("toggle_second", _, socket) do 105 | {:noreply, assign(socket, :render_second, !socket.assigns.render_second)} 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /test/support/live_views/expensive_runtime_checks.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.ExpensiveRuntimeChecksLive do 2 | use Phoenix.LiveView 3 | 4 | @impl Phoenix.LiveView 5 | def mount(_params, _session, socket) do 6 | {:ok, assign(socket, :bar, "bar")} 7 | end 8 | 9 | @impl Phoenix.LiveView 10 | def handle_event("expensive_assign_async_socket", _params, socket) do 11 | socket 12 | |> assign_async(:test, bad_assign_async_function_socket(socket)) 13 | |> then(&{:noreply, &1}) 14 | end 15 | 16 | def handle_event("expensive_assign_async_assigns", _params, socket) do 17 | socket 18 | |> assign_async(:test, bad_assign_async_function_assigns(socket)) 19 | |> then(&{:noreply, &1}) 20 | end 21 | 22 | def handle_event("good_assign_async", _params, socket) do 23 | socket 24 | |> assign_async(:test, good_assign_async_function(socket)) 25 | |> then(&{:noreply, &1}) 26 | end 27 | 28 | def handle_event("expensive_start_async_socket", _params, socket) do 29 | socket 30 | |> start_async(:test, bad_start_async_function_socket(socket)) 31 | |> then(&{:noreply, &1}) 32 | end 33 | 34 | def handle_event("expensive_start_async_assigns", _params, socket) do 35 | socket 36 | |> start_async(:test, bad_start_async_function_assigns(socket)) 37 | |> then(&{:noreply, &1}) 38 | end 39 | 40 | def handle_event("good_start_async", _params, socket) do 41 | socket 42 | |> start_async(:test, good_start_async_function(socket)) 43 | |> then(&{:noreply, &1}) 44 | end 45 | 46 | defp bad_assign_async_function_socket(socket) do 47 | fn -> 48 | {:ok, %{test: do_something_with(socket.assigns.bar)}} 49 | end 50 | end 51 | 52 | defp bad_assign_async_function_assigns(socket) do 53 | assigns = socket.assigns 54 | 55 | fn -> 56 | {:ok, %{test: do_something_with(assigns.bar)}} 57 | end 58 | end 59 | 60 | defp good_assign_async_function(socket) do 61 | bar = socket.assigns.bar 62 | 63 | fn -> 64 | {:ok, %{test: do_something_with(bar)}} 65 | end 66 | end 67 | 68 | defp bad_start_async_function_socket(socket) do 69 | fn -> do_something_with(socket.assigns.bar) end 70 | end 71 | 72 | defp bad_start_async_function_assigns(socket) do 73 | assigns = socket.assigns 74 | 75 | fn -> do_something_with(assigns.bar) end 76 | end 77 | 78 | defp good_start_async_function(socket) do 79 | bar = socket.assigns.bar 80 | 81 | fn -> do_something_with(bar) end 82 | end 83 | 84 | defp do_something_with(x), do: x 85 | 86 | @impl Phoenix.LiveView 87 | def handle_async(:test, {:ok, _val}, socket), do: {:noreply, socket} 88 | 89 | @impl Phoenix.LiveView 90 | def render(assigns) do 91 | ~H""" 92 |

Hello!

93 | """ 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/support/live_views/host.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.HostLive do 2 | use Phoenix.LiveView 3 | alias Phoenix.LiveViewTest.Support.Router.Helpers, as: Routes 4 | 5 | def handle_params(_params, uri, socket) do 6 | {:noreply, assign(socket, :uri, uri)} 7 | end 8 | 9 | def render(assigns) do 10 | ~H""" 11 |

URI: {@uri}

12 |

LiveAction: {@live_action}

13 | <.link id="path" patch={Routes.host_path(@socket, :path)}>Path 14 | <.link id="full" patch={"https://app.example.com" <> Routes.host_path(@socket, :full)}> 15 | Full 16 | 17 | """ 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/support/live_views/layout.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.ParentLayoutLive do 2 | use Phoenix.LiveView 3 | 4 | def render(assigns) do 5 | ~H""" 6 | {live_render(@socket, Phoenix.LiveViewTest.Support.LayoutLive, session: @session, id: "layout")} 7 | """ 8 | end 9 | 10 | def mount(_params, session, socket) do 11 | {:ok, assign(socket, session: session)} 12 | end 13 | end 14 | 15 | defmodule Phoenix.LiveViewTest.Support.LayoutLive do 16 | use Phoenix.LiveView, layout: {Phoenix.LiveViewTest.Support.LayoutView, :live} 17 | 18 | def render(assigns), do: ~H|The value is: {@val}| 19 | 20 | def mount(_params, session, socket) do 21 | socket 22 | |> assign(val: 123) 23 | |> maybe_put_layout(session) 24 | end 25 | 26 | def handle_event("double", _, socket) do 27 | {:noreply, update(socket, :val, &(&1 * 2))} 28 | end 29 | 30 | defp maybe_put_layout(socket, %{"live_layout" => value}) do 31 | {:ok, socket, layout: value} 32 | end 33 | 34 | defp maybe_put_layout(socket, _session), do: {:ok, socket} 35 | end 36 | -------------------------------------------------------------------------------- /test/support/live_views/live_in_component.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.LiveInComponent.Root do 2 | use Phoenix.LiveView 3 | 4 | def mount(_params, _session, socket) do 5 | {:ok, socket} 6 | end 7 | 8 | def render(assigns) do 9 | ~H"<.live_component 10 | module={Phoenix.LiveViewTest.Support.LiveInComponent.Component} 11 | id={:nested_component} 12 | />" 13 | end 14 | end 15 | 16 | defmodule Phoenix.LiveViewTest.Support.LiveInComponent.Component do 17 | use Phoenix.LiveComponent 18 | 19 | def render(assigns) do 20 | ~H""" 21 |
22 | {live_render(@socket, Phoenix.LiveViewTest.Support.LiveInComponent.Live, id: :nested_live)}" 23 |
24 | """ 25 | end 26 | end 27 | 28 | defmodule Phoenix.LiveViewTest.Support.LiveInComponent.Live do 29 | use Phoenix.LiveView 30 | 31 | def mount(_params, _session, socket) do 32 | {:ok, socket} 33 | end 34 | 35 | def render(assigns) do 36 | ~H"" 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/support/live_views/params.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.ParamCounterLive do 2 | use Phoenix.LiveView 3 | 4 | def render(assigns) do 5 | ~H""" 6 |

The value is: {@val}

7 |

mount: {inspect(@mount_params)}

8 |

params: {inspect(@params)}

9 | """ 10 | end 11 | 12 | def mount(params, session, socket) do 13 | on_handle_params = session["on_handle_params"] 14 | 15 | {:ok, 16 | assign( 17 | socket, 18 | val: 1, 19 | mount_params: params, 20 | test_pid: session["test_pid"], 21 | connected?: connected?(socket), 22 | on_handle_params: on_handle_params && :erlang.binary_to_term(on_handle_params) 23 | )} 24 | end 25 | 26 | def handle_params(%{"from" => "handle_params"} = params, uri, socket) do 27 | send(socket.assigns.test_pid, {:handle_params, uri, socket.assigns, params}) 28 | socket.assigns.on_handle_params.(assign(socket, :params, params)) 29 | end 30 | 31 | def handle_params(params, uri, socket) do 32 | send(socket.assigns.test_pid, {:handle_params, uri, socket.assigns, params}) 33 | {:noreply, assign(socket, :params, params)} 34 | end 35 | 36 | def handle_info({:set, var, val}, socket), do: {:noreply, assign(socket, var, val)} 37 | 38 | def handle_info({:push_patch, to}, socket) do 39 | {:noreply, push_patch(socket, to: to)} 40 | end 41 | 42 | def handle_info({:push_navigate, to}, socket) do 43 | {:noreply, push_navigate(socket, to: to)} 44 | end 45 | 46 | def handle_call({:push_patch, func}, _from, socket) do 47 | func.(socket) 48 | end 49 | 50 | def handle_call({:push_navigate, func}, _from, socket) do 51 | func.(socket) 52 | end 53 | 54 | def handle_cast({:push_patch, to}, socket) do 55 | {:noreply, push_patch(socket, to: to)} 56 | end 57 | 58 | def handle_cast({:push_navigate, to}, socket) do 59 | {:noreply, push_navigate(socket, to: to)} 60 | end 61 | 62 | def handle_event("push_patch", %{"to" => to}, socket) do 63 | {:noreply, push_patch(socket, to: to)} 64 | end 65 | 66 | def handle_event("push_navigate", %{"to" => to}, socket) do 67 | {:noreply, push_navigate(socket, to: to)} 68 | end 69 | end 70 | 71 | defmodule Phoenix.LiveViewTest.Support.ActionLive do 72 | use Phoenix.LiveView 73 | 74 | def render(assigns) do 75 | ~H""" 76 |

Live action: {inspect(@live_action)}

77 |

Mount action: {inspect(@mount_action)}

78 |

Params: {inspect(@params)}

79 | """ 80 | end 81 | 82 | def mount(_params, _session, socket) do 83 | {:ok, assign(socket, mount_action: socket.assigns.live_action)} 84 | end 85 | 86 | def handle_params(params, _url, socket) do 87 | {:noreply, assign(socket, params: params)} 88 | end 89 | 90 | def handle_event("push_patch", to, socket) do 91 | {:noreply, push_patch(socket, to: to)} 92 | end 93 | end 94 | 95 | defmodule Phoenix.LiveViewTest.Support.ErrorInHandleParamsLive do 96 | use Phoenix.LiveView 97 | 98 | def render(assigns), do: ~H|
I crash in handle_params
| 99 | def mount(_params, _session, socket), do: {:ok, socket} 100 | def handle_params(_params, _uri, _socket), do: raise("boom") 101 | end 102 | -------------------------------------------------------------------------------- /test/support/live_views/reload_live.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.ReloadLive do 2 | use Phoenix.LiveView 3 | 4 | def mount(_params, _session, socket) do 5 | {:ok, socket} 6 | end 7 | 8 | def render(assigns) do 9 | case Application.fetch_env(:phoenix_live_view, :vsn) do 10 | {:ok, 1} -> ~H"
Version 1
" 11 | {:ok, 2} -> ~H"
Version 2
" 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/support/live_views/render_with.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.RenderWithLive do 2 | use Phoenix.LiveView 3 | 4 | def mount(_params, _session, socket) do 5 | {:ok, 6 | render_with(socket, fn assigns -> 7 | ~H""" 8 | FROM RENDER WITH! 9 | """ 10 | end)} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/support/live_views/update.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveViewTest.Support.TZLive do 2 | use Phoenix.LiveView 3 | 4 | def render(assigns) do 5 | ~H""" 6 | time: {@time} {@name} 7 | """ 8 | end 9 | 10 | def mount(:not_mounted_at_router, session, socket) do 11 | {:ok, assign(socket, time: "12:00", items: [], name: session["name"] || "NY")} 12 | end 13 | end 14 | 15 | defmodule Phoenix.LiveViewTest.Support.ShuffleLive do 16 | use Phoenix.LiveView 17 | 18 | def render(assigns) do 19 | ~H""" 20 | <%= for zone <- @time_zones do %> 21 |
zone["id"]}> 22 | {live_render(@socket, Phoenix.LiveViewTest.Support.TZLive, 23 | id: "tz-#{zone["id"]}", 24 | session: %{"name" => zone["name"]} 25 | )} 26 |
27 | <% end %> 28 | """ 29 | end 30 | 31 | def mount(_params, %{"time_zones" => time_zones}, socket) do 32 | {:ok, assign(socket, time_zones: time_zones)} 33 | end 34 | 35 | def handle_event("reverse", _, socket) do 36 | {:noreply, assign(socket, :time_zones, Enum.reverse(socket.assigns.time_zones))} 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/support/telemetry_test_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveView.TelemetryTestHelpers do 2 | @moduledoc false 3 | 4 | import ExUnit.Callbacks, only: [on_exit: 1] 5 | 6 | def attach_telemetry(prefix) when is_list(prefix) do 7 | unique_name = :"PID#{System.unique_integer()}" 8 | Process.register(self(), unique_name) 9 | 10 | for suffix <- [:start, :stop, :exception] do 11 | :telemetry.attach( 12 | {suffix, unique_name}, 13 | prefix ++ [suffix], 14 | fn event, measurements, metadata, :none -> 15 | send(unique_name, {:event, event, measurements, metadata}) 16 | end, 17 | :none 18 | ) 19 | end 20 | 21 | on_exit(fn -> 22 | for suffix <- [:start, :stop] do 23 | :telemetry.detach({suffix, unique_name}) 24 | end 25 | end) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/templates/heex/dead_with_function_component.html.heex: -------------------------------------------------------------------------------- 1 | pre: <%= @pre %> 2 | 3 | post: <%= @post %> 4 | -------------------------------------------------------------------------------- /test/support/templates/heex/dead_with_function_component_with_inner_content.html.heex: -------------------------------------------------------------------------------- 1 | pre: <%= @pre %> 2 | 3 | The inner content 4 | 5 | post: <%= @post %> 6 | -------------------------------------------------------------------------------- /test/support/templates/heex/dead_with_live.html.eex: -------------------------------------------------------------------------------- 1 | pre: <%= @pre %> 2 | <%= render "inner_live.html", assigns %> 3 | post: <%= @post %> -------------------------------------------------------------------------------- /test/support/templates/heex/inner_dead.html.eex: -------------------------------------------------------------------------------- 1 | dead: <%= @inner_content %> -------------------------------------------------------------------------------- /test/support/templates/heex/inner_live.html.heex: -------------------------------------------------------------------------------- 1 | live: <%= @inner_content %> -------------------------------------------------------------------------------- /test/support/templates/heex/live_with_comprehension.html.heex: -------------------------------------------------------------------------------- 1 | pre: <%= @pre %> 2 | <%= for point <- @points do %> 3 | x: <%= point.x %> 4 | <%= render "inner_live.html", assigns %> 5 | y: <%= point.y %> 6 | <% end %> 7 | post: <%= @post %> -------------------------------------------------------------------------------- /test/support/templates/heex/live_with_dead.html.heex: -------------------------------------------------------------------------------- 1 | pre: <%= @pre %> 2 | <%= render "inner_dead.html", assigns %> 3 | post: <%= @post %> -------------------------------------------------------------------------------- /test/support/templates/heex/live_with_live.html.heex: -------------------------------------------------------------------------------- 1 | pre: <%= @pre %> 2 | <%= render "inner_live.html", assigns %> 3 | post: <%= @post %> -------------------------------------------------------------------------------- /test/support/templates/leex/dead_with_live.html.eex: -------------------------------------------------------------------------------- 1 | pre: <%= @pre %> 2 | <%= render "inner_live.html", assigns %> 3 | post: <%= @post %> -------------------------------------------------------------------------------- /test/support/templates/leex/inner_dead.html.eex: -------------------------------------------------------------------------------- 1 | dead: <%= @inner_content %> -------------------------------------------------------------------------------- /test/support/templates/leex/inner_live.html.leex: -------------------------------------------------------------------------------- 1 | live: <%= @inner_content %> -------------------------------------------------------------------------------- /test/support/templates/leex/live_with_comprehension.html.leex: -------------------------------------------------------------------------------- 1 | pre: <%= @pre %> 2 | <%= for point <- @points do %> 3 | x: <%= point.x %> 4 | <%= render "inner_live.html", assigns %> 5 | y: <%= point.y %> 6 | <% end %> 7 | post: <%= @post %> -------------------------------------------------------------------------------- /test/support/templates/leex/live_with_dead.html.leex: -------------------------------------------------------------------------------- 1 | pre: <%= @pre %> 2 | <%= render "inner_dead.html", assigns %> 3 | post: <%= @post %> -------------------------------------------------------------------------------- /test/support/templates/leex/live_with_live.html.leex: -------------------------------------------------------------------------------- 1 | pre: <%= @pre %> 2 | <%= render "inner_live.html", assigns %> 3 | post: <%= @post %> -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.put_env(:phoenix_live_view, :debug_heex_annotations, true) 2 | Application.put_env(:phoenix_live_view, :debug_tags_location, true) 3 | Code.require_file("test/support/live_views/debug_anno.exs") 4 | Application.put_env(:phoenix_live_view, :debug_tags_location, false) 5 | Application.put_env(:phoenix_live_view, :debug_heex_annotations, false) 6 | 7 | {:ok, _} = Phoenix.LiveViewTest.Support.Endpoint.start_link() 8 | ExUnit.start() 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "allowJs": true, 7 | "checkJs": true, 8 | "noEmit": false, 9 | "strict": false, 10 | "esModuleInterop": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "baseUrl": "./assets/js", 13 | "stripInternal": true, 14 | "paths": { 15 | "*": [ 16 | "*", 17 | "phoenix_live_view/*" 18 | ] 19 | }, 20 | "declaration": true, 21 | "emitDeclarationOnly": true, 22 | "outDir": "./assets/js/types" 23 | }, 24 | "include": [ 25 | "./assets/js/phoenix_live_view/*.js", 26 | "./assets/js/phoenix_live_view/*.ts" 27 | ], 28 | "exclude": [ 29 | "node_modules", 30 | "assets/test/**/*" 31 | ] 32 | } --------------------------------------------------------------------------------