Repository: phoenixframework/phoenix_live_view Branch: main Commit: 4408fa004406 Files: 371 Total size: 3.5 MB Directory structure: gitextract_3104ppvg/ ├── .formatter.exs ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── config.yml │ ├── extract-changelog.sh │ ├── single-file-samples/ │ │ ├── main.exs │ │ └── test.exs │ └── workflows/ │ ├── assets.yml │ ├── ci.yml │ ├── docs.yml │ ├── github_release.yml │ └── npm-publish.yml ├── .gitignore ├── .igniter.exs ├── 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 │ ├── hook_types_test.ts │ ├── index_test.ts │ ├── integration/ │ │ ├── event_test.ts │ │ ├── metadata_test.ts │ │ └── portal_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_live_view.upgrade.ex │ ├── phoenix_component/ │ │ ├── declarative.ex │ │ └── macro_component.ex │ ├── phoenix_component.ex │ ├── phoenix_live_component.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/ │ │ │ └── tag_formatter.ex │ │ ├── html_formatter.ex │ │ ├── igniter/ │ │ │ └── upgrade_to_1_1.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/ │ │ │ ├── compiler.ex │ │ │ ├── parser.ex │ │ │ └── tokenizer.ex │ │ ├── tag_engine.ex │ │ ├── test/ │ │ │ ├── client_proxy.ex │ │ │ ├── diff.ex │ │ │ ├── dom.ex │ │ │ ├── live_view_test.ex │ │ │ ├── structs.ex │ │ │ ├── tree_dom.ex │ │ │ ├── upload_client.ex │ │ │ └── utils.ex │ │ ├── upload.ex │ │ ├── upload_channel.ex │ │ ├── upload_config.ex │ │ ├── upload_tmp_file_writer.ex │ │ ├── upload_writer.ex │ │ └── utils.ex │ ├── phoenix_live_view.ex │ └── prettier.ex ├── mix.exs ├── package.json ├── priv/ │ └── static/ │ ├── phoenix_live_view.cjs.js │ ├── phoenix_live_view.esm.js │ └── phoenix_live_view.js ├── setupTests.js ├── test/ │ ├── e2e/ │ │ ├── .prettierignore │ │ ├── README.md │ │ ├── merge-coverage.js │ │ ├── playwright.config.js │ │ ├── support/ │ │ │ ├── colocated_live.ex │ │ │ ├── components_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_3636.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 │ │ │ │ ├── issue_3919.ex │ │ │ │ ├── issue_3931.ex │ │ │ │ ├── issue_3941.ex │ │ │ │ ├── issue_3953.ex │ │ │ │ ├── issue_3979.ex │ │ │ │ ├── issue_4027.ex │ │ │ │ ├── issue_4066.ex │ │ │ │ ├── issue_4078.ex │ │ │ │ ├── issue_4088.ex │ │ │ │ ├── issue_4094.ex │ │ │ │ ├── issue_4095.ex │ │ │ │ ├── issue_4102.ex │ │ │ │ ├── issue_4107.ex │ │ │ │ ├── issue_4121.ex │ │ │ │ └── issue_4147.ex │ │ │ ├── js_live.ex │ │ │ ├── keyed_comprehension_live.ex │ │ │ ├── navigation.ex │ │ │ ├── portal.ex │ │ │ ├── select_live.ex │ │ │ └── upload_live.ex │ │ ├── teardown.js │ │ ├── test-fixtures.js │ │ ├── test_helper.exs │ │ ├── tests/ │ │ │ ├── colocated.spec.js │ │ │ ├── components.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 │ │ │ │ ├── 3636.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 │ │ │ │ ├── 3919.spec.js │ │ │ │ ├── 3931.spec.js │ │ │ │ ├── 3941.spec.js │ │ │ │ ├── 3953.spec.js │ │ │ │ ├── 3979.spec.js │ │ │ │ ├── 4027.spec.js │ │ │ │ ├── 4066.spec.js │ │ │ │ ├── 4078.spec.js │ │ │ │ ├── 4088.spec.js │ │ │ │ ├── 4094.spec.js │ │ │ │ ├── 4095.spec.js │ │ │ │ ├── 4102.spec.js │ │ │ │ ├── 4107.spec.js │ │ │ │ ├── 4121.spec.js │ │ │ │ └── 4147.spec.js │ │ │ ├── js.spec.js │ │ │ ├── keyed-comprehension.spec.js │ │ │ ├── navigation.spec.js │ │ │ ├── portal.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 │ │ ├── igniter/ │ │ │ └── upgrade_to_1_1_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_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 │ │ ├── tag_engine/ │ │ │ └── tokenizer_test.exs │ │ ├── test/ │ │ │ ├── diff_test.exs │ │ │ ├── dom_test.exs │ │ │ └── tree_dom_test.exs │ │ ├── upload/ │ │ │ ├── channel_test.exs │ │ │ ├── config_test.exs │ │ │ └── external_test.exs │ │ └── utils_test.exs │ ├── phoenix_live_view_test.exs │ ├── support/ │ │ ├── async_sync.ex │ │ ├── controller.ex │ │ ├── endpoint.ex │ │ ├── layout_view.ex │ │ ├── live_views/ │ │ │ ├── assign_async.ex │ │ │ ├── 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 │ │ │ ├── debug_anno_opt_out.exs │ │ │ ├── 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 │ │ │ ├── root_tag_attr.exs │ │ │ ├── start_async.ex │ │ │ ├── stream_async.ex │ │ │ ├── streams.ex │ │ │ ├── test_warnings.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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .formatter.exs ================================================ [ import_deps: [:phoenix], plugins: [Phoenix.LiveView.HTMLFormatter], inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"], tag_formatters: %{script: Prettier} ] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- ### Environment * Elixir version (please paste the output of `elixir -v`): ``` ``` * Phoenix and LiveView versions (`mix deps | grep -w 'phoenix\|phoenix_live_view'`): ``` ``` * Operating system: - [ ] Windows - [ ] MacOS - [ ] Linux - [ ] Other (please specify): * Browsers (including version) you attempted to reproduce this bug on (the more the merrier): ``` ``` ### Actual behavior ### Expected behavior ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ --- blank_issues_enabled: true contact_links: - name: Ask questions, support, and general discussions url: https://elixirforum.com/c/phoenix-forum about: Ask questions, provide support, and more on Elixir Forum - name: Propose new features url: https://elixirforum.com/c/phoenix-forum about: Propose new features on Elixir Forum ================================================ FILE: .github/extract-changelog.sh ================================================ #!/usr/bin/env bash # # Extract changelog for a specific version from CHANGELOG.md # # Usage: ./extract-changelog.sh # Example: ./extract-changelog.sh v1.1.22 set -euo pipefail VERSION="${1:-}" if [[ -z "$VERSION" ]]; then echo "Usage: $0 " >&2 echo "Example: $0 v1.1.22" >&2 exit 1 fi # Normalize version to include 'v' prefix VERSION="${VERSION#v}" # Remove 'v' if present VERSION="v${VERSION}" # Add 'v' prefix SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CHANGELOG_PATH="${SCRIPT_DIR}/../CHANGELOG.md" if [[ ! -f "$CHANGELOG_PATH" ]]; then echo "Error: CHANGELOG.md not found at $CHANGELOG_PATH" >&2 exit 1 fi # Extract the section for the specified version # Match from "## vX.Y.Z" until the next "## v" header awk -v version="$VERSION" ' BEGIN { found = 0; printing = 0 } # Match the start of our target version section /^## v[0-9]/ { if (printing) { # We hit the next version, stop printing exit } # Check if this line contains our version if (index($0, "## " version " ") > 0) { found = 1 printing = 1 next # Skip the version header line } } printing { print } END { if (!found) { print "Error: Version " version " not found in changelog" > "/dev/stderr" exit 1 } } ' "$CHANGELOG_PATH" ================================================ FILE: .github/single-file-samples/main.exs ================================================ Application.put_env(:sample, Example.Endpoint, http: [ip: {127, 0, 0, 1}, port: 5001], adapter: Bandit.PhoenixAdapter, server: true, live_view: [signing_salt: "aaaaaaaa"], secret_key_base: String.duplicate("a", 64) ) Mix.install( [ {:bandit, "~> 1.8"}, {:jason, "~> 1.0"}, {:phoenix, "~> 1.8"}, {:phoenix_html, "~> 4.0"}, # please test your issue using the latest version of LV from GitHub! {:phoenix_live_view, github: "phoenixframework/phoenix_live_view", branch: "main", override: true} ] ) # if you're trying to test a specific LV commit, it may be necessary to manually build # the JS assets. To do this, uncomment the following lines: # this needs mix and npm available in your path! # # path = Phoenix.LiveView.__info__(:compile)[:source] |> Path.dirname() |> Path.join("../") # System.cmd("mix", ["deps.get"], cd: path, into: IO.binstream()) # System.cmd("npm", ["install"], cd: Path.join(path, "./assets"), into: IO.binstream()) # System.cmd("mix", ["assets.build"], cd: path, into: IO.binstream()) defmodule Example.ErrorView do def render(template, _), do: Phoenix.Controller.status_message_from_template(template) end defmodule Example.HomeLive do use Phoenix.LiveView, layout: {__MODULE__, :live} def mount(_params, _session, socket) do {:ok, assign(socket, :count, 0)} end def render("live.html", assigns) do ~H""" <%!-- uncomment to use enable tailwind --%> <%!-- --%> {@inner_content} """ end def render(assigns) do ~H""" {@count} """ end def handle_event("inc", _params, socket) do {:noreply, assign(socket, :count, socket.assigns.count + 1)} end def handle_event("dec", _params, socket) do {:noreply, assign(socket, :count, socket.assigns.count - 1)} end end defmodule Example.Router do use Phoenix.Router import Phoenix.LiveView.Router pipeline :browser do plug(:accepts, ["html"]) end scope "/", Example do pipe_through(:browser) live("/", HomeLive, :index) end end defmodule Example.Endpoint do use Phoenix.Endpoint, otp_app: :sample socket("/live", Phoenix.LiveView.Socket) plug Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix" plug Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view" plug Plug.Static, from: {:phoenix_html, "priv/static"}, at: "/assets/phoenix_html" plug(Example.Router) end {:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one) Process.sleep(:infinity) ================================================ FILE: .github/single-file-samples/test.exs ================================================ Application.put_env(:phoenix, Example.Endpoint, http: [ip: {127, 0, 0, 1}, port: 5001], adapter: Bandit.PhoenixAdapter, server: true, live_view: [signing_salt: "aaaaaaaa"], secret_key_base: String.duplicate("a", 64) ) Mix.install( [ {:bandit, "~> 1.8"}, {:jason, "~> 1.0"}, {:phoenix, "~> 1.8"}, # please test your issue using the latest version of LV from GitHub! {:phoenix_live_view, github: "phoenixframework/phoenix_live_view", branch: "main", override: true}, {:lazy_html, ">= 0.1.0"} ] ) ExUnit.start() defmodule Example.ErrorView do def render(template, _), do: Phoenix.Controller.status_message_from_template(template) end defmodule Example.HomeLive do use Phoenix.LiveView, layout: {__MODULE__, :live} def mount(_params, _session, socket) do socket |> then(&{:ok, &1}) end def render("live.html", assigns) do ~H""" {@inner_content} """ end def render(assigns) do ~H"""

The LiveView content goes here

""" end end defmodule Example.Router do use Phoenix.Router import Phoenix.LiveView.Router pipeline :browser do plug(:accepts, ["html"]) end scope "/", Example do pipe_through(:browser) live("/", HomeLive, :index) end end defmodule Example.Endpoint do use Phoenix.Endpoint, otp_app: :phoenix socket("/live", Phoenix.LiveView.Socket) plug Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix" plug Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view" plug Plug.Static, from: {:phoenix_html, "priv/static"}, at: "/assets/phoenix_html" plug(Example.Router) end defmodule Example.HomeLiveTest do use ExUnit.Case import Phoenix.ConnTest import Plug.Conn import Phoenix.LiveViewTest @endpoint Example.Endpoint test "works properly" do conn = Phoenix.ConnTest.build_conn() {:ok, _view, html} = live(conn, "/") assert html =~ "The LiveView content goes here" end end {:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one) ExUnit.run() Process.sleep(:infinity) ================================================ FILE: .github/workflows/assets.yml ================================================ name: Assets on: push: branches: - main - "v*.*" jobs: build: runs-on: ubuntu-22.04 env: elixir: 1.18.1 otp: 27.2 steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Elixir uses: erlef/setup-beam@v1 with: elixir-version: ${{ env.elixir }} otp-version: ${{ env.otp }} - name: Restore deps and _build cache uses: actions/cache@v4 with: path: | deps _build key: ${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-${{ hashFiles('**/mix.lock') }}-dev restore-keys: | ${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}- - name: Install Dependencies run: mix deps.get --only dev - name: Set up Node.js 20.x uses: actions/setup-node@v4 with: node-version: 20.x - name: Restore npm cache uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - name: Install npm dependencies run: npm ci - name: Build assets run: mix assets.build - name: Push updated assets id: push_assets uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: Update assets file_pattern: priv/static ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: pull_request: branches: - main jobs: mix_test: name: mix test (OTP ${{matrix.otp}} | Elixir ${{matrix.elixir}}) strategy: fail-fast: false matrix: include: - elixir: 1.15.4 otp: 25.3 - elixir: 1.16.3 otp: 26.2 - elixir: 1.18.4 otp: 27.3 # update coverage report as well - elixir: 1.19.1 otp: 28.1 lint: lint # run against latest Elixir to catch warnings early - elixir: main-otp-28 otp: maint-28 runs-on: ubuntu-latest steps: - name: Install inotify-tools run: | sudo apt update sudo apt install -y inotify-tools - name: Checkout uses: actions/checkout@v4 - name: Set up Elixir uses: erlef/setup-beam@v1 with: elixir-version: ${{ matrix.elixir }} otp-version: ${{ matrix.otp }} - name: Restore deps and _build cache uses: actions/cache@v4 with: path: | deps _build key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} restore-keys: | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} - name: Install dependencies run: mix deps.get - name: Remove compiled application files run: mix clean - name: Compile dependencies run: mix compile if: ${{ !matrix.lint }} env: MIX_ENV: test - name: Compile & lint dependencies run: mix compile --warnings-as-errors if: ${{ matrix.lint }} or ${{ matrix.elixir == 'main' }} env: MIX_ENV: test - name: Compile without optional deps run: mix compile --no-optional-deps env: MIX_ENV: dev - name: Check if formatted run: mix format --check-formatted if: ${{ matrix.lint }} env: MIX_ENV: test - name: Run tests run: mix test --cover --export-coverage default --warnings-as-errors - uses: actions/upload-artifact@v4 if: always() with: name: mix-test-coverage-${{ matrix.otp }}-${{ matrix.elixir }} path: cover/default.coverdata retention-days: 7 npm_test: name: npm test strategy: matrix: include: - elixir: 1.19.1 otp: 28.1 runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Elixir uses: erlef/setup-beam@v1 with: elixir-version: ${{ matrix.elixir }} otp-version: ${{ matrix.otp }} - name: Restore deps and _build cache uses: actions/cache@v4 with: path: | deps _build key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} restore-keys: | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} - name: Install dependencies run: mix deps.get --only test - name: Set up Node.js 20.x uses: actions/setup-node@v4 with: node-version: 20.x - name: Restore npm cache uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - name: setup JS run: npm run setup - name: typecheck run: npm run build && npm run typecheck:tests - name: check lint and format run: npm run js:lint && npm run js:format.check - name: test run: npm run js:test - uses: actions/upload-artifact@v4 if: always() with: name: js-unit-coverage path: coverage/ retention-days: 7 e2e_test: name: e2e test strategy: matrix: include: - elixir: 1.19.1 otp: 28.1 runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright:v1.58.2-noble env: ImageOS: ubuntu22 HOME: /root steps: - name: Checkout uses: actions/checkout@v4 - name: install unzip run: apt update && apt -y install unzip - name: Set up Elixir uses: erlef/setup-beam@v1 with: elixir-version: ${{ matrix.elixir }} otp-version: ${{ matrix.otp }} - name: Restore deps and _build cache uses: actions/cache@v4 with: path: | deps _build key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} restore-keys: | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} - name: Install dependencies run: mix deps.get - name: Restore npm cache uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - name: setup run: npm run setup - name: Run e2e tests run: npm run e2e:test - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: playwright-report/ retention-days: 7 - uses: actions/upload-artifact@v4 if: always() with: name: e2e-test-results path: test/e2e/test-results/ retention-days: 7 - uses: actions/upload-artifact@v4 if: always() with: name: mix-e2e-coverage path: cover/e2e.coverdata retention-days: 7 coverage_report: name: coverage report runs-on: ubuntu-latest needs: [mix_test, npm_test, e2e_test] strategy: matrix: include: - elixir: 1.19.1 otp: 28.1 steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Elixir uses: erlef/setup-beam@v1 with: elixir-version: ${{ matrix.elixir }} otp-version: ${{ matrix.otp }} - name: Download mix unit coverage uses: actions/download-artifact@v4 with: # This needs to be updated when changing the test matrix name: mix-test-coverage-${{ matrix.otp }}-${{ matrix.elixir }} path: cover/ - name: Download mix e2e coverage uses: actions/download-artifact@v4 with: name: mix-e2e-coverage path: cover/ - name: Restore deps and _build cache uses: actions/cache@v4 with: path: | deps _build key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} restore-keys: | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} - name: Generate mix coverage report run: mix test.coverage - name: Download js-unit-coverage artifact uses: actions/download-artifact@v4 with: name: js-unit-coverage path: coverage/ - name: Download e2e-test-results artifact uses: actions/download-artifact@v4 with: name: e2e-test-results path: test/e2e/test-results/ - name: Set up Node.js 20.x uses: actions/setup-node@v4 with: node-version: 20.x - name: Merge coverage reports run: npm install && npm run cover:merge - name: Upload coverage report uses: actions/upload-artifact@v4 with: name: overall-coverage path: cover/ retention-days: 7 ================================================ FILE: .github/workflows/docs.yml ================================================ name: Docs on: push: branches: - main permissions: contents: read pages: write id-token: write jobs: build: runs-on: ubuntu-22.04 env: elixir: 1.18.1 otp: 27.2 steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Elixir uses: erlef/setup-beam@v1 with: elixir-version: ${{ env.elixir }} otp-version: ${{ env.otp }} - name: Restore deps and _build cache uses: actions/cache@v4 with: path: | deps _build key: ${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-${{ hashFiles('**/mix.lock') }}-docs restore-keys: | ${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}- - name: Install Dependencies run: mix deps.get --only docs - name: Build docs run: mix docs - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: doc/ # Deployment job deploy: environment: name: github-pages url: ${{steps.deployment.outputs.page_url}} runs-on: ubuntu-latest needs: build steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .github/workflows/github_release.yml ================================================ name: Creates a GitHub Release on: push: tags: - "v*" jobs: release: runs-on: ubuntu-latest permissions: contents: write env: GITHUB_TOKEN: ${{ github.token }} steps: - name: Checkout code uses: actions/checkout@v4 - name: Extract version from tag id: version run: echo "version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" - name: Extract changelog for version run: bash .github/extract-changelog.sh "${{ steps.version.outputs.version }}" > release_notes.md - name: Create GitHub Release run: | PRERELEASE_FLAG="" if [[ "${{ steps.version.outputs.version }}" == *-rc* ]]; then PRERELEASE_FLAG="--prerelease" fi gh release create "${{ steps.version.outputs.version }}" \ --title "${{ steps.version.outputs.version }}" \ --notes-file release_notes.md \ $PRERELEASE_FLAG ================================================ FILE: .github/workflows/npm-publish.yml ================================================ # https://docs.npmjs.com/trusted-publishers name: NPM Publish on: push: tags: - "v*" permissions: id-token: write # Required for OIDC contents: read jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" registry-url: "https://registry.npmjs.org" # Ensure npm 11.5.1 or later is installed - name: Update npm run: npm install -g npm@latest - name: Determine npm tag id: npm-tag run: | TAG=${GITHUB_REF#refs/tags/} # Update this condition when bumping the major version! if [[ $TAG == v1.0.* ]]; then echo "tag=old-version" >> $GITHUB_OUTPUT elif [[ $TAG == *-rc* ]]; then echo "tag=rc" >> $GITHUB_OUTPUT else echo "tag=latest" >> $GITHUB_OUTPUT fi - name: Publish to npm run: npm publish --tag ${{ steps.npm-tag.outputs.tag }} ================================================ FILE: .gitignore ================================================ # The directory Mix will write compiled artifacts to. /_build/ # If you run "mix test --cover", coverage assets end up here. /cover/ # The directory Mix downloads your dependencies sources to. /deps/ # Where 3rd-party dependencies like ExDoc output generated docs. /doc/ # Ignore .fetch files in case you like to edit your project deps locally. /.fetch # If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump # Also ignore archive artifacts (built via "mix archive.build"). *.ez # Ignore package tarball (built via "mix hex.build"). phoenix_live_view-*.tar node_modules /test/e2e/test-results/ /playwright-report/ /coverage/ /assets/js/types/ ================================================ FILE: .igniter.exs ================================================ # This is a configuration file for igniter. # For option documentation, see https://hexdocs.pm/igniter/Igniter.Project.IgniterConfig.html # To keep it up to date, use `mix igniter.setup` [ module_location: :outside_matching_folder, extensions: [], deps_location: :last_list_literal, source_folders: ["lib", "test/support"], dont_move_files: [~r"lib/mix"] ] ================================================ FILE: CHANGELOG.md ================================================ # Changelog for v1.2 ## v1.2.0-rc.0 (Unreleased) ### Enhancements * Add `phx-no-unused-field` to prevent sending `_unused` parameters to the server ([#3577](https://github.com/phoenixframework/phoenix_live_view/issues/3577)) * Add `Phoenix.LiveView.JS.to_encodable/1` pushing JS commands via events ([#4060](https://github.com/phoenixframework/phoenix_live_view/pull/4060)) * `%JS{}` now also implements the `JSON.Encoder` and `Jason.Encoder` protocols * HTMLFormatter: Better preserve whitespace around tags and inside inline elements ([#3718](https://github.com/phoenixframework/phoenix_live_view/issues/3718)) * HEEx: Allow to opt out of debug annotations for a module ([#4119](https://github.com/phoenixframework/phoenix_live_view/pull/4119)) * HEEx: warn when missing a space between attributes ([#3999](https://github.com/phoenixframework/phoenix_live_view/issues/3999)) * HTMLFormatter: Add `TagFormatter` behaviour for formatting `" <> rest, line, column, buffer, acc, state) do acc = [ {:close, :tag, "style", %{line: line, column: column, inner_location: {line, column}}} | text_to_acc(buffer, acc, line, column, []) ] handle_text(rest, line, column + 9, [], acc, state) end defp handle_style("\r\n" <> rest, line, _column, buffer, acc, state) do handle_style(rest, line + 1, state.column_offset, ["\r\n" | buffer], acc, state) end defp handle_style("\n" <> rest, line, _column, buffer, acc, state) do handle_style(rest, line + 1, state.column_offset, ["\n" | buffer], acc, state) end defp handle_style(<>, line, column, buffer, acc, state) do handle_style(rest, line, column + 1, [char_or_bin(c) | buffer], acc, state) end defp handle_style(<<>>, line, column, buffer, acc, _state) do ok(text_to_acc(buffer, acc, line, column, []), :style) end ## handle_comment defp handle_comment(rest, line, column, buffer, acc, state) do case handle_comment(rest, line, column, buffer, state) do {:text, rest, line, column, buffer} -> state = update_in(state.context, &[:comment_end | &1]) handle_text(rest, line, column, buffer, acc, state) {:ok, line_end, column_end, buffer} -> acc = text_to_acc(buffer, acc, line_end, column_end, state.context) # We do column - 4 to point to the opening " <> rest, line, column, buffer, _state) do {:text, rest, line, column + 3, ["-->" | buffer]} end defp handle_comment(<>, line, column, buffer, state) do handle_comment(rest, line, column + 1, [char_or_bin(c) | buffer], state) end defp handle_comment(<<>>, line, column, buffer, _state) do {:ok, line, column, buffer} end ## handle_tag_open defp handle_tag_open(text, line, column, acc, state) do case handle_tag_name(text, column, []) do {:ok, name, new_column, rest} -> meta = %{line: line, column: column - 1, inner_location: nil, tag_name: name} case state.tag_handler.classify_type(name) do {:error, message} -> raise_syntax_error!(message, %{line: line, column: column}, state) {type, name} -> acc = [{type, name, [], meta} | acc] handle_maybe_tag_open_end(rest, line, new_column, acc, state) end :error -> message = "expected tag name after <. If you meant to use < as part of a text, use < instead" meta = %{line: line, column: column} raise_syntax_error!(message, meta, state) end end ## handle_tag_close defp handle_tag_close(text, line, column, acc, state) do case handle_tag_name(text, column, []) do {:ok, name, new_column, ">" <> rest} -> meta = %{ line: line, column: column - 2, inner_location: {line, column - 2}, tag_name: name } case state.tag_handler.classify_type(name) do {:error, message} -> raise_syntax_error!(message, meta, state) {type, name} -> acc = [{:close, type, name, meta} | acc] handle_text(rest, line, new_column + 1, [], acc, pop_braces(state)) end {:ok, _, new_column, _} -> message = "expected closing `>`" meta = %{line: line, column: new_column} raise_syntax_error!(message, meta, state) :error -> message = "expected tag name after > = text, column, buffer) when c in @stop_chars do done_tag_name(text, column, buffer) end defp handle_tag_name(<>, column, buffer) do handle_tag_name(rest, column + 1, [char_or_bin(c) | buffer]) end defp handle_tag_name(<<>>, column, buffer) do done_tag_name(<<>>, column, buffer) end defp done_tag_name(_text, _column, []) do :error end defp done_tag_name(text, column, buffer) do {:ok, buffer_to_string(buffer), column, text} end ## handle_maybe_tag_open_end defp handle_maybe_tag_open_end("\r\n" <> rest, line, _column, acc, state) do handle_maybe_tag_open_end(rest, line + 1, state.column_offset, acc, state) end defp handle_maybe_tag_open_end("\n" <> rest, line, _column, acc, state) do handle_maybe_tag_open_end(rest, line + 1, state.column_offset, acc, state) end defp handle_maybe_tag_open_end(<>, line, column, acc, state) when c in @space_chars do handle_maybe_tag_open_end(rest, line, column + 1, acc, state) end defp handle_maybe_tag_open_end("/>" <> rest, line, column, acc, state) do acc = normalize_tag(acc, line, column + 2, true, state) handle_text(rest, line, column + 2, [], acc, state) end defp handle_maybe_tag_open_end(">" <> rest, line, column, acc, state) do case normalize_tag(acc, line, column + 1, false, state) do [{:tag, "script", _, _} | _] = acc -> handle_script(rest, line, column + 1, [], acc, state) [{:tag, "style", _, _} | _] = acc -> handle_style(rest, line, column + 1, [], acc, state) acc -> handle_text(rest, line, column + 1, [], acc, push_braces(state)) end end defp handle_maybe_tag_open_end("{" <> rest, line, column, acc, state) do handle_root_attribute(rest, line, column + 1, acc, state) end defp handle_maybe_tag_open_end(<<>>, line, column, _acc, state) do message = ~S""" expected closing `>` or `/>` Make sure the tag is properly closed. This may happen if there is an EEx interpolation inside a tag, which is not supported. For instance, instead of
Content
do
Content
If @id is nil or false, then no attribute is sent at all. Inside {...} you can place any Elixir expression. If you want to interpolate in the middle of an attribute value, instead of Text you can pass an Elixir string with interpolation: Text """ raise_syntax_error!(message, %{line: line, column: column}, state) end defp handle_maybe_tag_open_end(text, line, column, acc, state) do handle_attribute(text, line, column, acc, state) end ## handle_attribute defp handle_attribute(text, line, column, acc, state) do case handle_attr_name(text, column, []) do {:ok, name, new_column, rest} -> attr_meta = %{line: line, column: column} {text, line, column, value} = handle_maybe_attr_value(rest, line, new_column, state) acc = put_attr(acc, name, attr_meta, value) maybe_warn_missing_attr_space(value, text, line, column, state) state = if name == "phx-no-curly-interpolation" and state.braces == :enabled and not script_or_style?(acc) do %{state | braces: 0} else state end handle_maybe_tag_open_end(text, line, column, acc, state) {:error, message, column} -> meta = %{line: line, column: column} raise_syntax_error!(message, meta, state) end end defp maybe_warn_missing_attr_space(nil, _text, _line, _column, _state), do: :ok defp maybe_warn_missing_attr_space(_value, <>, line, column, state) when c not in @space_chars and c not in ~c">/\r\n" do IO.warn( "missing space before attribute", line: line, column: column, file: state.file ) end defp maybe_warn_missing_attr_space(_value, _text, _line, _column, _state), do: :ok defp script_or_style?([{:tag, name, _, _} | _]) when name in ~w(script style), do: true defp script_or_style?(_), do: false ## handle_root_attribute defp handle_root_attribute(text, line, column, acc, state) do case handle_interpolation(text, line, column, [], 0, state) do {:ok, value, new_line, new_column, rest} -> meta = %{line: line, column: column} acc = put_attr(acc, :root, meta, {:expr, value, meta}) handle_maybe_tag_open_end(rest, new_line, new_column, acc, state) {:error, message} -> # We do column - 1 to point to the opening { meta = %{line: line, column: column - 1} raise_syntax_error!(message, meta, state) end end ## handle_attr_name defp handle_attr_name(<<"}"::utf8, _rest::binary>>, column, _buffer) do {:error, "expected attribute, but found end of interpolation: }", column} end defp handle_attr_name(<>, column, _buffer) when c in @quote_chars do {:error, "invalid character in attribute name: #{<>}", column} end defp handle_attr_name(<>, column, []) when c in @stop_chars do {:error, "expected attribute name", column} end defp handle_attr_name(<> = text, column, buffer) when c in @stop_chars do {:ok, buffer_to_string(buffer), column, text} end defp handle_attr_name(<>, column, buffer) do handle_attr_name(rest, column + 1, [char_or_bin(c) | buffer]) end defp handle_attr_name(<<>>, column, _buffer) do {:error, "unexpected end of string inside tag", column} end ## handle_maybe_attr_value defp handle_maybe_attr_value("\r\n" <> rest, line, _column, state) do handle_maybe_attr_value(rest, line + 1, state.column_offset, state) end defp handle_maybe_attr_value("\n" <> rest, line, _column, state) do handle_maybe_attr_value(rest, line + 1, state.column_offset, state) end defp handle_maybe_attr_value(<>, line, column, state) when c in @space_chars do handle_maybe_attr_value(rest, line, column + 1, state) end defp handle_maybe_attr_value("=" <> rest, line, column, state) do handle_attr_value_begin(rest, line, column + 1, state) end defp handle_maybe_attr_value(text, line, column, _state) do {text, line, column, nil} end ## handle_attr_value_begin defp handle_attr_value_begin("\r\n" <> rest, line, _column, state) do handle_attr_value_begin(rest, line + 1, state.column_offset, state) end defp handle_attr_value_begin("\n" <> rest, line, _column, state) do handle_attr_value_begin(rest, line + 1, state.column_offset, state) end defp handle_attr_value_begin(<>, line, column, state) when c in @space_chars do handle_attr_value_begin(rest, line, column + 1, state) end defp handle_attr_value_begin("\"" <> rest, line, column, state) do handle_attr_value_quote(rest, ?", line, column + 1, [], state) end defp handle_attr_value_begin("'" <> rest, line, column, state) do handle_attr_value_quote(rest, ?', line, column + 1, [], state) end defp handle_attr_value_begin("{" <> rest, line, column, state) do handle_attr_value_as_expr(rest, line, column + 1, state) end defp handle_attr_value_begin(_text, line, column, state) do message = "invalid attribute value after `=`. Expected either a value between quotes " <> "(such as \"value\" or \'value\') or an Elixir expression between curly braces (such as `{expr}`)" meta = %{line: line, column: column} raise_syntax_error!(message, meta, state) end ## handle_attr_value_quote defp handle_attr_value_quote("\r\n" <> rest, delim, line, _column, buffer, state) do column = state.column_offset handle_attr_value_quote(rest, delim, line + 1, column, ["\r\n" | buffer], state) end defp handle_attr_value_quote("\n" <> rest, delim, line, _column, buffer, state) do column = state.column_offset handle_attr_value_quote(rest, delim, line + 1, column, ["\n" | buffer], state) end defp handle_attr_value_quote(<>, delim, line, column, buffer, _state) do value = buffer_to_string(buffer) {rest, line, column + 1, {:string, value, %{delimiter: delim}}} end defp handle_attr_value_quote(<>, delim, line, column, buffer, state) do handle_attr_value_quote(rest, delim, line, column + 1, [char_or_bin(c) | buffer], state) end defp handle_attr_value_quote(<<>>, delim, line, column, _buffer, state) do message = """ expected closing `#{<>}` for attribute value Make sure the attribute is properly closed. This may also happen if there is an EEx interpolation inside a tag, which is not supported. Instead of
>
do
Where @some_attributes must be a keyword list or a map. """ meta = %{line: line, column: column} raise_syntax_error!(message, meta, state) end ## handle_attr_value_as_expr defp handle_attr_value_as_expr(text, line, column, state) do case handle_interpolation(text, line, column, [], 0, state) do {:ok, value, new_line, new_column, rest} -> {rest, new_line, new_column, {:expr, value, %{line: line, column: column}}} {:error, message} -> # We do column - 1 to point to the opening { meta = %{line: line, column: column - 1} raise_syntax_error!(message, meta, state) end end ## handle_interpolation defp handle_interpolation("\r\n" <> rest, line, _column, buffer, braces, state) do handle_interpolation(rest, line + 1, state.column_offset, ["\r\n" | buffer], braces, state) end defp handle_interpolation("\n" <> rest, line, _column, buffer, braces, state) do handle_interpolation(rest, line + 1, state.column_offset, ["\n" | buffer], braces, state) end defp handle_interpolation("}" <> rest, line, column, buffer, 0, _state) do value = buffer_to_string(buffer) {:ok, value, line, column + 1, rest} end defp handle_interpolation(~S(\}) <> rest, line, column, buffer, braces, state) do handle_interpolation(rest, line, column + 2, [~S(\}) | buffer], braces, state) end defp handle_interpolation(~S(\{) <> rest, line, column, buffer, braces, state) do handle_interpolation(rest, line, column + 2, [~S(\{) | buffer], braces, state) end defp handle_interpolation("}" <> rest, line, column, buffer, braces, state) do handle_interpolation(rest, line, column + 1, ["}" | buffer], braces - 1, state) end defp handle_interpolation("{" <> rest, line, column, buffer, braces, state) do handle_interpolation(rest, line, column + 1, ["{" | buffer], braces + 1, state) end defp handle_interpolation(<>, line, column, buffer, braces, state) do handle_interpolation(rest, line, column + 1, [char_or_bin(c) | buffer], braces, state) end defp handle_interpolation(<<>>, _line, _column, _buffer, _braces, _state) do {:error, """ expected closing `}` for expression In case you don't want `{` to begin a new interpolation, \ you may write it using `{` or using `<%= "{" %>`\ """} end ## helpers @compile {:inline, ok: 2, char_or_bin: 1} defp ok(acc, cont), do: {acc, cont} defp char_or_bin(c) when c <= 127, do: c defp char_or_bin(c), do: <> defp buffer_to_string(buffer) do IO.iodata_to_binary(Enum.reverse(buffer)) end defp text_to_acc(buffer, acc, line, column, context) defp text_to_acc([], acc, _line, _column, _context), do: acc defp text_to_acc(buffer, acc, line, column, context) do meta = %{line_end: line, column_end: column} meta = if context == [] do meta else Map.put(meta, :context, trim_context(context)) end [{:text, buffer_to_string(buffer), meta} | acc] end defp trim_context([:comment_end, :comment_start | [_ | _] = rest]), do: trim_context(rest) defp trim_context(rest), do: Enum.reverse(rest) defp push_braces(%{braces: :enabled} = state), do: state defp push_braces(%{braces: braces} = state), do: %{state | braces: braces + 1} defp pop_braces(%{braces: :enabled} = state), do: state defp pop_braces(%{braces: 1} = state), do: %{state | braces: :enabled} defp pop_braces(%{braces: braces} = state), do: %{state | braces: braces - 1} defp put_attr([{type, name, attrs, meta} | acc], attr, attr_meta, value) do attrs = [{attr, value, attr_meta} | attrs] [{type, name, attrs, meta} | acc] end defp normalize_tag([{type, name, attrs, meta} | acc], line, column, self_close?, state) do attrs = Enum.reverse(attrs) meta = %{meta | inner_location: {line, column}} meta = cond do type == :tag and state.tag_handler.void?(name) -> Map.put(meta, :closing, :void) self_close? -> Map.put(meta, :closing, :self) true -> meta end [{type, name, attrs, meta} | acc] end defp strip_text_token_fully(tokens) do with [{:text, text, _} | rest] <- tokens, "" <- String.trim_leading(text) do strip_text_token_fully(rest) else _ -> tokens end end defp raise_syntax_error!(message, meta, state) do raise ParseError, file: state.file, line: meta.line, column: meta.column, description: message <> ParseError.code_snippet(state.source, meta, state.indentation) end end ================================================ FILE: lib/phoenix_live_view/tag_engine.ex ================================================ defmodule Phoenix.LiveView.TagEngine do @moduledoc """ Building blocks for tag based `Phoenix.Template.Engine`s. This cannot be directly used by Phoenix applications. Instead, it is the building block for engines such as `Phoenix.LiveView.HTMLEngine`. It is typically invoked like this: Phoenix.LiveView.TagEngine.compile(source, line: 1, file: path, caller: __CALLER__, source: source, tag_handler: FooBarEngine ) Where `:tag_handler` implements the behaviour defined by this module. """ alias Phoenix.LiveView.TagEngine @doc """ Compiles the given string into Elixir AST. The accepted options are: * `tag_handler` - Required. The module implementing the `Phoenix.LiveView.TagEngine` behavior. * `caller` - Required. The `Macro.Env`. * `line` - the starting line offset. Defaults to 1. * `file` - the file of the template. Defaults to `"nofile"`. * `indentation` - the indentation of the template. Defaults to 0. """ def compile(source, options) do options = Keyword.validate!(options, [ :caller, :tag_handler, :trim, line: 1, indentation: 0, file: "nofile", engine: Phoenix.LiveView.Engine ]) |> Keyword.merge(source: source, trim_eex: false, strip_eex_comments: true) source |> TagEngine.Parser.parse!(options) |> TagEngine.Compiler.compile(options) end @doc """ Classify the tag type from the given binary. This must return a tuple containing the type of the tag and the name of tag. For instance, for LiveView which uses HTML as default tag handler this would return `{:tag, 'div'}` in case the given binary is identified as HTML tag. You can also return `{:error, "reason"}` so that the compiler will display this error. """ @callback classify_type(name :: binary()) :: {type :: atom(), name :: binary()} @doc """ Returns if the given tag name is void or not. That's mainly useful for HTML tags and used internally by the compiler. You can just implement as `def void?(_), do: false` if you want to ignore this. """ @callback void?(name :: binary()) :: boolean() @doc """ Implements processing of attributes. It returns a quoted expression or attributes. If attributes are returned, the second element is a list where each element in the list represents one attribute. If the list element is a two-element tuple, it is assumed the key is the name to be statically written in the template. The second element is the value which is also statically written to the template whenever possible (such as binaries or binaries inside a list). """ @callback handle_attributes(ast :: Macro.t(), meta :: keyword) :: {:attributes, [{binary(), Macro.t()} | Macro.t()]} | {:quoted, Macro.t()} @doc """ Callback invoked to add annotations around the whole body of a template. """ @callback annotate_body(caller :: Macro.Env.t()) :: {String.t(), String.t()} | nil @doc """ Callback invoked to add annotations around each slot of a template. In case the slot is an implicit inner block, the tag meta points to the component. """ @callback annotate_slot( name :: atom(), tag_meta :: %{line: non_neg_integer(), column: non_neg_integer()}, close_tag_meta :: %{line: non_neg_integer(), column: non_neg_integer()}, caller :: Macro.Env.t() ) :: {String.t(), String.t()} | nil @doc """ Callback invoked to add caller annotations before a function component is invoked. """ @callback annotate_caller(file :: String.t(), line :: integer(), caller :: Macro.Env.t()) :: String.t() | nil @doc """ Renders a component defined by the given function. This function is rarely invoked directly by users. Instead, it is used by `~H` and other engine implementations to render `Phoenix.Component`s. For example, the following: ```heex ``` Is the same as: ```heex <%= component( &MyApp.Weather.city/1, [name: "Kraków"], {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line} ) %> ``` """ def component(func, assigns, caller) when (is_function(func, 1) and is_list(assigns)) or is_map(assigns) do assigns = case assigns do %{__changed__: _} -> assigns _ -> assigns |> Map.new() |> Map.put_new(:__changed__, nil) end case func.(assigns) do %Phoenix.LiveView.Rendered{} = rendered -> %{rendered | caller: caller} %Phoenix.LiveView.Component{} = component -> component other -> raise RuntimeError, """ expected #{inspect(func)} to return a %Phoenix.LiveView.Rendered{} struct Ensure your render function uses ~H to define its template. Got: #{inspect(other)} """ end end @doc """ Define a inner block, generally used by slots. This macro is mostly used by custom HTML engines that provide a `slot` implementation and rarely called directly. The `name` must be the assign name the slot/block will be stored under. If you're using HEEx templates, you should use its higher level `<:slot>` notation instead. See `Phoenix.Component` for more information. """ defmacro inner_block(name, do: do_block) do # TODO: Remove the catch-all clause, it is no longer used case do_block do [{:->, meta, _} | _] -> inner_fun = {:fn, meta, do_block} quote do fn parent_changed, arg -> var!(assigns) = unquote(__MODULE__).__assigns__(var!(assigns), unquote(name), parent_changed) _ = var!(assigns) unquote(inner_fun).(arg) end end _ -> quote do fn parent_changed, arg -> var!(assigns) = unquote(__MODULE__).__assigns__(var!(assigns), unquote(name), parent_changed) _ = var!(assigns) unquote(do_block) end end end end @doc false def __assigns__(assigns, key, parent_changed) do # If the component is in its initial render (parent_changed == nil) # or the slot/block key is in parent_changed, then we render the # function with the assigns as is. # # Otherwise, we will set changed to an empty list, which is the same # as marking everything as not changed. This is correct because # parent_changed will always be marked as changed whenever any of the # assigns it references inside is changed. It will also be marked as # changed if it has any variable (such as the ones coming from let). if is_nil(parent_changed) or Map.has_key?(parent_changed, key) do assigns else Map.put(assigns, :__changed__, %{}) end end @doc false def __unmatched_let__!(pattern, value) do message = """ cannot match arguments sent from render_slot/2 against the pattern in :let. Expected a value matching `#{pattern}`, got: #{inspect(value)}\ """ stacktrace = self() |> Process.info(:current_stacktrace) |> elem(1) |> Enum.drop(2) reraise(message, stacktrace) end @behaviour EEx.Engine @impl true def init(opts) do IO.warn(""" Using Phoenix.LiveView.TagEngine as an EEx.Engine is deprecated! To compile HEEx, use Phoenix.LiveView.TagEngine.compile/2 instead. """) {subengine, opts} = Keyword.pop(opts, :subengine, Phoenix.LiveView.Engine) tag_handler = Keyword.fetch!(opts, :tag_handler) caller = Keyword.fetch!(opts, :caller) %{ subengine: subengine, substate: subengine.init(opts), file: Keyword.get(opts, :file, "nofile"), line: Keyword.get(opts, :line, caller.line), indentation: Keyword.get(opts, :indentation, 0), caller: caller, source: Keyword.fetch!(opts, :source), tag_handler: tag_handler } end ## EEx.Engine callbacks ## These delegate to the subengine to satisfy EEx's expectations, ## but handle_body ignores everything and reparses with TagEngine.Parser + TagEngine.Compiler. @impl true def handle_body(state) do trim = Application.get_env(:phoenix, :trim_on_html_eex_engine, true) %{ source: source, file: file, line: line, caller: caller, tag_handler: tag_handler, subengine: subengine, indentation: indentation } = state options = [ engine: subengine, file: file, line: line, caller: caller, indentation: indentation, source: source, tag_handler: tag_handler, trim_tokens: true, trim: trim ] compile(source, options) end @impl true def handle_end(_state) do nil end @impl true def handle_begin(_state) do nil end @impl true def handle_text(state, _meta, _text) do state end @impl true def handle_expr(state, _marker, _expr) do state end end ================================================ FILE: lib/phoenix_live_view/test/client_proxy.ex ================================================ defmodule Phoenix.LiveViewTest.ClientProxy do @moduledoc false use GenServer @data_phx_upload_ref "data-phx-upload-ref" @events :e @title :t @reply :r defstruct session_token: nil, static_token: nil, module: nil, endpoint: nil, router: nil, pid: nil, proxy: nil, topic: nil, ref: nil, rendered: nil, children: [], child_statics: %{}, id: nil, uri: nil, connect_params: %{}, connect_info: %{}, on_error: nil alias Plug.Conn.Query alias Phoenix.LiveViewTest.{ClientProxy, DOM, Diff, Element, TreeDOM, Upload, View} import Phoenix.LiveViewTest.Utils, only: [stringify: 2] @doc """ Encoding used by the Channel serializer. """ def encode!(msg), do: msg @doc """ Stops the client proxy gracefully. """ def stop(proxy_pid, reason) do GenServer.call(proxy_pid, {:stop, reason}) end @doc """ Returns the tokens of the root view. """ def root_view(proxy_pid) do GenServer.call(proxy_pid, :root_view) end @doc """ Reports upload progress to the proxy. """ def report_upload_progress(proxy_pid, from, element, entry_ref, percent, cid) do GenServer.call(proxy_pid, {:upload_progress, from, element, entry_ref, percent, cid}) end @doc """ Starts a client proxy. ## Options * `:caller` - the required `{ref, pid}` pair identifying the caller. * `:view` - the required `%Phoenix.LiveViewTest.View{}` * `:html` - the required string of HTML for the document. """ def start_link(opts) do GenServer.start_link(__MODULE__, opts) end def init(opts) do # Since we are always running in the test client, we will disable # our own logging and let the client do the job. Logger.put_process_level(self(), :none) %{ caller: {_, ref} = caller, html: response_html, connect_params: connect_params, connect_info: connect_info, live_module: module, endpoint: endpoint, router: router, session: session, url: url, test_supervisor: test_supervisor, on_error: on_error, start_location: start_location } = opts # We can assume there is at least one LiveView # because the live_module assign was set. # # On live_redirect, we only have a fragment of the full HTML response, # because the root layout is not included in the redirect response. html = case response_html do {:document, html} -> DOM.parse_document(html, fn type, msg -> send(self(), {:test_error, type, msg}) end) {:fragment, html} -> DOM.parse_fragment(html, fn type, msg -> send(self(), {:test_error, type, msg}) end) end {lazy_html, html_tree} = html {id, session_token, static_token, redirect_url} = case Map.fetch(opts, :live_redirect) do {:ok, {id, session_token, static_token}} -> {id, session_token, static_token, url} :error -> [{id, session_token, static_token} | _] = TreeDOM.find_live_views(html_tree) {id, session_token, static_token, nil} end root_view = %ClientProxy{ id: id, ref: ref, connect_params: connect_params, connect_info: connect_info, session_token: session_token, static_token: static_token, module: module, endpoint: endpoint, router: router, uri: URI.parse(url), child_statics: Map.delete(DOM.find_static_views(lazy_html), id), topic: "lv:#{id}", # we store on_error in the view ClientProxy struct as well # to pass it when live_redirecting on_error: on_error } # We build an absolute path to any relative # static assets through the root LiveView's endpoint. priv_dir = :otp_app |> endpoint.config() |> Application.app_dir("priv") static_url = endpoint.config(:static_url) || [] static_path = case Keyword.get(static_url, :path) do nil -> Path.join(priv_dir, "static") _path -> priv_dir end # clear stream elements from static render html_tree = TreeDOM.remove_stream_children(html_tree) state = %{ join_ref: 0, ref: 0, caller: caller, views: %{}, ids: %{}, pids: %{}, replies: %{}, dropped_replies: %{}, root_view: nil, html_tree: html_tree, lazy_cache: %{root_view.id => DOM.by_id!(lazy_html, root_view.id)}, static_path: static_path, session: session, test_supervisor: test_supervisor, url: url, page_title: :unset, on_error: on_error, start_location: start_location } try do {root_view, rendered, resp} = mount_view(state, root_view, url, redirect_url) new_state = state |> maybe_put_container(resp) |> Map.put(:root_view, root_view) |> put_view(root_view, rendered) |> detect_added_or_removed_children(root_view, html_tree, []) send_caller( new_state, {:ok, build_client_view(root_view), TreeDOM.to_html(new_state.html_tree)} ) {:ok, new_state} catch :throw, {:stop, {:shutdown, reason}, _state} -> send_caller(state, {:error, reason}) :ignore :throw, {:stop, reason, _} -> Process.unlink(elem(caller, 0)) {:stop, reason} end end defp maybe_put_container(state, %{container: container}) do [tag, attrs] = container %{ state | html_tree: TreeDOM.replace_root_container(state.html_tree, tag, attrs), lazy_cache: %{} } end defp maybe_put_container(state, %{} = _resp), do: state defp build_client_view(%ClientProxy{} = proxy) do %{id: id, ref: ref, topic: topic, module: module, endpoint: endpoint, pid: pid} = proxy %View{id: id, pid: pid, proxy: {ref, topic, self()}, module: module, endpoint: endpoint} end defp mount_view(state, view, url, redirect_url) do ref = make_ref() case start_supervised_channel(state, view, ref, url, redirect_url) do {:ok, pid} -> mon_ref = Process.monitor(pid) receive do {^ref, {:ok, %{rendered: rendered} = resp}} -> Process.demonitor(mon_ref, [:flush]) {%{view | pid: pid}, Diff.merge_diff(%{}, rendered), resp} {^ref, {:error, %{live_redirect: opts}}} -> throw(stop_redirect(state, view.topic, {:live_redirect, opts})) {^ref, {:error, %{redirect: opts}}} -> throw(stop_redirect(state, view.topic, {:redirect, opts})) {^ref, {:error, %{reason: reason}}} when reason in ~w(stale unauthorized) -> redir_to = case redirect_url do %URI{} = uri -> URI.to_string(uri) nil -> url end throw(stop_redirect(state, view.topic, {:redirect, %{to: redir_to}})) {^ref, {:error, reason}} -> throw({:stop, reason, state}) {:DOWN, ^mon_ref, _, _, reason} -> throw({:stop, reason, state}) end {:error, reason} -> throw({:stop, reason, state}) end end defp start_supervised_channel(state, view, ref, url, redirect_url) do socket = %Phoenix.Socket{ transport_pid: self(), serializer: __MODULE__, channel: view.module, endpoint: view.endpoint, private: %{connect_info: Map.put_new(view.connect_info, :session, state.session)}, topic: view.topic, join_ref: state.join_ref } params = %{ "session" => view.session_token, "static" => view.static_token, "params" => Map.put(view.connect_params, "_mounts", 0), "caller" => state.caller } params = put_non_nil(params, "url", url) params = put_non_nil(params, "redirect", redirect_url) from = {self(), ref} spec = %{ id: make_ref(), start: {Phoenix.LiveView.Channel, :start_link, [{view.endpoint, from}]}, restart: :temporary } with {:ok, pid} <- Supervisor.start_child(state.test_supervisor, spec) do send(pid, {Phoenix.Channel, params, from, socket}) {:ok, pid} end end defp put_non_nil(%{} = map, _key, nil), do: map defp put_non_nil(%{} = map, key, val), do: Map.put(map, key, val) def handle_info({:sync_children, topic, from}, state) do view = fetch_view_by_topic!(state, topic) children = Enum.flat_map(view.children, fn {id, _session} -> case fetch_view_by_id(state, id) do {:ok, child} -> [build_client_view(child)] :error -> [] end end) GenServer.reply(from, {:ok, children}) {:noreply, state} end def handle_info({:sync_render_element, operation, topic_or_element, from}, state) do view = fetch_view_by_topic!(state, proxy_topic(topic_or_element)) render_with_selector = fn topic_or_element -> {state, root} = root(state, view) result = select_node(root, topic_or_element) {state, case {operation, result} do {:find_element, {:ok, node}} -> {:ok, node} {:find_element, {:error, _, message}} -> {:raise, ArgumentError.exception(message)} {:has_element?, {:error, :none, _}} -> {:ok, false} {:has_element?, _} -> {:ok, true} end} end {state, reply} = case topic_or_element do %Element{} = element -> render_with_selector.(element) {_, _, nil} -> {state, {:ok, TreeDOM.by_id!(state.html_tree, view.id)}} {_, _, selector} when not is_nil(selector) -> render_with_selector.(selector) end GenServer.reply(from, reply) {:noreply, state} end def handle_info({:sync_render_event, topic_or_element, type, value, from}, state) do {state, result} = case topic_or_element do {topic, event, selector} -> view = fetch_view_by_topic!(state, topic) {state, root} = root(state, view) cids = if selector do DOM.targets_from_selector(root, selector) else [nil] end {values, upload} = case value do %Upload{} = upload -> {%{}, upload} _ -> {stringify(value, & &1), nil} end {state, [{:event, view, cids, event, values, upload}]} %Element{} = element -> view = fetch_view_by_topic!(state, proxy_topic(element)) {state, root} = root(state, view) result = with {:ok, node} <- select_node(root, element), :ok <- maybe_enabled(type, node, element), {:ok, event_or_js, fallback} <- maybe_event(type, node, element), {:ok, dom_values} <- maybe_values(type, root, node, element) do case maybe_js_commands(event_or_js, root, view, node, value, dom_values) do [] when fallback != [] -> fallback [] -> {:error, :invalid, "no push or navigation command found within JS commands: #{event_or_js}"} events -> events end end {state, result} end case result do [_ | _] = events -> last_event = length(events) - 1 events |> Enum.with_index() |> Enum.reduce({:noreply, state}, fn {event, event_index}, {:noreply, state} -> case event do {:event, view, cids, event, values, upload} -> last_cid = length(cids) - 1 state = cids |> Enum.with_index() |> Enum.reduce(state, fn {cid, cid_index}, acc -> {acc, root} = root(acc, view) payload = encode_payload(type, event, values) |> maybe_put_cid(cid) |> maybe_put_uploads(root, upload) push_with_callback(acc, from, view, "event", payload, fn reply, state -> if event_index == last_event and cid_index == last_cid do {:noreply, render_reply(reply, from, state)} else {:noreply, state} end end) end) {:noreply, state} {:patch, topic, path} -> handle_call({:render_patch, topic, path}, from, state) {:allow_upload, topic, ref} -> handle_call({:render_allow_upload, topic, ref, value}, from, state) {:upload_progress, topic, upload_ref} -> payload = Map.put(value, "ref", upload_ref) view = fetch_view_by_topic!(state, topic) {:noreply, push_with_reply(state, from, view, "progress", payload)} {:stop, topic, reason} -> stop_redirect(state, topic, reason) end {_event, _event_index}, return -> return end) {:error, _, message} -> GenServer.reply(from, {:raise, ArgumentError.exception(message)}) {:noreply, state} end end def handle_info( %Phoenix.Socket.Message{ event: "redirect", topic: _topic, payload: %{to: _to} = opts }, state ) do stop_redirect(state, state.root_view.topic, {:redirect, opts}) end def handle_info( %Phoenix.Socket.Message{ event: "live_patch", topic: _topic, payload: %{to: _to} = opts }, state ) do send_patch(state, state.root_view.topic, opts) {:noreply, state} end def handle_info( %Phoenix.Socket.Message{ event: "live_redirect", topic: _topic, payload: %{to: _to} = opts }, state ) do stop_redirect(state, state.root_view.topic, {:live_redirect, opts}) end def handle_info( %Phoenix.Socket.Message{ event: "diff", topic: topic, payload: diff }, state ) do {:noreply, merge_rendered(state, topic, diff)} end def handle_info(%Phoenix.Socket.Reply{ref: ref} = reply, state) do case fetch_reply(state, ref) do {:ok, {_pid, _from, callback}} -> case handle_reply(state, reply) do {:ok, new_state} -> callback.(reply, drop_reply(new_state, ref)) other -> other end :error -> case Map.fetch(state.dropped_replies, ref) do {:ok, from} -> from && GenServer.reply(from, {:ok, nil}) {:noreply, %{state | dropped_replies: Map.delete(state.dropped_replies, ref)}} :error -> {:noreply, state} end end end def handle_info({:DOWN, _ref, :process, pid, reason}, state) do case fetch_view_by_pid(state, pid) do {:ok, _view} -> {:stop, reason, state} :error -> {:noreply, state} end end def handle_info({:socket_close, pid, reason}, state) do case fetch_view_by_pid(state, pid) do {:ok, view} -> {:noreply, drop_view_by_id(state, view.id, reason)} :error -> {:noreply, state} end end def handle_info({:test_error, type, message}, state) do case {configured_test_warning(type, state.on_error), default_test_error(type)} do {nil, :raise} -> raise """ #{String.trim(message)} You can change this error by setting config :phoenix_live_view, :test_warnings, #{inspect(type)}: :warn # can be one of :warn, :raise, or :ignore See the `Phoenix.LiveViewTest` documentation for more details. """ {nil, :warn} -> IO.warn( """ #{String.trim(message)} You can change this warning by setting config :phoenix_live_view, :test_warnings, #{inspect(type)}: :raise # can be one of :warn, :raise, or :ignore See the `Phoenix.LiveViewTest` documentation for more details. """, state.start_location ) {:raise, _} -> raise message {:warn, _} -> IO.warn(message, state.start_location) {:ignore, _} -> :noop end {:noreply, state} end defp configured_test_warning(type, on_error) do case on_error do nil -> Phoenix.LiveViewTest.configured_test_warning(type) generic when is_atom(generic) -> generic config when is_list(config) -> Keyword.get_lazy(config, type, fn -> Phoenix.LiveViewTest.configured_test_warning(type) end) end end # We only warn for missing form IDs, because it is not necessarily an error # Note: remember to add a clause handling :ignore if we default some checks # to :ignore in the future. defp default_test_error(:missing_form_id), do: :warn defp default_test_error(_), do: :raise def handle_call({:upload_progress, from, %Element{} = el, entry_ref, progress, cid}, _, state) do payload = maybe_put_cid(%{"entry_ref" => entry_ref, "progress" => progress}, cid) topic = proxy_topic(el) %{pid: pid} = fetch_view_by_topic!(state, topic) ping!(pid, state, fn -> send(self(), {:sync_render_event, el, :upload_progress, payload, from}) {:reply, :ok, state} end) end def handle_call(:page_title, _from, %{page_title: :unset} = state) do state = %{state | page_title: root_page_title(state.html_tree)} {:reply, {:ok, state.page_title}, state} end def handle_call(:page_title, _from, state) do {:reply, {:ok, state.page_title}, state} end def handle_call(:url, _from, state) do {:reply, {:ok, state.url}, state} end def handle_call(:html, _from, state) do {:reply, {:ok, {state.html_tree, state.static_path}}, state} end def handle_call(:root_view, _from, state) do {:reply, {state.session, state.root_view}, state} end def handle_call({:live_children, topic}, from, state) do view = fetch_view_by_topic!(state, topic) ping!(view.pid, state, fn -> send(self(), {:sync_children, view.topic, from}) {:noreply, state} end) end def handle_call({:render_element, operation, topic_or_element}, from, state) do topic = proxy_topic(topic_or_element) %{pid: pid} = fetch_view_by_topic!(state, topic) ping!(pid, state, fn -> send(self(), {:sync_render_element, operation, topic_or_element, from}) {:noreply, state} end) end def handle_call({:async_pids, topic_or_element}, _from, state) do topic = proxy_topic(topic_or_element) %{pid: pid} = fetch_view_by_topic!(state, topic) {:reply, Phoenix.LiveView.Channel.async_pids(pid), state} end def handle_call({:render_event, topic_or_element, type, value}, from, state) do topic = proxy_topic(topic_or_element) %{pid: pid} = fetch_view_by_topic!(state, topic) ping!(pid, state, fn -> send(self(), {:sync_render_event, topic_or_element, type, value, from}) {:noreply, state} end) end def handle_call({:render_patch, topic, path}, from, state) do view = fetch_view_by_topic!(state, topic) path = URI.merge(state.root_view.uri, URI.parse(path)) |> to_string() state = push_with_reply(state, from, view, "live_patch", %{"url" => path}) send_patch(state, state.root_view.topic, %{to: path}) {:noreply, state} end def handle_call({:render_allow_upload, topic, ref, {entries, cid}}, from, state) do view = fetch_view_by_topic!(state, topic) payload = maybe_put_cid(%{"ref" => ref, "entries" => entries}, cid) new_state = push_with_callback(state, from, view, "allow_upload", payload, fn reply, state -> GenServer.reply(from, {:ok, reply.payload}) {:noreply, state} end) {:noreply, new_state} end def handle_call({:stop, reason}, _from, state) do %{caller: {pid, _}} = state Process.unlink(pid) {:stop, :ok, reason, state} end def handle_call({:sync_with_root, topic}, _from, state) do view = fetch_view_by_topic!(state, topic) ping!(view.pid, state, fn -> # if we target a child view, we ping the root view as well if view.pid !== state.root_view.pid do ping!(state.root_view.pid, state, fn -> {:reply, :ok, state} end) else {:reply, :ok, state} end end) end def handle_call({:get_lazy, %Element{} = element}, _from, state) do view = fetch_view_by_topic!(state, proxy_topic(element)) {state, root} = root(state, view.id) {:reply, {:ok, root}, state} end def handle_call({:get_lazy, id}, _from, state) do {state, root} = root(state, id) {:reply, {:ok, root}, state} end defp ping!(pid, state, fun) do try do # We send a message to the channel for synchronization purposes. # # It can happen that the channel shuts down before the ping is processed, # or even that the channel is already dead, therefore we catch the exit # and let it be handled by the regular handle_info callback for # the DOWN message. Phoenix.LiveView.Channel.ping(pid) catch :exit, _ -> receive do {:DOWN, _ref, :process, ^pid, _reason} = down -> handle_info(down, state) end else :ok -> fun.() end end defp drop_view_by_id(state, id, reason) do {:ok, view} = fetch_view_by_id(state, id) state = push(state, view, "phx_leave", %{}) state = Enum.reduce(view.children, state, fn {child_id, _child_session}, acc -> drop_view_by_id(acc, child_id, reason) end) flush_replies( %{ state | ids: Map.delete(state.ids, view.id), views: Map.delete(state.views, view.topic), pids: Map.delete(state.pids, view.pid) }, view.pid ) end defp flush_replies(state, pid) do Enum.reduce(state.replies, state, fn {ref, {^pid, _from, _callback}}, acc -> drop_reply(acc, ref) {_ref, {_pid, _from, _callback}}, acc -> acc end) end defp fetch_reply(state, ref) do Map.fetch(state.replies, ref) end defp put_reply(state, ref, pid, from, callback) do %{state | replies: Map.put(state.replies, ref, {pid, from, callback})} end defp drop_reply(state, ref) do dropped_replies = case Map.fetch(state.replies, ref) do {:ok, {_pid, from, _callback}} -> Map.put(state.dropped_replies, ref, from) :error -> state.dropped_replies end %{ state | replies: Map.delete(state.replies, ref), dropped_replies: dropped_replies } end defp put_child(state, %ClientProxy{} = parent, id, session) do update_in(state.views[parent.topic], fn %ClientProxy{} = parent -> %{parent | children: [{id, session} | parent.children]} end) end defp drop_child(state, %ClientProxy{} = parent, id, reason) do update_in(state.views[parent.topic], fn %ClientProxy{} = parent -> new_children = Enum.reject(parent.children, fn {cid, _session} -> id == cid end) %{parent | children: new_children} end) |> drop_view_by_id(id, reason) end defp verify_session(%ClientProxy{} = view) do Phoenix.LiveView.Session.verify_session( view.endpoint, view.topic, view.session_token, view.static_token ) end defp put_view(state, %ClientProxy{pid: pid} = view, rendered) do {:ok, %Phoenix.LiveView.Session{view: module}} = verify_session(view) new_view = %{view | module: module, proxy: self(), pid: pid, rendered: rendered} Process.monitor(pid) rendered = maybe_push_events(rendered, state) patch_view( %{ state | views: Map.put(state.views, new_view.topic, new_view), pids: Map.put(state.pids, pid, new_view.topic), ids: Map.put(state.ids, new_view.id, new_view.topic) }, view, Diff.render_diff(rendered), rendered.streams ) end defp patch_view(state, view, child_html, streams) do result = TreeDOM.patch_id(view.id, state.html_tree, child_html, streams, fn type, msg -> send(self(), {:test_error, type, msg}) end) # IO.puts("PATCH VIEW #{view.id}") # dbg(child_html) case result do {new_html, [_ | _] = will_destroy_cids} -> topic = view.topic state = %{state | html_tree: new_html, lazy_cache: %{}} payload = %{"cids" => will_destroy_cids} push_with_callback(state, nil, view, "cids_will_destroy", payload, fn _, state -> still_there_cids = TreeDOM.component_ids(view.id, state.html_tree) payload = %{"cids" => Enum.reject(will_destroy_cids, &(&1 in still_there_cids))} state = push_with_callback(state, nil, view, "cids_destroyed", payload, fn reply, state -> cids = reply.payload.cids {:noreply, update_in(state.views[topic].rendered, &Diff.drop_cids(&1, cids))} end) {:noreply, state} end) {new_html, [] = _deleted_cids} -> %{state | html_tree: new_html, lazy_cache: %{}} end end defp stop_redirect(%{caller: {pid, _}} = state, topic, {_kind, opts} = reason) when is_binary(topic) do send_caller(state, {:redirect, topic, opts}) Process.unlink(pid) {:stop, {:shutdown, reason}, state} end defp fetch_view_by_topic!(state, topic), do: Map.fetch!(state.views, topic) defp fetch_view_by_topic(state, topic), do: Map.fetch(state.views, topic) defp fetch_view_by_pid(state, pid) when is_pid(pid) do with {:ok, topic} <- Map.fetch(state.pids, pid) do fetch_view_by_topic(state, topic) end end defp fetch_view_by_id(state, id) do with {:ok, topic} <- Map.fetch(state.ids, id) do fetch_view_by_topic(state, topic) end end defp render_reply(reply, from, state) do case fetch_view_by_topic(state, reply.topic) do {:ok, view} -> GenServer.reply( from, {:ok, state.html_tree |> TreeDOM.inner_html!(view.id) |> TreeDOM.to_html()} ) state :error -> state end end defp merge_rendered(state, topic, %{diff: diff}), do: merge_rendered(state, topic, diff) defp merge_rendered(%{html_tree: html_before} = state, topic, %{} = diff) do {diff, state} = diff |> maybe_push_events(state) |> maybe_push_reply(state) |> maybe_push_title(state) if diff == %{} do state else case fetch_view_by_topic(state, topic) do {:ok, %ClientProxy{} = view} -> rendered = Diff.merge_diff(view.rendered, diff) new_view = %{view | rendered: rendered} streams = Diff.extract_streams(rendered, rendered.streams) %{state | views: Map.update!(state.views, topic, fn _ -> new_view end)} |> patch_view(new_view, Diff.render_diff(rendered), streams) |> detect_added_or_removed_children(new_view, html_before, streams) :error -> state end end end defp detect_added_or_removed_children(state, view, html_before, streams) do new_state = recursive_detect_added_or_removed_children(state, view, html_before, streams) {:ok, new_view} = fetch_view_by_topic(new_state, view.topic) ids_after = new_state.html_tree |> TreeDOM.reverse_filter(&TreeDOM.attribute(&1, "data-phx-session")) |> TreeDOM.all_attributes("id") |> MapSet.new() Enum.reduce(new_view.children, new_state, fn {id, _session}, acc -> if id in ids_after do acc else drop_child(acc, new_view, id, {:shutdown, :left}) end end) end defp recursive_detect_added_or_removed_children(state, view, html_before, streams) do state.html_tree |> TreeDOM.inner_html!(view.id) |> TreeDOM.find_live_views() |> Enum.reduce(state, fn {id, session, static}, acc -> case fetch_view_by_id(acc, id) do {:ok, view} -> streams = Diff.extract_streams(view.rendered, streams) patch_view(acc, view, TreeDOM.inner_html!(html_before, view.id), streams) :error -> static = static || Map.get(state.root_view.child_statics, id) child_view = build_child(view, id: id, session_token: session, static_token: static) {child_view, rendered, _resp} = mount_view(acc, child_view, nil, nil) streams = Diff.extract_streams(rendered, streams) acc |> put_view(child_view, rendered) |> put_child(view, id, child_view.session_token) |> recursive_detect_added_or_removed_children(child_view, acc.html_tree, streams) end end) end defp send_caller(%{caller: {pid, ref}}, msg) when is_pid(pid) do send(pid, {ref, msg}) end defp send_patch(state, topic, %{to: to} = opts) do relative = case URI.parse(to) do %{path: nil} -> "" %{path: path, query: nil} -> path %{path: path, query: query} -> path <> "?" <> query end send_caller(state, {:patch, topic, %{opts | to: relative}}) end defp push(state, view, event, payload) do ref = state.ref + 1 message = %Phoenix.Socket.Message{ join_ref: state.join_ref, topic: view.topic, event: event, payload: payload, ref: to_string(ref) } send(view.pid, message) %{state | ref: ref} end defp push_with_reply(state, from, view, event, payload) do push_with_callback(state, from, view, event, payload, fn reply, state -> {:noreply, render_reply(reply, from, state)} end) end defp handle_reply(state, reply) do %{payload: payload, topic: topic} = reply new_state = case payload do %{diff: diff} -> merge_rendered(state, topic, diff) %{} = diff -> merge_rendered(state, topic, diff) end case payload do %{live_redirect: %{to: _to} = opts} -> stop_redirect(new_state, topic, {:live_redirect, opts}) %{live_patch: %{to: _to} = opts} -> send_patch(new_state, topic, opts) {:ok, new_state} %{redirect: %{to: _to} = opts} -> stop_redirect(new_state, topic, {:redirect, opts}) %{} -> {:ok, new_state} end end defp push_with_callback(state, from, view, event, payload, callback) do ref = to_string(state.ref + 1) state |> push(view, event, payload) |> put_reply(ref, view.pid, from, callback) end defp build_child(%ClientProxy{ref: ref, proxy: proxy, endpoint: endpoint}, attrs) do attrs_with_defaults = Keyword.merge(attrs, ref: ref, proxy: proxy, endpoint: endpoint, topic: "lv:#{Keyword.fetch!(attrs, :id)}" ) struct!(__MODULE__, attrs_with_defaults) end ## Element helpers defp encode_payload(type, event, value) when type in [:change, :submit], do: %{ "type" => "form", "event" => event, "value" => Plug.Conn.Query.encode(value) } defp encode_payload(type, event, value), do: %{ "type" => Atom.to_string(type), "event" => event, "value" => value } defp proxy_topic({topic, _, _}) when is_binary(topic), do: topic defp proxy_topic(%{proxy: {_ref, topic, _pid}}), do: topic defp root(state, %ClientProxy{id: id}), do: root(state, id) defp root(state, id) when is_binary(id) do case state.lazy_cache do %{^id => lazy} -> {state, lazy} _ -> view_tree = TreeDOM.by_id!(state.html_tree, id) lazy = DOM.to_lazy(List.wrap(view_tree)) lazy_cache = Map.put(state.lazy_cache, id, lazy) {%{state | lazy_cache: lazy_cache}, lazy} end end defp select_node(root, %Element{selector: selector, text_filter: nil}) do select_node(root, selector) end defp select_node(root, %Element{selector: selector, text_filter: text_filter}) do nodes = root |> DOM.child_nodes() |> DOM.all(selector) |> DOM.to_tree() select_node_by_text(root, nodes, text_filter, selector) end defp select_node(root, selector) when is_binary(selector) do case root |> DOM.child_nodes() |> DOM.maybe_one(selector) do {:ok, result} -> {:ok, DOM.to_tree(result) |> hd()} error -> error end end defp select_node_by_text(root, nodes, text_filter, selector) do filtered_nodes = Enum.filter(nodes, &(TreeDOM.to_text(&1) =~ text_filter)) case {nodes, filtered_nodes} do {_, [filtered_node]} -> {:ok, filtered_node} {[], _} -> {:error, :none, "selector #{inspect(selector)} did not return any element within: \n\n" <> DOM.to_html(root)} {[node], []} -> {:error, :none, "selector #{inspect(selector)} did not match text filter #{inspect(text_filter)}, " <> "got: \n\n#{TreeDOM.inspect_html(node)}"} {_, []} -> {:error, :none, "selector #{inspect(selector)} returned #{length(nodes)} elements " <> "but none matched the text filter #{inspect(text_filter)}: \n\n" <> TreeDOM.inspect_html(nodes)} {_, _} -> {:error, :many, "selector #{inspect(selector)} returned #{length(nodes)} elements " <> "and #{length(filtered_nodes)} of them matched the text filter #{inspect(text_filter)}: \n\n " <> TreeDOM.inspect_html(filtered_nodes)} end end defp maybe_event(:upload_progress, node, %Element{} = element) do if ref = TreeDOM.attribute(node, @data_phx_upload_ref) do [{:upload_progress, proxy_topic(element), ref}] else {:error, :invalid, "element selected by #{inspect(element.selector)} does not have a #{@data_phx_upload_ref} attribute"} end end defp maybe_event(:allow_upload, node, %Element{} = element) do if ref = TreeDOM.attribute(node, @data_phx_upload_ref) do [{:allow_upload, proxy_topic(element), ref}] else {:error, :invalid, "element selected by #{inspect(element.selector)} does not have a #{@data_phx_upload_ref} attribute"} end end defp maybe_event(:hook, node, %Element{event: event} = element) do true = is_binary(event) cond do TreeDOM.attribute(node, "phx-hook") -> if TreeDOM.attribute(node, "id") do {:ok, event, []} else {:error, :invalid, "element selected by #{inspect(element.selector)} for phx-hook does not have an ID"} end TreeDOM.attribute(node, "phx-viewport-top") || TreeDOM.attribute(node, "phx-viewport-bottom") -> {:ok, event, []} true -> {:error, :invalid, "element selected by #{inspect(element.selector)} does not have phx-hook attribute"} end end defp maybe_event(:click, {"a", _, _} = node, element) do # If there is a phx-click, that's what we will use, otherwise fallback to href fallback = if to = TreeDOM.attribute(node, "href") do case TreeDOM.attribute(node, "data-phx-link") do "patch" -> [{:patch, proxy_topic(element), to}] "redirect" -> kind = TreeDOM.attribute(node, "data-phx-link-state") || "push" opts = %{to: to, kind: String.to_atom(kind)} [{:stop, proxy_topic(element), {:live_redirect, opts}}] nil -> [{:stop, proxy_topic(element), {:redirect, %{to: to}}}] end else [] end cond do event = TreeDOM.attribute(node, "phx-click") -> {:ok, event, fallback} fallback != [] -> fallback true -> message = "clicked link selected by #{inspect(element.selector)} does not have phx-click or href attributes" {:error, :invalid, message} end end defp maybe_event(type, node, element) when type in [:keyup, :keydown] do cond do event = TreeDOM.attribute(node, "phx-#{type}") -> {:ok, event, []} event = TreeDOM.attribute(node, "phx-window-#{type}") -> {:ok, event, []} true -> {:error, :invalid, "element selected by #{inspect(element.selector)} does not have " <> "phx-#{type} or phx-window-#{type} attributes"} end end defp maybe_event(type, node, element) do if event = TreeDOM.attribute(node, "phx-#{type}") do {:ok, event, []} else {:error, :invalid, "element selected by #{inspect(element.selector)} does not have phx-#{type} attribute"} end end defp maybe_js_decode("[" <> _ = encoded_js), do: Phoenix.json_library().decode!(encoded_js) defp maybe_js_decode(event), do: [["push", %{"event" => event}]] defp maybe_js_commands(event_or_js, root, view, node, value, dom_values) do event_or_js |> maybe_js_decode() |> Enum.flat_map(fn ["push", %{"event" => event} = args] -> js_values = args["value"] || %{} js_target_selector = args["target"] event_values = Map.merge(dom_values, js_values) {values, uploads} = case value do %Upload{} = upload -> {event_values, upload} other -> {deep_merge(event_values, stringify(other, & &1)), nil} end js_targets = DOM.targets_from_selector(root, js_target_selector) node_targets = DOM.targets_from_node(root, node) targets = case {js_targets, node_targets} do {[nil], right} -> right {left, [nil]} -> left {left, right} -> Enum.uniq(left ++ right) end [{:event, view, targets, event, values, uploads}] ["patch", %{"href" => to}] -> [{:patch, view.topic, to}] ["navigate", %{"href" => to, "replace" => true}] -> [{:stop, view.topic, {:live_redirect, %{to: to, kind: :replace}}}] ["navigate", %{"href" => to}] -> [{:stop, view.topic, {:live_redirect, %{to: to, kind: :push}}}] _ -> [] end) end defp maybe_enabled(_type, {tag, _, _}, %{form_data: form_data}) when tag != "form" and form_data != nil do {:error, :invalid, "a form element was given but the selected node is not a form, got #{inspect(tag)}}"} end defp maybe_enabled(type, node, element) do if TreeDOM.attribute(node, "disabled") do {:error, :invalid, "cannot #{type} element #{inspect(element.selector)} because it is disabled"} else :ok end end defp maybe_values(:hook, _root, _node, _element), do: {:ok, %{}} defp maybe_values(type, root, {tag, _, _} = node, element) when type in [:change, :submit] do cond do tag == "form" -> value_inputs = DOM.all_value_inputs(node, root) defaults = DOM.collect_form_values(node, root, fn defaults -> defaults end) lazy_submitter = case TreeDOM.attribute(node, "id") do nil -> # to collect the submitter by selector, # need to convert the tree to a lazy here :( fn -> DOM.to_lazy([node]) end id -> # a lazy function that returns a lazy node with all form inputs # that could be the submitter to collect the submitter by selector fn -> DOM.all(root, ~s< ##{id} :is(input, button):not([form]:not([form="#{id}"])), :is(input, button)[form="#{id}"] >) end end with {:ok, defaults} <- maybe_submitter(defaults, type, lazy_submitter, element), {:ok, value} <- fill_in_map(Enum.to_list(element.form_data || %{}), "", value_inputs, []) do {:ok, defaults |> Query.decode_done() |> deep_merge(TreeDOM.all_values(node)) |> deep_merge(value)} else {:error, _, _} = error -> error end type == :change and tag in ~w(input select textarea) -> {:ok, DOM.collect_input_values(node)} true -> {:error, :invalid, "phx-#{type} is only allowed in forms, got #{inspect(tag)}"} end end defp maybe_values(_type, _root, node, _element) do {:ok, TreeDOM.all_values(node)} end defp deep_merge(%{} = target, %{} = source), do: Map.merge(target, source, fn _, t, s -> deep_merge(t, s) end) defp deep_merge(_target, source), do: source defp maybe_submitter(defaults, :submit, lazy, %Element{meta: %{submitter: element}}) do base = lazy.() case DOM.maybe_one(base, element.selector) do {:ok, node} -> collect_submitter(node, base, element, defaults) {:error, _, msg} -> {:error, :invalid, "invalid form submitter, " <> msg} end end defp maybe_submitter(defaults, _, _, _), do: {:ok, defaults} defp collect_submitter(node, base, element, defaults) do name = DOM.attribute(node, "name") cond do is_nil(name) -> {:error, :invalid, "form submitter selected by #{inspect(element.selector)} must have a name"} submitter?(node) and is_nil(DOM.attribute(node, "disabled")) -> {:ok, Plug.Conn.Query.decode_each({name, DOM.attribute(node, "value")}, defaults)} true -> {:error, :invalid, "could not find non-disabled submit input or button with name #{inspect(name)} within:\n\n" <> DOM.to_html(base)} end end defp submitter?(node) do case DOM.tag(node) do "input" -> DOM.attribute(node, "type") == "submit" "button" -> DOM.attribute(node, "type") in ["submit", nil] _ -> false end end defp maybe_push_events(diff, state) do case diff do %{@events => events} -> for [name, payload] <- events, do: send_caller(state, {:push_event, name, payload}) Map.delete(diff, @events) %{} -> diff end end defp maybe_push_reply(diff, state) do case diff do %{@reply => reply} -> send_caller(state, {:reply, reply}) Map.delete(diff, @reply) %{} -> diff end end defp maybe_push_title(diff, state) do case diff do %{@title => title} -> escaped_title = title |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() {Map.delete(diff, @title), %{state | page_title: escaped_title}} %{} -> {diff, state} end end defp fill_in_map([{key, value} | rest], prefix, node, acc) do key = to_string(key) case fill_in_type(value, fill_in_name(prefix, key), node) do {:ok, value} -> fill_in_map(rest, prefix, node, [{key, value} | acc]) {:error, _, _} = error -> error end end defp fill_in_map([], _prefix, _node, acc) do {:ok, Map.new(acc)} end defp fill_in_type([{_, _} | _] = value, key, node), do: fill_in_map(value, key, node, []) defp fill_in_type(%_{} = value, key, node), do: fill_in_value(value, key, node) defp fill_in_type(%{} = value, key, node), do: fill_in_map(Map.to_list(value), key, node, []) defp fill_in_type(value, key, node), do: fill_in_value(value, key, node) @limited ["select", "multiple select", "checkbox", "radio", "hidden"] @forbidden ["submit", "image"] defp fill_in_value(non_string_value, name, node) do value = stringify(non_string_value, &to_string/1) name = if is_list(value), do: name <> "[]", else: name {types, dom_values} = node |> TreeDOM.filter(fn node -> TreeDOM.attribute(node, "name") == name and is_nil(TreeDOM.attribute(node, "disabled")) end) |> collect_values([], []) limited? = Enum.all?(types, &(&1 in @limited)) cond do calendar_value = calendar_value(types, non_string_value, name, node) -> {:ok, calendar_value} types == [] -> {:error, :invalid, "could not find non-disabled input, select or textarea with name #{inspect(name)} within:\n\n" <> TreeDOM.to_html(TreeDOM.filter(node, fn node -> TreeDOM.attribute(node, "name") end))} forbidden_type = Enum.find(types, &(&1 in @forbidden)) -> {:error, :invalid, "cannot provide value to #{inspect(name)} because #{forbidden_type} inputs are never submitted"} forbidden_value = limited? && value |> List.wrap() |> Enum.find(&(&1 not in dom_values)) -> {:error, :invalid, "value for #{hd(types)} #{inspect(name)} must be one of #{inspect(dom_values)}, " <> "got: #{inspect(forbidden_value)}"} true -> {:ok, value} end end @calendar_fields ~w(year month day hour minute second)a defp calendar_value([], %{calendar: _} = calendar_type, name, node) do @calendar_fields |> Enum.flat_map(fn field -> string_field = Atom.to_string(field) with value when not is_nil(value) <- Map.get(calendar_type, field), {:ok, string_value} <- fill_in_value(value, name <> "[" <> string_field <> "]", node) do [{string_field, string_value}] else _ -> [] end end) |> case do [] -> nil pairs -> Map.new(pairs) end end defp calendar_value(_, _, _, _) do nil end defp collect_values(nodes, types, values) do {types, values} = Enum.reduce(nodes, {types, values}, fn node, {types, values} -> tag = TreeDOM.tag(node) collect_values(tag, node, types, values) end) {types, Enum.reverse(values)} end defp collect_values("textarea", _node, types, values) do {["textarea" | types], values} end defp collect_values("input", node, types, values) do type = TreeDOM.attribute(node, "type") || "text" if type in ["radio", "checkbox", "hidden"] do value = TreeDOM.attribute(node, "value") || DOM.default_value(type) {[type | types], [value | values]} else {[type | types], values} end end defp collect_values("select", node, types, values) do options = node |> TreeDOM.filter(&(TreeDOM.tag(&1) == "option")) |> Enum.map(&(TreeDOM.attribute(&1, "value") || "")) if TreeDOM.attribute(node, "multiple") do {["multiple select" | types], Enum.reverse(options, values)} else {["select" | types], Enum.reverse(options, values)} end end defp collect_values(_tag, _node, types, values) do {types, values} end defp fill_in_name("", name), do: name defp fill_in_name(prefix, name), do: prefix <> "[" <> name <> "]" defp maybe_put_uploads(payload, root, %Upload{} = upload) do {:ok, node} = select_node(root, upload.element) ref = TreeDOM.attribute(node, "data-phx-upload-ref") Map.put(payload, "uploads", %{ref => upload.entries}) end defp maybe_put_uploads(payload, _root, nil), do: payload defp maybe_put_cid(payload, nil), do: payload defp maybe_put_cid(payload, cid), do: Map.put(payload, "cid", cid) defp root_page_title(root_html) do case TreeDOM.filter(root_html, fn node -> TreeDOM.tag(node) == "head" end) do [node] -> case TreeDOM.filter(node, fn node -> TreeDOM.tag(node) == "title" end) do [title] -> TreeDOM.to_text(title) _ -> nil end _ -> nil end end end ================================================ FILE: lib/phoenix_live_view/test/diff.ex ================================================ defmodule Phoenix.LiveViewTest.Diff do @moduledoc false alias Phoenix.LiveViewTest.DOM @components :c @static :s @keyed :k @keyed_count :kc @stream_id :stream @template :p @phx_component "data-phx-component" def merge_diff(rendered, diff) do old = Map.get(rendered, @components, %{}) # must extract streams from diff before we pop components streams = extract_streams(diff, []) {new, diff} = Map.pop(diff, @components) rendered = deep_merge_diff(rendered, diff) # If we have any component, we need to get the components # sent by the diff and remove any link between components # statics. We cannot let those links reside in the diff # as components can be removed at any time. rendered = cond do new -> {acc, _} = Enum.reduce(new, {old, %{}}, fn {cid, cdiff}, {acc, cache} -> {value, cache} = find_component(cid, cdiff, old, new, cache) {Map.put(acc, cid, value), cache} end) Map.put(rendered, @components, acc) old != %{} -> Map.put(rendered, @components, old) true -> rendered end Map.put(rendered, :streams, streams) end defp find_component(cid, cdiff, old, new, cache) do case cache do %{^cid => cached} -> {cached, cache} %{} -> {res, cache} = case cdiff do %{@static => cid} when is_integer(cid) and cid > 0 -> {res, cache} = find_component(cid, new[cid], old, new, cache) {deep_merge_diff(res, Map.delete(cdiff, @static)), cache} %{@static => cid} when is_integer(cid) and cid < 0 -> {deep_merge_diff(old[-cid], Map.delete(cdiff, @static)), cache} %{} -> {deep_merge_diff(Map.get(old, cid, %{}), cdiff), cache} end {res, Map.put(cache, cid, res)} end end def drop_cids(rendered, cids) do update_in(rendered[@components], &Map.drop(&1, cids)) end defp deep_merge_diff(target, %{@template => template} = source), do: deep_merge_diff(target, resolve_templates(Map.delete(source, @template), template)) defp deep_merge_diff(target, %{@keyed => source_keyed} = source) when is_map(target) do target_keyed = target[@keyed] merged_keyed = case source_keyed[@keyed_count] do 0 -> %{@keyed_count => 0} count -> for pos <- 0..(count - 1), into: %{@keyed_count => count} do value = case source_keyed[pos] do nil -> target_keyed[pos] value when is_number(value) -> target_keyed[value] value when is_map(value) -> deep_merge_diff(target_keyed[pos], value) [old_pos, value] -> deep_merge_diff(target_keyed[old_pos], value) end {pos, value} end end merged = deep_merge_diff(Map.delete(target, @keyed), Map.delete(source, @keyed)) Map.put(merged, @keyed, merged_keyed) end defp deep_merge_diff(_target, %{@static => _} = source), do: source defp deep_merge_diff(%{} = target, %{} = source), do: Map.merge(target, source, fn _, t, s -> deep_merge_diff(t, s) end) defp deep_merge_diff(_target, source), do: source # we resolve any templates when merging, because subsequent patches can # contain more templates that are not compatible with previous diffs defp resolve_templates(%{@template => template} = rendered, nil) do resolve_templates(Map.delete(rendered, @template), template) end defp resolve_templates(%{@static => static} = rendered, template) when is_integer(static) do resolve_templates(Map.put(rendered, @static, Map.fetch!(template, static)), template) end defp resolve_templates(rendered, template) when is_map(rendered) and not is_struct(rendered) do Map.new(rendered, fn {k, v} -> {k, resolve_templates(v, template)} end) end defp resolve_templates(other, _template), do: other def extract_streams(%{} = source, streams) when not is_struct(source) do Enum.reduce(source, streams, fn {@stream_id, stream}, acc -> [stream | acc] {_key, value}, acc -> extract_streams(value, acc) end) end # streams can also be in the dynamic part of the diff def extract_streams(source, streams) when is_list(source) do Enum.reduce(source, streams, fn el, acc -> extract_streams(el, acc) end) end def extract_streams(_value, acc), do: acc # Diff rendering def render_diff(rendered) do rendered |> Phoenix.LiveView.Diff.to_iodata(&add_cid_attr/2) |> IO.iodata_to_binary() |> DOM.parse_fragment() |> elem(1) end defp add_cid_attr(cid, [head | tail]) do head_with_cid = Regex.replace( ~r/^(\s*(?:\s*)*)<([^\s\/>]+)/, IO.iodata_to_binary(head), "\\0 #{@phx_component}=\"#{to_string(cid)}\"", global: false ) [head_with_cid | tail] end end ================================================ FILE: lib/phoenix_live_view/test/dom.ex ================================================ defmodule Phoenix.LiveViewTest.DOM do @moduledoc false @phx_component "data-phx-component" alias Phoenix.LiveViewTest.TreeDOM, as: Tree alias Plug.Conn.Query import Phoenix.LiveViewTest, only: [configured_test_warning: 1] defguardp is_lazy(html) when is_struct(html, LazyHTML) def ensure_loaded! do if not Code.ensure_loaded?(LazyHTML) do raise """ Phoenix LiveView requires lazy_html as a test dependency. Please add to your mix.exs: {:lazy_html, ">= 0.1.0", only: :test} """ end end @spec parse_document(binary) :: {LazyHTML.t(), LazyHTML.Tree.t()} def parse_document(html, error_reporter \\ nil) do lazydoc = LazyHTML.from_document(html) tree = LazyHTML.to_tree(lazydoc) run_checks(lazydoc, error_reporter) {lazydoc, tree} end @spec parse_fragment(binary) :: {LazyHTML.t(), LazyHTML.Tree.t()} def parse_fragment(html, error_reporter \\ nil) do lazydoc = LazyHTML.from_fragment(html) tree = LazyHTML.to_tree(lazydoc) run_checks(lazydoc, error_reporter) {lazydoc, tree} end defp run_checks(lazydoc, error_reporter) do if is_function(error_reporter, 2) do if configured_test_warning(:duplicate_id) != :ignore, do: detect_duplicate_ids(lazydoc, error_reporter) if configured_test_warning(:missing_form_id) != :ignore, do: detect_forms_without_id(lazydoc, error_reporter) end end defp detect_duplicate_ids(lazydoc, error_reporter) do lazydoc |> LazyHTML.query("[id]") |> LazyHTML.attribute("id") |> Enum.frequencies() |> Enum.each(fn {id, count} -> if count > 1 do error_reporter.(:duplicate_id, """ Duplicate id found while testing LiveView: #{id} #{lazydoc |> by_id!(id) |> to_tree() |> Tree.inspect_html()} LiveView requires that all elements have unique ids, duplicate IDs will cause undefined behavior at runtime, as DOM patching will not be able to target the correct elements. """) end end) end defp detect_forms_without_id(lazydoc, error_reporter) do lazydoc |> LazyHTML.query("form:not([id])") |> Enum.each(fn el -> error_reporter.(:missing_form_id, """ Detected a form with phx-change but missing id: #{el |> to_tree() |> Tree.inspect_html()} Without an id, LiveView will not be able to perform form recovery, for more information see: https://hexdocs.pm/phoenix_live_view/form-bindings.html#recovery-following-crashes-or-disconnects """) end) end def all(lazy, selector) do LazyHTML.query(lazy, selector) end def maybe_one(lazy, selector, type \\ :selector) do result = all(lazy, selector) count = Enum.count(result) case count do 1 -> {:ok, result} 0 -> {:error, :none, "expected #{type} #{inspect(selector)} to return a single element, but got none " <> "within: \n\n" <> to_html(lazy)} _ -> {:error, :many, "expected #{type} #{inspect(selector)} to return a single element, " <> "but got #{count}: \n\n" <> to_html(result)} end end def targets_from_node(lazy, node) do case node && Tree.all_attributes(node, "phx-target") do nil -> [nil] [] -> [nil] [selector] -> targets_from_selector(lazy, selector) end end def targets_from_selector(lazy, selector) def targets_from_selector(_lazy, nil), do: [nil] def targets_from_selector(_lazy, cid) when is_integer(cid), do: [cid] def targets_from_selector(lazy, selector) when is_binary(selector) do case Integer.parse(selector) do {cid, ""} -> [cid] _ -> result = for element <- all(lazy, selector) do if cid = component_id(element) do String.to_integer(cid) end end if result == [] do [nil] else result end end end defp component_id(tree) do LazyHTML.attribute(tree, @phx_component) |> List.first() end def tag(node) do case LazyHTML.tag(node) do [tag | _] -> tag _ -> nil end end def attribute(node, key) do case LazyHTML.attribute(node, key) do [value | _] -> value _ -> nil end end def to_html(lazy) when is_lazy(lazy) do LazyHTML.to_html(lazy, skip_whitespace_nodes: true) end def to_text(node) do LazyHTML.text(node) |> String.replace(~r/[\s]+/, " ") |> String.trim() end def child_nodes(lazy) when is_lazy(lazy) do LazyHTML.child_nodes(lazy) end def by_id!(lazy, id) do LazyHTML.query_by_id(lazy, id) end @doc """ Turns a lazy into a tree. """ def to_tree(lazy, opts \\ []) when is_struct(lazy, LazyHTML), do: LazyHTML.to_tree(lazy, opts) @doc """ Turns a tree into a lazy. """ def to_lazy(tree), do: LazyHTML.from_tree(tree) @doc """ Escapes a string for use as a CSS identifier. ## Examples iex> css_escape("hello world") "hello\\\\ world" iex> css_escape("-123") "-\\\\31 23" """ @spec css_escape(String.t()) :: String.t() def css_escape(value) when is_binary(value) do # This is a direct translation of # https://github.com/mathiasbynens/CSS.escape/blob/master/css.escape.js # into Elixir. value |> String.to_charlist() |> escape_css_chars() |> IO.iodata_to_binary() end defp escape_css_chars(chars) do case chars do # If the character is the first character and is a `-` (U+002D), and # there is no second character, […] [?- | []] -> ["\\-"] _ -> escape_css_chars(chars, 0, []) end end defp escape_css_chars([], _, acc), do: Enum.reverse(acc) defp escape_css_chars([char | rest], index, acc) do escaped = cond do # If the character is NULL (U+0000), then the REPLACEMENT CHARACTER # (U+FFFD). char == 0 -> <<0xFFFD::utf8>> # If the character is in the range [\1-\1F] (U+0001 to U+001F) or is # U+007F, # if the character is the first character and is in the range [0-9] # (U+0030 to U+0039), # if the character is the second character and is in the range [0-9] # (U+0030 to U+0039) and the first character is a `-` (U+002D), char in 0x0001..0x001F or char == 0x007F or (index == 0 and char in ?0..?9) or (index == 1 and char in ?0..?9 and hd(acc) == "-") -> # https://drafts.csswg.org/cssom/#escape-a-character-as-code-point ["\\", Integer.to_string(char, 16), " "] # If the character is not handled by one of the above rules and is # greater than or equal to U+0080, is `-` (U+002D) or `_` (U+005F), or # is in one of the ranges [0-9] (U+0030 to U+0039), [A-Z] (U+0041 to # U+005A), or [a-z] (U+0061 to U+007A), […] char >= 0x0080 or char in [?-, ?_] or char in ?0..?9 or char in ?A..?Z or char in ?a..?z -> # the character itself <> true -> # Otherwise, the escaped character. # https://drafts.csswg.org/cssom/#escape-a-character ["\\", <>] end escape_css_chars(rest, index + 1, [escaped | acc]) end ## Functions specific for LiveView @doc """ Find static information in the given HTML tree. """ def find_static_views(lazy) do all(lazy, "[data-phx-static]") |> Enum.into(%{}, fn node -> {attribute(node, "id"), attribute(node, "data-phx-static")} end) end ## Forms def all_value_inputs({"form", attrs, _} = form, root) do form_inputs = filtered_inputs(form) case Enum.into(attrs, %{}) do %{"id" => id} -> by_form_id = all(root, ~s<[form="#{id}"]>) |> to_tree() named_inputs = filtered_inputs(by_form_id) # All inputs including buttons # Remove the named inputs first to remove any possible # duplicates if the child inputs also had a form attribite. (form_inputs -- named_inputs) ++ named_inputs _ -> form_inputs end end def collect_form_values(form, root, done \\ &Query.decode_done/1) do form |> all_value_inputs(root) |> Enum.reduce(Query.decode_init(), &form_defaults/2) |> then(done) end def collect_input_values(node) do form_defaults(node, Query.decode_init()) |> Query.decode_done() end defp form_defaults(node, acc) do tag = Tree.tag(node) if name = Tree.attribute(node, "name") do form_defaults(tag, node, name, acc) else acc end end # Selectedness algorithm as outlined in # https://html.spec.whatwg.org/multipage/form-elements.html#the-select-element defp form_defaults("select", node, name, acc) do options = Tree.filter(node, &(Tree.tag(&1) == "option")) multiple_display_size = case valid_display_size(node) do int when is_integer(int) and int > 1 -> true _ -> false end all_selected = if Tree.attribute(node, "multiple") || multiple_display_size do Enum.filter(options, &Tree.attribute(&1, "selected")) else List.wrap( Enum.find(Enum.reverse(options), &Tree.attribute(&1, "selected")) || Enum.find(options, &(!Tree.attribute(&1, "disabled"))) ) end Enum.reduce(all_selected, acc, fn selected, acc -> Plug.Conn.Query.decode_each({name, Tree.attribute(selected, "value")}, acc) end) end defp form_defaults("textarea", node, name, acc) do value = Tree.to_text(node, false) if value == "" do Plug.Conn.Query.decode_each({name, ""}, acc) else Plug.Conn.Query.decode_each({name, String.replace_prefix(value, "\n", "")}, acc) end end defp form_defaults("input", node, name, acc) do type = Tree.attribute(node, "type") || "text" value = Tree.attribute(node, "value") || default_value(type) cond do type in ["radio", "checkbox"] -> if Tree.attribute(node, "checked") do Plug.Conn.Query.decode_each({name, value}, acc) else acc end type in ["image", "submit"] -> acc true -> Plug.Conn.Query.decode_each({name, value}, acc) end end def default_value("checkbox"), do: "on" def default_value(_type), do: "" defp valid_display_size(node) do with size when not is_nil(size) <- Tree.attribute(node, "size"), {int, ""} when int > 0 <- Integer.parse(size) do int else _ -> nil end end defp filtered_inputs(nodes) do Tree.filter(nodes, fn node -> Tree.tag(node) in ~w(input textarea select) and is_nil(Tree.attribute(node, "disabled")) end) end end ================================================ FILE: lib/phoenix_live_view/test/live_view_test.ex ================================================ defmodule Phoenix.LiveViewTest do @moduledoc ~S''' Conveniences for testing function components as well as LiveViews and LiveComponents. ## Testing function components There are two mechanisms for testing function components. Imagine the following component: def greet(assigns) do ~H"""
Hello, {@name}!
""" end You can test it by using `render_component/3`, passing the function reference to the component as first argument: import Phoenix.LiveViewTest test "greets" do assert render_component(&MyComponents.greet/1, name: "Mary") == "
Hello, Mary!
" end However, for complex components, often the simplest way to test them is by using the `~H` sigil itself: import Phoenix.Component import Phoenix.LiveViewTest test "greets" do assigns = %{} assert rendered_to_string(~H""" """) == "
Hello, Mary!
" end The difference is that we use `rendered_to_string/1` to convert the rendered template to a string for testing. ## Testing LiveViews and LiveComponents In LiveComponents and LiveView tests, we interact with views via process communication in substitution of a browser. Like a browser, our test process receives messages about the rendered updates from the view which can be asserted against to test the life-cycle and behavior of LiveViews and their children. ### Testing LiveViews The life-cycle of a LiveView as outlined in the `Phoenix.LiveView` docs details how a view starts as a stateless HTML render in a disconnected socket state. Once the browser receives the HTML, it connects to the server and a new LiveView process is started, remounted in a connected socket state, and the view continues statefully. The LiveView test functions support testing both disconnected and connected mounts separately, for example: import Plug.Conn import Phoenix.ConnTest import Phoenix.LiveViewTest @endpoint MyEndpoint test "disconnected and connected mount", %{conn: conn} do conn = get(conn, "/my-path") assert html_response(conn, 200) =~ "

My Disconnected View

" {:ok, view, html} = live(conn) end test "redirected mount", %{conn: conn} do assert {:error, {:redirect, %{to: "/somewhere"}}} = live(conn, "my-path") end Here, we start by using the familiar `Phoenix.ConnTest` function, `get/2` to test the regular HTTP GET request which invokes mount with a disconnected socket. Next, `live/1` is called with our sent connection to mount the view in a connected state, which starts our stateful LiveView process. In general, it's often more convenient to test the mounting of a view in a single step, provided you don't need the result of the stateless HTTP render. This is done with a single call to `live/2`, which performs the `get` step for us: test "connected mount", %{conn: conn} do {:ok, _view, html} = live(conn, "/my-path") assert html =~ "

My Connected View

" end ### Testing Events The browser can send a variety of events to a LiveView via `phx-` bindings, which are sent to the `handle_event/3` callback. To test events sent by the browser and assert on the rendered side effect of the event, use the `render_*` functions: * `render_click/1` - sends a phx-click event and value, returning the rendered result of the `handle_event/3` callback. * `render_focus/2` - sends a phx-focus event and value, returning the rendered result of the `handle_event/3` callback. * `render_blur/1` - sends a phx-blur event and value, returning the rendered result of the `handle_event/3` callback. * `render_submit/1` - sends a form phx-submit event and value, returning the rendered result of the `handle_event/3` callback. * `render_change/1` - sends a form phx-change event and value, returning the rendered result of the `handle_event/3` callback. * `render_keydown/1` - sends a form phx-keydown event and value, returning the rendered result of the `handle_event/3` callback. * `render_keyup/1` - sends a form phx-keyup event and value, returning the rendered result of the `handle_event/3` callback. * `render_hook/3` - sends a hook event and value, returning the rendered result of the `handle_event/3` callback. For example: {:ok, view, _html} = live(conn, "/thermo") assert view |> element("button#inc") |> render_click() =~ "The temperature is: 31℉" In the example above, we are looking for a particular element on the page and triggering its phx-click event. LiveView takes care of making sure the element has a phx-click and automatically sends its values to the server. You can also bypass the element lookup and directly trigger the LiveView event in most functions: assert render_click(view, :inc, %{}) =~ "The temperature is: 31℉" The `element` style is preferred as much as possible, as it helps LiveView perform validations and ensure the events in the HTML actually matches the event names on the server. ### Testing regular messages LiveViews are `GenServer`'s under the hood, and can send and receive messages just like any other server. To test the side effects of sending or receiving messages, simply message the view and use the `render` function to test the result: send(view.pid, {:set_temp, 50}) assert render(view) =~ "The temperature is: 50℉" ### Testing LiveComponents LiveComponents can be tested in two ways. One way is to use the same `render_component/2` function as function components. This will mount the LiveComponent and render it once, without testing any of its events: assert render_component(MyComponent, id: 123, user: %User{}) =~ "some markup in component" However, if you want to test how components are mounted by a LiveView and interact with DOM events, you must use the regular `live/2` macro to build the LiveView with the component and then scope events by passing the view and a **DOM selector** in a list: {:ok, view, html} = live(conn, "/users") html = view |> element("#user-13 a", "Delete") |> render_click() refute html =~ "user-13" refute view |> element("#user-13") |> has_element?() In the example above, LiveView will lookup for an element with ID=user-13 and retrieve its `phx-target`. If `phx-target` points to a component, that will be the component used, otherwise it will fallback to the view. ### Optional test warnings During LiveView tests (using `live/3` or `live_isolated/3`), LiveView performs some additional checks by default. Those include detection of duplicate DOM IDs and LiveComponents. When LiveViewTest detects such an issue, it is either raised as an exception or as a warning. You can configure this behavior in two ways: 1. Application environment config You can configure specific issue types in your application config: ```elixir config :phoenix_live_view, :test_warnings, duplicate_id: :warn, # one of :warn, :raise, :ignore ... ``` The supported keys are: - `:duplicate_id` - when LiveViewTest detects a duplicate DOM ID - `:duplicate_live_component` - when LiveViewTest detects a LiveComponent being rendered multiple times with the same ID - `:missing_form_id` - when LiveViewTest detects a form without an ID attribute (this prevents [form recovery](form-bindings.html#recovery-following-crashes-or-disconnects)) The supported values are: - `:raise` - crashes the test, default - `:warn` - only emits a warning (will still fail tests if combined with `--warnings-as-errors`) - `:ignore` - ignores the check 2. `on_error` option on `live/3` or `live_isolated/3`: By writing `live(conn, "/path", on_error: :warn)`, the default for this specific test can be changed. This example sets all detection types to `:warn`. You can also override specific types only: ```elixir {:ok, view, html} = live(conn, "/path", on_error: [duplicate_id: :ignore]) ``` which will be merged with the global configuration. Note that if a check is marked as `:ignore` in the config, it cannot be re-enabled in `on_error`. ''' @flash_cookie "__phoenix_flash__" alias Phoenix.LiveView.{Diff, Socket} alias Phoenix.LiveViewTest.{ClientProxy, DOM, TreeDOM, Element, View, Upload, UploadClient} @doc """ Puts connect params to be used on LiveView connections. See `Phoenix.LiveView.get_connect_params/1`. """ def put_connect_params(conn, params) when is_map(params) do Plug.Conn.put_private(conn, :live_view_connect_params, params) end @doc """ Spawns a connected LiveView process. If a `path` is given, then a regular `get(conn, path)` is done and the page is upgraded to a LiveView. If no path is given, it assumes a previously rendered `%Plug.Conn{}` is given, which will be converted to a LiveView immediately. ## Options * `:on_error` - Can be either `:raise` or `:warn` to control whether detected errors like duplicate IDs or live components fail the test or just log a warning. Defaults to `:raise`. ## Examples {:ok, view, html} = live(conn, "/path") assert view.module == MyLive assert html =~ "the count is 3" assert {:error, {:redirect, %{to: "/somewhere"}}} = live(conn, "/path") """ defmacro live(conn, path \\ nil, opts \\ []) do quote bind_quoted: binding(), generated: true do cond do is_binary(path) -> Phoenix.LiveViewTest.__live__(get(conn, path), path, opts) is_nil(path) -> Phoenix.LiveViewTest.__live__(conn, nil, opts) true -> raise RuntimeError, "path must be nil or a binary, got: #{inspect(path)}" end end end @doc """ Spawns a connected LiveView process mounted in isolation as the sole rendered element. Useful for testing LiveViews that are not directly routable, such as those built as small components to be re-used in multiple parents. Testing routable LiveViews is still recommended whenever possible since features such as live navigation require routable LiveViews. ## Options * `:session` - the session to be given to the LiveView * `:on_error` - Can be either `:raise` or `:warn` to control whether detected errors like duplicate IDs or live components fail the test or just log a warning. Defaults to `:raise`. All other options are forwarded to the LiveView for rendering. Refer to `Phoenix.Component.live_render/3` for a list of supported render options. ## Examples {:ok, view, html} = live_isolated(conn, MyAppWeb.ClockLive, session: %{"tz" => "EST"}) Use `put_connect_params/2` to put connect params for a call to `Phoenix.LiveView.get_connect_params/1` in `c:Phoenix.LiveView.mount/3`: {:ok, view, html} = conn |> put_connect_params(%{"param" => "value"}) |> live_isolated(AppWeb.ClockLive, session: %{"tz" => "EST"}) """ defmacro live_isolated(conn, live_view, opts \\ []) do endpoint = Module.get_attribute(__CALLER__.module, :endpoint) quote bind_quoted: binding(), unquote: true do unquote(__MODULE__).__isolated__(conn, endpoint, live_view, opts) end end @doc false def __isolated__(conn, endpoint, live_view, opts) do put_in(conn.private[:phoenix_endpoint], endpoint || raise("no @endpoint set in test module")) |> Plug.Test.init_test_session(%{}) |> Phoenix.LiveView.Router.fetch_live_flash([]) |> Phoenix.LiveView.Controller.live_render(live_view, opts) |> connect_from_static_token(nil, opts) end @doc false def __live__(%Plug.Conn{state: state, status: status} = conn, _path = nil, opts) do path = rebuild_path(conn) case {state, status} do {:sent, 200} -> connect_from_static_token(conn, path, opts) {:sent, 302} -> error_redirect_conn(conn) {:sent, _} -> raise ArgumentError, "request to #{conn.request_path} received unexpected #{status} response" {_, _} -> raise ArgumentError, """ a request has not yet been sent. live/1 must use a connection with a sent response. Either call get/2 prior to live/1, or use live/2 while providing a path to have a get request issued for you. For example issuing a get yourself: {:ok, view, _html} = conn |> get("#{path}") |> live() or performing the GET and live connect in a single step: {:ok, view, _html} = live(conn, "#{path}") """ end end @doc false def __live__(conn, path, opts) do connect_from_static_token(conn, path, opts) end defp connect_from_static_token( %Plug.Conn{status: 200, assigns: %{live_module: live_module}} = conn, path, opts ) do DOM.ensure_loaded!() router = try do Phoenix.Controller.router_module(conn) rescue KeyError -> nil end start_location = case Process.info(self(), :current_stacktrace) do {:current_stacktrace, [ {Process, :info, 2, _}, {Phoenix.LiveViewTest, :connect_from_static_token, _, _}, {_user_module, _test_name, 1, meta} | _ ]} -> meta _ -> [] end start_proxy(path, %{ response: {:document, Phoenix.ConnTest.response(conn, 200)}, connect_params: conn.private[:live_view_connect_params] || %{}, connect_info: conn.private[:live_view_connect_info] || prune_conn(conn), live_module: live_module, router: router, endpoint: Phoenix.Controller.endpoint_module(conn), session: maybe_get_session(conn), url: Plug.Conn.request_url(conn), on_error: opts[:on_error], start_location: start_location }) end defp connect_from_static_token(%Plug.Conn{status: 200}, _path, _opts) do {:error, :nosession} end defp connect_from_static_token(%Plug.Conn{status: redir} = conn, _path, _opts) when redir in [301, 302, 303] do error_redirect_conn(conn) end defp prune_conn(conn) do %{conn | resp_body: nil, resp_headers: []} end defp error_redirect_conn(conn) do to = hd(Plug.Conn.get_resp_header(conn, "location")) opts = if flash = conn.assigns[:flash] || conn.private[:phoenix_flash] do %{to: to, flash: flash} else %{to: to} end {:error, {error_redirect_key(conn), opts}} end defp error_redirect_key(%{private: %{phoenix_live_redirect: true}}), do: :live_redirect defp error_redirect_key(_), do: :redirect defp start_proxy(path, %{} = opts) do ref = make_ref() opts = Map.merge(opts, %{ caller: {self(), ref}, html: opts.response, connect_params: opts.connect_params, connect_info: opts.connect_info, live_module: opts.live_module, endpoint: opts.endpoint, session: opts.session, url: opts.url, test_supervisor: fetch_test_supervisor!(), on_error: opts.on_error }) case ClientProxy.start_link(opts) do {:ok, _pid} -> receive do {^ref, {:ok, view, html}} -> {:ok, view, html} end {:error, reason} -> exit({reason, {__MODULE__, :live, [path]}}) :ignore -> receive do {^ref, {:error, reason}} -> {:error, reason} end end end defp fetch_test_supervisor!() do case ExUnit.fetch_test_supervisor() do {:ok, sup} -> sup :error -> raise ArgumentError, "LiveView helpers can only be invoked from the test process" end end defp maybe_get_session(%Plug.Conn{} = conn) do try do Plug.Conn.get_session(conn) rescue _ -> %{} end end defp rebuild_path(%Plug.Conn{request_path: request_path, query_string: ""}), do: request_path defp rebuild_path(%Plug.Conn{request_path: request_path, query_string: query_string}), do: request_path <> "?" <> query_string @doc """ Renders a component. The first argument may either be a function component, as an anonymous function: assert render_component(&Weather.city/1, name: "Kraków") =~ "some markup in component" Or a stateful component as a module. In this case, this function will mount, update, and render the component. The `:id` option is a required argument: assert render_component(MyComponent, id: 123, user: %User{}) =~ "some markup in component" If your component is using the router, you can pass it as argument: assert render_component(MyComponent, %{id: 123, user: %User{}}, router: SomeRouter) =~ "some markup in component" """ defmacro render_component(component, assigns \\ Macro.escape(%{}), opts \\ []) do endpoint = Module.get_attribute(__CALLER__.module, :endpoint) component = if is_atom(component) do quote do unquote(component).__live__() unquote(component) end else component end quote do Phoenix.LiveViewTest.__render_component__( unquote(endpoint), unquote(component), unquote(assigns), unquote(opts) ) end end @doc false def __render_component__(endpoint, component, assigns, opts) when is_atom(component) do socket = %Socket{endpoint: endpoint, router: opts[:router]} assigns = Map.new(assigns) # TODO: Make the ID required once we support only stateful module components as live_component mount_assigns = if assigns[:id], do: %{myself: %Phoenix.LiveComponent.CID{cid: -1}}, else: %{} socket |> Diff.component_to_rendered(component, assigns, mount_assigns) |> rendered_to_diff_string(socket) end def __render_component__(endpoint, function, assigns, opts) when is_function(function, 1) do socket = %Socket{endpoint: endpoint, router: opts[:router]} assigns |> Map.new() |> Map.put_new(:__changed__, %{}) |> function.() |> rendered_to_diff_string(socket) end defp rendered_to_diff_string(rendered, socket) do {diff, _, _} = Diff.render(socket, rendered, Diff.new_fingerprints(), Diff.new_components()) diff |> Diff.to_iodata() |> IO.iodata_to_binary() end @doc ~S''' Converts a rendered template to a string. ## Examples import Phoenix.Component import Phoenix.LiveViewTest test "greets" do assigns = %{} assert rendered_to_string(~H""" """) == "
Hello, Mary!
" end ''' def rendered_to_string(rendered) do rendered |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() end @doc """ Sends a click event given by `element` and returns the rendered result. The `element` is created with `element/3` and must point to a single element on the page with a `phx-click` attribute in it. The event name given set on `phx-click` is then sent to the appropriate LiveView (or component if `phx-target` is set accordingly). All `phx-value-*` entries in the element are sent as values. Extra values can be given with the `value` argument. If the element does not have a `phx-click` attribute but it is a link (the `` tag), the link will be followed accordingly: * if the link is a `patch`, the current view will be patched * if the link is a `navigate`, this function will return `{:error, {:live_redirect, %{to: url}}}`, which can be followed with `follow_redirect/2` * if the link is a regular link, this function will return `{:error, {:redirect, %{to: url}}}`, which can be followed with `follow_redirect/2` It returns the contents of the whole LiveView or an `{:error, redirect}` tuple. ## Examples {:ok, view, html} = live(conn, "/thermo") assert view |> element("button", "Increment") |> render_click() =~ "The temperature is: 30℉" """ def render_click(element, value \\ %{}) def render_click(%Element{} = element, value), do: render_event(element, :click, value) def render_click(view, event), do: render_click(view, event, %{}) @doc """ Sends a click `event` to the `view` with `value` and returns the rendered result. It returns the contents of the whole LiveView or an `{:error, redirect}` tuple. ## Examples {:ok, view, html} = live(conn, "/thermo") assert html =~ "The temperature is: 30℉" assert render_click(view, :inc) =~ "The temperature is: 31℉" """ def render_click(view, event, value) do render_event(view, :click, event, value) end @doc """ Puts the submitter `element_or_selector` on the given `form` element. A submitter is an element that initiates the form's submit event on the client. When a submitter is put on an element created with `form/3` and then the form is submitted via `render_submit/2`, the name/value pair of the submitter will be included in the submit event payload. The given element or selector must exist within the form and match one of the following: - A `button` or `input` element with `type="submit"`. - A `button` element without a `type` attribute. ## Examples form = view |> form("#my-form") assert form |> put_submitter("button[name=example]") |> render_submit() =~ "Submitted example" """ def put_submitter(form, element_or_selector) def put_submitter(%Element{proxy: proxy} = form, submitter) when is_binary(submitter) do put_submitter(form, %Element{proxy: proxy, selector: submitter}) end def put_submitter(%Element{} = form, %Element{} = submitter) do %{form | meta: Map.put(form.meta, :submitter, submitter)} end @doc ~S''' Sends a form submit event given by `element` and returns the rendered result. The `element` is created with `element/3` and must point to a single element on the page with a `phx-submit` attribute in it. The event name given set on `phx-submit` is then sent to the appropriate LiveView (or component if `phx-target` is set accordingly). All `phx-value-*` entries in the element are sent as values. Extra values, including hidden input fields, can be given with the `value` argument. It returns the contents of the whole LiveView or an `{:error, redirect}` tuple. ## Examples {:ok, view, html} = live(conn, "/thermo") assert view |> element("form") |> render_submit(%{deg: 123, avatar: upload}) =~ "123 exceeds limits" To submit a form along with some hidden input values: assert view |> form("#term", user: %{name: "hello"}) |> render_submit(%{user: %{"hidden_field" => "example"}}) =~ "Name updated" To submit a form by a specific submit element via `put_submitter/2`: assert view |> form("#term", user: %{name: "hello"}) |> put_submitter("button[name=example_action]") |> render_submit() =~ "Action taken" > #### Anti-pattern: bypassing LiveViewTest's form validation unnecessarily {: .warning} > > **DO NOT** use the value parameter to pass data that you expect to be filled > by regular input fields in the form. Values given directly to `render_submit` > are not checked against the inputs rendered as part of the form. > > Imagine you have this code: > > ```elixir > def render(assigns) do > ~H""" >
> > >
> """ > end > > def handle_event("save", %{"name" => name}, socket) do > ... > end > ``` > > And you test it with: > > ```elixir > view |> form("form") |> render_submit(%{name: "hello"}) > ``` > > Because the values given to `render_submit` are not checked against the > form, if you later change the input name to something, the test will not fail. > Instead, you should always pass values that are part of visible input fields > as part of the `form/3` call: > > ```elixir > view |> form("form", %{name: "hello"}) |> render_submit() > ``` > > This way, if you run the tests and your input field is called ``, > you will get an error: > > ```text > ** (ArgumentError) could not find non-disabled input, select or textarea with name "name" within: > > > ``` > > Only use the `value` parameter to pass values for hidden input fields or submit events from a hook > that cannot be passed to `form/3`. The same applies to `render_change/2`. ''' def render_submit(element, value \\ %{}) def render_submit(%Element{} = element, value), do: render_event(element, :submit, value) def render_submit(view, event), do: render_submit(view, event, %{}) @doc """ Sends a form submit event to the view and returns the rendered result. It returns the contents of the whole LiveView or an `{:error, redirect}` tuple. ## Examples {:ok, view, html} = live(conn, "/thermo") assert html =~ "The temp is: 30℉" assert render_submit(view, :refresh, %{deg: 32}) =~ "The temp is: 32℉" """ def render_submit(view, event, value) do render_event(view, :submit, event, value) end @doc """ Sends a form change event given by `element` and returns the rendered result. The `element` is created with `element/3` and must point to a single element on the page with a `phx-change` attribute in it. The event name given set on `phx-change` is then sent to the appropriate LiveView (or component if `phx-target` is set accordingly). All `phx-value-*` entries in the element are sent as values. If you need to pass any extra values or metadata, such as the "_target" parameter, you can do so by giving a map under the `value` argument. It returns the contents of the whole LiveView or an `{:error, redirect}` tuple. ## Examples {:ok, view, html} = live(conn, "/thermo") assert view |> element("form") |> render_change(%{deg: 123}) =~ "123 exceeds limits" # Passing metadata {:ok, view, html} = live(conn, "/thermo") assert view |> element("form") |> render_change(%{_target: ["deg"], deg: 123}) =~ "123 exceeds limits" As with `render_submit/2`, hidden input field values can be provided like so: refute view |> form("#term", user: %{name: "hello"}) |> render_change(%{user: %{"hidden_field" => "example"}}) =~ "can't be blank" """ def render_change(element, value \\ %{}) def render_change(%Element{} = element, value), do: render_event(element, :change, value) def render_change(view, event), do: render_change(view, event, %{}) @doc """ Sends a form change event to the view and returns the rendered result. It returns the contents of the whole LiveView or an `{:error, redirect}` tuple. ## Examples {:ok, view, html} = live(conn, "/thermo") assert html =~ "The temp is: 30℉" assert render_change(view, :validate, %{deg: 123}) =~ "123 exceeds limits" """ def render_change(view, event, value) do render_event(view, :change, event, value) end @doc """ Sends a keydown event given by `element` and returns the rendered result. The `element` is created with `element/3` and must point to a single element on the page with a `phx-keydown` or `phx-window-keydown` attribute in it. The event name given set on `phx-keydown` is then sent to the appropriate LiveView (or component if `phx-target` is set accordingly). All `phx-value-*` entries in the element are sent as values. Extra values can be given with the `value` argument. It returns the contents of the whole LiveView or an `{:error, redirect}` tuple. ## Examples {:ok, view, html} = live(conn, "/thermo") assert html =~ "The temp is: 30℉" assert view |> element("#inc") |> render_keydown() =~ "The temp is: 31℉" """ def render_keydown(element, value \\ %{}) def render_keydown(%Element{} = element, value), do: render_event(element, :keydown, value) def render_keydown(view, event), do: render_keydown(view, event, %{}) @doc """ Sends a keydown event to the view and returns the rendered result. It returns the contents of the whole LiveView or an `{:error, redirect}` tuple. ## Examples {:ok, view, html} = live(conn, "/thermo") assert html =~ "The temp is: 30℉" assert render_keydown(view, :inc) =~ "The temp is: 31℉" """ def render_keydown(view, event, value) do render_event(view, :keydown, event, value) end @doc """ Sends a keyup event given by `element` and returns the rendered result. The `element` is created with `element/3` and must point to a single element on the page with a `phx-keyup` or `phx-window-keyup` attribute in it. The event name given set on `phx-keyup` is then sent to the appropriate LiveView (or component if `phx-target` is set accordingly). All `phx-value-*` entries in the element are sent as values. Extra values can be given with the `value` argument. It returns the contents of the whole LiveView or an `{:error, redirect}` tuple. ## Examples {:ok, view, html} = live(conn, "/thermo") assert html =~ "The temp is: 30℉" assert view |> element("#inc") |> render_keyup() =~ "The temp is: 31℉" """ def render_keyup(element, value \\ %{}) def render_keyup(%Element{} = element, value), do: render_event(element, :keyup, value) def render_keyup(view, event), do: render_keyup(view, event, %{}) @doc """ Sends a keyup event to the view and returns the rendered result. It returns the contents of the whole LiveView or an `{:error, redirect}` tuple. ## Examples {:ok, view, html} = live(conn, "/thermo") assert html =~ "The temp is: 30℉" assert render_keyup(view, :inc) =~ "The temp is: 31℉" """ def render_keyup(view, event, value) do render_event(view, :keyup, event, value) end @doc """ Sends a blur event given by `element` and returns the rendered result. The `element` is created with `element/3` and must point to a single element on the page with a `phx-blur` attribute in it. The event name given set on `phx-blur` is then sent to the appropriate LiveView (or component if `phx-target` is set accordingly). All `phx-value-*` entries in the element are sent as values. Extra values can be given with the `value` argument. It returns the contents of the whole LiveView or an `{:error, redirect}` tuple. ## Examples {:ok, view, html} = live(conn, "/thermo") assert view |> element("#inactive") |> render_blur() =~ "Tap to wake" """ def render_blur(element, value \\ %{}) def render_blur(%Element{} = element, value), do: render_event(element, :blur, value) def render_blur(view, event), do: render_blur(view, event, %{}) @doc """ Sends a blur event to the view and returns the rendered result. It returns the contents of the whole LiveView or an `{:error, redirect}` tuple. ## Examples {:ok, view, html} = live(conn, "/thermo") assert html =~ "The temp is: 30℉" assert render_blur(view, :inactive) =~ "Tap to wake" """ def render_blur(view, event, value) do render_event(view, :blur, event, value) end @doc """ Sends a focus event given by `element` and returns the rendered result. The `element` is created with `element/3` and must point to a single element on the page with a `phx-focus` attribute in it. The event name given set on `phx-focus` is then sent to the appropriate LiveView (or component if `phx-target` is set accordingly). All `phx-value-*` entries in the element are sent as values. Extra values can be given with the `value` argument. It returns the contents of the whole LiveView or an `{:error, redirect}` tuple. ## Examples {:ok, view, html} = live(conn, "/thermo") assert view |> element("#inactive") |> render_focus() =~ "Tap to wake" """ def render_focus(element, value \\ %{}) def render_focus(%Element{} = element, value), do: render_event(element, :focus, value) def render_focus(view, event), do: render_focus(view, event, %{}) @doc """ Sends a focus event to the view and returns the rendered result. It returns the contents of the whole LiveView or an `{:error, redirect}` tuple. ## Examples {:ok, view, html} = live(conn, "/thermo") assert html =~ "The temp is: 30℉" assert render_focus(view, :inactive) =~ "Tap to wake" """ def render_focus(view, event, value) do render_event(view, :focus, event, value) end @doc """ Sends a hook event to the view or an element and returns the rendered result. It returns the contents of the whole LiveView or an `{:error, redirect}` tuple. ## Examples {:ok, view, html} = live(conn, "/thermo") assert html =~ "The temp is: 30℉" assert render_hook(view, :refresh, %{deg: 32}) =~ "The temp is: 32℉" If you are pushing events from a hook to a component, then you must pass an `element`, created with `element/3`, as first argument and it must point to a single element on the page with a `phx-target` attribute in it: {:ok, view, _html} = live(conn, "/thermo") assert view |> element("#thermo-component") |> render_hook(:refresh, %{deg: 32}) =~ "The temp is: 32℉" """ def render_hook(view_or_element, event, value \\ %{}) def render_hook(%Element{} = element, event, value) do render_event(%{element | event: to_string(event)}, :hook, value) end def render_hook(view, event, value) do render_event(view, :hook, event, value) end defp render_event(%Element{} = element, type, value) when is_map(value) or is_list(value) do call(element, {:render_event, element, type, value}) end defp render_event(%View{} = view, type, event, value) when is_map(value) or is_list(value) do call(view, {:render_event, {proxy_topic(view), to_string(event), view.target}, type, value}) end @doc """ Awaits all current `assign_async`, `stream_async` and `start_async` tasks for a given LiveView or element. It renders the LiveView or Element once complete and returns the result. The default `timeout` is [ExUnit](https://hexdocs.pm/ex_unit/ExUnit.html#configure/1)'s `assert_receive_timeout` (100 ms). ## Examples {:ok, lv, html} = live(conn, "/path") assert html =~ "loading data..." assert render_async(lv) =~ "data loaded!" """ def render_async( view_or_element, timeout \\ Application.fetch_env!(:ex_unit, :assert_receive_timeout) ) do pids = case view_or_element do %View{} = view -> call(view, {:async_pids, {proxy_topic(view), nil, nil}}) %Element{} = element -> call(element, {:async_pids, element}) end timeout_ref = make_ref() Process.send_after(self(), {timeout_ref, :timeout}, timeout) pids |> Enum.map(&Process.monitor(&1)) |> Enum.each(fn ref -> receive do {^timeout_ref, :timeout} -> raise RuntimeError, "expected async processes to finish within #{timeout}ms" {:DOWN, ^ref, :process, _pid, _reason} -> :ok end end) if !Process.cancel_timer(timeout_ref) do receive do {^timeout_ref, :timeout} -> :noop after 0 -> :noop end end render(view_or_element) end @doc """ Simulates a `push_patch` to the given `path` and returns the rendered result. """ def render_patch(%View{} = view, path) when is_binary(path) do call(view, {:render_patch, proxy_topic(view), path}) end @doc """ Returns the current list of LiveView children for the `parent` LiveView. Children are returned in the order they appear in the rendered HTML. ## Examples {:ok, view, _html} = live(conn, "/thermo") assert [clock_view] = live_children(view) assert render_click(clock_view, :snooze) =~ "snoozing" """ def live_children(%View{} = parent) do call(parent, {:live_children, proxy_topic(parent)}) end @doc """ Gets the nested LiveView child by `child_id` from the `parent` LiveView. ## Examples {:ok, view, _html} = live(conn, "/thermo") assert clock_view = find_live_child(view, "clock") assert render_click(clock_view, :snooze) =~ "snoozing" """ def find_live_child(%View{} = parent, child_id) do parent |> live_children() |> Enum.find(fn %View{id: id} -> id == child_id end) end @doc """ Checks if the given element exists on the page. ## Examples assert view |> element("#some-element") |> has_element?() """ def has_element?(%Element{} = element) do call(element, {:render_element, :has_element?, element}) end defguardp is_text_filter(text_filter) when is_binary(text_filter) or is_struct(text_filter, Regex) or is_nil(text_filter) @doc """ Checks if the given `selector` with `text_filter` is on `view`. See `element/3` for more information. ## Examples assert has_element?(view, "#some-element") """ def has_element?(%View{} = view, selector, text_filter \\ nil) when is_binary(selector) and is_text_filter(text_filter) do has_element?(element(view, selector, text_filter)) end @doc """ Returns the HTML string of the rendered view or element. If a view is provided, the entire LiveView is rendered. If a view after calling `with_target/2` or an element are given, only that particular context is returned. ## Examples {:ok, view, _html} = live(conn, "/thermo") assert render(view) =~ ~s|
<% end %> ``` def handle_event("cancel-upload", %{"ref" => ref}, socket) do {:noreply, cancel_upload(socket, :avatar, ref)} end """ defdelegate cancel_upload(socket, name, entry_ref), to: Phoenix.LiveView.Upload @doc """ Returns the completed and in progress entries for the upload. ## Examples case uploaded_entries(socket, :photos) do {[_ | _] = completed, []} -> # all entries are completed {[], [_ | _] = in_progress} -> # all entries are still in progress end """ defdelegate uploaded_entries(socket, name), to: Phoenix.LiveView.Upload @doc ~S""" Consumes the uploaded entries. Raises when there are still entries in progress. Typically called when submitting a form to handle the uploaded entries alongside the form data. For form submissions, it is guaranteed that all entries have completed before the submit event is invoked. Once entries are consumed, they are removed from the upload. The function passed to consume may return a tagged tuple of the form `{:ok, my_result}` to collect results about the consumed entries, or `{:postpone, my_result}` to collect results, but postpone the file consumption to be performed later. A list of all `my_result` values produced by the passed function is returned, regardless of whether they were consumed or postponed. ## Examples def handle_event("save", _params, socket) do uploaded_files = consume_uploaded_entries(socket, :avatar, fn %{path: path}, _entry -> dest = Path.join("priv/static/uploads", Path.basename(path)) File.cp!(path, dest) {:ok, ~p"/uploads/#{Path.basename(dest)}"} end) {:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded_files))} end """ defdelegate consume_uploaded_entries(socket, name, func), to: Phoenix.LiveView.Upload @doc ~S""" Consumes an individual uploaded entry. Raises when the entry is still in progress. Typically called when submitting a form to handle the uploaded entries alongside the form data. Once entries are consumed, they are removed from the upload. This is a lower-level feature than `consume_uploaded_entries/3` and useful for scenarios where you want to consume entries as they are individually completed. Like `consume_uploaded_entries/3`, the function passed to consume may return a tagged tuple of the form `{:ok, my_result}` to collect results about the consumed entries, or `{:postpone, my_result}` to collect results, but postpone the file consumption to be performed later. ## Examples def handle_event("save", _params, socket) do case uploaded_entries(socket, :avatar) do {[_|_] = entries, []} -> uploaded_files = for entry <- entries do consume_uploaded_entry(socket, entry, fn %{path: path} -> dest = Path.join("priv/static/uploads", Path.basename(path)) File.cp!(path, dest) {:ok, ~p"/uploads/#{Path.basename(dest)}"} end) end {:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded_files))} _ -> {:noreply, socket} end end """ defdelegate consume_uploaded_entry(socket, entry, func), to: Phoenix.LiveView.Upload @doc """ Annotates the socket for redirect to a destination path. *Note*: LiveView redirects rely on instructing client to perform a `window.location` update on the provided redirect location. The whole page will be reloaded and all state will be discarded. Calling redirect shuts down the LiveView channel. If you need to programatically open an external link without causing the LiveView to shut down, for example because of `mailto:` or `tel:` URL schemes, consider using `push_event/3` with a custom client-side handler instead. ## Options * `:to` - the path to redirect to. It must always be a local path * `:status` - the HTTP status code to use for the redirect. Defaults to 302. * `:external` - an external path to redirect to. Either a string or `{scheme, url}` to redirect to a custom scheme ## Examples {:noreply, redirect(socket, to: "/")} {:noreply, redirect(socket, to: "/", status: 301)} {:noreply, redirect(socket, external: "https://example.com")} """ def redirect(socket, opts \\ []) do status = Keyword.get(opts, :status, 302) cond do Keyword.has_key?(opts, :to) -> do_internal_redirect(socket, Keyword.fetch!(opts, :to), status) Keyword.has_key?(opts, :external) -> do_external_redirect(socket, Keyword.fetch!(opts, :external), status) true -> raise ArgumentError, "expected :to or :external option in redirect/2" end end defp do_internal_redirect(%Socket{} = socket, url, redirect_status) do validate_local_url!(url, "redirect/2") put_redirect(socket, {:redirect, %{to: url, status: redirect_status}}) end defp do_external_redirect(%Socket{} = socket, url, redirect_status) do case url do {scheme, rest} -> put_redirect( socket, {:redirect, %{external: "#{scheme}:#{rest}", status: redirect_status}} ) url when is_binary(url) -> external_url = Phoenix.LiveView.Utils.valid_string_destination!(url, "redirect/2") put_redirect( socket, {:redirect, %{external: external_url, status: redirect_status}} ) other -> raise ArgumentError, "expected :external option in redirect/2 to be valid URL, got: #{inspect(other)}" end end @doc """ Annotates the socket for navigation within the current LiveView. When navigating to the current LiveView, `c:handle_params/3` is immediately invoked to handle the change of params and URL state. Then the new state is pushed to the client, without reloading the whole page while also maintaining the current scroll position. For live navigation to another LiveView in the same `live_session`, use `push_navigate/2`. Otherwise, use `redirect/2`. ## Options * `:to` - the required path to link to. It must always be a local path * `:replace` - the flag to replace the current history or push a new state. Defaults `false`. ## Examples {:noreply, push_patch(socket, to: "/")} {:noreply, push_patch(socket, to: "/", replace: true)} """ def push_patch(%Socket{} = socket, opts) do opts = push_opts!(opts, "push_patch/2") put_redirect(socket, {:live, :patch, opts}) end @doc """ Annotates the socket for navigation to another LiveView in the same `live_session`. The current LiveView will be shutdown and a new one will be mounted in its place, without reloading the whole page. This can also be used to remount the same LiveView, in case you want to start fresh. If you want to navigate to the same LiveView without remounting it, use `push_patch/2` instead. ## Options * `:to` - the required path to link to. It must always be a local path * `:replace` - the flag to replace the current history or push a new state. Defaults `false`. ## Examples {:noreply, push_navigate(socket, to: "/")} {:noreply, push_navigate(socket, to: "/", replace: true)} """ def push_navigate(%Socket{} = socket, opts) do opts = push_opts!(opts, "push_navigate/2") put_redirect(socket, {:live, :redirect, opts}) end @doc false @deprecated "Use push_navigate/2 instead" def push_redirect(%Socket{} = socket, opts) do opts = push_opts!(opts, "push_redirect/2") put_redirect(socket, {:live, :redirect, opts}) end defp push_opts!(opts, context) do to = Keyword.fetch!(opts, :to) validate_local_url!(to, context) kind = if opts[:replace], do: :replace, else: :push %{to: to, kind: kind} end defp put_redirect(%Socket{redirected: nil} = socket, command) do %{socket | redirected: command} end defp put_redirect(%Socket{redirected: to} = _socket, _command) do raise ArgumentError, "socket already prepared to redirect with #{inspect(to)}" end @invalid_local_url_chars ["\\"] defp validate_local_url!("//" <> _ = to, where) do raise_invalid_local_url!(to, where) end defp validate_local_url!("/" <> _ = to, where) do if String.contains?(to, @invalid_local_url_chars) do raise ArgumentError, "unsafe characters detected for #{where} in URL #{inspect(to)}" else to end end defp validate_local_url!(to, where) do raise_invalid_local_url!(to, where) end defp raise_invalid_local_url!(to, where) do raise ArgumentError, "the :to option in #{where} expects a path but was #{inspect(to)}" end @doc """ Accesses the connect params sent by the client for use on connected mount. Connect params are sent from the client on every connection and reconnection. The parameters in the client can be computed dynamically, allowing you to pass client state to the server. For example, you could use it to compute and pass the user time zone from a JavaScript client: ```javascript let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: (_liveViewName) => { return { _csrf_token: csrfToken, time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone } } }) ``` By computing the parameters with a function, reconnections will reevalute the code, allowing you to fetch the latest data. On the LiveView, you will use `get_connect_params/1` to read the data, which only remains available during mount. `nil` is returned when called in a disconnected state and a `RuntimeError` is raised if called after mount. ## Reserved params The following params have special meaning in LiveView: * `"_csrf_token"` - the CSRF Token which must be explicitly set by the user when connecting * `"_mounts"` - the number of times the current LiveView is mounted. It is 0 on first mount, then increases on each reconnect. It resets when navigating away from the current LiveView or on errors * `"_track_static"` - set automatically with a list of all href/src from tags with the `phx-track-static` annotation in them. If there are no such tags, nothing is sent * `"_live_referer"` - sent by the client as the referer URL when a live navigation has occurred from `push_navigate` or client link navigate. ## Examples def mount(_params, _session, socket) do {:ok, assign(socket, width: get_connect_params(socket)["width"] || @width)} end """ def get_connect_params(%Socket{private: private} = socket) do if connect_params = private[:connect_params] do if connected?(socket), do: connect_params, else: nil else raise_root_and_mount_only!(socket, "connect_params") end end @doc """ Accesses a given connect info key from the socket. The following keys are supported: `:peer_data`, `:trace_context_headers`, `:x_headers`, `:uri`, and `:user_agent`. The connect information is available only during mount. During disconnected render, all keys are available. On connected render, only the keys explicitly declared in your socket are available. See `Phoenix.Endpoint.socket/3` for a complete description of the keys. ## Examples The first step is to declare the `connect_info` you want to receive. Typically, it includes at least the session, but you must include all other keys you want to access on connected mount, such as `:peer_data`: socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [:peer_data, session: @session_options]] Those values can now be accessed on the connected mount as `get_connect_info/2`: def mount(_params, _session, socket) do peer_data = get_connect_info(socket, :peer_data) {:ok, assign(socket, ip: peer_data.address)} end If the key is not available, usually because it was not specified in `connect_info`, it returns nil. """ def get_connect_info(%Socket{private: private} = socket, key) when is_atom(key) do if connect_info = private[:connect_info] do case connect_info do %Plug.Conn{} -> conn_connect_info(connect_info, key) %{} -> connect_info[key] end else raise_root_and_mount_only!(socket, "connect_info") end end defp conn_connect_info(conn, :peer_data) do Plug.Conn.get_peer_data(conn) end defp conn_connect_info(conn, :x_headers) do for {header, _} = pair <- conn.req_headers, String.starts_with?(header, "x-"), do: pair end defp conn_connect_info(conn, :trace_context_headers) do for {header, _} = pair <- conn.req_headers, header in ["traceparent", "tracestate"], do: pair end defp conn_connect_info(conn, :uri) do %URI{ scheme: to_string(conn.scheme), query: conn.query_string, port: conn.port, host: conn.host, path: conn.request_path } end defp conn_connect_info(conn, :user_agent) do with {_, value} <- List.keyfind(conn.req_headers, "user-agent", 0) do value end end @doc """ Returns true if the socket is connected and the tracked static assets have changed. This function is useful to detect if the client is running on an outdated version of the marked static files. It works by comparing the static paths sent by the client with the one on the server. **Note:** this functionality requires Phoenix v1.5.2 or later. To use this functionality, the first step is to annotate which static files you want to be tracked by LiveView, with the `phx-track-static`. For example: ```heex ``` Now, whenever LiveView connects to the server, it will send a copy `src` or `href` attributes of all tracked statics and compare those values with the latest entries computed by `mix phx.digest` in the server. The tracked statics on the client will match the ones on the server the huge majority of times. However, if there is a new deployment, those values may differ. You can use this function to detect those cases and show a banner to the user, asking them to reload the page. To do so, first set the assign on mount: def mount(params, session, socket) do {:ok, assign(socket, static_changed?: static_changed?(socket))} end And then in your views: ```heex
The app has been updated. Click here to reload.
``` For larger projects, you can extract this into [a hook](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#on_mount/1): # MyAppWeb.CheckStaticChanged def on_mount(:default, _params, _session, socket) do {:cont, assign(socket, static_changed?: static_changed?(socket))} end And then add it to the existing `live_view` macro in your `my_app_web.ex` file or add it as part of your `live_session` hooks. If you prefer, you can also send a JavaScript script that immediately reloads the page, but this will cause the client-side to lose all work in progress. **Note:** only set `phx-track-static` on your own assets. For example, do not set it in external JavaScript files: ```heex ``` Because you don't actually serve the file above, LiveView will interpret the static above as missing, and this function will return true. """ def static_changed?(%Socket{private: private, endpoint: endpoint} = socket) do if connect_params = private[:connect_params] do connected?(socket) and static_changed?( connect_params["_track_static"], endpoint.config(:cache_static_manifest_latest) ) else raise_root_and_mount_only!(socket, "static_changed?") end end defp static_changed?([_ | _] = statics, %{} = latest) do latest = Map.to_list(latest) not Enum.all?(statics, fn static -> [static | _] = :binary.split(static, "?") Enum.any?(latest, fn {non_digested, digested} -> String.ends_with?(static, non_digested) or String.ends_with?(static, digested) end) end) end defp static_changed?(_, _), do: false defp raise_root_and_mount_only!(socket, fun) do if child?(socket) do raise RuntimeError, """ attempted to read #{fun} from a nested child LiveView #{inspect(socket.view)}. Only the root LiveView has access to #{fun}. """ else raise RuntimeError, """ attempted to read #{fun} outside of #{inspect(socket.view)}.mount/3. #{fun} only exists while mounting. If you require access to this information after mount, store the state in socket assigns. """ end end @doc ~S''' Asynchronously updates a `Phoenix.LiveComponent` with new assigns. The `pid` argument is optional and it defaults to the current process, which means the update instruction will be sent to a component running on the same LiveView. If the current process is not a LiveView or you want to send updates to a live component running on another LiveView, you should explicitly pass the LiveView's pid instead. The second argument can be either the value of the `@myself` or the module of the live component. If you pass the module, then the `:id` that identifies the component must be passed as part of the assigns. When the component receives the update, [`update_many/1`](`c:Phoenix.LiveComponent.update_many/1`) will be invoked if it is defined, otherwise [`update/2`](`c:Phoenix.LiveComponent.update/2`) is invoked with the new assigns. If [`update/2`](`c:Phoenix.LiveComponent.update/2`) is not defined all assigns are simply merged into the socket. The assigns received as the first argument of the [`update/2`](`c:Phoenix.LiveComponent.update/2`) callback will only include the _new_ assigns passed from this function. Pre-existing assigns may be found in `socket.assigns`. While a component may always be updated from the parent by updating some parent assigns which will re-render the child, thus invoking [`update/2`](`c:Phoenix.LiveComponent.update/2`) on the child component, `send_update/3` is useful for updating a component that entirely manages its own state, as well as messaging between components mounted in the same LiveView. ## Examples def handle_event("cancel-order", _, socket) do ... send_update(Cart, id: "cart", status: "cancelled") {:noreply, socket} end def handle_event("cancel-order-asynchronously", _, socket) do ... pid = self() Task.Supervisor.start_child(MyTaskSup, fn -> # Do something asynchronously send_update(pid, Cart, id: "cart", status: "cancelled") end) {:noreply, socket} end def render(assigns) do ~H""" <.some_component on_complete={&send_update(@myself, completed: &1)} /> """ end ''' def send_update(pid \\ self(), module_or_cid, assigns) def send_update(pid, module, assigns) when is_atom(module) and is_pid(pid) do assigns = Enum.into(assigns, %{}) id = assigns[:id] || raise ArgumentError, "missing required :id in send_update. Got: #{inspect(assigns)}" Phoenix.LiveView.Channel.send_update(pid, {module, id}, assigns) end def send_update(pid, %Phoenix.LiveComponent.CID{} = cid, assigns) when is_pid(pid) do assigns = Enum.into(assigns, %{}) Phoenix.LiveView.Channel.send_update(pid, cid, assigns) end @doc """ Similar to `send_update/3` but the update will be delayed according to the given `time_in_milliseconds`. It returns a reference which can be cancelled with `Process.cancel_timer/1`. ## Examples def handle_event("cancel-order", _, socket) do ... send_update_after(Cart, [id: "cart", status: "cancelled"], 3000) {:noreply, socket} end def handle_event("cancel-order-asynchronously", _, socket) do ... pid = self() Task.start(fn -> # Do something asynchronously send_update_after(pid, Cart, [id: "cart", status: "cancelled"], 3000) end) {:noreply, socket} end """ def send_update_after(pid \\ self(), module_or_cid, assigns, time_in_milliseconds) def send_update_after(pid, %Phoenix.LiveComponent.CID{} = cid, assigns, time_in_milliseconds) when is_integer(time_in_milliseconds) and is_pid(pid) do assigns = Enum.into(assigns, %{}) Phoenix.LiveView.Channel.send_update_after(pid, cid, assigns, time_in_milliseconds) end def send_update_after(pid, module, assigns, time_in_milliseconds) when is_atom(module) and is_integer(time_in_milliseconds) and is_pid(pid) do assigns = Enum.into(assigns, %{}) id = assigns[:id] || raise ArgumentError, "missing required :id in send_update_after. Got: #{inspect(assigns)}" Phoenix.LiveView.Channel.send_update_after(pid, {module, id}, assigns, time_in_milliseconds) end @doc """ Returns the transport pid of the socket. Raises `ArgumentError` if the socket is not connected. ## Examples iex> transport_pid(socket) #PID<0.107.0> """ def transport_pid(%Socket{}) do case Process.get(:"$callers") do [transport_pid | _] -> transport_pid _ -> raise ArgumentError, "transport_pid/1 may only be called when the socket is connected." end end defp child?(%Socket{parent_pid: pid}), do: is_pid(pid) @doc """ Attaches the given `fun` by `name` for the lifecycle `stage` into `socket`. > Note: This function is for server-side lifecycle callbacks. > For client-side hooks, see the > [JS Interop guide](js-interop.html#client-hooks-via-phx-hook). Hooks provide a mechanism to tap into key stages of the LiveView lifecycle in order to bind/update assigns, intercept events, patches, and regular messages when necessary, and to inject common functionality. Use `attach_hook/4` on any of the following lifecycle stages: `:handle_params`, `:handle_event`, `:handle_info`, `:handle_async`, and `:after_render`. To attach a hook to the `:mount` stage, use `on_mount/1`. > Note: only `:after_render`, `:handle_event` and `:handle_async` hooks are currently supported in > LiveComponents. ## Return Values Lifecycle hooks take place immediately before a given lifecycle callback is invoked on the LiveView. With the exception of `:after_render`, a hook may return `{:halt, socket}` to halt the reduction, otherwise it must return `{:cont, socket}` so the operation may continue until all hooks have been invoked for the current stage. For `:after_render` hooks, the `socket` itself must be returned. Any updates to the socket assigns *will not* trigger a new render or diff calculation to the client. ## Halting the lifecycle Note that halting from a hook _will halt the entire lifecycle stage_. This means that when a hook returns `{:halt, socket}` then the LiveView callback will **not** be invoked. This has some implications. ### Implications for plugin authors When defining a plugin that matches on specific callbacks, you **must** define a catch-all clause, as your hook will be invoked even for events you may not be interested in. ### Implications for end-users Allowing a hook to halt the invocation of the callback means that you can attach hooks to intercept specific events before detaching themselves, while allowing other events to continue normally. ## Replying to events Hooks attached to the `:handle_event` stage are able to reply to client events by returning `{:halt, reply, socket}`. This is useful especially for [JavaScript interoperability](js-interop.html#client-hooks-via-phx-hook) because a client hook can push an event and receive a reply. ## Sharing event handling logic Lifecycle hooks are an excellent way to extract related events out of the parent LiveView and into separate modules without resorting unnecessarily to LiveComponents for organization. defmodule DemoLive do use Phoenix.LiveView def render(assigns) do ~H\"""
Counter: {@counter}
\""" end def mount(_params, _session, socket) do first_list = for(i <- 1..9, do: "First List \#{i}") |> Enum.shuffle() second_list = for(i <- 1..9, do: "Second List \#{i}") |> Enum.shuffle() socket = socket |> assign(:counter, 0) |> assign(first_list: first_list) |> assign(second_list: second_list) |> attach_hook(:sort, :handle_event, &MySortComponent.hooked_event/3) # 2) Delegated events {:ok, socket} end # 1) Normal event def handle_event("inc", _params, socket) do {:noreply, update(socket, :counter, &(&1 + 1))} end end defmodule MySortComponent do use Phoenix.Component def display(assigns) do ~H\"""
  • {item}
\""" end def hooked_event("shuffle", %{"list" => key}, socket) do key = String.to_existing_atom(key) shuffled = Enum.shuffle(socket.assigns[key]) {:halt, assign(socket, key, shuffled)} end def hooked_event("sort", %{"list" => key}, socket) do key = String.to_existing_atom(key) sorted = Enum.sort(socket.assigns[key]) {:halt, assign(socket, key, sorted)} end def hooked_event(_event, _params, socket), do: {:cont, socket} end ## Other examples Attaching and detaching a hook: def mount(_params, _session, socket) do socket = attach_hook(socket, :my_hook, :handle_event, fn "very-special-event", _params, socket -> # Handle the very special event and then detach the hook {:halt, detach_hook(socket, :my_hook, :handle_event)} _event, _params, socket -> {:cont, socket} end) {:ok, socket} end Replying to a client event: ```javascript /** * @type {import("phoenix_live_view").HooksOption} */ let Hooks = {} Hooks.ClientHook = { mounted() { this.pushEvent("ClientHook:mounted", {hello: "world"}, (reply) => { console.log("received reply:", reply) }) } } let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, ...}) ``` def render(assigns) do ~H"\""
"\"" end def mount(_params, _session, socket) do socket = attach_hook(socket, :reply_on_client_hook_mounted, :handle_event, fn "ClientHook:mounted", params, socket -> {:halt, params, socket} _, _, socket -> {:cont, socket} end) {:ok, socket} end """ defdelegate attach_hook(socket, name, stage, fun), to: Phoenix.LiveView.Lifecycle @doc """ Detaches a hook with the given `name` from the lifecycle `stage`. > Note: This function is for server-side lifecycle callbacks. > For client-side hooks, see the > [JS Interop guide](js-interop.html#client-hooks-via-phx-hook). If no hook is found, this function is a no-op. ## Examples def handle_event(_, socket) do {:noreply, detach_hook(socket, :hook_that_was_attached, :handle_event)} end """ defdelegate detach_hook(socket, name, stage), to: Phoenix.LiveView.Lifecycle @doc ~S""" Assigns a new stream to the socket or inserts items into an existing stream. Returns an updated `socket`. Streams are a mechanism for managing large collections on the client without keeping the resources on the server. * `name` - A string or atom name of the key to place under the `@streams` assign. * `items` - An enumerable of items to insert. The following options are supported: * `:at` - The index to insert or update the items in the collection on the client. By default `-1` is used, which appends the items to the parent DOM container. A value of `0` prepends the items. Note that this operation is equal to inserting the items one by one, each at the given index. Therefore, when inserting multiple items at an index other than `-1`, the UI will display the items in reverse order: stream(socket, :songs, [song1, song2, song3], at: 0) In this case the UI will prepend `song1`, then `song2` and then `song3`, so it will show `song3`, `song2`, `song1` and then any previously inserted items. To insert in the order of the list, use `Enum.reverse/1`: stream(socket, :songs, Enum.reverse([song1, song2, song3]), at: 0) * `:reset` - A boolean to reset the stream on the client or not. Defaults to `false`. * `:limit` - An optional positive or negative number of results to limit on the UI on the client. As new items are streamed, the UI will remove existing items to maintain the limit. For example, to limit the stream to the last 10 items in the UI while appending new items, pass a negative value: stream(socket, :songs, songs, at: -1, limit: -10) Likewise, to limit the stream to the first 10 items, while prepending new items, pass a positive value: stream(socket, :songs, songs, at: 0, limit: 10) Once a stream is defined, a new `@streams` assign is available containing the name of the defined streams. For example, in the above definition, the stream may be referenced as `@streams.songs` in your template. Stream items are temporary and freed from socket state immediately after the `render/1` function is invoked (or a template is rendered from disk). By default, calling `stream/4` on an existing stream will bulk insert the new items on the client while leaving the existing items in place. Streams may also be reset when calling `stream/4`, which we discuss below. ## Resetting a stream To empty a stream container on the client, you can pass `:reset` with an empty list: stream(socket, :songs, [], reset: true) Or you can replace the entire stream on the client with a new collection: stream(socket, :songs, new_songs, reset: true) ## Limiting a stream It is often useful to limit the number of items in the UI while allowing the server to stream new items in a fire-and-forget fashion. This prevents the server from overwhelming the client with new results while also opening up powerful features like virtualized infinite scrolling. See a complete bidirectional infinite scrolling example with stream limits in the [scroll events guide](bindings.md#scroll-events-and-infinite-pagination) When a stream exceeds the limit on the client, the existing items will be pruned based on the number of items in the stream container and the limit direction. A positive limit will prune items from the end of the container, while a negative limit will prune items from the beginning of the container. Note that the limit is not enforced on the first `c:mount/3` render (when no websocket connection was established yet), as it means more data than necessary has been loaded. In such cases, you should only load and pass the desired amount of items to the stream. When inserting single items using `stream_insert/4`, the limit needs to be passed as an option for it to be enforced on the client: stream_insert(socket, :songs, song, limit: -10) ## Required DOM attributes For stream items to be trackable on the client, the following requirements must be met: 1. The parent DOM container must include a `phx-update="stream"` attribute, along with a unique DOM id. 2. Each stream item must include its DOM id on the item's element. > #### Note {: .warning} > > Failing to place `phx-update="stream"` on the **immediate parent** for > **each stream** will result in broken behavior. > > Also, do not alter the generated DOM ids, e.g., by prefixing them. Doing so will > result in broken behavior. When consuming a stream in a template, the DOM id and item is passed as a tuple, allowing convenient inclusion of the DOM id for each item. For example: ```heex
{song.title} {song.duration}
``` We consume the stream in a for comprehension by referencing the `@streams.songs` assign. We used the computed DOM id to populate the `` id, then we render the table row as usual. Now `stream_insert/3` and `stream_delete/3` may be issued and new rows will be inserted or deleted from the client. ## Handling the empty case When rendering a list of items, it is common to show a message for the empty case. But when using streams, we cannot rely on `Enum.empty?/1` or similar approaches to check if the list is empty. Instead we can use the CSS `:only-child` selector and show the message client side: ```heex
{song.title} {song.duration}
``` It is important to set a unique ID on the empty row, otherwise it cannot be tracked in the stream container and subsequent patches will duplicate the node. ## Non-stream items in stream containers In the section on handling the empty case, we showed how to render a message when the stream is empty by rendering a non-stream item inside the stream container. Note that for non-stream items inside a `phx-update="stream"` container, the following needs to be considered: 1. Non-stream items must have a unique DOM id. 2. Items can be added and updated, but not removed, even if the stream is reset. This means that if you try to conditionally render a non-stream item inside a stream container, it won't be removed if it was rendered once. 3. Items are affected by the `:at` option. For example, when you render a non-stream item at the beginning of the stream container and then prepend items (with `at: 0`) to the stream, the non-stream item will be pushed down. """ @spec stream( socket :: Socket.t(), name :: atom | String.t(), items :: Enumerable.t(), opts :: Keyword.t() ) :: Socket.t() def stream(%Socket{} = socket, name, items, opts \\ []) do socket |> ensure_streams() |> assign_stream(name, items, opts) end @doc ~S""" Configures a stream. The following options are supported: * `:dom_id` - An optional function to generate each stream item's DOM id. The function accepts each stream item and converts the item to a string id. By default, the `:id` field of a map or struct will be used if the item has such a field, and will be prefixed by the `name` hyphenated with the id. For example, the following examples are equivalent: stream(socket, :songs, songs) socket |> stream_configure(:songs, dom_id: &("songs-#{&1.id}")) |> stream(:songs, songs) A stream must be configured before items are inserted, and once configured, a stream may not be re-configured. To ensure a stream is only configured a single time in a LiveComponent, use the `mount/1` callback. For example: def mount(socket) do {:ok, stream_configure(socket, :songs, dom_id: &("songs-#{&1.id}"))} end def update(assigns, socket) do {:ok, stream(socket, :songs, ...)} end Returns an updated `socket`. """ @spec stream_configure(socket :: Socket.t(), name :: atom | String.t(), opts :: Keyword.t()) :: Socket.t() def stream_configure(%Socket{} = socket, name, opts) when is_list(opts) do new_socket = ensure_streams(socket) case new_socket.assigns.streams do %{^name => %LiveStream{}} -> raise ArgumentError, "cannot configure stream :#{name} after it has been streamed" %{__configured__: %{^name => _opts}} -> raise ArgumentError, "cannot re-configure stream :#{name} after it has been configured" %{} -> Phoenix.Component.update(new_socket, :streams, fn streams -> Map.update!(streams, :__configured__, fn conf -> Map.put(conf, name, opts) end) end) end end defp ensure_streams(%Socket{} = socket) do # don't use assign_new here because we DON'T want to copy parent streams # during the dead render of nested or sticky LiveViews case socket.assigns do %{streams: _} -> socket _ -> Phoenix.LiveView.Utils.assign(socket, :streams, %{ __ref__: 0, __changed__: MapSet.new(), __configured__: %{} }) end end @doc """ Inserts a new item or updates an existing item in the stream. Returns an updated `socket`. See `stream/4` for inserting multiple items at once. The following options are supported: * `:at` - The index to insert or update the item in the collection on the client. By default, the item is appended to the parent DOM container. This is the same as passing a value of `-1`. If the item already exists in the parent DOM container then it will be updated in place. * `:limit` - A limit of items to maintain in the UI. A limit passed to `stream/4` does not affect subsequent calls to `stream_insert/4`, therefore the limit must be passed here as well in order to be enforced. See `stream/4` for more information on limiting streams. * `:update_only` - A boolean to only update the item in the stream. If the item does not exist on the client, it will not be inserted. Defaults to `false`. ## Examples Imagine you define a stream on mount with a single item: stream(socket, :songs, [%Song{id: 1, title: "Song 1"}]) Then, in a callback such as `handle_info` or `handle_event`, you can append a new song: stream_insert(socket, :songs, %Song{id: 2, title: "Song 2"}) Or prepend a new song with `at: 0`: stream_insert(socket, :songs, %Song{id: 2, title: "Song 2"}, at: 0) Or update an existing song (in this case the `:at` option has no effect): stream_insert(socket, :songs, %Song{id: 1, title: "Song 1 updated"}, at: 0) Or append a new song while limiting the stream to the last 10 items: stream_insert(socket, :songs, %Song{id: 2, title: "Song 2"}, limit: -10) ## Updating Items As shown, an existing item on the client can be updated by issuing a `stream_insert` for the existing item. When the client updates an existing item, the item will remain in the same location as it was previously, and will not be moved to the end of the parent children. To both update an existing item and move it to another position, issue a `stream_delete`, followed by a `stream_insert`. For example: song = get_song!(id) socket |> stream_delete(:songs, song) |> stream_insert(:songs, song, at: -1) See `stream_delete/3` for more information on deleting items. """ @spec stream_insert( socket :: Socket.t(), name :: atom | String.t(), item :: any, opts :: Keyword.t() ) :: Socket.t() def stream_insert(%Socket{} = socket, name, item, opts \\ []) do at = Keyword.get(opts, :at, -1) limit = Keyword.get(opts, :limit) update_only = Keyword.get(opts, :update_only, false) update_stream(socket, name, &LiveStream.insert_item(&1, item, at, limit, update_only)) end @doc """ Deletes an item from the stream. The item's DOM is computed from the `:dom_id` provided in the `stream/3` definition. Delete information for this DOM id is sent to the client and the item's element is removed from the DOM, following the same behavior of element removal, such as invoking `phx-remove` commands and executing client hook `destroyed()` callbacks. ## Examples def handle_event("delete", %{"id" => id}, socket) do song = get_song!(id) {:noreply, stream_delete(socket, :songs, song)} end See `stream_delete_by_dom_id/3` to remove an item without requiring the original data structure. Returns an updated `socket`. """ @spec stream_delete(socket :: Socket.t(), name :: atom | String.t(), item :: any) :: Socket.t() def stream_delete(%Socket{} = socket, name, item) do update_stream(socket, name, &LiveStream.delete_item(&1, item)) end @doc ~S''' Deletes an item from the stream given its computed DOM id. Returns an updated `socket`. Behaves just like `stream_delete/3`, but accept the precomputed DOM id, which allows deleting from a stream without fetching or building the original stream data structure. ## Examples def render(assigns) do ~H"""
{song.title}
""" end def handle_event("delete", %{"id" => dom_id}, socket) do {:noreply, stream_delete_by_dom_id(socket, :songs, dom_id)} end ''' @spec stream_delete_by_dom_id(socket :: Socket.t(), name :: atom | String.t(), id :: String.t()) :: Socket.t() def stream_delete_by_dom_id(%Socket{} = socket, name, id) do update_stream(socket, name, &LiveStream.delete_item_by_dom_id(&1, id)) end defp assign_stream(%Socket{} = socket, name, items, opts) do streams = socket.assigns.streams case streams do %{^name => %LiveStream{}} -> new_socket = if opts[:reset] do update_stream(socket, name, &LiveStream.reset(&1)) else socket end Enum.reduce(items, new_socket, fn item, acc -> stream_insert(acc, name, item, opts) end) %{} -> config = get_in(streams, [:__configured__, name]) || [] opts = Keyword.merge(opts, config) ref = if cid = socket.assigns[:myself] do "#{cid}-#{streams.__ref__}" else to_string(streams.__ref__) end stream = LiveStream.new(name, ref, items, opts) socket |> Phoenix.Component.update(:streams, fn streams -> %{streams | __ref__: streams.__ref__ + 1} |> Map.put(name, stream) |> Map.update!(:__changed__, &MapSet.put(&1, name)) end) |> attach_hook(name, :after_render, fn hook_socket -> if name in hook_socket.assigns.streams.__changed__ do Phoenix.Component.update(hook_socket, :streams, fn streams -> streams |> Map.update!(:__changed__, &MapSet.delete(&1, name)) |> Map.update!(name, &LiveStream.prune(&1)) end) else hook_socket end end) end end defp update_stream(%Socket{} = socket, name, func) do Phoenix.Component.update(socket, :streams, fn streams -> stream = case Map.fetch(streams, name) do {:ok, stream} -> stream :error -> raise ArgumentError, "no stream with name #{inspect(name)} previously defined" end streams |> Map.put(name, func.(stream)) |> Map.update!(:__changed__, &MapSet.put(&1, name)) end) end @doc """ Assigns keys asynchronously. Wraps your function in a task linked to the caller, errors are wrapped. Each key passed to `assign_async/3` will be assigned to an `Phoenix.LiveView.AsyncResult` struct holding the status of the operation and the result when the function completes. The function must return either a map or a keyword list with the assigns to merge into the socket. The task is only started when the socket is connected. ## Options * `:supervisor` - allows you to specify a `Task.Supervisor` to supervise the task. * `:reset` - remove previous results during async operation when true. Possible values are `true`, `false`, or a list of keys to reset. Defaults to `false`. ## Examples ```elixir def mount(%{"slug" => slug}, _, socket) do {:ok, socket |> assign(:foo, "bar") |> assign_async(:org, fn -> {:ok, %{org: fetch_org!(slug)}} end) |> assign_async([:profile, :rank], fn -> {:ok, %{profile: ..., rank: ...}} end)} end ``` See [Async Operations](#module-async-operations) for more information. ## `assign_async/3` and `send_update/3` Since the code inside `assign_async/3` runs in a separate process, `send_update(Component, data)` does not work inside `assign_async/3`, since `send_update/2` assumes it is running inside the LiveView process. The solution is to explicitly send the update to the LiveView: ```elixir parent = self() assign_async(socket, :org, fn -> # ... send_update(parent, Component, data) end) ``` ## Testing async operations When testing LiveViews and LiveComponents with async assigns, use `Phoenix.LiveViewTest.render_async/2` to ensure the test waits until the async operations are complete before proceeding with assertions or before ending the test. For example: ```elixir {:ok, view, _html} = live(conn, "/my_live_view") html = render_async(view) assert html =~ "My assertion" ``` Not calling `render_async/2` to ensure all async assigns have finished might result in errors in cases where your process has side effects: ``` [error] MyXQL.Connection (#PID<0.308.0>) disconnected: ** (DBConnection.ConnectionError) client #PID<0.794.0> ``` """ defmacro assign_async(socket, key_or_keys, func, opts \\ []) do Async.assign_async(socket, key_or_keys, func, opts, __CALLER__) end @doc """ Wraps your function in an asynchronous task and invokes a callback `name` to handle the result. The task is linked to the caller and errors/exits are wrapped. The result of the task is sent to the `c:handle_async/3` callback of the caller LiveView or LiveComponent. If there is an in-flight task with the same `name`, the later `start_async` wins and the previous task’s result is ignored. If you wish to replace an existing task, you can use `cancel_async/3` before `start_async/3`. You are not restricted to just atoms for `name`, it can be any term such as a tuple. The task is only started when the socket is connected. ## Options * `:supervisor` - allows you to specify a `Task.Supervisor` to supervise the task. ## Examples def mount(%{"id" => id}, _, socket) do {:ok, socket |> assign(:org, AsyncResult.loading()) |> start_async(:my_task, fn -> fetch_org!(id) end)} end def handle_async(:my_task, {:ok, fetched_org}, socket) do %{org: org} = socket.assigns {:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))} end def handle_async(:my_task, {:exit, reason}, socket) do %{org: org} = socket.assigns {:noreply, assign(socket, :org, AsyncResult.failed(org, {:exit, reason}))} end See the moduledoc for more information. """ defmacro start_async(socket, name, func, opts \\ []) do Async.start_async(socket, name, func, opts, __CALLER__) end @doc """ Inserts data into a stream asynchronously. Wraps your function in a task linked to the caller, errors are wrapped. The key passed to `stream_async/3` will be used as the stream name. Furthermore, a regular assign with the same name gets assigned a `Phoenix.LiveView.AsyncResult` struct holding the status of the operation. The stream is initialized to an empty list before starting the asynchronous function, so accessing `@streams.name` is always possible. The function must return `{:ok, Enumerable.t()}` or `{:ok, Enumerable.t(), opts}` where the opts are the same as in `stream/4`. The enumerable contains the values to be streamed. If the function returns `{:error, any()}`, the `AsyncResult` is assigned as failed and the stream is not updated. The task is only started when the socket is connected. ## Options * `:supervisor` - allows you to specify a `Task.Supervisor` to supervise the task. * `:reset` - remove previous results during async operation when true. Possible values are `true`, `false`, or a list of keys to reset. Defaults to `false`. ## Examples def mount(%{"slug" => slug}, _, socket) do current_scope = socket.assigns.current_scope {:ok, socket |> assign(:foo, "bar") |> assign_async(:org, fn -> {:ok, %{org: fetch_org!(current_scope)}} end) |> stream_async(:posts, fn -> {:ok, list_posts!(current_scope), limit: 10} end) end Note the `reset` option controls the async assign, not the stream: def mount(_, _, socket) do {:ok, socket # IMPORTANT: reset here does NOT reset the stream, but only the loading state |> stream_async(:my_stream, fn -> {:ok, list_items!()} end, reset: true) # This resets the stream |> stream_async(:my_reset_stream, fn -> {:ok, list_items!(), reset: true} end) end Any stream options need to be returned as optional third argument in the return value of the asynchronous function. """ defmacro stream_async(socket, name, func, opts \\ []) do Async.stream_async(socket, name, func, opts, __CALLER__) end @doc """ Cancels an async operation if one exists. Accepts either the `%AsyncResult{}` when using `assign_async/3` or the key passed to `start_async/3`. The underlying process will be killed with the provided reason, or with `{:shutdown, :cancel}` if no reason is passed. For `assign_async/3` operations, the `:failed` field will be set to `{:exit, reason}`. For `start_async/3`, the `c:handle_async/3` callback will receive `{:exit, reason}` as the result. Returns the `%Phoenix.LiveView.Socket{}`. ## Examples cancel_async(socket, :preview) cancel_async(socket, :preview, :my_reason) cancel_async(socket, socket.assigns.preview) """ def cancel_async(socket, async_or_keys, reason \\ {:shutdown, :cancel}) do Async.cancel_async(socket, async_or_keys, reason) end end ================================================ FILE: lib/prettier.ex ================================================ if Mix.env() == :dev do defmodule Prettier do @moduledoc false @behaviour Phoenix.LiveView.HTMLFormatter.TagFormatter require Logger @impl true def render_tag({"script", attrs, content}, _opts) when not is_map_key(attrs, "runtime") do manifest = Map.get(attrs, "manifest", "index.js") tmp_file = Path.join(System.tmp_dir!(), "prettier_#{System.unique_integer([:positive])}_#{manifest}") try do File.write!(tmp_file, content) case System.cmd("npx", ["prettier", tmp_file], stderr_to_stdout: true) do {output, 0} -> {:ok, String.trim(output)} {error, _} -> Logger.error("Failed to format with prettier: #{error}") :skip end after File.rm(tmp_file) end end def render_tag({_other, _attrs, _content}, _opts) do :skip end end end ================================================ FILE: mix.exs ================================================ defmodule Phoenix.LiveView.MixProject do use Mix.Project @version "1.2.0-dev" def project do [ app: :phoenix_live_view, version: @version, elixir: "~> 1.15", start_permanent: Mix.env() == :prod, elixirc_paths: elixirc_paths(Mix.env()), test_options: [docs: true], test_coverage: [summary: [threshold: 85], ignore_modules: coverage_ignore_modules()], xref: [exclude: [LazyHTML, LazyHTML.Tree]], package: package(), deps: deps(), aliases: aliases(), docs: &docs/0, name: "Phoenix LiveView", homepage_url: "http://www.phoenixframework.org", description: """ Rich, real-time user experiences with server-rendered HTML """, listeners: [Phoenix.CodeReloader], # ignore misnamed test file warnings for e2e support files test_ignore_filters: [&String.starts_with?(&1, "test/e2e/support")] ] end def cli do [preferred_envs: [docs: :docs]] end defp elixirc_paths(:e2e), do: ["lib", "test/support", "test/e2e/support"] defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] def application do [ mod: {Phoenix.LiveView.Application, []}, extra_applications: [:logger] ] end defp deps do [ {:igniter, "~> 0.6 and >= 0.6.16", optional: true}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0"}, {:plug, "~> 1.15"}, {:phoenix_template, "~> 1.0"}, {:phoenix_html, "~> 3.3 or ~> 4.0 or ~> 4.1"}, {:telemetry, "~> 0.4.2 or ~> 1.0"}, {:esbuild, "~> 0.2", only: :dev}, {:phoenix_view, "~> 2.0", optional: true}, {:jason, "~> 1.0", optional: true}, {:lazy_html, "~> 0.1.0", optional: true}, {:ex_doc, "~> 0.29", only: :docs}, {:makeup_elixir, "~> 1.0.1 or ~> 1.1", only: [:docs, :e2e]}, {:makeup_eex, "~> 2.0", only: [:docs, :e2e]}, {:makeup_syntect, "~> 0.1.0", only: [:docs, :e2e]}, {:html_entities, ">= 0.0.0", only: :test}, {:phoenix_live_reload, "~> 1.4", only: :test}, {:phoenix_html_helpers, "~> 1.0", only: :test}, {:bandit, "~> 1.5", only: :e2e}, {:ecto, "~> 3.11", only: :e2e}, {:phoenix_ecto, "~> 4.5", only: :e2e} ] end defp docs do [ main: "welcome", source_ref: "v#{@version}", source_url: "https://github.com/phoenixframework/phoenix_live_view", extra_section: "GUIDES", extras: extras(), groups_for_extras: groups_for_extras(), groups_for_modules: groups_for_modules(), groups_for_docs: [ Components: &(&1[:type] == :component), Macros: &(&1[:type] == :macro) ], skip_undefined_reference_warnings_on: ["CHANGELOG.md"], before_closing_body_tag: &before_closing_body_tag/1 ] end defp before_closing_body_tag(:html) do """ """ end defp before_closing_body_tag(_), do: "" defp extras do ["CHANGELOG.md"] ++ Path.wildcard("guides/*/*.md") ++ Path.wildcard("guides/cheatsheets/*.cheatmd") end defp groups_for_extras do [ Introduction: ~r"guides/introduction/", "Server-side features": ~r"guides/server/", "Client-side integration": ~r"guides/client/", Cheatsheets: ~r"guides/cheatsheets/" ] end defp groups_for_modules do # Ungrouped Modules: # # Phoenix.Component # Phoenix.LiveComponent # Phoenix.LiveView # Phoenix.LiveView.Controller # Phoenix.LiveView.JS # Phoenix.LiveView.Router # Phoenix.LiveViewTest [ Configuration: [ Phoenix.LiveView.HTMLFormatter, Phoenix.LiveView.Logger, Phoenix.LiveView.Socket ], "Testing structures": [ Phoenix.LiveViewTest.Element, Phoenix.LiveViewTest.Upload, Phoenix.LiveViewTest.View ], "Upload structures": [ Phoenix.LiveView.UploadConfig, Phoenix.LiveView.UploadEntry, Phoenix.LiveView.UploadWriter ], "Plugin API": [ Phoenix.LiveComponent.CID, Phoenix.LiveView.Engine, Phoenix.LiveView.TagEngine, Phoenix.LiveView.HTMLEngine, Phoenix.LiveView.Component, Phoenix.LiveView.Rendered, Phoenix.LiveView.Comprehension ] ] end defp package do [ maintainers: ["Chris McCord", "José Valim", "Gary Rennie", "Alex Garibay", "Scott Newcomer"], licenses: ["MIT"], links: %{ Changelog: "https://hexdocs.pm/phoenix_live_view/changelog.html", GitHub: "https://github.com/phoenixframework/phoenix_live_view" }, files: ~w(assets/js lib priv) ++ ~w(CHANGELOG.md LICENSE.md mix.exs package.json README.md .formatter.exs) ] end defp aliases do [ "assets.build": [ "cmd npm run build", "esbuild module", "esbuild cdn", "esbuild cdn_min", "esbuild main" ], "assets.watch": ["cmd npm run build -- --watch", "esbuild module --watch"] ] end defp coverage_ignore_modules do [ ~r/Phoenix\.LiveViewTest\.Support\..*/, ~r/Phoenix\.LiveViewTest\.E2E\..*/, ~r/Inspect\..*/ ] end end ================================================ FILE: package.json ================================================ { "name": "phoenix_live_view", "version": "1.2.0-dev", "description": "The Phoenix LiveView JavaScript client.", "license": "MIT", "type": "module", "module": "./priv/static/phoenix_live_view.esm.js", "main": "./priv/static/phoenix_live_view.cjs.js", "unpkg": "./priv/static/phoenix_live_view.min.js", "jsdelivr": "./priv/static/phoenix_live_view.min.js", "exports": { "import": { "types": "./assets/js/types/index.d.ts", "default": "./priv/static/phoenix_live_view.esm.js" }, "require": "./priv/static/phoenix_live_view.cjs.js" }, "author": "Chris McCord (http://www.phoenixframework.org)", "repository": { "type": "git", "url": "git://github.com/phoenixframework/phoenix_live_view.git" }, "files": [ "README.md", "LICENSE.md", "package.json", "priv/static/*", "assets/js/**" ], "types": "./assets/js/types/index.d.ts", "dependencies": { "morphdom": "2.7.8" }, "devDependencies": { "@babel/cli": "7.27.2", "@babel/core": "7.27.4", "@babel/preset-env": "7.27.2", "@babel/preset-typescript": "^7.27.1", "@eslint/js": "^9.29.0", "@playwright/test": "^1.58.2", "@types/jest": "^30.0.0", "@types/phoenix": "^1.6.6", "css.escape": "^1.5.1", "eslint": "9.29.0", "eslint-plugin-jest": "28.14.0", "eslint-plugin-playwright": "^2.2.0", "globals": "^16.2.0", "jest": "^30.0.0", "jest-environment-jsdom": "^30.0.0", "jest-monocart-coverage": "^1.1.1", "monocart-reporter": "^2.9.21", "phoenix": "1.7.21", "prettier": "3.5.3", "ts-jest": "^29.4.0", "typescript": "^5.8.3", "typescript-eslint": "^8.34.0" }, "scripts": { "setup": "mix deps.get && npm install", "build": "tsc", "e2e:server": "MIX_ENV=e2e mix test --cover --export-coverage e2e test/e2e/test_helper.exs", "e2e:test": "mix assets.build && cd test/e2e && npx playwright install && npx playwright test", "js:test": "npm run build && jest", "js:test.coverage": "npm run build && jest --coverage", "js:test.watch": "npm run build && jest --watch", "js:lint": "eslint", "js:format": "prettier --write assets --log-level warn && prettier --write test/e2e --log-level warn", "js:format.check": "prettier --check assets --log-level warn && prettier --check test/e2e --log-level warn", "test": "npm run js:test && npm run e2e:test", "typecheck:tests": "tsc -p assets/test/tsconfig.json", "cover:merge": "node test/e2e/merge-coverage.js", "cover": "npm run test && npm run cover:merge", "cover:report": "npx monocart show-report cover/merged-js/index.html" } } ================================================ FILE: priv/static/phoenix_live_view.cjs.js ================================================ var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // js/phoenix_live_view/index.ts var phoenix_live_view_exports = {}; __export(phoenix_live_view_exports, { LiveSocket: () => LiveSocket2, ViewHook: () => ViewHook, createHook: () => createHook, isUsedInput: () => isUsedInput }); module.exports = __toCommonJS(phoenix_live_view_exports); // js/phoenix_live_view/constants.js var CONSECUTIVE_RELOADS = "consecutive-reloads"; var MAX_RELOADS = 10; var RELOAD_JITTER_MIN = 5e3; var RELOAD_JITTER_MAX = 1e4; var FAILSAFE_JITTER = 3e4; var PHX_EVENT_CLASSES = [ "phx-click-loading", "phx-change-loading", "phx-submit-loading", "phx-keydown-loading", "phx-keyup-loading", "phx-blur-loading", "phx-focus-loading", "phx-hook-loading" ]; var PHX_DROP_TARGET_ACTIVE_CLASS = "phx-drop-target-active"; var PHX_COMPONENT = "data-phx-component"; var PHX_VIEW_REF = "data-phx-view"; var PHX_LIVE_LINK = "data-phx-link"; var PHX_TRACK_STATIC = "track-static"; var PHX_LINK_STATE = "data-phx-link-state"; var PHX_REF_LOADING = "data-phx-ref-loading"; var PHX_REF_SRC = "data-phx-ref-src"; var PHX_REF_LOCK = "data-phx-ref-lock"; var PHX_PENDING_REFS = "phx-pending-refs"; var PHX_TRACK_UPLOADS = "track-uploads"; var PHX_UPLOAD_REF = "data-phx-upload-ref"; var PHX_PREFLIGHTED_REFS = "data-phx-preflighted-refs"; var PHX_DONE_REFS = "data-phx-done-refs"; var PHX_DROP_TARGET = "drop-target"; var PHX_ACTIVE_ENTRY_REFS = "data-phx-active-refs"; var PHX_LIVE_FILE_UPDATED = "phx:live-file:updated"; var PHX_SKIP = "data-phx-skip"; var PHX_MAGIC_ID = "data-phx-id"; var PHX_PRUNE = "data-phx-prune"; var PHX_CONNECTED_CLASS = "phx-connected"; var PHX_LOADING_CLASS = "phx-loading"; var PHX_ERROR_CLASS = "phx-error"; var PHX_CLIENT_ERROR_CLASS = "phx-client-error"; var PHX_SERVER_ERROR_CLASS = "phx-server-error"; var PHX_PARENT_ID = "data-phx-parent-id"; var PHX_MAIN = "data-phx-main"; var PHX_ROOT_ID = "data-phx-root-id"; var PHX_VIEWPORT_TOP = "viewport-top"; var PHX_VIEWPORT_BOTTOM = "viewport-bottom"; var PHX_VIEWPORT_OVERRUN_TARGET = "viewport-overrun-target"; var PHX_TRIGGER_ACTION = "trigger-action"; var PHX_HAS_FOCUSED = "phx-has-focused"; var FOCUSABLE_INPUTS = [ "text", "textarea", "number", "email", "password", "search", "tel", "url", "date", "time", "datetime-local", "color", "range" ]; var CHECKABLE_INPUTS = ["checkbox", "radio"]; var PHX_HAS_SUBMITTED = "phx-has-submitted"; var PHX_SESSION = "data-phx-session"; var PHX_VIEW_SELECTOR = `[${PHX_SESSION}]`; var PHX_STICKY = "data-phx-sticky"; var PHX_STATIC = "data-phx-static"; var PHX_READONLY = "data-phx-readonly"; var PHX_DISABLED = "data-phx-disabled"; var PHX_DISABLE_WITH = "disable-with"; var PHX_DISABLE_WITH_RESTORE = "data-phx-disable-with-restore"; var PHX_HOOK = "hook"; var PHX_DEBOUNCE = "debounce"; var PHX_THROTTLE = "throttle"; var PHX_UPDATE = "update"; var PHX_STREAM = "stream"; var PHX_STREAM_REF = "data-phx-stream"; var PHX_PORTAL = "data-phx-portal"; var PHX_TELEPORTED_REF = "data-phx-teleported"; var PHX_TELEPORTED_SRC = "data-phx-teleported-src"; var PHX_RUNTIME_HOOK = "data-phx-runtime-hook"; var PHX_LV_PID = "data-phx-pid"; var PHX_KEY = "key"; var PHX_PRIVATE = "phxPrivate"; var PHX_AUTO_RECOVER = "auto-recover"; var PHX_NO_UNUSED_FIELD = "no-unused-field"; var PHX_LV_DEBUG = "phx:live-socket:debug"; var PHX_LV_PROFILE = "phx:live-socket:profiling"; var PHX_LV_LATENCY_SIM = "phx:live-socket:latency-sim"; var PHX_LV_HISTORY_POSITION = "phx:nav-history-position"; var PHX_PROGRESS = "progress"; var PHX_MOUNTED = "mounted"; var PHX_RELOAD_STATUS = "__phoenix_reload_status__"; var LOADER_TIMEOUT = 1; var MAX_CHILD_JOIN_ATTEMPTS = 3; var BEFORE_UNLOAD_LOADER_TIMEOUT = 200; var DISCONNECTED_TIMEOUT = 500; var BINDING_PREFIX = "phx-"; var PUSH_TIMEOUT = 3e4; var DEBOUNCE_TRIGGER = "debounce-trigger"; var THROTTLED = "throttled"; var DEBOUNCE_PREV_KEY = "debounce-prev-key"; var DEFAULTS = { debounce: 300, throttle: 300 }; var PHX_PENDING_ATTRS = [PHX_REF_LOADING, PHX_REF_SRC, PHX_REF_LOCK]; var STATIC = "s"; var ROOT = "r"; var COMPONENTS = "c"; var KEYED = "k"; var KEYED_COUNT = "kc"; var EVENTS = "e"; var REPLY = "r"; var TITLE = "t"; var TEMPLATES = "p"; var STREAM = "stream"; // js/phoenix_live_view/entry_uploader.js var EntryUploader = class { constructor(entry, config, liveSocket) { const { chunk_size, chunk_timeout } = config; this.liveSocket = liveSocket; this.entry = entry; this.offset = 0; this.chunkSize = chunk_size; this.chunkTimeout = chunk_timeout; this.chunkTimer = null; this.errored = false; this.uploadChannel = liveSocket.channel(`lvu:${entry.ref}`, { token: entry.metadata() }); } error(reason) { if (this.errored) { return; } this.uploadChannel.leave(); this.errored = true; clearTimeout(this.chunkTimer); this.entry.error(reason); } upload() { this.uploadChannel.onError((reason) => this.error(reason)); this.uploadChannel.join().receive("ok", (_data) => this.readNextChunk()).receive("error", (reason) => this.error(reason)); } isDone() { return this.offset >= this.entry.file.size; } readNextChunk() { const reader = new window.FileReader(); const blob = this.entry.file.slice( this.offset, this.chunkSize + this.offset ); reader.onload = (e) => { if (e.target.error === null) { this.offset += /** @type {ArrayBuffer} */ e.target.result.byteLength; this.pushChunk( /** @type {ArrayBuffer} */ e.target.result ); } else { return logError("Read error: " + e.target.error); } }; reader.readAsArrayBuffer(blob); } pushChunk(chunk) { if (!this.uploadChannel.isJoined()) { return; } this.uploadChannel.push("chunk", chunk, this.chunkTimeout).receive("ok", () => { this.entry.progress(this.offset / this.entry.file.size * 100); if (!this.isDone()) { this.chunkTimer = setTimeout( () => this.readNextChunk(), this.liveSocket.getLatencySim() || 0 ); } }).receive("error", ({ reason }) => this.error(reason)); } }; // js/phoenix_live_view/utils.js var logError = (msg, obj) => console.error && console.error(msg, obj); var isCid = (cid) => { const type = typeof cid; return type === "number" || type === "string" && /^(0|[1-9]\d*)$/.test(cid); }; function detectDuplicateIds() { const ids = /* @__PURE__ */ new Set(); const elems = document.querySelectorAll("*[id]"); for (let i = 0, len = elems.length; i < len; i++) { if (ids.has(elems[i].id)) { console.error( `Multiple IDs detected: ${elems[i].id}. Ensure unique element ids.` ); } else { ids.add(elems[i].id); } } } function detectInvalidStreamInserts(inserts) { const errors = /* @__PURE__ */ new Set(); Object.keys(inserts).forEach((id) => { const streamEl = document.getElementById(id); if (streamEl && streamEl.parentElement && streamEl.parentElement.getAttribute("phx-update") !== "stream") { errors.add( `The stream container with id "${streamEl.parentElement.id}" is missing the phx-update="stream" attribute. Ensure it is set for streams to work properly.` ); } }); errors.forEach((error) => console.error(error)); } var debug = (view, kind, msg, obj) => { if (view.liveSocket.isDebugEnabled()) { console.log(`${view.id} ${kind}: ${msg} - `, obj); } }; var closure = (val) => typeof val === "function" ? val : function() { return val; }; var clone = (obj) => { return JSON.parse(JSON.stringify(obj)); }; var closestPhxBinding = (el, binding, borderEl) => { do { if (el.matches(`[${binding}]`) && !el.disabled) { return el; } el = el.parentElement || el.parentNode; } while (el !== null && el.nodeType === 1 && !(borderEl && borderEl.isSameNode(el) || el.matches(PHX_VIEW_SELECTOR))); return null; }; var isObject = (obj) => { return obj !== null && typeof obj === "object" && !(obj instanceof Array); }; var isEqualObj = (obj1, obj2) => JSON.stringify(obj1) === JSON.stringify(obj2); var isEmpty = (obj) => { for (const x in obj) { return false; } return true; }; var maybe = (el, callback) => el && callback(el); var channelUploader = function(entries, onError, resp, liveSocket) { entries.forEach((entry) => { const entryUploader = new EntryUploader(entry, resp.config, liveSocket); entryUploader.upload(); }); }; var eventContainsFiles = (e) => { if (e.dataTransfer.types) { for (let i = 0; i < e.dataTransfer.types.length; i++) { if (e.dataTransfer.types[i] === "Files") { return true; } } } return false; }; // js/phoenix_live_view/browser.js var Browser = { canPushState() { return typeof history.pushState !== "undefined"; }, dropLocal(localStorage, namespace, subkey) { return localStorage.removeItem(this.localKey(namespace, subkey)); }, updateLocal(localStorage, namespace, subkey, initial, func) { const current = this.getLocal(localStorage, namespace, subkey); const key = this.localKey(namespace, subkey); const newVal = current === null ? initial : func(current); localStorage.setItem(key, JSON.stringify(newVal)); return newVal; }, getLocal(localStorage, namespace, subkey) { return JSON.parse(localStorage.getItem(this.localKey(namespace, subkey))); }, updateCurrentState(callback) { if (!this.canPushState()) { return; } history.replaceState( callback(history.state || {}), "", window.location.href ); }, pushState(kind, meta, to) { if (this.canPushState()) { if (to !== window.location.href) { if (meta.type == "redirect" && meta.scroll) { const currentState = history.state || {}; currentState.scroll = meta.scroll; history.replaceState(currentState, "", window.location.href); } delete meta.scroll; history[kind + "State"](meta, "", to || null); window.requestAnimationFrame(() => { const hashEl = this.getHashTargetEl(window.location.hash); if (hashEl) { hashEl.scrollIntoView(); } else if (meta.type === "redirect") { window.scroll(0, 0); } }); } } else { this.redirect(to); } }, setCookie(name, value, maxAgeSeconds) { const expires = typeof maxAgeSeconds === "number" ? ` max-age=${maxAgeSeconds};` : ""; document.cookie = `${name}=${value};${expires} path=/`; }, getCookie(name) { return document.cookie.replace( new RegExp(`(?:(?:^|.*;s*)${name}s*=s*([^;]*).*$)|^.*$`), "$1" ); }, deleteCookie(name) { document.cookie = `${name}=; max-age=-1; path=/`; }, redirect(toURL, flash, navigate = (url) => { window.location.href = url; }) { if (flash) { this.setCookie("__phoenix_flash__", flash, 60); } navigate(toURL); }, localKey(namespace, subkey) { return `${namespace}-${subkey}`; }, getHashTargetEl(maybeHash) { const hash = maybeHash.toString().substring(1); if (hash === "") { return; } return document.getElementById(hash) || document.querySelector(`a[name="${hash}"]`); } }; var browser_default = Browser; // js/phoenix_live_view/dom.js var DOM = { byId(id) { return document.getElementById(id) || logError(`no id found for ${id}`); }, removeClass(el, className) { el.classList.remove(className); if (el.classList.length === 0) { el.removeAttribute("class"); } }, all(node, query, callback) { if (!node) { return []; } const array = Array.from(node.querySelectorAll(query)); if (callback) { array.forEach(callback); } return array; }, childNodeLength(html) { const template = document.createElement("template"); template.innerHTML = html; return template.content.childElementCount; }, isUploadInput(el) { return el.type === "file" && el.getAttribute(PHX_UPLOAD_REF) !== null; }, isAutoUpload(inputEl) { return inputEl.hasAttribute("data-phx-auto-upload"); }, findUploadInputs(node) { const formId = node.id; const inputsOutsideForm = this.all( document, `input[type="file"][${PHX_UPLOAD_REF}][form="${formId}"]` ); return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`).concat( inputsOutsideForm ); }, findComponentNodeList(viewId, cid, doc2 = document) { return this.all( doc2, `[${PHX_VIEW_REF}="${viewId}"][${PHX_COMPONENT}="${cid}"]` ); }, isPhxDestroyed(node) { return node.id && DOM.private(node, "destroyed") ? true : false; }, wantsNewTab(e) { const wantsNewTab = e.ctrlKey || e.shiftKey || e.metaKey || e.button && e.button === 1; const isDownload = e.target instanceof HTMLAnchorElement && e.target.hasAttribute("download"); const isTargetBlank = e.target.hasAttribute("target") && e.target.getAttribute("target").toLowerCase() === "_blank"; const isTargetNamedTab = e.target.hasAttribute("target") && !e.target.getAttribute("target").startsWith("_"); return wantsNewTab || isTargetBlank || isDownload || isTargetNamedTab; }, isUnloadableFormSubmit(e) { const isDialogSubmit = e.target && e.target.getAttribute("method") === "dialog" || e.submitter && e.submitter.getAttribute("formmethod") === "dialog"; if (isDialogSubmit) { return false; } else { return !e.defaultPrevented && !this.wantsNewTab(e); } }, isNewPageClick(e, currentLocation) { const href = e.target instanceof HTMLAnchorElement ? e.target.getAttribute("href") : null; let url; if (e.defaultPrevented || href === null || this.wantsNewTab(e)) { return false; } if (href.startsWith("mailto:") || href.startsWith("tel:")) { return false; } if (e.target.isContentEditable) { return false; } try { url = new URL(href); } catch { try { url = new URL(href, currentLocation); } catch { return true; } } if (url.host === currentLocation.host && url.protocol === currentLocation.protocol) { if (url.pathname === currentLocation.pathname && url.search === currentLocation.search) { return url.hash === "" && !url.href.endsWith("#"); } } return url.protocol.startsWith("http"); }, markPhxChildDestroyed(el) { if (this.isPhxChild(el)) { el.setAttribute(PHX_SESSION, ""); } this.putPrivate(el, "destroyed", true); }, findPhxChildrenInFragment(html, parentId) { const template = document.createElement("template"); template.innerHTML = html; return this.findPhxChildren(template.content, parentId); }, isIgnored(el, phxUpdate) { return (el.getAttribute(phxUpdate) || el.getAttribute("data-phx-update")) === "ignore"; }, isPhxUpdate(el, phxUpdate, updateTypes) { return el.getAttribute && updateTypes.indexOf(el.getAttribute(phxUpdate)) >= 0; }, findPhxSticky(el) { return this.all(el, `[${PHX_STICKY}]`); }, findPhxChildren(el, parentId) { return this.all(el, `${PHX_VIEW_SELECTOR}[${PHX_PARENT_ID}="${parentId}"]`); }, findExistingParentCIDs(viewId, cids) { const parentCids = /* @__PURE__ */ new Set(); const childrenCids = /* @__PURE__ */ new Set(); cids.forEach((cid) => { this.all( document, `[${PHX_VIEW_REF}="${viewId}"][${PHX_COMPONENT}="${cid}"]` ).forEach((parent) => { parentCids.add(cid); this.all(parent, `[${PHX_VIEW_REF}="${viewId}"][${PHX_COMPONENT}]`).map((el) => parseInt(el.getAttribute(PHX_COMPONENT))).forEach((childCID) => childrenCids.add(childCID)); }); }); childrenCids.forEach((childCid) => parentCids.delete(childCid)); return parentCids; }, private(el, key) { return el[PHX_PRIVATE] && el[PHX_PRIVATE][key]; }, deletePrivate(el, key) { el[PHX_PRIVATE] && delete el[PHX_PRIVATE][key]; }, putPrivate(el, key, value) { if (!el[PHX_PRIVATE]) { el[PHX_PRIVATE] = {}; } el[PHX_PRIVATE][key] = value; }, updatePrivate(el, key, defaultVal, updateFunc) { const existing = this.private(el, key); if (existing === void 0) { this.putPrivate(el, key, updateFunc(defaultVal)); } else { this.putPrivate(el, key, updateFunc(existing)); } }, syncPendingAttrs(fromEl, toEl) { if (!fromEl.hasAttribute(PHX_REF_SRC)) { return; } PHX_EVENT_CLASSES.forEach((className) => { fromEl.classList.contains(className) && toEl.classList.add(className); }); PHX_PENDING_ATTRS.filter((attr) => fromEl.hasAttribute(attr)).forEach( (attr) => { toEl.setAttribute(attr, fromEl.getAttribute(attr)); } ); }, copyPrivates(target, source) { if (source[PHX_PRIVATE]) { target[PHX_PRIVATE] = source[PHX_PRIVATE]; } }, putTitle(str) { const titleEl = document.querySelector("title"); if (titleEl) { const { prefix, suffix, default: defaultTitle } = titleEl.dataset; const isEmpty2 = typeof str !== "string" || str.trim() === ""; if (isEmpty2 && typeof defaultTitle !== "string") { return; } const inner = isEmpty2 ? defaultTitle : str; document.title = `${prefix || ""}${inner || ""}${suffix || ""}`; } else { document.title = str; } }, debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, callback) { let debounce = el.getAttribute(phxDebounce); let throttle = el.getAttribute(phxThrottle); if (debounce === "") { debounce = defaultDebounce; } if (throttle === "") { throttle = defaultThrottle; } const value = debounce || throttle; switch (value) { case null: return callback(); case "blur": this.incCycle(el, "debounce-blur-cycle", () => { if (asyncFilter()) { callback(); } }); if (this.once(el, "debounce-blur")) { el.addEventListener( "blur", () => this.triggerCycle(el, "debounce-blur-cycle") ); } return; default: const timeout = parseInt(value); const trigger = () => throttle ? this.deletePrivate(el, THROTTLED) : callback(); const currentCycle = this.incCycle(el, DEBOUNCE_TRIGGER, trigger); if (isNaN(timeout)) { return logError(`invalid throttle/debounce value: ${value}`); } if (throttle) { let newKeyDown = false; if (event.type === "keydown") { const prevKey = this.private(el, DEBOUNCE_PREV_KEY); this.putPrivate(el, DEBOUNCE_PREV_KEY, event.key); newKeyDown = prevKey !== event.key; } if (!newKeyDown && this.private(el, THROTTLED)) { return false; } else { callback(); const t = setTimeout(() => { if (asyncFilter()) { this.triggerCycle(el, DEBOUNCE_TRIGGER); } }, timeout); this.putPrivate(el, THROTTLED, t); } } else { setTimeout(() => { if (asyncFilter()) { this.triggerCycle(el, DEBOUNCE_TRIGGER, currentCycle); } }, timeout); } const form = el.form; if (form && this.once(form, "bind-debounce")) { form.addEventListener("submit", () => { Array.from(new FormData(form).entries(), ([name]) => { const namedItem = form.elements.namedItem(name); const input = namedItem instanceof RadioNodeList ? namedItem[0] : namedItem; if (input) { this.incCycle(input, DEBOUNCE_TRIGGER); this.deletePrivate(input, THROTTLED); } }); }); } if (this.once(el, "bind-debounce")) { el.addEventListener("blur", () => { clearTimeout(this.private(el, THROTTLED)); this.triggerCycle(el, DEBOUNCE_TRIGGER); }); } } }, triggerCycle(el, key, currentCycle) { const [cycle, trigger] = this.private(el, key); if (!currentCycle) { currentCycle = cycle; } if (currentCycle === cycle) { this.incCycle(el, key); trigger(); } }, once(el, key) { if (this.private(el, key) === true) { return false; } this.putPrivate(el, key, true); return true; }, incCycle(el, key, trigger = function() { }) { let [currentCycle] = this.private(el, key) || [0, trigger]; currentCycle++; this.putPrivate(el, key, [currentCycle, trigger]); return currentCycle; }, // maintains or adds privately used hook information // fromEl and toEl can be the same element in the case of a newly added node // fromEl and toEl can be any HTML node type, so we need to check if it's an element node maintainPrivateHooks(fromEl, toEl, phxViewportTop, phxViewportBottom) { if (fromEl.hasAttribute && fromEl.hasAttribute("data-phx-hook") && !toEl.hasAttribute("data-phx-hook")) { toEl.setAttribute("data-phx-hook", fromEl.getAttribute("data-phx-hook")); } if (toEl.hasAttribute && (toEl.hasAttribute(phxViewportTop) || toEl.hasAttribute(phxViewportBottom))) { toEl.setAttribute("data-phx-hook", "Phoenix.InfiniteScroll"); } }, putCustomElHook(el, hook) { if (el.isConnected) { el.setAttribute("data-phx-hook", ""); } else { console.error(` hook attached to non-connected DOM element ensure you are calling createHook within your connectedCallback. ${el.outerHTML} `); } this.putPrivate(el, "custom-el-hook", hook); }, getCustomElHook(el) { return this.private(el, "custom-el-hook"); }, isUsedInput(el) { return el.nodeType === Node.ELEMENT_NODE && (this.private(el, PHX_HAS_FOCUSED) || this.private(el, PHX_HAS_SUBMITTED)); }, resetForm(form) { Array.from(form.elements).forEach((input) => { this.deletePrivate(input, PHX_HAS_FOCUSED); this.deletePrivate(input, PHX_HAS_SUBMITTED); }); }, isPhxChild(node) { return node.getAttribute && node.getAttribute(PHX_PARENT_ID); }, isPhxSticky(node) { return node.getAttribute && node.getAttribute(PHX_STICKY) !== null; }, isChildOfAny(el, parents) { return !!parents.find((parent) => parent.contains(el)); }, firstPhxChild(el) { return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0]; }, isPortalTemplate(el) { return el.tagName === "TEMPLATE" && el.hasAttribute(PHX_PORTAL); }, closestViewEl(el) { const portalOrViewEl = el.closest( `[${PHX_TELEPORTED_REF}],${PHX_VIEW_SELECTOR}` ); if (!portalOrViewEl) { return null; } if (portalOrViewEl.hasAttribute(PHX_TELEPORTED_REF)) { return this.byId(portalOrViewEl.getAttribute(PHX_TELEPORTED_REF)); } else if (portalOrViewEl.hasAttribute(PHX_SESSION)) { return portalOrViewEl; } return null; }, dispatchEvent(target, name, opts = {}) { let defaultBubble = true; const isUploadTarget = target.nodeName === "INPUT" && target.type === "file"; if (isUploadTarget && name === "click") { defaultBubble = false; } const bubbles = opts.bubbles === void 0 ? defaultBubble : !!opts.bubbles; const eventOpts = { bubbles, cancelable: true, detail: opts.detail || {} }; const event = name === "click" ? new MouseEvent("click", eventOpts) : new CustomEvent(name, eventOpts); target.dispatchEvent(event); }, cloneNode(node, html) { if (typeof html === "undefined") { return node.cloneNode(true); } else { const cloned = node.cloneNode(false); cloned.innerHTML = html; return cloned; } }, // merge attributes from source to target // if an element is ignored, we only merge data attributes // including removing data attributes that are no longer in the source mergeAttrs(target, source, opts = {}) { const exclude = new Set(opts.exclude || []); const isIgnored = opts.isIgnored; const sourceAttrs = source.attributes; for (let i = sourceAttrs.length - 1; i >= 0; i--) { const name = sourceAttrs[i].name; if (!exclude.has(name)) { const sourceValue = source.getAttribute(name); if (target.getAttribute(name) !== sourceValue && (!isIgnored || isIgnored && name.startsWith("data-"))) { target.setAttribute(name, sourceValue); } } else { if (name === "value") { const sourceValue = source.value ?? source.getAttribute(name); if (target.value === sourceValue) { target.setAttribute("value", source.getAttribute(name)); } } } } const targetAttrs = target.attributes; for (let i = targetAttrs.length - 1; i >= 0; i--) { const name = targetAttrs[i].name; if (isIgnored) { if (name.startsWith("data-") && !source.hasAttribute(name) && !PHX_PENDING_ATTRS.includes(name)) { target.removeAttribute(name); } } else { if (!source.hasAttribute(name)) { target.removeAttribute(name); } } } }, mergeFocusedInput(target, source) { if (!(target instanceof HTMLSelectElement)) { DOM.mergeAttrs(target, source, { exclude: ["value"] }); } if (source.readOnly) { target.setAttribute("readonly", true); } else { target.removeAttribute("readonly"); } }, hasSelectionRange(el) { return el.setSelectionRange && (el.type === "text" || el.type === "textarea"); }, restoreFocus(focused, selectionStart, selectionEnd) { if (focused instanceof HTMLSelectElement) { focused.focus(); } if (!DOM.isTextualInput(focused)) { return; } const wasFocused = focused.matches(":focus"); if (!wasFocused) { focused.focus(); } if (this.hasSelectionRange(focused)) { focused.setSelectionRange(selectionStart, selectionEnd); } }, isFormInput(el) { if (el.localName && customElements.get(el.localName)) { return customElements.get(el.localName)[`formAssociated`]; } return /^(?:input|select|textarea)$/i.test(el.tagName) && el.type !== "button"; }, syncAttrsToProps(el) { if (el instanceof HTMLInputElement && CHECKABLE_INPUTS.indexOf(el.type.toLocaleLowerCase()) >= 0) { el.checked = el.getAttribute("checked") !== null; } }, isTextualInput(el) { return FOCUSABLE_INPUTS.indexOf(el.type) >= 0; }, isNowTriggerFormExternal(el, phxTriggerExternal) { return el.getAttribute && el.getAttribute(phxTriggerExternal) !== null && document.body.contains(el); }, cleanChildNodes(container, phxUpdate) { if (DOM.isPhxUpdate(container, phxUpdate, ["append", "prepend", PHX_STREAM])) { const toRemove = []; container.childNodes.forEach((childNode) => { if (!childNode.id) { const isEmptyTextNode = childNode.nodeType === Node.TEXT_NODE && childNode.nodeValue.trim() === ""; if (!isEmptyTextNode && childNode.nodeType !== Node.COMMENT_NODE) { logError( `only HTML element tags with an id are allowed inside containers with phx-update. removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}" ` ); } toRemove.push(childNode); } }); toRemove.forEach((childNode) => childNode.remove()); } }, replaceRootContainer(container, tagName, attrs) { const retainedAttrs = /* @__PURE__ */ new Set([ "id", PHX_SESSION, PHX_STATIC, PHX_MAIN, PHX_ROOT_ID ]); if (container.tagName.toLowerCase() === tagName.toLowerCase()) { Array.from(container.attributes).filter((attr) => !retainedAttrs.has(attr.name.toLowerCase())).forEach((attr) => container.removeAttribute(attr.name)); Object.keys(attrs).filter((name) => !retainedAttrs.has(name.toLowerCase())).forEach((attr) => container.setAttribute(attr, attrs[attr])); return container; } else { const newContainer = document.createElement(tagName); Object.keys(attrs).forEach( (attr) => newContainer.setAttribute(attr, attrs[attr]) ); retainedAttrs.forEach( (attr) => newContainer.setAttribute(attr, container.getAttribute(attr)) ); newContainer.innerHTML = container.innerHTML; container.replaceWith(newContainer); return newContainer; } }, getSticky(el, name, defaultVal) { const op = (DOM.private(el, "sticky") || []).find( ([existingName]) => name === existingName ); if (op) { const [_name, _op, stashedResult] = op; return stashedResult; } else { return typeof defaultVal === "function" ? defaultVal() : defaultVal; } }, deleteSticky(el, name) { this.updatePrivate(el, "sticky", [], (ops) => { return ops.filter(([existingName, _]) => existingName !== name); }); }, putSticky(el, name, op) { const stashedResult = op(el); this.updatePrivate(el, "sticky", [], (ops) => { const existingIndex = ops.findIndex( ([existingName]) => name === existingName ); if (existingIndex >= 0) { ops[existingIndex] = [name, op, stashedResult]; } else { ops.push([name, op, stashedResult]); } return ops; }); }, applyStickyOperations(el) { const ops = DOM.private(el, "sticky"); if (!ops) { return; } ops.forEach(([name, op, _stashed]) => this.putSticky(el, name, op)); }, isLocked(el) { return el.hasAttribute && el.hasAttribute(PHX_REF_LOCK); }, attributeIgnored(attribute, ignoredAttributes) { return ignoredAttributes.some( (toIgnore) => attribute.name == toIgnore || toIgnore === "*" || toIgnore.includes("*") && attribute.name.match(toIgnore) != null ); } }; var dom_default = DOM; // js/phoenix_live_view/upload_entry.js var UploadEntry = class { static isActive(fileEl, file) { const isNew = file._phxRef === void 0; const activeRefs = fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(","); const isActive = activeRefs.indexOf(LiveUploader.genFileRef(file)) >= 0; return file.size > 0 && (isNew || isActive); } static isPreflighted(fileEl, file) { const preflightedRefs = fileEl.getAttribute(PHX_PREFLIGHTED_REFS).split(","); const isPreflighted = preflightedRefs.indexOf(LiveUploader.genFileRef(file)) >= 0; return isPreflighted && this.isActive(fileEl, file); } static isPreflightInProgress(file) { return file._preflightInProgress === true; } static markPreflightInProgress(file) { file._preflightInProgress = true; } constructor(fileEl, file, view, autoUpload) { this.ref = LiveUploader.genFileRef(file); this.fileEl = fileEl; this.file = file; this.view = view; this.meta = null; this._isCancelled = false; this._isDone = false; this._progress = 0; this._lastProgressSent = -1; this._onDone = function() { }; this._onElUpdated = this.onElUpdated.bind(this); this.fileEl.addEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated); this.autoUpload = autoUpload; } metadata() { return this.meta; } progress(progress) { this._progress = Math.floor(progress); if (this._progress > this._lastProgressSent) { if (this._progress >= 100) { this._progress = 100; this._lastProgressSent = 100; this._isDone = true; this.view.pushFileProgress(this.fileEl, this.ref, 100, () => { LiveUploader.untrackFile(this.fileEl, this.file); this._onDone(); }); } else { this._lastProgressSent = this._progress; this.view.pushFileProgress(this.fileEl, this.ref, this._progress); } } } isCancelled() { return this._isCancelled; } cancel() { this.file._preflightInProgress = false; this._isCancelled = true; this._isDone = true; this._onDone(); } isDone() { return this._isDone; } error(reason = "failed") { this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated); this.view.pushFileProgress(this.fileEl, this.ref, { error: reason }); if (!this.isAutoUpload()) { LiveUploader.clearFiles(this.fileEl); } } isAutoUpload() { return this.autoUpload; } //private onDone(callback) { this._onDone = () => { this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated); callback(); }; } onElUpdated() { const activeRefs = this.fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(","); if (activeRefs.indexOf(this.ref) === -1) { LiveUploader.untrackFile(this.fileEl, this.file); this.cancel(); } } toPreflightPayload() { return { last_modified: this.file.lastModified, name: this.file.name, relative_path: this.file.webkitRelativePath, size: this.file.size, type: this.file.type, ref: this.ref, meta: typeof this.file.meta === "function" ? this.file.meta() : void 0 }; } uploader(uploaders) { if (this.meta.uploader) { const callback = uploaders[this.meta.uploader] || logError(`no uploader configured for ${this.meta.uploader}`); return { name: this.meta.uploader, callback }; } else { return { name: "channel", callback: channelUploader }; } } zipPostFlight(resp) { this.meta = resp.entries[this.ref]; if (!this.meta) { logError(`no preflight upload response returned with ref ${this.ref}`, { input: this.fileEl, response: resp }); } } }; // js/phoenix_live_view/live_uploader.js var liveUploaderFileRef = 0; var LiveUploader = class _LiveUploader { static genFileRef(file) { const ref = file._phxRef; if (ref !== void 0) { return ref; } else { file._phxRef = (liveUploaderFileRef++).toString(); return file._phxRef; } } static getEntryDataURL(inputEl, ref, callback) { const file = this.activeFiles(inputEl).find( (file2) => this.genFileRef(file2) === ref ); callback(URL.createObjectURL(file)); } static hasUploadsInProgress(formEl) { let active = 0; dom_default.findUploadInputs(formEl).forEach((input) => { if (input.getAttribute(PHX_PREFLIGHTED_REFS) !== input.getAttribute(PHX_DONE_REFS)) { active++; } }); return active > 0; } static serializeUploads(inputEl) { const files = this.activeFiles(inputEl); const fileData = {}; files.forEach((file) => { const entry = { path: inputEl.name }; const uploadRef = inputEl.getAttribute(PHX_UPLOAD_REF); fileData[uploadRef] = fileData[uploadRef] || []; entry.ref = this.genFileRef(file); entry.last_modified = file.lastModified; entry.name = file.name || entry.ref; entry.relative_path = file.webkitRelativePath; entry.type = file.type; entry.size = file.size; if (typeof file.meta === "function") { entry.meta = file.meta(); } fileData[uploadRef].push(entry); }); return fileData; } static clearFiles(inputEl) { inputEl.value = null; inputEl.removeAttribute(PHX_UPLOAD_REF); dom_default.putPrivate(inputEl, "files", []); } static untrackFile(inputEl, file) { dom_default.putPrivate( inputEl, "files", dom_default.private(inputEl, "files").filter((f) => !Object.is(f, file)) ); } /** * @param {HTMLInputElement} inputEl * @param {Array} files * @param {DataTransfer} [dataTransfer] */ static trackFiles(inputEl, files, dataTransfer) { if (inputEl.getAttribute("multiple") !== null) { const newFiles = files.filter( (file) => !this.activeFiles(inputEl).find((f) => Object.is(f, file)) ); dom_default.updatePrivate( inputEl, "files", [], (existing) => existing.concat(newFiles) ); inputEl.value = null; } else { if (dataTransfer && dataTransfer.files.length > 0) { inputEl.files = dataTransfer.files; } dom_default.putPrivate(inputEl, "files", files); } } static activeFileInputs(formEl) { const fileInputs = dom_default.findUploadInputs(formEl); return Array.from(fileInputs).filter( (el) => el.files && this.activeFiles(el).length > 0 ); } static activeFiles(input) { return (dom_default.private(input, "files") || []).filter( (f) => UploadEntry.isActive(input, f) ); } static inputsAwaitingPreflight(formEl) { const fileInputs = dom_default.findUploadInputs(formEl); return Array.from(fileInputs).filter( (input) => this.filesAwaitingPreflight(input).length > 0 ); } static filesAwaitingPreflight(input) { return this.activeFiles(input).filter( (f) => !UploadEntry.isPreflighted(input, f) && !UploadEntry.isPreflightInProgress(f) ); } static markPreflightInProgress(entries) { entries.forEach((entry) => UploadEntry.markPreflightInProgress(entry.file)); } constructor(inputEl, view, onComplete) { this.autoUpload = dom_default.isAutoUpload(inputEl); this.view = view; this.onComplete = onComplete; this._entries = Array.from( _LiveUploader.filesAwaitingPreflight(inputEl) || [] ).map((file) => new UploadEntry(inputEl, file, view, this.autoUpload)); _LiveUploader.markPreflightInProgress(this._entries); this.numEntriesInProgress = this._entries.length; } isAutoUpload() { return this.autoUpload; } entries() { return this._entries; } initAdapterUpload(resp, onError, liveSocket) { this._entries = this._entries.map((entry) => { if (entry.isCancelled()) { this.numEntriesInProgress--; if (this.numEntriesInProgress === 0) { this.onComplete(); } } else { entry.zipPostFlight(resp); entry.onDone(() => { this.numEntriesInProgress--; if (this.numEntriesInProgress === 0) { this.onComplete(); } }); } return entry; }); const groupedEntries = this._entries.reduce((acc, entry) => { if (!entry.meta) { return acc; } const { name, callback } = entry.uploader(liveSocket.uploaders); acc[name] = acc[name] || { callback, entries: [] }; acc[name].entries.push(entry); return acc; }, {}); for (const name in groupedEntries) { const { callback, entries } = groupedEntries[name]; callback(entries, onError, resp, liveSocket); } } }; // js/phoenix_live_view/aria.js var ARIA = { anyOf(instance, classes) { return classes.find((name) => instance instanceof name); }, isFocusable(el, interactiveOnly) { return el instanceof HTMLAnchorElement && el.rel !== "ignore" || el instanceof HTMLAreaElement && el.href !== void 0 || !el.disabled && this.anyOf(el, [ HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLButtonElement ]) || el instanceof HTMLIFrameElement || el.tabIndex >= 0 && el.getAttribute("aria-hidden") !== "true" || !interactiveOnly && el.getAttribute("tabindex") !== null && el.getAttribute("aria-hidden") !== "true"; }, attemptFocus(el, interactiveOnly) { if (this.isFocusable(el, interactiveOnly)) { try { el.focus(); } catch { } } return !!document.activeElement && document.activeElement.isSameNode(el); }, focusFirstInteractive(el) { let child = el.firstElementChild; while (child) { if (this.attemptFocus(child, true) || this.focusFirstInteractive(child)) { return true; } child = child.nextElementSibling; } }, focusFirst(el) { let child = el.firstElementChild; while (child) { if (this.attemptFocus(child) || this.focusFirst(child)) { return true; } child = child.nextElementSibling; } }, focusLast(el) { let child = el.lastElementChild; while (child) { if (this.attemptFocus(child) || this.focusLast(child)) { return true; } child = child.previousElementSibling; } } }; var aria_default = ARIA; // js/phoenix_live_view/hooks.js var Hooks = { LiveFileUpload: { activeRefs() { return this.el.getAttribute(PHX_ACTIVE_ENTRY_REFS); }, preflightedRefs() { return this.el.getAttribute(PHX_PREFLIGHTED_REFS); }, mounted() { this.js().ignoreAttributes(this.el, ["value"]); this.preflightedWas = this.preflightedRefs(); }, updated() { const newPreflights = this.preflightedRefs(); if (this.preflightedWas !== newPreflights) { this.preflightedWas = newPreflights; if (newPreflights === "") { this.__view().cancelSubmit(this.el.form); } } if (this.activeRefs() === "") { this.el.value = null; } this.el.dispatchEvent(new CustomEvent(PHX_LIVE_FILE_UPDATED)); } }, LiveImgPreview: { mounted() { this.ref = this.el.getAttribute("data-phx-entry-ref"); this.inputEl = document.getElementById( this.el.getAttribute(PHX_UPLOAD_REF) ); LiveUploader.getEntryDataURL(this.inputEl, this.ref, (url) => { this.url = url; this.el.src = url; }); }, destroyed() { URL.revokeObjectURL(this.url); } }, FocusWrap: { mounted() { this.focusStart = this.el.firstElementChild; this.focusEnd = this.el.lastElementChild; this.focusStart.addEventListener("focus", (e) => { if (!e.relatedTarget || !this.el.contains(e.relatedTarget)) { const nextFocus = e.target.nextElementSibling; aria_default.attemptFocus(nextFocus) || aria_default.focusFirst(nextFocus); } else { aria_default.focusLast(this.el); } }); this.focusEnd.addEventListener("focus", (e) => { if (!e.relatedTarget || !this.el.contains(e.relatedTarget)) { const nextFocus = e.target.previousElementSibling; aria_default.attemptFocus(nextFocus) || aria_default.focusLast(nextFocus); } else { aria_default.focusFirst(this.el); } }); if (!this.el.contains(document.activeElement)) { this.el.addEventListener("phx:show-end", () => this.el.focus()); if (window.getComputedStyle(this.el).display !== "none") { aria_default.focusFirst(this.el); } } } } }; var findScrollContainer = (el) => { if (["HTML", "BODY"].indexOf(el.nodeName.toUpperCase()) >= 0) return null; if (["scroll", "auto"].indexOf(getComputedStyle(el).overflowY) >= 0) return el; return findScrollContainer(el.parentElement); }; var scrollTop = (scrollContainer) => { if (scrollContainer) { return scrollContainer.scrollTop; } else { return document.documentElement.scrollTop || document.body.scrollTop; } }; var bottom = (scrollContainer) => { if (scrollContainer) { return scrollContainer.getBoundingClientRect().bottom; } else { return window.innerHeight || document.documentElement.clientHeight; } }; var top = (scrollContainer) => { if (scrollContainer) { return scrollContainer.getBoundingClientRect().top; } else { return 0; } }; var isAtViewportTop = (el, scrollContainer) => { const rect = el.getBoundingClientRect(); return Math.ceil(rect.top) >= top(scrollContainer) && Math.ceil(rect.left) >= 0 && Math.floor(rect.top) <= bottom(scrollContainer); }; var isAtViewportBottom = (el, scrollContainer) => { const rect = el.getBoundingClientRect(); return Math.ceil(rect.bottom) >= top(scrollContainer) && Math.ceil(rect.left) >= 0 && Math.floor(rect.bottom) <= bottom(scrollContainer); }; var isWithinViewport = (el, scrollContainer) => { const rect = el.getBoundingClientRect(); return Math.ceil(rect.top) >= top(scrollContainer) && Math.ceil(rect.left) >= 0 && Math.floor(rect.top) <= bottom(scrollContainer); }; Hooks.InfiniteScroll = { mounted() { this.scrollContainer = findScrollContainer(this.el); let scrollBefore = scrollTop(this.scrollContainer); let topOverran = false; const throttleInterval = 500; let pendingOp = null; const onTopOverrun = this.throttle( throttleInterval, (topEvent, firstChild) => { pendingOp = () => true; this.liveSocket.js().push(this.el, topEvent, { value: { id: firstChild.id, _overran: true }, callback: () => { pendingOp = null; } }); } ); const onFirstChildAtTop = this.throttle( throttleInterval, (topEvent, firstChild) => { pendingOp = () => firstChild.scrollIntoView({ block: "start" }); this.liveSocket.js().push(this.el, topEvent, { value: { id: firstChild.id }, callback: () => { pendingOp = null; window.requestAnimationFrame(() => { if (!isWithinViewport(firstChild, this.scrollContainer)) { firstChild.scrollIntoView({ block: "start" }); } }); } }); } ); const onLastChildAtBottom = this.throttle( throttleInterval, (bottomEvent, lastChild) => { pendingOp = () => lastChild.scrollIntoView({ block: "end" }); this.liveSocket.js().push(this.el, bottomEvent, { value: { id: lastChild.id }, callback: () => { pendingOp = null; window.requestAnimationFrame(() => { if (!isWithinViewport(lastChild, this.scrollContainer)) { lastChild.scrollIntoView({ block: "end" }); } }); } }); } ); this.onScroll = (_e) => { const scrollNow = scrollTop(this.scrollContainer); if (pendingOp) { scrollBefore = scrollNow; return pendingOp(); } const rect = this.findOverrunTarget(); const topEvent = this.el.getAttribute( this.liveSocket.binding("viewport-top") ); const bottomEvent = this.el.getAttribute( this.liveSocket.binding("viewport-bottom") ); const lastChild = this.el.lastElementChild; const firstChild = this.el.firstElementChild; const isScrollingUp = scrollNow < scrollBefore; const isScrollingDown = scrollNow > scrollBefore; if (isScrollingUp && topEvent && !topOverran && rect.top >= 0) { topOverran = true; onTopOverrun(topEvent, firstChild); } else if (isScrollingDown && topOverran && rect.top <= 0) { topOverran = false; } if (topEvent && isScrollingUp && isAtViewportTop(firstChild, this.scrollContainer)) { onFirstChildAtTop(topEvent, firstChild); } else if (bottomEvent && isScrollingDown && isAtViewportBottom(lastChild, this.scrollContainer)) { onLastChildAtBottom(bottomEvent, lastChild); } scrollBefore = scrollNow; }; if (this.scrollContainer) { this.scrollContainer.addEventListener("scroll", this.onScroll); } else { window.addEventListener("scroll", this.onScroll); } }, destroyed() { if (this.scrollContainer) { this.scrollContainer.removeEventListener("scroll", this.onScroll); } else { window.removeEventListener("scroll", this.onScroll); } }, throttle(interval, callback) { let lastCallAt = 0; let timer; return (...args) => { const now = Date.now(); const remainingTime = interval - (now - lastCallAt); if (remainingTime <= 0 || remainingTime > interval) { if (timer) { clearTimeout(timer); timer = null; } lastCallAt = now; callback(...args); } else if (!timer) { timer = setTimeout(() => { lastCallAt = Date.now(); timer = null; callback(...args); }, remainingTime); } }; }, findOverrunTarget() { let rect; const overrunTarget = this.el.getAttribute( this.liveSocket.binding(PHX_VIEWPORT_OVERRUN_TARGET) ); if (overrunTarget) { const overrunEl = document.getElementById(overrunTarget); if (overrunEl) { rect = overrunEl.getBoundingClientRect(); } else { throw new Error("did not find element with id " + overrunTarget); } } else { rect = this.el.getBoundingClientRect(); } return rect; } }; var hooks_default = Hooks; // js/phoenix_live_view/element_ref.js var ElementRef = class { static onUnlock(el, callback) { if (!dom_default.isLocked(el) && !el.closest(`[${PHX_REF_LOCK}]`)) { return callback(); } const closestLock = el.closest(`[${PHX_REF_LOCK}]`); const ref = closestLock.closest(`[${PHX_REF_LOCK}]`).getAttribute(PHX_REF_LOCK); closestLock.addEventListener( `phx:undo-lock:${ref}`, () => { callback(); }, { once: true } ); } constructor(el) { this.el = el; this.loadingRef = el.hasAttribute(PHX_REF_LOADING) ? parseInt(el.getAttribute(PHX_REF_LOADING), 10) : null; this.lockRef = el.hasAttribute(PHX_REF_LOCK) ? parseInt(el.getAttribute(PHX_REF_LOCK), 10) : null; } // public maybeUndo(ref, phxEvent, eachCloneCallback) { if (!this.isWithin(ref)) { dom_default.updatePrivate(this.el, PHX_PENDING_REFS, [], (pendingRefs) => { pendingRefs.push(ref); return pendingRefs; }); return; } this.undoLocks(ref, phxEvent, eachCloneCallback); this.undoLoading(ref, phxEvent); dom_default.updatePrivate(this.el, PHX_PENDING_REFS, [], (pendingRefs) => { return pendingRefs.filter((pendingRef) => { let opts = { detail: { ref: pendingRef, event: phxEvent }, bubbles: true, cancelable: false }; if (this.loadingRef && this.loadingRef > pendingRef) { this.el.dispatchEvent( new CustomEvent(`phx:undo-loading:${pendingRef}`, opts) ); } if (this.lockRef && this.lockRef > pendingRef) { this.el.dispatchEvent( new CustomEvent(`phx:undo-lock:${pendingRef}`, opts) ); } return pendingRef > ref; }); }); if (this.isFullyResolvedBy(ref)) { this.el.removeAttribute(PHX_REF_SRC); } } // private isWithin(ref) { return !(this.loadingRef !== null && this.loadingRef > ref && this.lockRef !== null && this.lockRef > ref); } // Check for cloned PHX_REF_LOCK element that has been morphed behind // the scenes while this element was locked in the DOM. // When we apply the cloned tree to the active DOM element, we must // // 1. execute pending mounted hooks for nodes now in the DOM // 2. undo any ref inside the cloned tree that has since been ack'd undoLocks(ref, phxEvent, eachCloneCallback) { if (!this.isLockUndoneBy(ref)) { return; } const clonedTree = dom_default.private(this.el, PHX_REF_LOCK); if (clonedTree) { eachCloneCallback(clonedTree); dom_default.deletePrivate(this.el, PHX_REF_LOCK); } this.el.removeAttribute(PHX_REF_LOCK); const opts = { detail: { ref, event: phxEvent }, bubbles: true, cancelable: false }; this.el.dispatchEvent( new CustomEvent(`phx:undo-lock:${this.lockRef}`, opts) ); } undoLoading(ref, phxEvent) { if (!this.isLoadingUndoneBy(ref)) { if (this.canUndoLoading(ref) && this.el.classList.contains("phx-submit-loading")) { this.el.classList.remove("phx-change-loading"); } return; } if (this.canUndoLoading(ref)) { this.el.removeAttribute(PHX_REF_LOADING); const disabledVal = this.el.getAttribute(PHX_DISABLED); const readOnlyVal = this.el.getAttribute(PHX_READONLY); if (readOnlyVal !== null) { this.el.readOnly = readOnlyVal === "true" ? true : false; this.el.removeAttribute(PHX_READONLY); } if (disabledVal !== null) { this.el.disabled = disabledVal === "true" ? true : false; this.el.removeAttribute(PHX_DISABLED); } const disableRestore = this.el.getAttribute(PHX_DISABLE_WITH_RESTORE); if (disableRestore !== null) { this.el.textContent = disableRestore; this.el.removeAttribute(PHX_DISABLE_WITH_RESTORE); } const opts = { detail: { ref, event: phxEvent }, bubbles: true, cancelable: false }; this.el.dispatchEvent( new CustomEvent(`phx:undo-loading:${this.loadingRef}`, opts) ); } PHX_EVENT_CLASSES.forEach((name) => { if (name !== "phx-submit-loading" || this.canUndoLoading(ref)) { dom_default.removeClass(this.el, name); } }); } isLoadingUndoneBy(ref) { return this.loadingRef === null ? false : this.loadingRef <= ref; } isLockUndoneBy(ref) { return this.lockRef === null ? false : this.lockRef <= ref; } isFullyResolvedBy(ref) { return (this.loadingRef === null || this.loadingRef <= ref) && (this.lockRef === null || this.lockRef <= ref); } // only remove the phx-submit-loading class if we are not locked canUndoLoading(ref) { return this.lockRef === null || this.lockRef <= ref; } }; // js/phoenix_live_view/dom_post_morph_restorer.js var DOMPostMorphRestorer = class { constructor(containerBefore, containerAfter, updateType) { const idsBefore = /* @__PURE__ */ new Set(); const idsAfter = new Set( [...containerAfter.children].map((child) => child.id) ); const elementsToModify = []; Array.from(containerBefore.children).forEach((child) => { if (child.id) { idsBefore.add(child.id); if (idsAfter.has(child.id)) { const previousElementId = child.previousElementSibling && child.previousElementSibling.id; elementsToModify.push({ elementId: child.id, previousElementId }); } } }); this.containerId = containerAfter.id; this.updateType = updateType; this.elementsToModify = elementsToModify; this.elementIdsToAdd = [...idsAfter].filter((id) => !idsBefore.has(id)); } // We do the following to optimize append/prepend operations: // 1) Track ids of modified elements & of new elements // 2) All the modified elements are put back in the correct position in the DOM tree // by storing the id of their previous sibling // 3) New elements are going to be put in the right place by morphdom during append. // For prepend, we move them to the first position in the container perform() { const container = dom_default.byId(this.containerId); if (!container) { return; } this.elementsToModify.forEach((elementToModify) => { if (elementToModify.previousElementId) { maybe( document.getElementById(elementToModify.previousElementId), (previousElem) => { maybe( document.getElementById(elementToModify.elementId), (elem) => { const isInRightPlace = elem.previousElementSibling && elem.previousElementSibling.id == previousElem.id; if (!isInRightPlace) { previousElem.insertAdjacentElement("afterend", elem); } } ); } ); } else { maybe(document.getElementById(elementToModify.elementId), (elem) => { const isInRightPlace = elem.previousElementSibling == null; if (!isInRightPlace) { container.insertAdjacentElement("afterbegin", elem); } }); } }); if (this.updateType == "prepend") { this.elementIdsToAdd.reverse().forEach((elemId) => { maybe( document.getElementById(elemId), (elem) => container.insertAdjacentElement("afterbegin", elem) ); }); } } }; // ../node_modules/morphdom/dist/morphdom-esm.js var DOCUMENT_FRAGMENT_NODE = 11; function morphAttrs(fromNode, toNode) { var toNodeAttrs = toNode.attributes; var attr; var attrName; var attrNamespaceURI; var attrValue; var fromValue; if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE || fromNode.nodeType === DOCUMENT_FRAGMENT_NODE) { return; } for (var i = toNodeAttrs.length - 1; i >= 0; i--) { attr = toNodeAttrs[i]; attrName = attr.name; attrNamespaceURI = attr.namespaceURI; attrValue = attr.value; if (attrNamespaceURI) { attrName = attr.localName || attrName; fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName); if (fromValue !== attrValue) { if (attr.prefix === "xmlns") { attrName = attr.name; } fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); } } else { fromValue = fromNode.getAttribute(attrName); if (fromValue !== attrValue) { fromNode.setAttribute(attrName, attrValue); } } } var fromNodeAttrs = fromNode.attributes; for (var d = fromNodeAttrs.length - 1; d >= 0; d--) { attr = fromNodeAttrs[d]; attrName = attr.name; attrNamespaceURI = attr.namespaceURI; if (attrNamespaceURI) { attrName = attr.localName || attrName; if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) { fromNode.removeAttributeNS(attrNamespaceURI, attrName); } } else { if (!toNode.hasAttribute(attrName)) { fromNode.removeAttribute(attrName); } } } } var range; var NS_XHTML = "http://www.w3.org/1999/xhtml"; var doc = typeof document === "undefined" ? void 0 : document; var HAS_TEMPLATE_SUPPORT = !!doc && "content" in doc.createElement("template"); var HAS_RANGE_SUPPORT = !!doc && doc.createRange && "createContextualFragment" in doc.createRange(); function createFragmentFromTemplate(str) { var template = doc.createElement("template"); template.innerHTML = str; return template.content.childNodes[0]; } function createFragmentFromRange(str) { if (!range) { range = doc.createRange(); range.selectNode(doc.body); } var fragment = range.createContextualFragment(str); return fragment.childNodes[0]; } function createFragmentFromWrap(str) { var fragment = doc.createElement("body"); fragment.innerHTML = str; return fragment.childNodes[0]; } function toElement(str) { str = str.trim(); if (HAS_TEMPLATE_SUPPORT) { return createFragmentFromTemplate(str); } else if (HAS_RANGE_SUPPORT) { return createFragmentFromRange(str); } return createFragmentFromWrap(str); } function compareNodeNames(fromEl, toEl) { var fromNodeName = fromEl.nodeName; var toNodeName = toEl.nodeName; var fromCodeStart, toCodeStart; if (fromNodeName === toNodeName) { return true; } fromCodeStart = fromNodeName.charCodeAt(0); toCodeStart = toNodeName.charCodeAt(0); if (fromCodeStart <= 90 && toCodeStart >= 97) { return fromNodeName === toNodeName.toUpperCase(); } else if (toCodeStart <= 90 && fromCodeStart >= 97) { return toNodeName === fromNodeName.toUpperCase(); } else { return false; } } function createElementNS(name, namespaceURI) { return !namespaceURI || namespaceURI === NS_XHTML ? doc.createElement(name) : doc.createElementNS(namespaceURI, name); } function moveChildren(fromEl, toEl) { var curChild = fromEl.firstChild; while (curChild) { var nextChild = curChild.nextSibling; toEl.appendChild(curChild); curChild = nextChild; } return toEl; } function syncBooleanAttrProp(fromEl, toEl, name) { if (fromEl[name] !== toEl[name]) { fromEl[name] = toEl[name]; if (fromEl[name]) { fromEl.setAttribute(name, ""); } else { fromEl.removeAttribute(name); } } } var specialElHandlers = { OPTION: function(fromEl, toEl) { var parentNode = fromEl.parentNode; if (parentNode) { var parentName = parentNode.nodeName.toUpperCase(); if (parentName === "OPTGROUP") { parentNode = parentNode.parentNode; parentName = parentNode && parentNode.nodeName.toUpperCase(); } if (parentName === "SELECT" && !parentNode.hasAttribute("multiple")) { if (fromEl.hasAttribute("selected") && !toEl.selected) { fromEl.setAttribute("selected", "selected"); fromEl.removeAttribute("selected"); } parentNode.selectedIndex = -1; } } syncBooleanAttrProp(fromEl, toEl, "selected"); }, /** * The "value" attribute is special for the element since it sets * the initial value. Changing the "value" attribute without changing the * "value" property will have no effect since it is only used to the set the * initial value. Similar for the "checked" attribute, and "disabled". */ INPUT: function(fromEl, toEl) { syncBooleanAttrProp(fromEl, toEl, "checked"); syncBooleanAttrProp(fromEl, toEl, "disabled"); if (fromEl.value !== toEl.value) { fromEl.value = toEl.value; } if (!toEl.hasAttribute("value")) { fromEl.removeAttribute("value"); } }, TEXTAREA: function(fromEl, toEl) { var newValue = toEl.value; if (fromEl.value !== newValue) { fromEl.value = newValue; } var firstChild = fromEl.firstChild; if (firstChild) { var oldValue = firstChild.nodeValue; if (oldValue == newValue || !newValue && oldValue == fromEl.placeholder) { return; } firstChild.nodeValue = newValue; } }, SELECT: function(fromEl, toEl) { if (!toEl.hasAttribute("multiple")) { var selectedIndex = -1; var i = 0; var curChild = fromEl.firstChild; var optgroup; var nodeName; while (curChild) { nodeName = curChild.nodeName && curChild.nodeName.toUpperCase(); if (nodeName === "OPTGROUP") { optgroup = curChild; curChild = optgroup.firstChild; if (!curChild) { curChild = optgroup.nextSibling; optgroup = null; } } else { if (nodeName === "OPTION") { if (curChild.hasAttribute("selected")) { selectedIndex = i; break; } i++; } curChild = curChild.nextSibling; if (!curChild && optgroup) { curChild = optgroup.nextSibling; optgroup = null; } } } fromEl.selectedIndex = selectedIndex; } } }; var ELEMENT_NODE = 1; var DOCUMENT_FRAGMENT_NODE$1 = 11; var TEXT_NODE = 3; var COMMENT_NODE = 8; function noop() { } function defaultGetNodeKey(node) { if (node) { return node.getAttribute && node.getAttribute("id") || node.id; } } function morphdomFactory(morphAttrs2) { return function morphdom2(fromNode, toNode, options) { if (!options) { options = {}; } if (typeof toNode === "string") { if (fromNode.nodeName === "#document" || fromNode.nodeName === "HTML") { var toNodeHtml = toNode; toNode = doc.createElement("html"); toNode.innerHTML = toNodeHtml; } else if (fromNode.nodeName === "BODY") { var toNodeBody = toNode; toNode = doc.createElement("html"); toNode.innerHTML = toNodeBody; var bodyElement = toNode.querySelector("body"); if (bodyElement) { toNode = bodyElement; } } else { toNode = toElement(toNode); } } else if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE$1) { toNode = toNode.firstElementChild; } var getNodeKey = options.getNodeKey || defaultGetNodeKey; var onBeforeNodeAdded = options.onBeforeNodeAdded || noop; var onNodeAdded = options.onNodeAdded || noop; var onBeforeElUpdated = options.onBeforeElUpdated || noop; var onElUpdated = options.onElUpdated || noop; var onBeforeNodeDiscarded = options.onBeforeNodeDiscarded || noop; var onNodeDiscarded = options.onNodeDiscarded || noop; var onBeforeElChildrenUpdated = options.onBeforeElChildrenUpdated || noop; var skipFromChildren = options.skipFromChildren || noop; var addChild = options.addChild || function(parent, child) { return parent.appendChild(child); }; var childrenOnly = options.childrenOnly === true; var fromNodesLookup = /* @__PURE__ */ Object.create(null); var keyedRemovalList = []; function addKeyedRemoval(key) { keyedRemovalList.push(key); } function walkDiscardedChildNodes(node, skipKeyedNodes) { if (node.nodeType === ELEMENT_NODE) { var curChild = node.firstChild; while (curChild) { var key = void 0; if (skipKeyedNodes && (key = getNodeKey(curChild))) { addKeyedRemoval(key); } else { onNodeDiscarded(curChild); if (curChild.firstChild) { walkDiscardedChildNodes(curChild, skipKeyedNodes); } } curChild = curChild.nextSibling; } } } function removeNode(node, parentNode, skipKeyedNodes) { if (onBeforeNodeDiscarded(node) === false) { return; } if (parentNode) { parentNode.removeChild(node); } onNodeDiscarded(node); walkDiscardedChildNodes(node, skipKeyedNodes); } function indexTree(node) { if (node.nodeType === ELEMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE$1) { var curChild = node.firstChild; while (curChild) { var key = getNodeKey(curChild); if (key) { fromNodesLookup[key] = curChild; } indexTree(curChild); curChild = curChild.nextSibling; } } } indexTree(fromNode); function handleNodeAdded(el) { onNodeAdded(el); var curChild = el.firstChild; while (curChild) { var nextSibling = curChild.nextSibling; var key = getNodeKey(curChild); if (key) { var unmatchedFromEl = fromNodesLookup[key]; if (unmatchedFromEl && compareNodeNames(curChild, unmatchedFromEl)) { curChild.parentNode.replaceChild(unmatchedFromEl, curChild); morphEl(unmatchedFromEl, curChild); } else { handleNodeAdded(curChild); } } else { handleNodeAdded(curChild); } curChild = nextSibling; } } function cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey) { while (curFromNodeChild) { var fromNextSibling = curFromNodeChild.nextSibling; if (curFromNodeKey = getNodeKey(curFromNodeChild)) { addKeyedRemoval(curFromNodeKey); } else { removeNode( curFromNodeChild, fromEl, true /* skip keyed nodes */ ); } curFromNodeChild = fromNextSibling; } } function morphEl(fromEl, toEl, childrenOnly2) { var toElKey = getNodeKey(toEl); if (toElKey) { delete fromNodesLookup[toElKey]; } if (!childrenOnly2) { var beforeUpdateResult = onBeforeElUpdated(fromEl, toEl); if (beforeUpdateResult === false) { return; } else if (beforeUpdateResult instanceof HTMLElement) { fromEl = beforeUpdateResult; indexTree(fromEl); } morphAttrs2(fromEl, toEl); onElUpdated(fromEl); if (onBeforeElChildrenUpdated(fromEl, toEl) === false) { return; } } if (fromEl.nodeName !== "TEXTAREA") { morphChildren(fromEl, toEl); } else { specialElHandlers.TEXTAREA(fromEl, toEl); } } function morphChildren(fromEl, toEl) { var skipFrom = skipFromChildren(fromEl, toEl); var curToNodeChild = toEl.firstChild; var curFromNodeChild = fromEl.firstChild; var curToNodeKey; var curFromNodeKey; var fromNextSibling; var toNextSibling; var matchingFromEl; outer: while (curToNodeChild) { toNextSibling = curToNodeChild.nextSibling; curToNodeKey = getNodeKey(curToNodeChild); while (!skipFrom && curFromNodeChild) { fromNextSibling = curFromNodeChild.nextSibling; if (curToNodeChild.isSameNode && curToNodeChild.isSameNode(curFromNodeChild)) { curToNodeChild = toNextSibling; curFromNodeChild = fromNextSibling; continue outer; } curFromNodeKey = getNodeKey(curFromNodeChild); var curFromNodeType = curFromNodeChild.nodeType; var isCompatible = void 0; if (curFromNodeType === curToNodeChild.nodeType) { if (curFromNodeType === ELEMENT_NODE) { if (curToNodeKey) { if (curToNodeKey !== curFromNodeKey) { if (matchingFromEl = fromNodesLookup[curToNodeKey]) { if (fromNextSibling === matchingFromEl) { isCompatible = false; } else { fromEl.insertBefore(matchingFromEl, curFromNodeChild); if (curFromNodeKey) { addKeyedRemoval(curFromNodeKey); } else { removeNode( curFromNodeChild, fromEl, true /* skip keyed nodes */ ); } curFromNodeChild = matchingFromEl; curFromNodeKey = getNodeKey(curFromNodeChild); } } else { isCompatible = false; } } } else if (curFromNodeKey) { isCompatible = false; } isCompatible = isCompatible !== false && compareNodeNames(curFromNodeChild, curToNodeChild); if (isCompatible) { morphEl(curFromNodeChild, curToNodeChild); } } else if (curFromNodeType === TEXT_NODE || curFromNodeType == COMMENT_NODE) { isCompatible = true; if (curFromNodeChild.nodeValue !== curToNodeChild.nodeValue) { curFromNodeChild.nodeValue = curToNodeChild.nodeValue; } } } if (isCompatible) { curToNodeChild = toNextSibling; curFromNodeChild = fromNextSibling; continue outer; } if (curFromNodeKey) { addKeyedRemoval(curFromNodeKey); } else { removeNode( curFromNodeChild, fromEl, true /* skip keyed nodes */ ); } curFromNodeChild = fromNextSibling; } if (curToNodeKey && (matchingFromEl = fromNodesLookup[curToNodeKey]) && compareNodeNames(matchingFromEl, curToNodeChild)) { if (!skipFrom) { addChild(fromEl, matchingFromEl); } morphEl(matchingFromEl, curToNodeChild); } else { var onBeforeNodeAddedResult = onBeforeNodeAdded(curToNodeChild); if (onBeforeNodeAddedResult !== false) { if (onBeforeNodeAddedResult) { curToNodeChild = onBeforeNodeAddedResult; } if (curToNodeChild.actualize) { curToNodeChild = curToNodeChild.actualize(fromEl.ownerDocument || doc); } addChild(fromEl, curToNodeChild); handleNodeAdded(curToNodeChild); } } curToNodeChild = toNextSibling; curFromNodeChild = fromNextSibling; } cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey); var specialElHandler = specialElHandlers[fromEl.nodeName]; if (specialElHandler) { specialElHandler(fromEl, toEl); } } var morphedNode = fromNode; var morphedNodeType = morphedNode.nodeType; var toNodeType = toNode.nodeType; if (!childrenOnly) { if (morphedNodeType === ELEMENT_NODE) { if (toNodeType === ELEMENT_NODE) { if (!compareNodeNames(fromNode, toNode)) { onNodeDiscarded(fromNode); morphedNode = moveChildren(fromNode, createElementNS(toNode.nodeName, toNode.namespaceURI)); } } else { morphedNode = toNode; } } else if (morphedNodeType === TEXT_NODE || morphedNodeType === COMMENT_NODE) { if (toNodeType === morphedNodeType) { if (morphedNode.nodeValue !== toNode.nodeValue) { morphedNode.nodeValue = toNode.nodeValue; } return morphedNode; } else { morphedNode = toNode; } } } if (morphedNode === toNode) { onNodeDiscarded(fromNode); } else { if (toNode.isSameNode && toNode.isSameNode(morphedNode)) { return; } morphEl(morphedNode, toNode, childrenOnly); if (keyedRemovalList) { for (var i = 0, len = keyedRemovalList.length; i < len; i++) { var elToRemove = fromNodesLookup[keyedRemovalList[i]]; if (elToRemove) { removeNode(elToRemove, elToRemove.parentNode, false); } } } } if (!childrenOnly && morphedNode !== fromNode && fromNode.parentNode) { if (morphedNode.actualize) { morphedNode = morphedNode.actualize(fromNode.ownerDocument || doc); } fromNode.parentNode.replaceChild(morphedNode, fromNode); } return morphedNode; }; } var morphdom = morphdomFactory(morphAttrs); var morphdom_esm_default = morphdom; // js/phoenix_live_view/dom_patch.js var DOMPatch = class { constructor(view, container, id, html, streams, targetCID, opts = {}) { this.view = view; this.liveSocket = view.liveSocket; this.container = container; this.id = id; this.rootID = view.root.id; this.html = html; this.streams = streams; this.streamInserts = {}; this.streamComponentRestore = {}; this.targetCID = targetCID; this.cidPatch = isCid(this.targetCID); this.pendingRemoves = []; this.phxRemove = this.liveSocket.binding("remove"); this.targetContainer = this.isCIDPatch() ? this.targetCIDContainer(html) : container; this.callbacks = { beforeadded: [], beforeupdated: [], beforephxChildAdded: [], afteradded: [], afterupdated: [], afterdiscarded: [], afterphxChildAdded: [], aftertransitionsDiscarded: [] }; this.withChildren = opts.withChildren || opts.undoRef || false; this.undoRef = opts.undoRef; } before(kind, callback) { this.callbacks[`before${kind}`].push(callback); } after(kind, callback) { this.callbacks[`after${kind}`].push(callback); } trackBefore(kind, ...args) { this.callbacks[`before${kind}`].forEach((callback) => callback(...args)); } trackAfter(kind, ...args) { this.callbacks[`after${kind}`].forEach((callback) => callback(...args)); } markPrunableContentForRemoval() { const phxUpdate = this.liveSocket.binding(PHX_UPDATE); dom_default.all( this.container, `[${phxUpdate}=append] > *, [${phxUpdate}=prepend] > *`, (el) => { el.setAttribute(PHX_PRUNE, ""); } ); } perform(isJoinPatch) { const { view, liveSocket, html, container } = this; let targetContainer = this.targetContainer; if (this.isCIDPatch() && !this.targetContainer) { return; } if (this.isCIDPatch()) { const closestLock = targetContainer.closest(`[${PHX_REF_LOCK}]`); if (closestLock && !closestLock.isSameNode(targetContainer)) { const clonedTree = dom_default.private(closestLock, PHX_REF_LOCK); if (clonedTree) { targetContainer = clonedTree.querySelector( `[data-phx-component="${this.targetCID}"]` ); } } } const focused = liveSocket.getActiveElement(); const { selectionStart, selectionEnd } = focused && dom_default.hasSelectionRange(focused) ? focused : {}; const phxUpdate = liveSocket.binding(PHX_UPDATE); const phxViewportTop = liveSocket.binding(PHX_VIEWPORT_TOP); const phxViewportBottom = liveSocket.binding(PHX_VIEWPORT_BOTTOM); const phxTriggerExternal = liveSocket.binding(PHX_TRIGGER_ACTION); const added = []; const updates = []; const appendPrependUpdates = []; let portalCallbacks = []; let externalFormTriggered = null; const morph = (targetContainer2, source, withChildren = this.withChildren) => { const morphCallbacks = { // normally, we are running with childrenOnly, as the patch HTML for a LV // does not include the LV attrs (data-phx-session, etc.) // when we are patching a live component, we do want to patch the root element as well; // another case is the recursive patch of a stream item that was kept on reset (-> onBeforeNodeAdded) childrenOnly: targetContainer2.getAttribute(PHX_COMPONENT) === null && !withChildren, getNodeKey: (node) => { if (dom_default.isPhxDestroyed(node)) { return null; } if (isJoinPatch) { return node.id; } return node.id || node.getAttribute && node.getAttribute(PHX_MAGIC_ID); }, // skip indexing from children when container is stream skipFromChildren: (from) => { return from.getAttribute(phxUpdate) === PHX_STREAM; }, // tell morphdom how to add a child addChild: (parent, child) => { const { ref, streamAt } = this.getStreamInsert(child); if (ref === void 0) { return parent.appendChild(child); } this.setStreamRef(child, ref); if (streamAt === 0) { parent.insertAdjacentElement("afterbegin", child); } else if (streamAt === -1) { const lastChild = parent.lastElementChild; if (lastChild && !lastChild.hasAttribute(PHX_STREAM_REF)) { const nonStreamChild = Array.from(parent.children).find( (c) => !c.hasAttribute(PHX_STREAM_REF) ); parent.insertBefore(child, nonStreamChild); } else { parent.appendChild(child); } } else if (streamAt > 0) { const sibling = Array.from(parent.children)[streamAt]; parent.insertBefore(child, sibling); } }, onBeforeNodeAdded: (el) => { if (this.getStreamInsert(el)?.updateOnly && !this.streamComponentRestore[el.id]) { return false; } dom_default.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom); this.trackBefore("added", el); let morphedEl = el; if (this.streamComponentRestore[el.id]) { morphedEl = this.streamComponentRestore[el.id]; delete this.streamComponentRestore[el.id]; morph(morphedEl, el, true); } return morphedEl; }, onNodeAdded: (el) => { if (el.getAttribute) { this.maybeReOrderStream(el, true); } if (dom_default.isPortalTemplate(el)) { portalCallbacks.push(() => this.teleport(el, morph)); } if (el instanceof HTMLImageElement && el.srcset) { el.srcset = el.srcset; } else if (el instanceof HTMLVideoElement && el.autoplay) { el.play(); } if (dom_default.isNowTriggerFormExternal(el, phxTriggerExternal)) { externalFormTriggered = el; } if (dom_default.isPhxChild(el) && view.ownsElement(el) || dom_default.isPhxSticky(el) && view.ownsElement(el.parentNode)) { this.trackAfter("phxChildAdded", el); } if (el.nodeName === "SCRIPT" && el.hasAttribute(PHX_RUNTIME_HOOK)) { this.handleRuntimeHook(el, source); } added.push(el); }, onNodeDiscarded: (el) => this.onNodeDiscarded(el), onBeforeNodeDiscarded: (el) => { if (el.getAttribute && el.getAttribute(PHX_PRUNE) !== null) { return true; } if (el.parentElement !== null && el.id && dom_default.isPhxUpdate(el.parentElement, phxUpdate, [ PHX_STREAM, "append", "prepend" ])) { return false; } if (el.getAttribute && el.getAttribute(PHX_TELEPORTED_REF)) { return false; } if (this.maybePendingRemove(el)) { return false; } if (this.skipCIDSibling(el)) { return false; } if (dom_default.isPortalTemplate(el)) { const teleportedEl = document.getElementById( el.content.firstElementChild.id ); if (teleportedEl) { teleportedEl.remove(); morphCallbacks.onNodeDiscarded(teleportedEl); this.view.dropPortalElementId(teleportedEl.id); } } return true; }, onElUpdated: (el) => { if (dom_default.isNowTriggerFormExternal(el, phxTriggerExternal)) { externalFormTriggered = el; } updates.push(el); this.maybeReOrderStream(el, false); }, onBeforeElUpdated: (fromEl, toEl) => { if (fromEl.id && fromEl.isSameNode(targetContainer2) && fromEl.id !== toEl.id) { morphCallbacks.onNodeDiscarded(fromEl); fromEl.replaceWith(toEl); return morphCallbacks.onNodeAdded(toEl); } dom_default.syncPendingAttrs(fromEl, toEl); dom_default.maintainPrivateHooks( fromEl, toEl, phxViewportTop, phxViewportBottom ); dom_default.cleanChildNodes(toEl, phxUpdate); if (this.skipCIDSibling(toEl)) { this.maybeReOrderStream(fromEl); return false; } if (dom_default.isPhxSticky(fromEl)) { [PHX_SESSION, PHX_STATIC, PHX_ROOT_ID].map((attr) => [ attr, fromEl.getAttribute(attr), toEl.getAttribute(attr) ]).forEach(([attr, fromVal, toVal]) => { if (toVal && fromVal !== toVal) { fromEl.setAttribute(attr, toVal); } }); return false; } if (dom_default.isIgnored(fromEl, phxUpdate) || fromEl.form && fromEl.form.isSameNode(externalFormTriggered)) { this.trackBefore("updated", fromEl, toEl); dom_default.mergeAttrs(fromEl, toEl, { isIgnored: dom_default.isIgnored(fromEl, phxUpdate) }); updates.push(fromEl); dom_default.applyStickyOperations(fromEl); return false; } if (fromEl.type === "number" && fromEl.validity && fromEl.validity.badInput) { return false; } const isFocusedFormEl = focused && fromEl.isSameNode(focused) && dom_default.isFormInput(fromEl); const focusedSelectChanged = isFocusedFormEl && this.isChangedSelect(fromEl, toEl); if (fromEl.hasAttribute(PHX_REF_SRC)) { const ref = new ElementRef(fromEl); if (ref.lockRef && (!this.undoRef || !ref.isLockUndoneBy(this.undoRef))) { dom_default.applyStickyOperations(fromEl); const isLocked = fromEl.hasAttribute(PHX_REF_LOCK); const clone2 = isLocked ? dom_default.private(fromEl, PHX_REF_LOCK) || fromEl.cloneNode(true) : null; if (clone2) { dom_default.putPrivate(fromEl, PHX_REF_LOCK, clone2); if (!isFocusedFormEl) { fromEl = clone2; } } } } if (dom_default.isPhxChild(toEl)) { const prevSession = fromEl.getAttribute(PHX_SESSION); dom_default.mergeAttrs(fromEl, toEl, { exclude: [PHX_STATIC] }); if (prevSession !== "") { fromEl.setAttribute(PHX_SESSION, prevSession); } fromEl.setAttribute(PHX_ROOT_ID, this.rootID); dom_default.applyStickyOperations(fromEl); return false; } if (this.undoRef && dom_default.private(toEl, PHX_REF_LOCK)) { dom_default.putPrivate( fromEl, PHX_REF_LOCK, dom_default.private(toEl, PHX_REF_LOCK) ); } dom_default.copyPrivates(toEl, fromEl); if (dom_default.isPortalTemplate(toEl)) { portalCallbacks.push(() => this.teleport(toEl, morph)); fromEl.content.replaceChildren(toEl.content.cloneNode(true)); return false; } if (isFocusedFormEl && fromEl.type !== "hidden" && !focusedSelectChanged) { this.trackBefore("updated", fromEl, toEl); dom_default.mergeFocusedInput(fromEl, toEl); dom_default.syncAttrsToProps(fromEl); updates.push(fromEl); dom_default.applyStickyOperations(fromEl); return false; } else { if (focusedSelectChanged) { fromEl.blur(); } if (dom_default.isPhxUpdate(toEl, phxUpdate, ["append", "prepend"])) { appendPrependUpdates.push( new DOMPostMorphRestorer( fromEl, toEl, toEl.getAttribute(phxUpdate) ) ); } dom_default.syncAttrsToProps(toEl); dom_default.applyStickyOperations(toEl); this.trackBefore("updated", fromEl, toEl); return fromEl; } } }; morphdom_esm_default(targetContainer2, source, morphCallbacks); }; this.trackBefore("added", container); this.trackBefore("updated", container, container); liveSocket.time("morphdom", () => { this.streams.forEach(([ref, inserts, deleteIds, reset]) => { inserts.forEach(([key, streamAt, limit, updateOnly]) => { this.streamInserts[key] = { ref, streamAt, limit, reset, updateOnly }; }); if (reset !== void 0) { dom_default.all(document, `[${PHX_STREAM_REF}="${ref}"]`, (child) => { this.removeStreamChildElement(child); }); } deleteIds.forEach((id) => { const child = document.getElementById(id); if (child) { this.removeStreamChildElement(child); } }); }); if (isJoinPatch) { dom_default.all(this.container, `[${phxUpdate}=${PHX_STREAM}]`).filter((el) => this.view.ownsElement(el)).forEach((el) => { Array.from(el.children).forEach((child) => { this.removeStreamChildElement(child, true); }); }); } morph(targetContainer, html); let teleportCount = 0; while (portalCallbacks.length > 0 && teleportCount < 5) { const copy = portalCallbacks.slice(); portalCallbacks = []; copy.forEach((callback) => callback()); teleportCount++; } this.view.portalElementIds.forEach((id) => { const el = document.getElementById(id); if (el) { const source = document.getElementById( el.getAttribute(PHX_TELEPORTED_SRC) ); if (!source) { el.remove(); this.onNodeDiscarded(el); this.view.dropPortalElementId(id); } } }); }); if (liveSocket.isDebugEnabled()) { detectDuplicateIds(); detectInvalidStreamInserts(this.streamInserts); Array.from(document.querySelectorAll("input[name=id]")).forEach( (node) => { if (node instanceof HTMLInputElement && node.form) { console.error( 'Detected an input with name="id" inside a form! This will cause problems when patching the DOM.\n', node ); } } ); } if (appendPrependUpdates.length > 0) { liveSocket.time("post-morph append/prepend restoration", () => { appendPrependUpdates.forEach((update) => update.perform()); }); } liveSocket.silenceEvents( () => dom_default.restoreFocus(focused, selectionStart, selectionEnd) ); dom_default.dispatchEvent(document, "phx:update"); added.forEach((el) => this.trackAfter("added", el)); updates.forEach((el) => this.trackAfter("updated", el)); this.transitionPendingRemoves(); if (externalFormTriggered) { liveSocket.unload(); const submitter = dom_default.private(externalFormTriggered, "submitter"); if (submitter && submitter.name && targetContainer.contains(submitter)) { const input = document.createElement("input"); input.type = "hidden"; const formId = submitter.getAttribute("form"); if (formId) { input.setAttribute("form", formId); } input.name = submitter.name; input.value = submitter.value; submitter.parentElement.insertBefore(input, submitter); } Object.getPrototypeOf(externalFormTriggered).submit.call( externalFormTriggered ); } return true; } onNodeDiscarded(el) { if (dom_default.isPhxChild(el) || dom_default.isPhxSticky(el)) { this.liveSocket.destroyViewByEl(el); } this.trackAfter("discarded", el); } maybePendingRemove(node) { if (node.getAttribute && node.getAttribute(this.phxRemove) !== null) { this.pendingRemoves.push(node); return true; } else { return false; } } removeStreamChildElement(child, force = false) { if (!force && !this.view.ownsElement(child)) { return; } if (this.streamInserts[child.id]) { this.streamComponentRestore[child.id] = child; child.remove(); } else { if (!this.maybePendingRemove(child)) { child.remove(); this.onNodeDiscarded(child); } } } getStreamInsert(el) { const insert = el.id ? this.streamInserts[el.id] : {}; return insert || {}; } setStreamRef(el, ref) { dom_default.putSticky( el, PHX_STREAM_REF, (el2) => el2.setAttribute(PHX_STREAM_REF, ref) ); } maybeReOrderStream(el, isNew) { const { ref, streamAt, reset } = this.getStreamInsert(el); if (streamAt === void 0) { return; } this.setStreamRef(el, ref); if (!reset && !isNew) { return; } if (!el.parentElement) { return; } if (streamAt === 0) { el.parentElement.insertBefore(el, el.parentElement.firstElementChild); } else if (streamAt > 0) { const children = Array.from(el.parentElement.children); const oldIndex = children.indexOf(el); if (streamAt >= children.length - 1) { el.parentElement.appendChild(el); } else { const sibling = children[streamAt]; if (oldIndex > streamAt) { el.parentElement.insertBefore(el, sibling); } else { el.parentElement.insertBefore(el, sibling.nextElementSibling); } } } this.maybeLimitStream(el); } maybeLimitStream(el) { const { limit } = this.getStreamInsert(el); const children = limit !== null && Array.from(el.parentElement.children); if (limit && limit < 0 && children.length > limit * -1) { children.slice(0, children.length + limit).forEach((child) => this.removeStreamChildElement(child)); } else if (limit && limit >= 0 && children.length > limit) { children.slice(limit).forEach((child) => this.removeStreamChildElement(child)); } } transitionPendingRemoves() { const { pendingRemoves, liveSocket } = this; if (pendingRemoves.length > 0) { liveSocket.transitionRemoves(pendingRemoves, () => { pendingRemoves.forEach((el) => { const child = dom_default.firstPhxChild(el); if (child) { liveSocket.destroyViewByEl(child); } el.remove(); }); this.trackAfter("transitionsDiscarded", pendingRemoves); }); } } isChangedSelect(fromEl, toEl) { if (!(fromEl instanceof HTMLSelectElement) || fromEl.multiple) { return false; } if (fromEl.options.length !== toEl.options.length) { return true; } toEl.value = fromEl.value; return !fromEl.isEqualNode(toEl); } isCIDPatch() { return this.cidPatch; } skipCIDSibling(el) { return el.nodeType === Node.ELEMENT_NODE && el.hasAttribute(PHX_SKIP); } targetCIDContainer(html) { if (!this.isCIDPatch()) { return; } const [first, ...rest] = dom_default.findComponentNodeList( this.view.id, this.targetCID ); if (rest.length === 0 && dom_default.childNodeLength(html) === 1) { return first; } else { return first && first.parentNode; } } indexOf(parent, child) { return Array.from(parent.children).indexOf(child); } teleport(el, morph) { const targetSelector = el.getAttribute(PHX_PORTAL); const portalContainer = document.querySelector(targetSelector); if (!portalContainer) { throw new Error( "portal target with selector " + targetSelector + " not found" ); } const toTeleport = el.content.firstElementChild; if (this.skipCIDSibling(toTeleport)) { return; } if (!toTeleport?.id) { throw new Error( "phx-portal template must have a single root element with ID!" ); } const existing = document.getElementById(toTeleport.id); let portalTarget; if (existing) { if (!portalContainer.contains(existing)) { portalContainer.appendChild(existing); } portalTarget = existing; } else { portalTarget = document.createElement(toTeleport.tagName); portalContainer.appendChild(portalTarget); } toTeleport.setAttribute(PHX_TELEPORTED_REF, this.view.id); toTeleport.setAttribute(PHX_TELEPORTED_SRC, el.id); morph(portalTarget, toTeleport, true); toTeleport.removeAttribute(PHX_TELEPORTED_REF); toTeleport.removeAttribute(PHX_TELEPORTED_SRC); this.view.pushPortalElementId(toTeleport.id); } handleRuntimeHook(el, source) { const name = el.getAttribute(PHX_RUNTIME_HOOK); let nonce = el.hasAttribute("nonce") ? el.getAttribute("nonce") : null; if (el.hasAttribute("nonce")) { const template = document.createElement("template"); template.innerHTML = source; nonce = template.content.querySelector(`script[${PHX_RUNTIME_HOOK}="${CSS.escape(name)}"]`).getAttribute("nonce"); } const script = document.createElement("script"); script.textContent = el.textContent; dom_default.mergeAttrs(script, el, { isIgnored: false }); if (nonce) { script.nonce = nonce; } el.replaceWith(script); el = script; } }; // js/phoenix_live_view/rendered.js var VOID_TAGS = /* @__PURE__ */ new Set([ "area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "meta", "param", "source", "track", "wbr" ]); var quoteChars = /* @__PURE__ */ new Set(["'", '"']); var modifyRoot = (html, attrs, clearInnerHTML) => { let i = 0; let insideComment = false; let beforeTag, afterTag, tag, tagNameEndsAt, id, newHTML; const lookahead = html.match(/^(\s*(?:\s*)*)<([^\s\/>]+)/); if (lookahead === null) { throw new Error(`malformed html ${html}`); } i = lookahead[0].length; beforeTag = lookahead[1]; tag = lookahead[2]; tagNameEndsAt = i; for (i; i < html.length; i++) { if (html.charAt(i) === ">") { break; } if (html.charAt(i) === "=") { const isId = html.slice(i - 3, i) === " id"; i++; const char = html.charAt(i); if (quoteChars.has(char)) { const attrStartsAt = i; i++; for (i; i < html.length; i++) { if (html.charAt(i) === char) { break; } } if (isId) { id = html.slice(attrStartsAt + 1, i); break; } } } } let closeAt = html.length - 1; insideComment = false; while (closeAt >= beforeTag.length + tag.length) { const char = html.charAt(closeAt); if (insideComment) { if (char === "-" && html.slice(closeAt - 3, closeAt) === "" && html.slice(closeAt - 2, closeAt) === "--") { insideComment = true; closeAt -= 3; } else if (char === ">") { break; } else { closeAt -= 1; } } afterTag = html.slice(closeAt + 1, html.length); const attrsStr = Object.keys(attrs).map((attr) => attrs[attr] === true ? attr : `${attr}="${attrs[attr]}"`).join(" "); if (clearInnerHTML) { const idAttrStr = id ? ` id="${id}"` : ""; if (VOID_TAGS.has(tag)) { newHTML = `<${tag}${idAttrStr}${attrsStr === "" ? "" : " "}${attrsStr}/>`; } else { newHTML = `<${tag}${idAttrStr}${attrsStr === "" ? "" : " "}${attrsStr}>`; } } else { const rest = html.slice(tagNameEndsAt, closeAt + 1); newHTML = `<${tag}${attrsStr === "" ? "" : " "}${attrsStr}${rest}`; } return [newHTML, beforeTag, afterTag]; }; var Rendered = class { static extract(diff) { const { [REPLY]: reply, [EVENTS]: events, [TITLE]: title } = diff; delete diff[REPLY]; delete diff[EVENTS]; delete diff[TITLE]; return { diff, title, reply: reply || null, events: events || [] }; } constructor(viewId, rendered) { this.viewId = viewId; this.rendered = {}; this.magicId = 0; this.mergeDiff(rendered); } parentViewId() { return this.viewId; } toString(onlyCids) { const { buffer: str, streams } = this.recursiveToString( this.rendered, this.rendered[COMPONENTS], onlyCids, true, {} ); return { buffer: str, streams }; } recursiveToString(rendered, components = rendered[COMPONENTS], onlyCids, changeTracking, rootAttrs) { onlyCids = onlyCids ? new Set(onlyCids) : null; const output = { buffer: "", components, onlyCids, streams: /* @__PURE__ */ new Set() }; this.toOutputBuffer(rendered, null, output, changeTracking, rootAttrs); return { buffer: output.buffer, streams: output.streams }; } componentCIDs(diff) { return Object.keys(diff[COMPONENTS] || {}).map((i) => parseInt(i)); } isComponentOnlyDiff(diff) { if (!diff[COMPONENTS]) { return false; } return Object.keys(diff).length === 1; } getComponent(diff, cid) { return diff[COMPONENTS][cid]; } resetRender(cid) { if (this.rendered[COMPONENTS][cid]) { this.rendered[COMPONENTS][cid].reset = true; } } mergeDiff(diff) { const newc = diff[COMPONENTS]; const cache = {}; delete diff[COMPONENTS]; this.rendered = this.mutableMerge(this.rendered, diff); this.rendered[COMPONENTS] = this.rendered[COMPONENTS] || {}; if (newc) { const oldc = this.rendered[COMPONENTS]; for (const cid in newc) { newc[cid] = this.cachedFindComponent(cid, newc[cid], oldc, newc, cache); } for (const cid in newc) { oldc[cid] = newc[cid]; } diff[COMPONENTS] = newc; } } cachedFindComponent(cid, cdiff, oldc, newc, cache) { if (cache[cid]) { return cache[cid]; } else { let ndiff, stat, scid = cdiff[STATIC]; if (isCid(scid)) { let tdiff; if (scid > 0) { tdiff = this.cachedFindComponent(scid, newc[scid], oldc, newc, cache); } else { tdiff = oldc[-scid]; } stat = tdiff[STATIC]; ndiff = this.cloneMerge(tdiff, cdiff, true); ndiff[STATIC] = stat; } else { ndiff = cdiff[STATIC] !== void 0 || oldc[cid] === void 0 ? cdiff : this.cloneMerge(oldc[cid], cdiff, false); } cache[cid] = ndiff; return ndiff; } } mutableMerge(target, source) { if (source[STATIC] !== void 0) { return source; } else { this.doMutableMerge(target, source); return target; } } doMutableMerge(target, source) { if (source[KEYED]) { this.mergeKeyed(target, source); } else { for (const key in source) { const val = source[key]; const targetVal = target[key]; const isObjVal = isObject(val); if (isObjVal && val[STATIC] === void 0 && isObject(targetVal)) { this.doMutableMerge(targetVal, val); } else { target[key] = val; } } } if (target[ROOT]) { target.newRender = true; } } clone(diff) { if ("structuredClone" in window) { return structuredClone(diff); } else { return JSON.parse(JSON.stringify(diff)); } } // keyed comprehensions mergeKeyed(target, source) { const clonedTarget = this.clone(target); Object.entries(source[KEYED]).forEach(([i, entry]) => { if (i === KEYED_COUNT) { return; } if (Array.isArray(entry)) { const [old_idx, diff] = entry; target[KEYED][i] = clonedTarget[KEYED][old_idx]; this.doMutableMerge(target[KEYED][i], diff); } else if (typeof entry === "number") { const old_idx = entry; target[KEYED][i] = clonedTarget[KEYED][old_idx]; } else if (typeof entry === "object") { if (!target[KEYED][i]) { target[KEYED][i] = {}; } this.doMutableMerge(target[KEYED][i], entry); } }); if (source[KEYED][KEYED_COUNT] < target[KEYED][KEYED_COUNT]) { for (let i = source[KEYED][KEYED_COUNT]; i < target[KEYED][KEYED_COUNT]; i++) { delete target[KEYED][i]; } } target[KEYED][KEYED_COUNT] = source[KEYED][KEYED_COUNT]; if (source[STREAM]) { target[STREAM] = source[STREAM]; } if (source[TEMPLATES]) { target[TEMPLATES] = source[TEMPLATES]; } } // Merges cid trees together, copying statics from source tree. // // The `pruneMagicId` is passed to control pruning the magicId of the // target. We must always prune the magicId when we are sharing statics // from another component. If not pruning, we replicate the logic from // mutableMerge, where we set newRender to true if there is a root // (effectively forcing the new version to be rendered instead of skipped) // cloneMerge(target, source, pruneMagicId) { let merged; if (source[KEYED]) { merged = this.clone(target); this.mergeKeyed(merged, source); } else { merged = { ...target, ...source }; for (const key in merged) { const val = source[key]; const targetVal = target[key]; if (isObject(val) && val[STATIC] === void 0 && isObject(targetVal)) { merged[key] = this.cloneMerge(targetVal, val, pruneMagicId); } else if (val === void 0 && isObject(targetVal)) { merged[key] = this.cloneMerge(targetVal, {}, pruneMagicId); } } } if (pruneMagicId) { delete merged.magicId; delete merged.newRender; } else if (target[ROOT]) { merged.newRender = true; } return merged; } componentToString(cid) { const { buffer: str, streams } = this.recursiveCIDToString( this.rendered[COMPONENTS], cid, null ); const [strippedHTML, _before, _after] = modifyRoot(str, {}); return { buffer: strippedHTML, streams }; } pruneCIDs(cids) { cids.forEach((cid) => delete this.rendered[COMPONENTS][cid]); } // private get() { return this.rendered; } isNewFingerprint(diff = {}) { return !!diff[STATIC]; } templateStatic(part, templates) { if (typeof part === "number") { return templates[part]; } else { return part; } } nextMagicID() { this.magicId++; return `m${this.magicId}-${this.parentViewId()}`; } // Converts rendered tree to output buffer. // // changeTracking controls if we can apply the PHX_SKIP optimization. toOutputBuffer(rendered, templates, output, changeTracking, rootAttrs = {}) { if (rendered[KEYED]) { return this.comprehensionToBuffer( rendered, templates, output, changeTracking ); } if (rendered[TEMPLATES]) { templates = rendered[TEMPLATES]; delete rendered[TEMPLATES]; } let { [STATIC]: statics } = rendered; statics = this.templateStatic(statics, templates); rendered[STATIC] = statics; const isRoot = rendered[ROOT]; const prevBuffer = output.buffer; if (isRoot) { output.buffer = ""; } if (changeTracking && isRoot && !rendered.magicId) { rendered.newRender = true; rendered.magicId = this.nextMagicID(); } output.buffer += statics[0]; for (let i = 1; i < statics.length; i++) { this.dynamicToBuffer(rendered[i - 1], templates, output, changeTracking); output.buffer += statics[i]; } if (isRoot) { let skip = false; let attrs; if (changeTracking || rendered.magicId) { skip = changeTracking && !rendered.newRender; attrs = { [PHX_MAGIC_ID]: rendered.magicId, ...rootAttrs }; } else { attrs = rootAttrs; } if (skip) { attrs[PHX_SKIP] = true; } const [newRoot, commentBefore, commentAfter] = modifyRoot( output.buffer, attrs, skip ); rendered.newRender = false; output.buffer = prevBuffer + commentBefore + newRoot + commentAfter; } } comprehensionToBuffer(rendered, templates, output, changeTracking) { const keyedTemplates = templates || rendered[TEMPLATES]; const statics = this.templateStatic(rendered[STATIC], templates); rendered[STATIC] = statics; delete rendered[TEMPLATES]; for (let i = 0; i < rendered[KEYED][KEYED_COUNT]; i++) { output.buffer += statics[0]; for (let j = 1; j < statics.length; j++) { this.dynamicToBuffer( rendered[KEYED][i][j - 1], keyedTemplates, output, changeTracking ); output.buffer += statics[j]; } } if (rendered[STREAM]) { const stream = rendered[STREAM]; const [_ref, _inserts, deleteIds, reset] = stream || [null, {}, [], null]; if (stream !== void 0 && (rendered[KEYED][KEYED_COUNT] > 0 || deleteIds.length > 0 || reset)) { delete rendered[STREAM]; rendered[KEYED] = { [KEYED_COUNT]: 0 }; output.streams.add(stream); } } } dynamicToBuffer(rendered, templates, output, changeTracking) { if (typeof rendered === "number") { const { buffer: str, streams } = this.recursiveCIDToString( output.components, rendered, output.onlyCids ); output.buffer += str; output.streams = /* @__PURE__ */ new Set([...output.streams, ...streams]); } else if (isObject(rendered)) { this.toOutputBuffer(rendered, templates, output, changeTracking, {}); } else { output.buffer += rendered; } } recursiveCIDToString(components, cid, onlyCids) { const component = components[cid] || logError(`no component for CID ${cid}`, components); const attrs = { [PHX_COMPONENT]: cid, [PHX_VIEW_REF]: this.viewId }; const skip = onlyCids && !onlyCids.has(cid); component.newRender = !skip; component.magicId = `c${cid}-${this.parentViewId()}`; const changeTracking = !component.reset; const { buffer: html, streams } = this.recursiveToString( component, components, onlyCids, changeTracking, attrs ); delete component.reset; return { buffer: html, streams }; } }; // js/phoenix_live_view/js.js var focusStack = []; var default_transition_time = 200; var JS = { // private exec(e, eventType, phxEvent, view, sourceEl, defaults) { const [defaultKind, defaultArgs] = defaults || [ null, { callback: defaults && defaults.callback } ]; const commands = Array.isArray(phxEvent) ? phxEvent : typeof phxEvent === "string" && phxEvent.startsWith("[") ? JSON.parse(phxEvent) : [[defaultKind, defaultArgs]]; commands.forEach(([kind, args]) => { if (kind === defaultKind) { args = { ...defaultArgs, ...args }; args.callback = args.callback || defaultArgs.callback; } this.filterToEls(view.liveSocket, sourceEl, args).forEach((el) => { this[`exec_${kind}`](e, eventType, phxEvent, view, sourceEl, el, args); }); }); }, isVisible(el) { return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0); }, // returns true if any part of the element is inside the viewport isInViewport(el) { const rect = el.getBoundingClientRect(); const windowHeight = window.innerHeight || document.documentElement.clientHeight; const windowWidth = window.innerWidth || document.documentElement.clientWidth; return rect.right > 0 && rect.bottom > 0 && rect.left < windowWidth && rect.top < windowHeight; }, // private // commands exec_exec(e, eventType, phxEvent, view, sourceEl, el, { attr, to }) { const encodedJS = el.getAttribute(attr); if (!encodedJS) { throw new Error(`expected ${attr} to contain JS command on "${to}"`); } view.liveSocket.execJS(el, encodedJS, eventType); }, exec_dispatch(e, eventType, phxEvent, view, sourceEl, el, { event, detail, bubbles, blocking }) { detail = detail || {}; detail.dispatcher = sourceEl; if (blocking) { const promise = new Promise((resolve, _reject) => { detail.done = resolve; }); view.liveSocket.asyncTransition(promise); } dom_default.dispatchEvent(el, event, { detail, bubbles }); }, exec_push(e, eventType, phxEvent, view, sourceEl, el, args) { const { event, data, target, page_loading, loading, value, dispatcher, callback } = args; const pushOpts = { loading, value, target, page_loading: !!page_loading, originalEvent: e }; const targetSrc = eventType === "change" && dispatcher ? dispatcher : sourceEl; const phxTarget = target || targetSrc.getAttribute(view.binding("target")) || targetSrc; const handler = (targetView, targetCtx) => { if (!targetView.isConnected()) { return; } if (eventType === "change") { let { newCid, _target } = args; _target = _target || (dom_default.isFormInput(sourceEl) ? sourceEl.name : void 0); if (_target) { pushOpts._target = _target; } targetView.pushInput( sourceEl, targetCtx, newCid, event || phxEvent, pushOpts, callback ); } else if (eventType === "submit") { const { submitter } = args; targetView.submitForm( sourceEl, targetCtx, event || phxEvent, submitter, pushOpts, callback ); } else { targetView.pushEvent( eventType, sourceEl, targetCtx, event || phxEvent, data, pushOpts, callback ); } }; if (args.targetView && args.targetCtx) { handler(args.targetView, args.targetCtx); } else { view.withinTargets(phxTarget, handler); } }, exec_navigate(e, eventType, phxEvent, view, sourceEl, el, { href, replace }) { view.liveSocket.historyRedirect( e, href, replace ? "replace" : "push", null, sourceEl ); }, exec_patch(e, eventType, phxEvent, view, sourceEl, el, { href, replace }) { view.liveSocket.pushHistoryPatch( e, href, replace ? "replace" : "push", sourceEl ); }, exec_focus(e, eventType, phxEvent, view, sourceEl, el) { aria_default.attemptFocus(el); window.requestAnimationFrame(() => { window.requestAnimationFrame(() => aria_default.attemptFocus(el)); }); }, exec_focus_first(e, eventType, phxEvent, view, sourceEl, el) { aria_default.focusFirstInteractive(el) || aria_default.focusFirst(el); window.requestAnimationFrame(() => { window.requestAnimationFrame( () => aria_default.focusFirstInteractive(el) || aria_default.focusFirst(el) ); }); }, exec_push_focus(e, eventType, phxEvent, view, sourceEl, el) { focusStack.push(el || sourceEl); }, exec_pop_focus(_e, _eventType, _phxEvent, _view, _sourceEl, _el) { const el = focusStack.pop(); if (el) { el.focus(); window.requestAnimationFrame(() => { window.requestAnimationFrame(() => el.focus()); }); } }, exec_add_class(e, eventType, phxEvent, view, sourceEl, el, { names, transition, time, blocking }) { this.addOrRemoveClasses(el, names, [], transition, time, view, blocking); }, exec_remove_class(e, eventType, phxEvent, view, sourceEl, el, { names, transition, time, blocking }) { this.addOrRemoveClasses(el, [], names, transition, time, view, blocking); }, exec_toggle_class(e, eventType, phxEvent, view, sourceEl, el, { names, transition, time, blocking }) { this.toggleClasses(el, names, transition, time, view, blocking); }, exec_toggle_attr(e, eventType, phxEvent, view, sourceEl, el, { attr: [attr, val1, val2] }) { this.toggleAttr(el, attr, val1, val2); }, exec_ignore_attrs(e, eventType, phxEvent, view, sourceEl, el, { attrs }) { this.ignoreAttrs(el, attrs); }, exec_transition(e, eventType, phxEvent, view, sourceEl, el, { time, transition, blocking }) { this.addOrRemoveClasses(el, [], [], transition, time, view, blocking); }, exec_toggle(e, eventType, phxEvent, view, sourceEl, el, { display, ins, outs, time, blocking }) { this.toggle(eventType, view, el, display, ins, outs, time, blocking); }, exec_show(e, eventType, phxEvent, view, sourceEl, el, { display, transition, time, blocking }) { this.show(eventType, view, el, display, transition, time, blocking); }, exec_hide(e, eventType, phxEvent, view, sourceEl, el, { display, transition, time, blocking }) { this.hide(eventType, view, el, display, transition, time, blocking); }, exec_set_attr(e, eventType, phxEvent, view, sourceEl, el, { attr: [attr, val] }) { this.setOrRemoveAttrs(el, [[attr, val]], []); }, exec_remove_attr(e, eventType, phxEvent, view, sourceEl, el, { attr }) { this.setOrRemoveAttrs(el, [], [attr]); }, ignoreAttrs(el, attrs) { dom_default.putPrivate(el, "JS:ignore_attrs", { apply: (fromEl, toEl) => { let fromAttributes = Array.from(fromEl.attributes); let fromAttributeNames = fromAttributes.map((attr) => attr.name); Array.from(toEl.attributes).filter((attr) => { return !fromAttributeNames.includes(attr.name); }).forEach((attr) => { if (dom_default.attributeIgnored(attr, attrs)) { toEl.removeAttribute(attr.name); } }); fromAttributes.forEach((attr) => { if (dom_default.attributeIgnored(attr, attrs)) { toEl.setAttribute(attr.name, attr.value); } }); } }); }, onBeforeElUpdated(fromEl, toEl) { const ignoreAttrs = dom_default.private(fromEl, "JS:ignore_attrs"); if (ignoreAttrs) { ignoreAttrs.apply(fromEl, toEl); } }, // utils for commands show(eventType, view, el, display, transition, time, blocking) { if (!this.isVisible(el)) { this.toggle( eventType, view, el, display, transition, null, time, blocking ); } }, hide(eventType, view, el, display, transition, time, blocking) { if (this.isVisible(el)) { this.toggle( eventType, view, el, display, null, transition, time, blocking ); } }, toggle(eventType, view, el, display, ins, outs, time, blocking) { time = time || default_transition_time; const [inClasses, inStartClasses, inEndClasses] = ins || [[], [], []]; const [outClasses, outStartClasses, outEndClasses] = outs || [[], [], []]; if (inClasses.length > 0 || outClasses.length > 0) { if (this.isVisible(el)) { const onStart = () => { this.addOrRemoveClasses( el, outStartClasses, inClasses.concat(inStartClasses).concat(inEndClasses) ); window.requestAnimationFrame(() => { this.addOrRemoveClasses(el, outClasses, []); window.requestAnimationFrame( () => this.addOrRemoveClasses(el, outEndClasses, outStartClasses) ); }); }; const onEnd = () => { this.addOrRemoveClasses(el, [], outClasses.concat(outEndClasses)); dom_default.putSticky( el, "toggle", (currentEl) => currentEl.style.display = "none" ); el.dispatchEvent(new Event("phx:hide-end")); }; el.dispatchEvent(new Event("phx:hide-start")); if (blocking === false) { onStart(); setTimeout(onEnd, time); } else { view.transition(time, onStart, onEnd); } } else { if (eventType === "remove") { return; } const onStart = () => { this.addOrRemoveClasses( el, inStartClasses, outClasses.concat(outStartClasses).concat(outEndClasses) ); const stickyDisplay = display || this.defaultDisplay(el); window.requestAnimationFrame(() => { this.addOrRemoveClasses(el, inClasses, []); window.requestAnimationFrame(() => { dom_default.putSticky( el, "toggle", (currentEl) => currentEl.style.display = stickyDisplay ); this.addOrRemoveClasses(el, inEndClasses, inStartClasses); }); }); }; const onEnd = () => { this.addOrRemoveClasses(el, [], inClasses.concat(inEndClasses)); el.dispatchEvent(new Event("phx:show-end")); }; el.dispatchEvent(new Event("phx:show-start")); if (blocking === false) { onStart(); setTimeout(onEnd, time); } else { view.transition(time, onStart, onEnd); } } } else { if (this.isVisible(el)) { window.requestAnimationFrame(() => { el.dispatchEvent(new Event("phx:hide-start")); dom_default.putSticky( el, "toggle", (currentEl) => currentEl.style.display = "none" ); el.dispatchEvent(new Event("phx:hide-end")); }); } else { window.requestAnimationFrame(() => { el.dispatchEvent(new Event("phx:show-start")); const stickyDisplay = display || this.defaultDisplay(el); dom_default.putSticky( el, "toggle", (currentEl) => currentEl.style.display = stickyDisplay ); el.dispatchEvent(new Event("phx:show-end")); }); } } }, toggleClasses(el, classes, transition, time, view, blocking) { window.requestAnimationFrame(() => { const [prevAdds, prevRemoves] = dom_default.getSticky(el, "classes", [[], []]); const newAdds = classes.filter( (name) => prevAdds.indexOf(name) < 0 && !el.classList.contains(name) ); const newRemoves = classes.filter( (name) => prevRemoves.indexOf(name) < 0 && el.classList.contains(name) ); this.addOrRemoveClasses( el, newAdds, newRemoves, transition, time, view, blocking ); }); }, toggleAttr(el, attr, val1, val2) { if (el.hasAttribute(attr)) { if (val2 !== void 0) { if (el.getAttribute(attr) === val1) { this.setOrRemoveAttrs(el, [[attr, val2]], []); } else { this.setOrRemoveAttrs(el, [[attr, val1]], []); } } else { this.setOrRemoveAttrs(el, [], [attr]); } } else { this.setOrRemoveAttrs(el, [[attr, val1]], []); } }, addOrRemoveClasses(el, adds, removes, transition, time, view, blocking) { time = time || default_transition_time; const [transitionRun, transitionStart, transitionEnd] = transition || [ [], [], [] ]; if (transitionRun.length > 0) { const onStart = () => { this.addOrRemoveClasses( el, transitionStart, [].concat(transitionRun).concat(transitionEnd) ); window.requestAnimationFrame(() => { this.addOrRemoveClasses(el, transitionRun, []); window.requestAnimationFrame( () => this.addOrRemoveClasses(el, transitionEnd, transitionStart) ); }); }; const onDone = () => this.addOrRemoveClasses( el, adds.concat(transitionEnd), removes.concat(transitionRun).concat(transitionStart) ); if (blocking === false) { onStart(); setTimeout(onDone, time); } else { view.transition(time, onStart, onDone); } return; } window.requestAnimationFrame(() => { const [prevAdds, prevRemoves] = dom_default.getSticky(el, "classes", [[], []]); const keepAdds = adds.filter( (name) => prevAdds.indexOf(name) < 0 && !el.classList.contains(name) ); const keepRemoves = removes.filter( (name) => prevRemoves.indexOf(name) < 0 && el.classList.contains(name) ); const newAdds = prevAdds.filter((name) => removes.indexOf(name) < 0).concat(keepAdds); const newRemoves = prevRemoves.filter((name) => adds.indexOf(name) < 0).concat(keepRemoves); dom_default.putSticky(el, "classes", (currentEl) => { currentEl.classList.remove(...newRemoves); currentEl.classList.add(...newAdds); return [newAdds, newRemoves]; }); }); }, setOrRemoveAttrs(el, sets, removes) { const [prevSets, prevRemoves] = dom_default.getSticky(el, "attrs", [[], []]); const alteredAttrs = sets.map(([attr, _val]) => attr).concat(removes); const newSets = prevSets.filter(([attr, _val]) => !alteredAttrs.includes(attr)).concat(sets); const newRemoves = prevRemoves.filter((attr) => !alteredAttrs.includes(attr)).concat(removes); dom_default.putSticky(el, "attrs", (currentEl) => { newRemoves.forEach((attr) => currentEl.removeAttribute(attr)); newSets.forEach(([attr, val]) => currentEl.setAttribute(attr, val)); return [newSets, newRemoves]; }); }, hasAllClasses(el, classes) { return classes.every((name) => el.classList.contains(name)); }, isToggledOut(el, outClasses) { return !this.isVisible(el) || this.hasAllClasses(el, outClasses); }, filterToEls(liveSocket, sourceEl, { to }) { const defaultQuery = () => { if (typeof to === "string") { return document.querySelectorAll(to); } else if (to.closest) { const toEl = sourceEl.closest(to.closest); return toEl ? [toEl] : []; } else if (to.inner) { return sourceEl.querySelectorAll(to.inner); } }; return to ? liveSocket.jsQuerySelectorAll(sourceEl, to, defaultQuery) : [sourceEl]; }, defaultDisplay(el) { return { tr: "table-row", td: "table-cell" }[el.tagName.toLowerCase()] || "block"; }, transitionClasses(val) { if (!val) { return null; } let [trans, tStart, tEnd] = Array.isArray(val) ? val : [val.split(" "), [], []]; trans = Array.isArray(trans) ? trans : trans.split(" "); tStart = Array.isArray(tStart) ? tStart : tStart.split(" "); tEnd = Array.isArray(tEnd) ? tEnd : tEnd.split(" "); return [trans, tStart, tEnd]; } }; var js_default = JS; // js/phoenix_live_view/js_commands.ts var js_commands_default = (liveSocket, eventType) => { return { exec(el, encodedJS) { liveSocket.execJS(el, encodedJS, eventType); }, show(el, opts = {}) { const owner = liveSocket.owner(el); js_default.show( eventType, owner, el, opts.display, js_default.transitionClasses(opts.transition), opts.time, opts.blocking ); }, hide(el, opts = {}) { const owner = liveSocket.owner(el); js_default.hide( eventType, owner, el, null, js_default.transitionClasses(opts.transition), opts.time, opts.blocking ); }, toggle(el, opts = {}) { const owner = liveSocket.owner(el); const inTransition = js_default.transitionClasses(opts.in); const outTransition = js_default.transitionClasses(opts.out); js_default.toggle( eventType, owner, el, opts.display, inTransition, outTransition, opts.time, opts.blocking ); }, addClass(el, names, opts = {}) { const classNames = Array.isArray(names) ? names : names.split(" "); const owner = liveSocket.owner(el); js_default.addOrRemoveClasses( el, classNames, [], js_default.transitionClasses(opts.transition), opts.time, owner, opts.blocking ); }, removeClass(el, names, opts = {}) { const classNames = Array.isArray(names) ? names : names.split(" "); const owner = liveSocket.owner(el); js_default.addOrRemoveClasses( el, [], classNames, js_default.transitionClasses(opts.transition), opts.time, owner, opts.blocking ); }, toggleClass(el, names, opts = {}) { const classNames = Array.isArray(names) ? names : names.split(" "); const owner = liveSocket.owner(el); js_default.toggleClasses( el, classNames, js_default.transitionClasses(opts.transition), opts.time, owner, opts.blocking ); }, transition(el, transition, opts = {}) { const owner = liveSocket.owner(el); js_default.addOrRemoveClasses( el, [], [], js_default.transitionClasses(transition), opts.time, owner, opts.blocking ); }, setAttribute(el, attr, val) { js_default.setOrRemoveAttrs(el, [[attr, val]], []); }, removeAttribute(el, attr) { js_default.setOrRemoveAttrs(el, [], [attr]); }, toggleAttribute(el, attr, val1, val2) { js_default.toggleAttr(el, attr, val1, val2); }, push(el, type, opts = {}) { liveSocket.withinOwners(el, (view) => { const data = opts.value || {}; delete opts.value; let e = new CustomEvent("phx:exec", { detail: { sourceElement: el } }); js_default.exec(e, eventType, type, view, el, ["push", { data, ...opts }]); }); }, navigate(href, opts = {}) { const customEvent = new CustomEvent("phx:exec"); liveSocket.historyRedirect( customEvent, href, opts.replace ? "replace" : "push", null, null ); }, patch(href, opts = {}) { const customEvent = new CustomEvent("phx:exec"); liveSocket.pushHistoryPatch( customEvent, href, opts.replace ? "replace" : "push", null ); }, ignoreAttributes(el, attrs) { js_default.ignoreAttrs(el, Array.isArray(attrs) ? attrs : [attrs]); } }; }; // js/phoenix_live_view/view_hook.ts var HOOK_ID = "hookId"; var DEAD_HOOK = "deadHook"; var viewHookID = 1; var ViewHook = class _ViewHook { get liveSocket() { return this.__liveSocket(); } static makeID() { return viewHookID++; } static elementID(el) { return dom_default.private(el, HOOK_ID); } static deadHook(el) { return dom_default.private(el, DEAD_HOOK) === true; } constructor(view, el, callbacks) { this.el = el; this.__attachView(view); this.__listeners = /* @__PURE__ */ new Set(); this.__isDisconnected = false; dom_default.putPrivate(this.el, HOOK_ID, _ViewHook.makeID()); if (view && view.isDead) { dom_default.putPrivate(this.el, DEAD_HOOK, true); } if (callbacks) { const protectedProps = /* @__PURE__ */ new Set([ "el", "liveSocket", "__view", "__listeners", "__isDisconnected", "constructor", // Standard object properties // Core ViewHook API methods "js", "pushEvent", "pushEventTo", "handleEvent", "removeHandleEvent", "upload", "uploadTo", // Internal lifecycle callers "__mounted", "__updated", "__beforeUpdate", "__destroyed", "__reconnected", "__disconnected", "__cleanup__" ]); for (const key in callbacks) { if (Object.prototype.hasOwnProperty.call(callbacks, key)) { this[key] = callbacks[key]; if (protectedProps.has(key)) { console.warn( `Hook object for element #${el.id} overwrites core property '${key}'!` ); } } } const lifecycleMethods = [ "mounted", "beforeUpdate", "updated", "destroyed", "disconnected", "reconnected" ]; lifecycleMethods.forEach((methodName) => { if (callbacks[methodName] && typeof callbacks[methodName] === "function") { this[methodName] = callbacks[methodName]; } }); } } /** @internal */ __attachView(view) { if (view) { this.__view = () => view; this.__liveSocket = () => view.liveSocket; } else { this.__view = () => { throw new Error( `hook not yet attached to a live view: ${this.el.outerHTML}` ); }; this.__liveSocket = () => { throw new Error( `hook not yet attached to a live view: ${this.el.outerHTML}` ); }; } } // Default lifecycle methods mounted() { } beforeUpdate() { } updated() { } destroyed() { } disconnected() { } reconnected() { } // Internal lifecycle callers - called by the View /** @internal */ __mounted() { this.mounted(); } /** @internal */ __updated() { this.updated(); } /** @internal */ __beforeUpdate() { this.beforeUpdate(); } /** @internal */ __destroyed() { this.destroyed(); dom_default.deletePrivate(this.el, HOOK_ID); } /** @internal */ __reconnected() { if (this.__isDisconnected) { this.__isDisconnected = false; this.reconnected(); } } /** @internal */ __disconnected() { this.__isDisconnected = true; this.disconnected(); } js() { return { ...js_commands_default(this.__view().liveSocket, "hook"), exec: (encodedJS) => { this.__view().liveSocket.execJS(this.el, encodedJS, "hook"); } }; } pushEvent(event, payload, onReply) { const promise = this.__view().pushHookEvent( this.el, null, event, payload || {} ); if (onReply === void 0) { return promise.then(({ reply }) => reply); } promise.then( ({ reply, ref }) => onReply(reply, ref) ).catch(() => { }); } pushEventTo(selectorOrTarget, event, payload, onReply) { if (onReply === void 0) { const targetPair = []; this.__view().withinTargets( selectorOrTarget, (view, targetCtx) => { targetPair.push({ view, targetCtx }); } ); const promises = targetPair.map(({ view, targetCtx }) => { return view.pushHookEvent(this.el, targetCtx, event, payload || {}); }); return Promise.allSettled(promises); } this.__view().withinTargets( selectorOrTarget, (view, targetCtx) => { view.pushHookEvent(this.el, targetCtx, event, payload || {}).then( ({ reply, ref }) => onReply(reply, ref) ).catch(() => { }); } ); } handleEvent(event, callback) { const callbackRef = { event, callback: (customEvent) => callback(customEvent.detail) }; window.addEventListener( `phx:${event}`, callbackRef.callback ); this.__listeners.add(callbackRef); return callbackRef; } removeHandleEvent(ref) { window.removeEventListener( `phx:${ref.event}`, ref.callback ); this.__listeners.delete(ref); } upload(name, files) { return this.__view().dispatchUploads(null, name, files); } uploadTo(selectorOrTarget, name, files) { return this.__view().withinTargets( selectorOrTarget, (view, targetCtx) => { view.dispatchUploads(targetCtx, name, files); } ); } /** @internal */ __cleanup__() { this.__listeners.forEach( (callbackRef) => this.removeHandleEvent(callbackRef) ); } }; // js/phoenix_live_view/view.js var prependFormDataKey = (key, prefix) => { const isArray = key.endsWith("[]"); let baseKey = isArray ? key.slice(0, -2) : key; baseKey = baseKey.replace(/([^\[\]]+)(\]?$)/, `${prefix}$1$2`); if (isArray) { baseKey += "[]"; } return baseKey; }; var View = class _View { static closestView(el) { const liveViewEl = el.closest(PHX_VIEW_SELECTOR); return liveViewEl ? dom_default.private(liveViewEl, "view") : null; } constructor(el, liveSocket, parentView, flash, liveReferer) { this.isDead = false; this.liveSocket = liveSocket; this.flash = flash; this.parent = parentView; this.root = parentView ? parentView.root : this; this.el = el; const boundView = dom_default.private(this.el, "view"); if (boundView !== void 0 && boundView.isDead !== true) { logError( `The DOM element for this view has already been bound to a view. An element can only ever be associated with a single view! Please ensure that you are not trying to initialize multiple LiveSockets on the same page. This could happen if you're accidentally trying to render your root layout more than once. Ensure that the template set on the LiveView is different than the root layout. `, { view: boundView } ); throw new Error("Cannot bind multiple views to the same DOM element."); } dom_default.putPrivate(this.el, "view", this); this.id = this.el.id; this.ref = 0; this.lastAckRef = null; this.childJoins = 0; this.loaderTimer = null; this.disconnectedTimer = null; this.pendingDiffs = []; this.pendingForms = /* @__PURE__ */ new Set(); this.redirect = false; this.href = null; this.joinCount = this.parent ? this.parent.joinCount - 1 : 0; this.joinAttempts = 0; this.joinPending = true; this.destroyed = false; this.joinCallback = function(onDone) { onDone && onDone(); }; this.stopCallback = function() { }; this.pendingJoinOps = []; this.viewHooks = {}; this.formSubmits = []; this.children = this.parent ? null : {}; this.root.children[this.id] = {}; this.formsForRecovery = {}; this.channel = this.liveSocket.channel(`lv:${this.id}`, () => { const url = this.href && this.expandURL(this.href); return { redirect: this.redirect ? url : void 0, url: this.redirect ? void 0 : url || void 0, params: this.connectParams(liveReferer), session: this.getSession(), static: this.getStatic(), flash: this.flash, sticky: this.el.hasAttribute(PHX_STICKY) }; }); this.portalElementIds = /* @__PURE__ */ new Set(); } setHref(href) { this.href = href; } setRedirect(href) { this.redirect = true; this.href = href; } isMain() { return this.el.hasAttribute(PHX_MAIN); } connectParams(liveReferer) { const params = this.liveSocket.params(this.el); const manifest = dom_default.all(document, `[${this.binding(PHX_TRACK_STATIC)}]`).map((node) => node.src || node.href).filter((url) => typeof url === "string"); if (manifest.length > 0) { params["_track_static"] = manifest; } params["_mounts"] = this.joinCount; params["_mount_attempts"] = this.joinAttempts; params["_live_referer"] = liveReferer; this.joinAttempts++; return params; } isConnected() { return this.channel.canPush(); } getSession() { return this.el.getAttribute(PHX_SESSION); } getStatic() { const val = this.el.getAttribute(PHX_STATIC); return val === "" ? null : val; } destroy(callback = function() { }) { this.destroyAllChildren(); this.destroyPortalElements(); this.destroyed = true; dom_default.deletePrivate(this.el, "view"); delete this.root.children[this.id]; if (this.parent) { delete this.root.children[this.parent.id][this.id]; } clearTimeout(this.loaderTimer); const onFinished = () => { callback(); for (const id in this.viewHooks) { this.destroyHook(this.viewHooks[id]); } }; dom_default.markPhxChildDestroyed(this.el); this.log("destroyed", () => ["the child has been removed from the parent"]); this.channel.leave().receive("ok", onFinished).receive("error", onFinished).receive("timeout", onFinished); } setContainerClasses(...classes) { this.el.classList.remove( PHX_CONNECTED_CLASS, PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_CLIENT_ERROR_CLASS, PHX_SERVER_ERROR_CLASS ); this.el.classList.add(...classes); } showLoader(timeout) { clearTimeout(this.loaderTimer); if (timeout) { this.loaderTimer = setTimeout(() => this.showLoader(), timeout); } else { for (const id in this.viewHooks) { this.viewHooks[id].__disconnected(); } this.setContainerClasses(PHX_LOADING_CLASS); } } execAll(binding) { dom_default.all( this.el, `[${binding}]`, (el) => this.liveSocket.execJS(el, el.getAttribute(binding)) ); } hideLoader() { clearTimeout(this.loaderTimer); clearTimeout(this.disconnectedTimer); this.setContainerClasses(PHX_CONNECTED_CLASS); this.execAll(this.binding("connected")); } triggerReconnected() { for (const id in this.viewHooks) { this.viewHooks[id].__reconnected(); } } log(kind, msgCallback) { this.liveSocket.log(this, kind, msgCallback); } transition(time, onStart, onDone = function() { }) { this.liveSocket.transition(time, onStart, onDone); } // calls the callback with the view and target element for the given phxTarget // targets can be: // * an element itself, then it is simply passed to liveSocket.owner; // * a CID (Component ID), then we first search the component's element in the DOM // * a selector, then we search the selector in the DOM and call the callback // for each element found with the corresponding owner view withinTargets(phxTarget, callback, dom = document) { if (phxTarget instanceof HTMLElement || phxTarget instanceof SVGElement) { return this.liveSocket.owner( phxTarget, (view) => callback(view, phxTarget) ); } if (isCid(phxTarget)) { const targets = dom_default.findComponentNodeList(this.id, phxTarget, dom); if (targets.length === 0) { logError(`no component found matching phx-target of ${phxTarget}`); } else { callback(this, parseInt(phxTarget)); } } else { const targets = Array.from(dom.querySelectorAll(phxTarget)); if (targets.length === 0) { logError( `nothing found matching the phx-target selector "${phxTarget}"` ); } targets.forEach( (target) => this.liveSocket.owner(target, (view) => callback(view, target)) ); } } applyDiff(type, rawDiff, callback) { this.log(type, () => ["", clone(rawDiff)]); const { diff, reply, events, title } = Rendered.extract(rawDiff); const ev = events.reduce( (acc, args) => { if (args.length === 3 && args[2] == true) { acc.pre.push(args.slice(0, -1)); } else { acc.post.push(args); } return acc; }, { pre: [], post: [] } ); this.liveSocket.dispatchEvents(ev.pre); const update = () => { callback({ diff, reply, events: ev.post }); if (typeof title === "string" || type == "mount" && this.isMain()) { window.requestAnimationFrame(() => dom_default.putTitle(title)); } }; if ("onDocumentPatch" in this.liveSocket.domCallbacks) { this.liveSocket.triggerDOM("onDocumentPatch", [update]); } else { update(); } } onJoin(resp) { const { rendered, container, liveview_version, pid } = resp; if (container) { const [tag, attrs] = container; this.el = dom_default.replaceRootContainer(this.el, tag, attrs); } this.childJoins = 0; this.joinPending = true; this.flash = null; if (this.root === this) { this.formsForRecovery = this.getFormsForRecovery(); } if (this.isMain() && window.history.state === null) { browser_default.pushState("replace", { type: "patch", id: this.id, position: this.liveSocket.currentHistoryPosition }); } if (liveview_version !== this.liveSocket.version()) { console.warn( `LiveView asset version mismatch. JavaScript version ${this.liveSocket.version()} vs. server ${liveview_version}. To avoid issues, please ensure that your assets use the same version as the server.` ); } if (pid) { this.el.setAttribute(PHX_LV_PID, pid); } browser_default.dropLocal( this.liveSocket.localStorage, window.location.pathname, CONSECUTIVE_RELOADS ); this.applyDiff("mount", rendered, ({ diff, events }) => { this.rendered = new Rendered(this.id, diff); const [html, streams] = this.renderContainer(null, "join"); this.dropPendingRefs(); this.joinCount++; this.joinAttempts = 0; this.maybeRecoverForms(html, () => { this.onJoinComplete(resp, html, streams, events); }); }); } dropPendingRefs() { dom_default.all(document, `[${PHX_REF_SRC}="${this.refSrc()}"]`, (el) => { el.removeAttribute(PHX_REF_LOADING); el.removeAttribute(PHX_REF_SRC); el.removeAttribute(PHX_REF_LOCK); }); } onJoinComplete({ live_patch }, html, streams, events) { if (this.joinCount > 1 || this.parent && !this.parent.isJoinPending()) { return this.applyJoinPatch(live_patch, html, streams, events); } const newChildren = dom_default.findPhxChildrenInFragment(html, this.id).filter( (toEl) => { const fromEl = toEl.id && this.el.querySelector(`[id="${toEl.id}"]`); const phxStatic = fromEl && fromEl.getAttribute(PHX_STATIC); if (phxStatic) { toEl.setAttribute(PHX_STATIC, phxStatic); } if (fromEl) { fromEl.setAttribute(PHX_ROOT_ID, this.root.id); } return this.joinChild(toEl); } ); if (newChildren.length === 0) { if (this.parent) { this.root.pendingJoinOps.push([ this, () => this.applyJoinPatch(live_patch, html, streams, events) ]); this.parent.ackJoin(this); } else { this.onAllChildJoinsComplete(); this.applyJoinPatch(live_patch, html, streams, events); } } else { this.root.pendingJoinOps.push([ this, () => this.applyJoinPatch(live_patch, html, streams, events) ]); } } attachTrueDocEl() { this.el = dom_default.byId(this.id); this.el.setAttribute(PHX_ROOT_ID, this.root.id); } // this is invoked for dead and live views, so we must filter by // by owner to ensure we aren't duplicating hooks across disconnect // and connected states. This also handles cases where hooks exist // in a root layout with a LV in the body execNewMounted(parent = document) { let phxViewportTop = this.binding(PHX_VIEWPORT_TOP); let phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM); this.all( parent, `[${phxViewportTop}], [${phxViewportBottom}]`, (hookEl) => { dom_default.maintainPrivateHooks( hookEl, hookEl, phxViewportTop, phxViewportBottom ); this.maybeAddNewHook(hookEl); } ); this.all( parent, `[${this.binding(PHX_HOOK)}], [data-phx-${PHX_HOOK}]`, (hookEl) => { this.maybeAddNewHook(hookEl); } ); this.all(parent, `[${this.binding(PHX_MOUNTED)}]`, (el) => { this.maybeMounted(el); }); } all(parent, selector, callback) { dom_default.all(parent, selector, (el) => { if (this.ownsElement(el)) { callback(el); } }); } applyJoinPatch(live_patch, html, streams, events) { if (this.joinCount > 1) { if (this.pendingJoinOps.length) { this.pendingJoinOps.forEach((cb) => typeof cb === "function" && cb()); this.pendingJoinOps = []; } } this.attachTrueDocEl(); const patch = new DOMPatch(this, this.el, this.id, html, streams, null); patch.markPrunableContentForRemoval(); this.performPatch(patch, false, true); this.joinNewChildren(); this.execNewMounted(); this.joinPending = false; this.liveSocket.dispatchEvents(events); this.applyPendingUpdates(); if (live_patch) { const { kind, to } = live_patch; this.liveSocket.historyPatch(to, kind); } this.hideLoader(); if (this.joinCount > 1) { this.triggerReconnected(); } this.stopCallback(); } triggerBeforeUpdateHook(fromEl, toEl) { this.liveSocket.triggerDOM("onBeforeElUpdated", [fromEl, toEl]); const hook = this.getHook(fromEl); const isIgnored = hook && dom_default.isIgnored(fromEl, this.binding(PHX_UPDATE)); if (hook && !fromEl.isEqualNode(toEl) && !(isIgnored && isEqualObj(fromEl.dataset, toEl.dataset))) { hook.__beforeUpdate(); return hook; } } maybeMounted(el) { const phxMounted = el.getAttribute(this.binding(PHX_MOUNTED)); const hasBeenInvoked = phxMounted && dom_default.private(el, "mounted"); if (phxMounted && !hasBeenInvoked) { this.liveSocket.execJS(el, phxMounted); dom_default.putPrivate(el, "mounted", true); } } maybeAddNewHook(el) { const newHook = this.addHook(el); if (newHook) { newHook.__mounted(); } } performPatch(patch, pruneCids, isJoinPatch = false) { const removedEls = []; let phxChildrenAdded = false; const updatedHookIds = /* @__PURE__ */ new Set(); this.liveSocket.triggerDOM("onPatchStart", [patch.targetContainer]); patch.after("added", (el) => { this.liveSocket.triggerDOM("onNodeAdded", [el]); const phxViewportTop = this.binding(PHX_VIEWPORT_TOP); const phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM); dom_default.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom); this.maybeAddNewHook(el); if (el.getAttribute) { this.maybeMounted(el); } }); patch.after("phxChildAdded", (el) => { if (dom_default.isPhxSticky(el)) { this.liveSocket.joinRootViews(); } else { phxChildrenAdded = true; } }); patch.before("updated", (fromEl, toEl) => { const hook = this.triggerBeforeUpdateHook(fromEl, toEl); if (hook) { updatedHookIds.add(fromEl.id); } js_default.onBeforeElUpdated(fromEl, toEl); }); patch.after("updated", (el) => { if (updatedHookIds.has(el.id)) { this.getHook(el).__updated(); } }); patch.after("discarded", (el) => { if (el.nodeType === Node.ELEMENT_NODE) { removedEls.push(el); } }); patch.after( "transitionsDiscarded", (els) => this.afterElementsRemoved(els, pruneCids) ); patch.perform(isJoinPatch); this.afterElementsRemoved(removedEls, pruneCids); this.liveSocket.triggerDOM("onPatchEnd", [patch.targetContainer]); return phxChildrenAdded; } afterElementsRemoved(elements, pruneCids) { const destroyedCIDs = []; elements.forEach((parent) => { const components = dom_default.all( parent, `[${PHX_VIEW_REF}="${this.id}"][${PHX_COMPONENT}]` ); const hooks = dom_default.all( parent, `[${this.binding(PHX_HOOK)}], [data-phx-hook]` ); components.concat(parent).forEach((el) => { const cid = this.componentID(el); if (isCid(cid) && destroyedCIDs.indexOf(cid) === -1 && el.getAttribute(PHX_VIEW_REF) === this.id) { destroyedCIDs.push(cid); } }); hooks.concat(parent).forEach((hookEl) => { const hook = this.getHook(hookEl); hook && this.destroyHook(hook); }); }); if (pruneCids) { this.maybePushComponentsDestroyed(destroyedCIDs); } } joinNewChildren() { dom_default.findPhxChildren(document, this.id).forEach((el) => this.joinChild(el)); } maybeRecoverForms(html, callback) { const phxChange = this.binding("change"); const oldForms = this.root.formsForRecovery; const template = document.createElement("template"); template.innerHTML = html; dom_default.all(template.content, `[${PHX_PORTAL}]`).forEach((portalTemplate) => { template.content.firstElementChild.appendChild( portalTemplate.content.firstElementChild ); }); const rootEl = template.content.firstElementChild; rootEl.id = this.id; rootEl.setAttribute(PHX_ROOT_ID, this.root.id); rootEl.setAttribute(PHX_SESSION, this.getSession()); rootEl.setAttribute(PHX_STATIC, this.getStatic()); rootEl.setAttribute(PHX_PARENT_ID, this.parent ? this.parent.id : null); const formsToRecover = ( // we go over all forms in the new DOM; because this is only the HTML for the current // view, we can be sure that all forms are owned by this view: dom_default.all(template.content, "form").filter((newForm) => newForm.id && oldForms[newForm.id]).filter((newForm) => !this.pendingForms.has(newForm.id)).filter( (newForm) => oldForms[newForm.id].getAttribute(phxChange) === newForm.getAttribute(phxChange) ).map((newForm) => { return [oldForms[newForm.id], newForm]; }) ); if (formsToRecover.length === 0) { return callback(); } formsToRecover.forEach(([oldForm, newForm], i) => { this.pendingForms.add(newForm.id); this.pushFormRecovery( oldForm, newForm, template.content.firstElementChild, () => { this.pendingForms.delete(newForm.id); if (i === formsToRecover.length - 1) { callback(); } } ); }); } getChildById(id) { return this.root.children[this.id][id]; } getDescendentByEl(el) { if (el.id === this.id) { return this; } else { return this.children[el.getAttribute(PHX_PARENT_ID)]?.[el.id]; } } destroyDescendent(id) { for (const parentId in this.root.children) { for (const childId in this.root.children[parentId]) { if (childId === id) { return this.root.children[parentId][childId].destroy(); } } } } joinChild(el) { const child = this.getChildById(el.id); if (!child) { const view = new _View(el, this.liveSocket, this); this.root.children[this.id][view.id] = view; view.join(); this.childJoins++; return true; } } isJoinPending() { return this.joinPending; } ackJoin(_child) { this.childJoins--; if (this.childJoins === 0) { if (this.parent) { this.parent.ackJoin(this); } else { this.onAllChildJoinsComplete(); } } } onAllChildJoinsComplete() { this.pendingForms.clear(); this.formsForRecovery = {}; this.joinCallback(() => { this.pendingJoinOps.forEach(([view, op]) => { if (!view.isDestroyed()) { op(); } }); this.pendingJoinOps = []; }); } update(diff, events, isPending = false) { if (this.isJoinPending() || this.liveSocket.hasPendingLink() && this.root.isMain()) { if (!isPending) { this.pendingDiffs.push({ diff, events }); } return false; } this.rendered.mergeDiff(diff); let phxChildrenAdded = false; if (this.rendered.isComponentOnlyDiff(diff)) { this.liveSocket.time("component patch complete", () => { const parentCids = dom_default.findExistingParentCIDs( this.id, this.rendered.componentCIDs(diff) ); parentCids.forEach((parentCID) => { if (this.componentPatch( this.rendered.getComponent(diff, parentCID), parentCID )) { phxChildrenAdded = true; } }); }); } else if (!isEmpty(diff)) { this.liveSocket.time("full patch complete", () => { const [html, streams] = this.renderContainer(diff, "update"); const patch = new DOMPatch(this, this.el, this.id, html, streams, null); phxChildrenAdded = this.performPatch(patch, true); }); } this.liveSocket.dispatchEvents(events); if (phxChildrenAdded) { this.joinNewChildren(); } return true; } renderContainer(diff, kind) { return this.liveSocket.time(`toString diff (${kind})`, () => { const tag = this.el.tagName; const cids = diff ? this.rendered.componentCIDs(diff) : null; const { buffer: html, streams } = this.rendered.toString(cids); return [`<${tag}>${html}`, streams]; }); } componentPatch(diff, cid) { if (isEmpty(diff)) return false; const { buffer: html, streams } = this.rendered.componentToString(cid); const patch = new DOMPatch(this, this.el, this.id, html, streams, cid); const childrenAdded = this.performPatch(patch, true); return childrenAdded; } getHook(el) { return this.viewHooks[ViewHook.elementID(el)]; } addHook(el) { const hookElId = ViewHook.elementID(el); if (el.getAttribute && !this.ownsElement(el)) { return; } if (hookElId && !this.viewHooks[hookElId]) { if (ViewHook.deadHook(el)) { return; } const hook = dom_default.getCustomElHook(el) || logError(`no hook found for custom element: ${el.id}`); this.viewHooks[hookElId] = hook; hook.__attachView(this); return hook; } else if (hookElId || !el.getAttribute) { return; } else { const hookName = el.getAttribute(`data-phx-${PHX_HOOK}`) || el.getAttribute(this.binding(PHX_HOOK)); if (!hookName) { return; } const hookDefinition = this.liveSocket.getHookDefinition(hookName); if (hookDefinition) { if (!el.id) { logError( `no DOM ID for hook "${hookName}". Hooks require a unique ID on each element.`, el ); return; } let hookInstance; try { if (typeof hookDefinition === "function" && hookDefinition.prototype instanceof ViewHook) { hookInstance = new hookDefinition(this, el); } else if (typeof hookDefinition === "object" && hookDefinition !== null) { hookInstance = new ViewHook(this, el, hookDefinition); } else { logError( `Invalid hook definition for "${hookName}". Expected a class extending ViewHook or an object definition.`, el ); return; } } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); logError(`Failed to create hook "${hookName}": ${errorMessage}`, el); return; } this.viewHooks[ViewHook.elementID(hookInstance.el)] = hookInstance; return hookInstance; } else if (hookName !== null) { logError(`unknown hook found for "${hookName}"`, el); } } } destroyHook(hook) { const hookId = ViewHook.elementID(hook.el); hook.__destroyed(); hook.__cleanup__(); delete this.viewHooks[hookId]; } applyPendingUpdates() { this.pendingDiffs = this.pendingDiffs.filter( ({ diff, events }) => !this.update(diff, events, true) ); this.eachChild((child) => child.applyPendingUpdates()); } eachChild(callback) { const children = this.root.children[this.id] || {}; for (const id in children) { callback(this.getChildById(id)); } } onChannel(event, cb) { this.liveSocket.onChannel(this.channel, event, (resp) => { if (this.isJoinPending()) { if (this.joinCount > 1) { this.pendingJoinOps.push(() => cb(resp)); } else { this.root.pendingJoinOps.push([this, () => cb(resp)]); } } else { this.liveSocket.requestDOMUpdate(() => cb(resp)); } }); } bindChannel() { this.liveSocket.onChannel(this.channel, "diff", (rawDiff) => { this.liveSocket.requestDOMUpdate(() => { this.applyDiff( "update", rawDiff, ({ diff, events }) => this.update(diff, events) ); }); }); this.onChannel( "redirect", ({ to, flash }) => this.onRedirect({ to, flash }) ); this.onChannel("live_patch", (redir) => this.onLivePatch(redir)); this.onChannel("live_redirect", (redir) => this.onLiveRedirect(redir)); this.channel.onError((reason) => this.onError(reason)); this.channel.onClose((reason) => this.onClose(reason)); } destroyAllChildren() { this.eachChild((child) => child.destroy()); } onLiveRedirect(redir) { const { to, kind, flash } = redir; const url = this.expandURL(to); const e = new CustomEvent("phx:server-navigate", { detail: { to, kind, flash } }); this.liveSocket.historyRedirect(e, url, kind, flash); } onLivePatch(redir) { const { to, kind } = redir; this.href = this.expandURL(to); this.liveSocket.historyPatch(to, kind); } expandURL(to) { return to.startsWith("/") ? `${window.location.protocol}//${window.location.host}${to}` : to; } /** * @param {{to: string, flash?: string, reloadToken?: string}} redirect */ onRedirect({ to, flash, reloadToken }) { this.liveSocket.redirect(to, flash, reloadToken); } isDestroyed() { return this.destroyed; } joinDead() { this.isDead = true; } joinPush() { this.joinPush = this.joinPush || this.channel.join(); return this.joinPush; } join(callback) { this.showLoader(this.liveSocket.loaderTimeout); this.bindChannel(); if (this.isMain()) { this.stopCallback = this.liveSocket.withPageLoading({ to: this.href, kind: "initial" }); } this.joinCallback = (onDone) => { onDone = onDone || function() { }; callback ? callback(this.joinCount, onDone) : onDone(); }; this.wrapPush(() => this.channel.join(), { ok: (resp) => this.liveSocket.requestDOMUpdate(() => this.onJoin(resp)), error: (error) => this.onJoinError(error), timeout: () => this.onJoinError({ reason: "timeout" }) }); } onJoinError(resp) { if (resp.reason === "reload") { this.log("error", () => [ `failed mount with ${resp.status}. Falling back to page reload`, resp ]); this.onRedirect({ to: this.liveSocket.main.href, reloadToken: resp.token }); return; } else if (resp.reason === "unauthorized" || resp.reason === "stale") { this.log("error", () => [ "unauthorized live_redirect. Falling back to page request", resp ]); this.onRedirect({ to: this.liveSocket.main.href, flash: this.flash }); return; } if (resp.redirect || resp.live_redirect) { this.joinPending = false; this.channel.leave(); } if (resp.redirect) { return this.onRedirect(resp.redirect); } if (resp.live_redirect) { return this.onLiveRedirect(resp.live_redirect); } this.log("error", () => ["unable to join", resp]); if (this.isMain()) { this.displayError( [PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS], { unstructuredError: resp, errorKind: "server" } ); if (this.liveSocket.isConnected()) { this.liveSocket.reloadWithJitter(this); } } else { if (this.joinAttempts >= MAX_CHILD_JOIN_ATTEMPTS) { this.root.displayError( [PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS], { unstructuredError: resp, errorKind: "server" } ); this.log("error", () => [ `giving up trying to mount after ${MAX_CHILD_JOIN_ATTEMPTS} tries`, resp ]); this.destroy(); } const trueChildEl = dom_default.byId(this.el.id); if (trueChildEl) { dom_default.mergeAttrs(trueChildEl, this.el); this.displayError( [PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS], { unstructuredError: resp, errorKind: "server" } ); this.el = trueChildEl; } else { this.destroy(); } } } onClose(reason) { if (this.isDestroyed()) { return; } if (this.isMain() && this.liveSocket.hasPendingLink() && reason !== "leave") { return this.liveSocket.reloadWithJitter(this); } this.destroyAllChildren(); this.liveSocket.dropActiveElement(this); if (this.liveSocket.isUnloaded()) { this.showLoader(BEFORE_UNLOAD_LOADER_TIMEOUT); } } onError(reason) { this.onClose(reason); if (this.liveSocket.isConnected()) { this.log("error", () => ["view crashed", reason]); } if (!this.liveSocket.isUnloaded()) { if (this.liveSocket.isConnected()) { this.displayError( [PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS], { unstructuredError: reason, errorKind: "server" } ); } else { this.displayError( [PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_CLIENT_ERROR_CLASS], { unstructuredError: reason, errorKind: "client" } ); } } } displayError(classes, details = {}) { if (this.isMain()) { dom_default.dispatchEvent(window, "phx:page-loading-start", { detail: { to: this.href, kind: "error", ...details } }); } this.showLoader(); this.setContainerClasses(...classes); this.delayedDisconnected(); } delayedDisconnected() { this.disconnectedTimer = setTimeout(() => { this.execAll(this.binding("disconnected")); }, this.liveSocket.disconnectedTimeout); } wrapPush(callerPush, receives) { const latency = this.liveSocket.getLatencySim(); const withLatency = latency ? (cb) => setTimeout(() => !this.isDestroyed() && cb(), latency) : (cb) => !this.isDestroyed() && cb(); withLatency(() => { callerPush().receive( "ok", (resp) => withLatency(() => receives.ok && receives.ok(resp)) ).receive( "error", (reason) => withLatency(() => receives.error && receives.error(reason)) ).receive( "timeout", () => withLatency(() => receives.timeout && receives.timeout()) ); }); } pushWithReply(refGenerator, event, payload) { if (!this.isConnected()) { return Promise.reject(new Error("no connection")); } const [ref, [el], opts] = refGenerator ? refGenerator({ payload }) : [null, [], {}]; const oldJoinCount = this.joinCount; let onLoadingDone = function() { }; if (opts.page_loading) { onLoadingDone = this.liveSocket.withPageLoading({ kind: "element", target: el }); } if (typeof payload.cid !== "number") { delete payload.cid; } return new Promise((resolve, reject) => { this.wrapPush(() => this.channel.push(event, payload, PUSH_TIMEOUT), { ok: (resp) => { if (ref !== null) { this.lastAckRef = ref; } const finish = (hookReply) => { if (resp.redirect) { this.onRedirect(resp.redirect); } if (resp.live_patch) { this.onLivePatch(resp.live_patch); } if (resp.live_redirect) { this.onLiveRedirect(resp.live_redirect); } onLoadingDone(); resolve({ resp, reply: hookReply, ref }); }; if (resp.diff) { this.liveSocket.requestDOMUpdate(() => { this.applyDiff("update", resp.diff, ({ diff, reply, events }) => { if (ref !== null) { this.undoRefs(ref, payload.event); } this.update(diff, events); finish(reply); }); }); } else { if (ref !== null) { this.undoRefs(ref, payload.event); } finish(null); } }, error: (reason) => reject(new Error(`failed with reason: ${JSON.stringify(reason)}`)), timeout: () => { reject(new Error("timeout")); if (this.joinCount === oldJoinCount) { this.liveSocket.reloadWithJitter(this, () => { this.log("timeout", () => [ "received timeout while communicating with server. Falling back to hard refresh for recovery" ]); }); } } }); }); } undoRefs(ref, phxEvent, onlyEls) { if (!this.isConnected()) { return; } const selector = `[${PHX_REF_SRC}="${this.refSrc()}"]`; if (onlyEls) { onlyEls = new Set(onlyEls); dom_default.all(document, selector, (parent) => { if (onlyEls && !onlyEls.has(parent)) { return; } dom_default.all( parent, selector, (child) => this.undoElRef(child, ref, phxEvent) ); this.undoElRef(parent, ref, phxEvent); }); } else { dom_default.all(document, selector, (el) => this.undoElRef(el, ref, phxEvent)); } } undoElRef(el, ref, phxEvent) { const elRef = new ElementRef(el); elRef.maybeUndo(ref, phxEvent, (clonedTree) => { const patch = new DOMPatch(this, el, this.id, clonedTree, [], null, { undoRef: ref }); const phxChildrenAdded = this.performPatch(patch, true); dom_default.all( el, `[${PHX_REF_SRC}="${this.refSrc()}"]`, (child) => this.undoElRef(child, ref, phxEvent) ); if (phxChildrenAdded) { this.joinNewChildren(); } }); } refSrc() { return this.el.id; } putRef(elements, phxEvent, eventType, opts = {}) { const newRef = this.ref++; const disableWith = this.binding(PHX_DISABLE_WITH); if (opts.loading) { const loadingEls = dom_default.all(document, opts.loading).map((el) => { return { el, lock: true, loading: true }; }); elements = elements.concat(loadingEls); } for (const { el, lock, loading } of elements) { if (!lock && !loading) { throw new Error("putRef requires lock or loading"); } el.setAttribute(PHX_REF_SRC, this.refSrc()); if (loading) { el.setAttribute(PHX_REF_LOADING, newRef); } if (lock) { el.setAttribute(PHX_REF_LOCK, newRef); } if (!loading || opts.submitter && !(el === opts.submitter || el === opts.form)) { continue; } const lockCompletePromise = new Promise((resolve) => { el.addEventListener(`phx:undo-lock:${newRef}`, () => resolve(detail), { once: true }); }); const loadingCompletePromise = new Promise((resolve) => { el.addEventListener( `phx:undo-loading:${newRef}`, () => resolve(detail), { once: true } ); }); el.classList.add(`phx-${eventType}-loading`); const disableText = el.getAttribute(disableWith); if (disableText !== null) { if (!el.getAttribute(PHX_DISABLE_WITH_RESTORE)) { el.setAttribute(PHX_DISABLE_WITH_RESTORE, el.textContent); } if (disableText !== "") { el.textContent = disableText; } el.setAttribute( PHX_DISABLED, el.getAttribute(PHX_DISABLED) || el.disabled ); el.setAttribute("disabled", ""); } const detail = { event: phxEvent, eventType, ref: newRef, isLoading: loading, isLocked: lock, lockElements: elements.filter(({ lock: lock2 }) => lock2).map(({ el: el2 }) => el2), loadingElements: elements.filter(({ loading: loading2 }) => loading2).map(({ el: el2 }) => el2), unlock: (els) => { els = Array.isArray(els) ? els : [els]; this.undoRefs(newRef, phxEvent, els); }, lockComplete: lockCompletePromise, loadingComplete: loadingCompletePromise, lock: (lockEl) => { return new Promise((resolve) => { if (this.isAcked(newRef)) { return resolve(detail); } lockEl.setAttribute(PHX_REF_LOCK, newRef); lockEl.setAttribute(PHX_REF_SRC, this.refSrc()); lockEl.addEventListener( `phx:lock-stop:${newRef}`, () => resolve(detail), { once: true } ); }); } }; if (opts.payload) { detail["payload"] = opts.payload; } if (opts.target) { detail["target"] = opts.target; } if (opts.originalEvent) { detail["originalEvent"] = opts.originalEvent; } el.dispatchEvent( new CustomEvent("phx:push", { detail, bubbles: true, cancelable: false }) ); if (phxEvent) { el.dispatchEvent( new CustomEvent(`phx:push:${phxEvent}`, { detail, bubbles: true, cancelable: false }) ); } } return [newRef, elements.map(({ el }) => el), opts]; } isAcked(ref) { return this.lastAckRef !== null && this.lastAckRef >= ref; } componentID(el) { const cid = el.getAttribute && el.getAttribute(PHX_COMPONENT); return cid ? parseInt(cid) : null; } targetComponentID(target, targetCtx, opts = {}) { if (isCid(targetCtx)) { return targetCtx; } const cidOrSelector = opts.target || target.getAttribute(this.binding("target")); if (isCid(cidOrSelector)) { return parseInt(cidOrSelector); } else if (targetCtx && (cidOrSelector !== null || opts.target)) { return this.closestComponentID(targetCtx); } else { return null; } } closestComponentID(targetCtx) { if (isCid(targetCtx)) { return targetCtx; } else if (targetCtx) { return maybe( // We either use the closest data-phx-component binding, or - // in case of portals - continue with the portal source. // This is necessary if teleporting an element outside of its LiveComponent. targetCtx.closest(`[${PHX_COMPONENT}],[${PHX_TELEPORTED_SRC}]`), (el) => { if (el.hasAttribute(PHX_COMPONENT)) { return this.ownsElement(el) && this.componentID(el); } if (el.hasAttribute(PHX_TELEPORTED_SRC)) { const portalParent = dom_default.byId(el.getAttribute(PHX_TELEPORTED_SRC)); return this.closestComponentID(portalParent); } } ); } else { return null; } } pushHookEvent(el, targetCtx, event, payload) { if (!this.isConnected()) { this.log("hook", () => [ "unable to push hook event. LiveView not connected", event, payload ]); return Promise.reject( new Error("unable to push hook event. LiveView not connected") ); } const refGenerator = () => this.putRef([{ el, loading: true, lock: true }], event, "hook", { payload, target: targetCtx }); return this.pushWithReply(refGenerator, "event", { type: "hook", event, value: payload, cid: this.closestComponentID(targetCtx) }).then(({ resp: _resp, reply, ref }) => ({ reply, ref })); } extractMeta(el, meta, value) { const prefix = this.binding("value-"); for (let i = 0; i < el.attributes.length; i++) { if (!meta) { meta = {}; } const name = el.attributes[i].name; if (name.startsWith(prefix)) { meta[name.replace(prefix, "")] = el.getAttribute(name); } } if (el.value !== void 0 && !(el instanceof HTMLFormElement)) { if (!meta) { meta = {}; } meta.value = el.value; if (el.tagName === "INPUT" && CHECKABLE_INPUTS.indexOf(el.type) >= 0 && !el.checked) { delete meta.value; } } if (value) { if (!meta) { meta = {}; } for (const key in value) { meta[key] = value[key]; } } return meta; } serializeForm(form, opts, onlyNames = []) { const { submitter } = opts; let injectedElement; if (submitter && submitter.name) { const input = document.createElement("input"); input.type = "hidden"; const formId = submitter.getAttribute("form"); if (formId) { input.setAttribute("form", formId); } input.name = submitter.name; input.value = submitter.value; submitter.parentElement.insertBefore(input, submitter); injectedElement = input; } const formData = new FormData(form); const toRemove = []; formData.forEach((val, key, _index) => { if (val instanceof File) { toRemove.push(key); } }); toRemove.forEach((key) => formData.delete(key)); const params = new URLSearchParams(); const { inputsUnused, onlyHiddenInputs } = Array.from(form.elements).reduce( (acc, input) => { const { inputsUnused: inputsUnused2, onlyHiddenInputs: onlyHiddenInputs2 } = acc; const key = input.name; if (!key) { return acc; } if (inputsUnused2[key] === void 0) { inputsUnused2[key] = true; } if (onlyHiddenInputs2[key] === void 0) { onlyHiddenInputs2[key] = true; } const inputSkipUnusedField = input.hasAttribute( this.binding(PHX_NO_UNUSED_FIELD) ); const isUsed = dom_default.private(input, PHX_HAS_FOCUSED) || dom_default.private(input, PHX_HAS_SUBMITTED) || inputSkipUnusedField; const isHidden = input.type === "hidden"; inputsUnused2[key] = inputsUnused2[key] && !isUsed; onlyHiddenInputs2[key] = onlyHiddenInputs2[key] && isHidden; return acc; }, { inputsUnused: {}, onlyHiddenInputs: {} } ); const formSkipUnusedFields = form.hasAttribute( this.binding(PHX_NO_UNUSED_FIELD) ); for (const [key, val] of formData.entries()) { if (onlyNames.length === 0 || onlyNames.indexOf(key) >= 0) { const isUnused = inputsUnused[key]; const hidden = onlyHiddenInputs[key]; const skipUnusedCheck = formSkipUnusedFields; if (!skipUnusedCheck && isUnused && !(submitter && submitter.name == key) && !hidden) { params.append(prependFormDataKey(key, "_unused_"), ""); } if (typeof val === "string") { params.append(key, val); } } } if (submitter && injectedElement) { submitter.parentElement.removeChild(injectedElement); } return params.toString(); } pushEvent(type, el, targetCtx, phxEvent, meta, opts = {}, onReply) { this.pushWithReply( (maybePayload) => this.putRef([{ el, loading: true, lock: true }], phxEvent, type, { ...opts, payload: maybePayload?.payload }), "event", { type, event: phxEvent, value: this.extractMeta(el, meta, opts.value), cid: this.targetComponentID(el, targetCtx, opts) } ).then(({ reply }) => onReply && onReply(reply)).catch((error) => logError("Failed to push event", error)); } pushFileProgress(fileEl, entryRef, progress, onReply = function() { }) { this.liveSocket.withinOwners(fileEl.form, (view, targetCtx) => { view.pushWithReply(null, "progress", { event: fileEl.getAttribute(view.binding(PHX_PROGRESS)), ref: fileEl.getAttribute(PHX_UPLOAD_REF), entry_ref: entryRef, progress, cid: view.targetComponentID(fileEl.form, targetCtx) }).then(() => onReply()).catch((error) => logError("Failed to push file progress", error)); }); } pushInput(inputEl, targetCtx, forceCid, phxEvent, opts, callback) { if (!inputEl.form) { throw new Error("form events require the input to be inside a form"); } let uploads; const cid = isCid(forceCid) ? forceCid : this.targetComponentID(inputEl.form, targetCtx, opts); const refGenerator = (maybePayload) => { return this.putRef( [ { el: inputEl, loading: true, lock: true }, { el: inputEl.form, loading: true, lock: true } ], phxEvent, "change", { ...opts, payload: maybePayload?.payload } ); }; let formData; const meta = this.extractMeta(inputEl.form, {}, opts.value); const serializeOpts = {}; if (inputEl instanceof HTMLButtonElement) { serializeOpts.submitter = inputEl; } if (inputEl.getAttribute(this.binding("change"))) { formData = this.serializeForm(inputEl.form, serializeOpts, [ inputEl.name ]); } else { formData = this.serializeForm(inputEl.form, serializeOpts); } if (dom_default.isUploadInput(inputEl) && inputEl.files && inputEl.files.length > 0) { LiveUploader.trackFiles(inputEl, Array.from(inputEl.files)); } uploads = LiveUploader.serializeUploads(inputEl); const event = { type: "form", event: phxEvent, value: formData, meta: { // no target was implicitly sent as "undefined" in LV <= 1.0.5, therefore // we have to keep it. In 1.0.6 we switched from passing meta as URL encoded data // to passing it directly in the event, but the JSON encode would drop keys with // undefined values. _target: opts._target || "undefined", ...meta }, uploads, cid }; this.pushWithReply(refGenerator, "event", event).then(({ resp }) => { if (dom_default.isUploadInput(inputEl) && dom_default.isAutoUpload(inputEl)) { ElementRef.onUnlock(inputEl, () => { if (LiveUploader.filesAwaitingPreflight(inputEl).length > 0) { const [ref, _els] = refGenerator(); this.undoRefs(ref, phxEvent, [inputEl.form]); this.uploadFiles( inputEl.form, phxEvent, targetCtx, ref, cid, (_uploads) => { callback && callback(resp); this.triggerAwaitingSubmit(inputEl.form, phxEvent); this.undoRefs(ref, phxEvent); } ); } }); } else { callback && callback(resp); } }).catch((error) => logError("Failed to push input event", error)); } triggerAwaitingSubmit(formEl, phxEvent) { const awaitingSubmit = this.getScheduledSubmit(formEl); if (awaitingSubmit) { const [_el, _ref, _opts, callback] = awaitingSubmit; this.cancelSubmit(formEl, phxEvent); callback(); } } getScheduledSubmit(formEl) { return this.formSubmits.find( ([el, _ref, _opts, _callback]) => el.isSameNode(formEl) ); } scheduleSubmit(formEl, ref, opts, callback) { if (this.getScheduledSubmit(formEl)) { return true; } this.formSubmits.push([formEl, ref, opts, callback]); } cancelSubmit(formEl, phxEvent) { this.formSubmits = this.formSubmits.filter( ([el, ref, _opts, _callback]) => { if (el.isSameNode(formEl)) { this.undoRefs(ref, phxEvent); return false; } else { return true; } } ); } disableForm(formEl, phxEvent, opts = {}) { const filterIgnored = (el) => { const userIgnored = closestPhxBinding( el, `${this.binding(PHX_UPDATE)}=ignore`, el.form ); return !(userIgnored || closestPhxBinding(el, "data-phx-update=ignore", el.form)); }; const filterDisables = (el) => { return el.hasAttribute(this.binding(PHX_DISABLE_WITH)); }; const filterButton = (el) => el.tagName == "BUTTON"; const filterInput = (el) => ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName); const formElements = Array.from(formEl.elements); const disables = formElements.filter(filterDisables); const buttons = formElements.filter(filterButton).filter(filterIgnored); const inputs = formElements.filter(filterInput).filter(filterIgnored); buttons.forEach((button) => { button.setAttribute(PHX_DISABLED, button.disabled); button.disabled = true; }); inputs.forEach((input) => { input.setAttribute(PHX_READONLY, input.readOnly); input.readOnly = true; if (input.files) { input.setAttribute(PHX_DISABLED, input.disabled); input.disabled = true; } }); const formEls = disables.concat(buttons).concat(inputs).map((el) => { return { el, loading: true, lock: true }; }); const els = [{ el: formEl, loading: true, lock: false }].concat(formEls).reverse(); return this.putRef(els, phxEvent, "submit", opts); } pushFormSubmit(formEl, targetCtx, phxEvent, submitter, opts, onReply) { const refGenerator = (maybePayload) => this.disableForm(formEl, phxEvent, { ...opts, form: formEl, payload: maybePayload?.payload, submitter }); dom_default.putPrivate(formEl, "submitter", submitter); const cid = this.targetComponentID(formEl, targetCtx); if (LiveUploader.hasUploadsInProgress(formEl)) { const [ref, _els] = refGenerator(); const push = () => this.pushFormSubmit( formEl, targetCtx, phxEvent, submitter, opts, onReply ); return this.scheduleSubmit(formEl, ref, opts, push); } else if (LiveUploader.inputsAwaitingPreflight(formEl).length > 0) { const [ref, els] = refGenerator(); const proxyRefGen = () => [ref, els, opts]; this.uploadFiles(formEl, phxEvent, targetCtx, ref, cid, (_uploads) => { if (LiveUploader.inputsAwaitingPreflight(formEl).length > 0) { return this.undoRefs(ref, phxEvent); } const meta = this.extractMeta(formEl, {}, opts.value); const formData = this.serializeForm(formEl, { submitter }); this.pushWithReply(proxyRefGen, "event", { type: "form", event: phxEvent, value: formData, meta, cid }).then(({ resp }) => onReply(resp)).catch((error) => logError("Failed to push form submit", error)); }); } else if (!(formEl.hasAttribute(PHX_REF_SRC) && formEl.classList.contains("phx-submit-loading"))) { const meta = this.extractMeta(formEl, {}, opts.value); const formData = this.serializeForm(formEl, { submitter }); this.pushWithReply(refGenerator, "event", { type: "form", event: phxEvent, value: formData, meta, cid }).then(({ resp }) => onReply(resp)).catch((error) => logError("Failed to push form submit", error)); } } uploadFiles(formEl, phxEvent, targetCtx, ref, cid, onComplete) { const joinCountAtUpload = this.joinCount; const inputEls = LiveUploader.activeFileInputs(formEl); let numFileInputsInProgress = inputEls.length; inputEls.forEach((inputEl) => { const uploader = new LiveUploader(inputEl, this, () => { numFileInputsInProgress--; if (numFileInputsInProgress === 0) { onComplete(); } }); const entries = uploader.entries().map((entry) => entry.toPreflightPayload()); if (entries.length === 0) { numFileInputsInProgress--; return; } const payload = { ref: inputEl.getAttribute(PHX_UPLOAD_REF), entries, cid: this.targetComponentID(inputEl.form, targetCtx) }; this.log("upload", () => ["sending preflight request", payload]); this.pushWithReply(null, "allow_upload", payload).then(({ resp }) => { this.log("upload", () => ["got preflight response", resp]); uploader.entries().forEach((entry) => { if (resp.entries && !resp.entries[entry.ref]) { this.handleFailedEntryPreflight( entry.ref, "failed preflight", uploader ); } }); if (resp.error || Object.keys(resp.entries).length === 0) { this.undoRefs(ref, phxEvent); const errors = resp.error || []; errors.map(([entry_ref, reason]) => { this.handleFailedEntryPreflight(entry_ref, reason, uploader); }); } else { const onError = (callback) => { this.channel.onError(() => { if (this.joinCount === joinCountAtUpload) { callback(); } }); }; uploader.initAdapterUpload(resp, onError, this.liveSocket); } }).catch((error) => logError("Failed to push upload", error)); }); } handleFailedEntryPreflight(uploadRef, reason, uploader) { if (uploader.isAutoUpload()) { const entry = uploader.entries().find((entry2) => entry2.ref === uploadRef.toString()); if (entry) { entry.cancel(); } } else { uploader.entries().map((entry) => entry.cancel()); } this.log("upload", () => [`error for entry ${uploadRef}`, reason]); } dispatchUploads(targetCtx, name, filesOrBlobs) { const targetElement = this.targetCtxElement(targetCtx) || this.el; const inputs = dom_default.findUploadInputs(targetElement).filter( (el) => el.name === name ); if (inputs.length === 0) { logError(`no live file inputs found matching the name "${name}"`); } else if (inputs.length > 1) { logError(`duplicate live file inputs found matching the name "${name}"`); } else { dom_default.dispatchEvent(inputs[0], PHX_TRACK_UPLOADS, { detail: { files: filesOrBlobs } }); } } targetCtxElement(targetCtx) { if (isCid(targetCtx)) { const [target] = dom_default.findComponentNodeList(this.id, targetCtx); return target; } else if (targetCtx) { return targetCtx; } else { return null; } } pushFormRecovery(oldForm, newForm, templateDom, callback) { const phxChange = this.binding("change"); const phxTarget = newForm.getAttribute(this.binding("target")) || newForm; const phxEvent = newForm.getAttribute(this.binding(PHX_AUTO_RECOVER)) || newForm.getAttribute(this.binding("change")); const inputs = Array.from(oldForm.elements).filter( (el) => dom_default.isFormInput(el) && el.name && !el.hasAttribute(phxChange) ); if (inputs.length === 0) { callback(); return; } inputs.forEach( (input2) => input2.hasAttribute(PHX_UPLOAD_REF) && LiveUploader.clearFiles(input2) ); const input = inputs.find((el) => el.type !== "hidden") || inputs[0]; let pending = 0; this.withinTargets( phxTarget, (targetView, targetCtx) => { const cid = this.targetComponentID(newForm, targetCtx); pending++; let e = new CustomEvent("phx:form-recovery", { detail: { sourceElement: oldForm } }); js_default.exec(e, "change", phxEvent, this, input, [ "push", { _target: input.name, targetView, targetCtx, newCid: cid, callback: () => { pending--; if (pending === 0) { callback(); } } } ]); }, templateDom ); } pushLinkPatch(e, href, targetEl, callback) { const linkRef = this.liveSocket.setPendingLink(href); const loading = e.isTrusted && e.type !== "popstate"; const refGen = targetEl ? () => this.putRef( [{ el: targetEl, loading, lock: true }], null, "click" ) : null; const fallback = () => this.liveSocket.redirect(window.location.href); const url = href.startsWith("/") ? `${location.protocol}//${location.host}${href}` : href; this.pushWithReply(refGen, "live_patch", { url }).then( ({ resp }) => { this.liveSocket.requestDOMUpdate(() => { if (resp.link_redirect) { this.liveSocket.replaceMain(href, null, callback, linkRef); } else if (resp.redirect) { return; } else { if (this.liveSocket.commitPendingLink(linkRef)) { this.href = href; } this.applyPendingUpdates(); callback && callback(linkRef); } }); }, ({ error: _error, timeout: _timeout }) => fallback() ); } getFormsForRecovery() { if (this.joinCount === 0) { return {}; } const phxChange = this.binding("change"); return dom_default.all( document, `#${CSS.escape(this.id)} form[${phxChange}], [${PHX_TELEPORTED_REF}="${CSS.escape(this.id)}"] form[${phxChange}]` ).filter((form) => form.id).filter((form) => form.elements.length > 0).filter( (form) => form.getAttribute(this.binding(PHX_AUTO_RECOVER)) !== "ignore" ).map((form) => { const clonedForm = form.cloneNode(true); morphdom_esm_default(clonedForm, form, { onBeforeElUpdated: (fromEl, toEl) => { dom_default.copyPrivates(fromEl, toEl); if (fromEl.getAttribute("form") === form.id) { fromEl.parentNode.removeChild(fromEl); return false; } return true; } }); const externalElements = document.querySelectorAll( `[form="${CSS.escape(form.id)}"]` ); Array.from(externalElements).forEach((el) => { const clonedEl = ( /** @type {HTMLElement} */ el.cloneNode(true) ); morphdom_esm_default(clonedEl, el); dom_default.copyPrivates(clonedEl, el); clonedEl.removeAttribute("form"); clonedForm.appendChild(clonedEl); }); return clonedForm; }).reduce((acc, form) => { acc[form.id] = form; return acc; }, {}); } maybePushComponentsDestroyed(destroyedCIDs) { let willDestroyCIDs = destroyedCIDs.filter((cid) => { return dom_default.findComponentNodeList(this.id, cid).length === 0; }); const onError = (error) => { if (!this.isDestroyed()) { logError("Failed to push components destroyed", error); } }; if (willDestroyCIDs.length > 0) { willDestroyCIDs.forEach((cid) => this.rendered.resetRender(cid)); this.pushWithReply(null, "cids_will_destroy", { cids: willDestroyCIDs }).then(() => { this.liveSocket.requestDOMUpdate(() => { let completelyDestroyCIDs = willDestroyCIDs.filter((cid) => { return dom_default.findComponentNodeList(this.id, cid).length === 0; }); if (completelyDestroyCIDs.length > 0) { this.pushWithReply(null, "cids_destroyed", { cids: completelyDestroyCIDs }).then(({ resp }) => { this.rendered.pruneCIDs(resp.cids); }).catch(onError); } }); }).catch(onError); } } ownsElement(el) { let parentViewEl = dom_default.closestViewEl(el); return el.getAttribute(PHX_PARENT_ID) === this.id || parentViewEl && parentViewEl.id === this.id || !parentViewEl && this.isDead; } submitForm(form, targetCtx, phxEvent, submitter, opts = {}) { dom_default.putPrivate(form, PHX_HAS_SUBMITTED, true); const inputs = Array.from(form.elements); inputs.forEach((input) => dom_default.putPrivate(input, PHX_HAS_SUBMITTED, true)); this.liveSocket.blurActiveElement(this); this.pushFormSubmit(form, targetCtx, phxEvent, submitter, opts, () => { this.liveSocket.restorePreviouslyActiveFocus(); }); } binding(kind) { return this.liveSocket.binding(kind); } // phx-portal pushPortalElementId(id) { this.portalElementIds.add(id); } dropPortalElementId(id) { this.portalElementIds.delete(id); } destroyPortalElements() { if (!this.liveSocket.unloaded) { this.portalElementIds.forEach((id) => { const el = document.getElementById(id); if (el) { el.remove(); } }); } } }; // js/phoenix_live_view/live_socket.js var isUsedInput = (el) => dom_default.isUsedInput(el); var LiveSocket = class { constructor(url, phxSocket, opts = {}) { this.unloaded = false; if (!phxSocket || phxSocket.constructor.name === "Object") { throw new Error(` a phoenix Socket must be provided as the second argument to the LiveSocket constructor. For example: import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" let liveSocket = new LiveSocket("/live", Socket, {...}) `); } this.socket = new phxSocket(url, opts); this.bindingPrefix = opts.bindingPrefix || BINDING_PREFIX; this.opts = opts; this.params = closure(opts.params || {}); this.viewLogger = opts.viewLogger; this.metadataCallbacks = opts.metadata || {}; this.defaults = Object.assign(clone(DEFAULTS), opts.defaults || {}); this.prevActive = null; this.silenced = false; this.main = null; this.outgoingMainEl = null; this.clickStartedAtTarget = null; this.linkRef = 1; this.roots = {}; this.href = window.location.href; this.pendingLink = null; this.currentLocation = clone(window.location); this.hooks = opts.hooks || {}; this.uploaders = opts.uploaders || {}; this.loaderTimeout = opts.loaderTimeout || LOADER_TIMEOUT; this.disconnectedTimeout = opts.disconnectedTimeout || DISCONNECTED_TIMEOUT; this.reloadWithJitterTimer = null; this.maxReloads = opts.maxReloads || MAX_RELOADS; this.reloadJitterMin = opts.reloadJitterMin || RELOAD_JITTER_MIN; this.reloadJitterMax = opts.reloadJitterMax || RELOAD_JITTER_MAX; this.failsafeJitter = opts.failsafeJitter || FAILSAFE_JITTER; this.localStorage = opts.localStorage || window.localStorage; this.sessionStorage = opts.sessionStorage || window.sessionStorage; this.boundTopLevelEvents = false; this.boundEventNames = /* @__PURE__ */ new Set(); this.blockPhxChangeWhileComposing = opts.blockPhxChangeWhileComposing || false; this.serverCloseRef = null; this.domCallbacks = Object.assign( { jsQuerySelectorAll: null, onPatchStart: closure(), onPatchEnd: closure(), onNodeAdded: closure(), onBeforeElUpdated: closure() }, opts.dom || {} ); this.transitions = new TransitionSet(); this.currentHistoryPosition = parseInt(this.sessionStorage.getItem(PHX_LV_HISTORY_POSITION)) || 0; window.addEventListener("pagehide", (_e) => { this.unloaded = true; }); this.socket.onOpen(() => { if (this.isUnloaded()) { window.location.reload(); } }); } // public version() { return "1.2.0-dev"; } isProfileEnabled() { return this.sessionStorage.getItem(PHX_LV_PROFILE) === "true"; } isDebugEnabled() { return this.sessionStorage.getItem(PHX_LV_DEBUG) === "true"; } isDebugDisabled() { return this.sessionStorage.getItem(PHX_LV_DEBUG) === "false"; } enableDebug() { this.sessionStorage.setItem(PHX_LV_DEBUG, "true"); } enableProfiling() { this.sessionStorage.setItem(PHX_LV_PROFILE, "true"); } disableDebug() { this.sessionStorage.setItem(PHX_LV_DEBUG, "false"); } disableProfiling() { this.sessionStorage.removeItem(PHX_LV_PROFILE); } enableLatencySim(upperBoundMs) { this.enableDebug(); console.log( "latency simulator enabled for the duration of this browser session. Call disableLatencySim() to disable" ); this.sessionStorage.setItem(PHX_LV_LATENCY_SIM, upperBoundMs); } disableLatencySim() { this.sessionStorage.removeItem(PHX_LV_LATENCY_SIM); } getLatencySim() { const str = this.sessionStorage.getItem(PHX_LV_LATENCY_SIM); return str ? parseInt(str) : null; } getSocket() { return this.socket; } connect() { if (window.location.hostname === "localhost" && !this.isDebugDisabled()) { this.enableDebug(); } const doConnect = () => { this.resetReloadStatus(); if (this.joinRootViews()) { this.bindTopLevelEvents(); this.socket.connect(); } else if (this.main) { this.socket.connect(); } else { this.bindTopLevelEvents({ dead: true }); } this.joinDeadView(); }; if (["complete", "loaded", "interactive"].indexOf(document.readyState) >= 0) { doConnect(); } else { document.addEventListener("DOMContentLoaded", () => doConnect()); } } disconnect(callback) { clearTimeout(this.reloadWithJitterTimer); if (this.serverCloseRef) { this.socket.off(this.serverCloseRef); this.serverCloseRef = null; } this.socket.disconnect(callback); } replaceTransport(transport) { clearTimeout(this.reloadWithJitterTimer); this.socket.replaceTransport(transport); this.connect(); } /** * @param {HTMLElement} el * @param {import("./js_commands").EncodedJS} encodedJS * @param {string | null} [eventType] */ execJS(el, encodedJS, eventType = null) { const e = new CustomEvent("phx:exec", { detail: { sourceElement: el } }); this.owner(el, (view) => js_default.exec(e, eventType, encodedJS, view, el)); } /** * Returns an object with methods to manipulate the DOM and execute JavaScript. * The applied changes integrate with server DOM patching. * * @returns {import("./js_commands").LiveSocketJSCommands} */ js() { return js_commands_default(this, "js"); } // private unload() { if (this.unloaded) { return; } if (this.main && this.isConnected()) { this.log(this.main, "socket", () => ["disconnect for page nav"]); } this.unloaded = true; this.destroyAllViews(); this.disconnect(); } triggerDOM(kind, args) { this.domCallbacks[kind](...args); } time(name, func) { if (!this.isProfileEnabled() || !console.time) { return func(); } console.time(name); const result = func(); console.timeEnd(name); return result; } log(view, kind, msgCallback) { if (this.viewLogger) { const [msg, obj] = msgCallback(); this.viewLogger(view, kind, msg, obj); } else if (this.isDebugEnabled()) { const [msg, obj] = msgCallback(); debug(view, kind, msg, obj); } } requestDOMUpdate(callback) { this.transitions.after(callback); } asyncTransition(promise) { this.transitions.addAsyncTransition(promise); } transition(time, onStart, onDone = function() { }) { this.transitions.addTransition(time, onStart, onDone); } onChannel(channel, event, cb) { channel.on(event, (data) => { const latency = this.getLatencySim(); if (!latency) { cb(data); } else { setTimeout(() => cb(data), latency); } }); } reloadWithJitter(view, log) { clearTimeout(this.reloadWithJitterTimer); this.disconnect(); const minMs = this.reloadJitterMin; const maxMs = this.reloadJitterMax; let afterMs = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs; const tries = browser_default.updateLocal( this.localStorage, window.location.pathname, CONSECUTIVE_RELOADS, 0, (count) => count + 1 ); if (tries >= this.maxReloads) { afterMs = this.failsafeJitter; } this.reloadWithJitterTimer = setTimeout(() => { if (view.isDestroyed() || view.isConnected()) { return; } view.destroy(); log ? log() : this.log(view, "join", () => [ `encountered ${tries} consecutive reloads` ]); if (tries >= this.maxReloads) { this.log(view, "join", () => [ `exceeded ${this.maxReloads} consecutive reloads. Entering failsafe mode` ]); } if (this.hasPendingLink()) { window.location = this.pendingLink; } else { window.location.reload(); } }, afterMs); } getHookDefinition(name) { if (!name) { return; } return this.maybeInternalHook(name) || this.hooks[name] || this.maybeRuntimeHook(name); } maybeInternalHook(name) { return name && name.startsWith("Phoenix.") && hooks_default[name.split(".")[1]]; } maybeRuntimeHook(name) { const runtimeHook = document.querySelector( `script[${PHX_RUNTIME_HOOK}="${CSS.escape(name)}"]` ); if (!runtimeHook) { return; } let callbacks = window[`phx_hook_${name}`]; if (!callbacks || typeof callbacks !== "function") { logError("a runtime hook must be a function", runtimeHook); return; } const hookDefiniton = callbacks(); if (hookDefiniton && (typeof hookDefiniton === "object" || typeof hookDefiniton === "function")) { return hookDefiniton; } logError( "runtime hook must return an object with hook callbacks or an instance of ViewHook", runtimeHook ); } isUnloaded() { return this.unloaded; } isConnected() { return this.socket.isConnected(); } getBindingPrefix() { return this.bindingPrefix; } binding(kind) { return `${this.getBindingPrefix()}${kind}`; } channel(topic, params) { return this.socket.channel(topic, params); } joinDeadView() { const body = document.body; if (body && !this.isPhxView(body) && !this.isPhxView(document.firstElementChild)) { const view = this.newRootView(body); view.setHref(this.getHref()); view.joinDead(); if (!this.main) { this.main = view; } window.requestAnimationFrame(() => { view.execNewMounted(); this.maybeScroll(history.state?.scroll); }); } } joinRootViews() { let rootsFound = false; dom_default.all( document, `${PHX_VIEW_SELECTOR}:not([${PHX_PARENT_ID}])`, (rootEl) => { if (!this.getRootById(rootEl.id)) { const view = this.newRootView(rootEl); if (!dom_default.isPhxSticky(rootEl)) { view.setHref(this.getHref()); } view.join(); if (rootEl.hasAttribute(PHX_MAIN)) { this.main = view; } } rootsFound = true; } ); return rootsFound; } redirect(to, flash, reloadToken) { if (reloadToken) { browser_default.setCookie(PHX_RELOAD_STATUS, reloadToken, 60); } this.unload(); browser_default.redirect(to, flash); } replaceMain(href, flash, callback = null, linkRef = this.setPendingLink(href)) { const liveReferer = this.currentLocation.href; this.outgoingMainEl = this.outgoingMainEl || this.main.el; const stickies = dom_default.findPhxSticky(document) || []; const removeEls = dom_default.all( this.outgoingMainEl, `[${this.binding("remove")}]` ).filter((el) => !dom_default.isChildOfAny(el, stickies)); const newMainEl = dom_default.cloneNode(this.outgoingMainEl, ""); this.main.showLoader(this.loaderTimeout); this.main.destroy(); this.main = this.newRootView(newMainEl, flash, liveReferer); this.main.setRedirect(href); this.transitionRemoves(removeEls); this.main.join((joinCount, onDone) => { if (joinCount === 1 && this.commitPendingLink(linkRef)) { this.requestDOMUpdate(() => { removeEls.forEach((el) => el.remove()); stickies.forEach((el) => newMainEl.appendChild(el)); this.outgoingMainEl.replaceWith(newMainEl); this.outgoingMainEl = null; callback && callback(linkRef); onDone(); }); } }); } transitionRemoves(elements, callback) { const removeAttr = this.binding("remove"); const silenceEvents = (e) => { e.preventDefault(); e.stopImmediatePropagation(); }; elements.forEach((el) => { for (const event of this.boundEventNames) { el.addEventListener(event, silenceEvents, true); } this.execJS(el, el.getAttribute(removeAttr), "remove"); }); this.requestDOMUpdate(() => { elements.forEach((el) => { for (const event of this.boundEventNames) { el.removeEventListener(event, silenceEvents, true); } }); callback && callback(); }); } isPhxView(el) { return el.getAttribute && el.getAttribute(PHX_SESSION) !== null; } newRootView(el, flash, liveReferer) { const view = new View(el, this, null, flash, liveReferer); this.roots[view.id] = view; return view; } owner(childEl, callback) { let view; const viewEl = dom_default.closestViewEl(childEl); if (viewEl) { view = this.getViewByEl(viewEl); } else { if (!childEl.isConnected) { return null; } view = this.main; } return view && callback ? callback(view) : view; } withinOwners(childEl, callback) { this.owner(childEl, (view) => callback(view, childEl)); } getViewByEl(el) { const rootId = el.getAttribute(PHX_ROOT_ID); return maybe( this.getRootById(rootId), (root) => root.getDescendentByEl(el) ); } getRootById(id) { return this.roots[id]; } destroyAllViews() { for (const id in this.roots) { this.roots[id].destroy(); delete this.roots[id]; } this.main = null; } destroyViewByEl(el) { const root = this.getRootById(el.getAttribute(PHX_ROOT_ID)); if (root && root.id === el.id) { root.destroy(); delete this.roots[root.id]; } else if (root) { root.destroyDescendent(el.id); } } getActiveElement() { return document.activeElement; } dropActiveElement(view) { if (this.prevActive && view.ownsElement(this.prevActive)) { this.prevActive = null; } } restorePreviouslyActiveFocus() { if (this.prevActive && this.prevActive !== document.body && this.prevActive instanceof HTMLElement) { this.prevActive.focus(); } } blurActiveElement() { this.prevActive = this.getActiveElement(); if (this.prevActive !== document.body && this.prevActive instanceof HTMLElement) { this.prevActive.blur(); } } /** * @param {{dead?: boolean}} [options={}] */ bindTopLevelEvents({ dead } = {}) { if (this.boundTopLevelEvents) { return; } this.boundTopLevelEvents = true; this.serverCloseRef = this.socket.onClose((event) => { if (event && event.code === 1e3 && this.main) { return this.reloadWithJitter(this.main); } }); document.body.addEventListener("click", function() { }); window.addEventListener( "pageshow", (e) => { if (e.persisted) { this.getSocket().disconnect(); this.withPageLoading({ to: window.location.href, kind: "redirect" }); window.location.reload(); } }, true ); if (!dead) { this.bindNav(); } this.bindClicks(); if (!dead) { this.bindForms(); } this.bind( { keyup: "keyup", keydown: "keydown" }, (e, type, view, targetEl, phxEvent, _phxTarget) => { const matchKey = targetEl.getAttribute(this.binding(PHX_KEY)); const pressedKey = e.key && e.key.toLowerCase(); if (matchKey && matchKey.toLowerCase() !== pressedKey) { return; } const data = { key: e.key, ...this.eventMeta(type, e, targetEl) }; js_default.exec(e, type, phxEvent, view, targetEl, ["push", { data }]); } ); this.bind( { blur: "focusout", focus: "focusin" }, (e, type, view, targetEl, phxEvent, phxTarget) => { if (!phxTarget) { const data = { key: e.key, ...this.eventMeta(type, e, targetEl) }; js_default.exec(e, type, phxEvent, view, targetEl, ["push", { data }]); } } ); this.bind( { blur: "blur", focus: "focus" }, (e, type, view, targetEl, phxEvent, phxTarget) => { if (phxTarget === "window") { const data = this.eventMeta(type, e, targetEl); js_default.exec(e, type, phxEvent, view, targetEl, ["push", { data }]); } } ); this.on("dragover", (e) => e.preventDefault()); this.on("dragenter", (e) => { const dropzone = closestPhxBinding( e.target, this.binding(PHX_DROP_TARGET) ); if (!dropzone || !(dropzone instanceof HTMLElement)) { return; } if (eventContainsFiles(e)) { this.js().addClass(dropzone, PHX_DROP_TARGET_ACTIVE_CLASS); } }); this.on("dragleave", (e) => { const dropzone = closestPhxBinding( e.target, this.binding(PHX_DROP_TARGET) ); if (!dropzone || !(dropzone instanceof HTMLElement)) { return; } const rect = dropzone.getBoundingClientRect(); if (e.clientX <= rect.left || e.clientX >= rect.right || e.clientY <= rect.top || e.clientY >= rect.bottom) { this.js().removeClass(dropzone, PHX_DROP_TARGET_ACTIVE_CLASS); } }); this.on("drop", (e) => { e.preventDefault(); const dropzone = closestPhxBinding( e.target, this.binding(PHX_DROP_TARGET) ); if (!dropzone || !(dropzone instanceof HTMLElement)) { return; } this.js().removeClass(dropzone, PHX_DROP_TARGET_ACTIVE_CLASS); const dropTargetId = dropzone.getAttribute(this.binding(PHX_DROP_TARGET)); const dropTarget = dropTargetId && document.getElementById(dropTargetId); const files = Array.from(e.dataTransfer.files || []); if (!dropTarget || !(dropTarget instanceof HTMLInputElement) || dropTarget.disabled || files.length === 0 || !(dropTarget.files instanceof FileList)) { return; } LiveUploader.trackFiles(dropTarget, files, e.dataTransfer); dropTarget.dispatchEvent(new Event("input", { bubbles: true })); }); this.on(PHX_TRACK_UPLOADS, (e) => { const uploadTarget = e.target; if (!dom_default.isUploadInput(uploadTarget)) { return; } const files = Array.from(e.detail.files || []).filter( (f) => f instanceof File || f instanceof Blob ); LiveUploader.trackFiles(uploadTarget, files); uploadTarget.dispatchEvent(new Event("input", { bubbles: true })); }); } eventMeta(eventName, e, targetEl) { const callback = this.metadataCallbacks[eventName]; return callback ? callback(e, targetEl) : {}; } setPendingLink(href) { this.linkRef++; this.pendingLink = href; this.resetReloadStatus(); return this.linkRef; } // anytime we are navigating or connecting, drop reload cookie in case // we issue the cookie but the next request was interrupted and the server never dropped it resetReloadStatus() { browser_default.deleteCookie(PHX_RELOAD_STATUS); } commitPendingLink(linkRef) { if (this.linkRef !== linkRef) { return false; } else { this.href = this.pendingLink; this.pendingLink = null; return true; } } getHref() { return this.href; } hasPendingLink() { return !!this.pendingLink; } bind(events, callback) { for (const event in events) { const browserEventName = events[event]; this.on(browserEventName, (e) => { const binding = this.binding(event); const windowBinding = this.binding(`window-${event}`); const targetPhxEvent = e.target.getAttribute && e.target.getAttribute(binding); if (targetPhxEvent) { this.debounce(e.target, e, browserEventName, () => { this.withinOwners(e.target, (view) => { callback(e, event, view, e.target, targetPhxEvent, null); }); }); } else { dom_default.all(document, `[${windowBinding}]`, (el) => { const phxEvent = el.getAttribute(windowBinding); this.debounce(el, e, browserEventName, () => { this.withinOwners(el, (view) => { callback(e, event, view, el, phxEvent, "window"); }); }); }); } }); } } bindClicks() { this.on("mousedown", (e) => this.clickStartedAtTarget = e.target); this.bindClick("click", "click"); } bindClick(eventName, bindingName) { const click = this.binding(bindingName); window.addEventListener( eventName, (e) => { let target = null; if (e.detail === 0) this.clickStartedAtTarget = e.target; const clickStartedAtTarget = this.clickStartedAtTarget || e.target; target = closestPhxBinding(e.target, click); this.dispatchClickAway(e, clickStartedAtTarget); this.clickStartedAtTarget = null; const phxEvent = target && target.getAttribute(click); if (!phxEvent) { if (dom_default.isNewPageClick(e, window.location)) { this.unload(); } return; } if (target.getAttribute("href") === "#") { e.preventDefault(); } if (target.hasAttribute(PHX_REF_SRC)) { return; } this.debounce(target, e, "click", () => { this.withinOwners(target, (view) => { js_default.exec(e, "click", phxEvent, view, target, [ "push", { data: this.eventMeta("click", e, target) } ]); }); }); }, false ); } dispatchClickAway(e, clickStartedAt) { const phxClickAway = this.binding("click-away"); const portal = clickStartedAt.closest(`[${PHX_TELEPORTED_SRC}]`); const portalStartedAt = portal && dom_default.byId(portal.getAttribute(PHX_TELEPORTED_SRC)); dom_default.all(document, `[${phxClickAway}]`, (el) => { let startedAt = clickStartedAt; if (portal && !portal.contains(el)) { startedAt = portalStartedAt; } if (!(el.isSameNode(startedAt) || el.contains(startedAt) || // When clicking a link with custom method, // phoenix_html triggers a click on a submit button // of a hidden form appended to the body. For such cases // where the clicked target is hidden, we skip click-away. // // Also, when we have a portal, we don't want to check the visibility // of the portal source, as it's a