diff --git a/assets/js/phoenix_live_view/dom_patch.js b/assets/js/phoenix_live_view/dom_patch.js index 18c6a6a396..fe93a977a6 100644 --- a/assets/js/phoenix_live_view/dom_patch.js +++ b/assets/js/phoenix_live_view/dom_patch.js @@ -28,7 +28,8 @@ import morphdom from "morphdom" export default class DOMPatch { static patchWithClonedTree(container, clonedTree, liveSocket){ - let activeElement = liveSocket.getActiveElement() + let focused = liveSocket.getActiveElement() + let {selectionStart, selectionEnd} = focused && DOM.hasSelectionRange(focused) ? focused : {} let phxUpdate = liveSocket.binding(PHX_UPDATE) morphdom(container, clonedTree, { @@ -38,12 +39,14 @@ export default class DOMPatch { // we cannot morph locked children if(!container.isSameNode(fromEl) && fromEl.hasAttribute(PHX_REF_LOCK)){ return false } if(DOM.isIgnored(fromEl, phxUpdate)){ return false } - if(activeElement && activeElement.isSameNode(fromEl) && DOM.isFormInput(fromEl)){ + if(focused && focused.isSameNode(fromEl) && DOM.isFormInput(fromEl)){ DOM.mergeFocusedInput(fromEl, toEl) return false } } }) + + liveSocket.silenceEvents(() => DOM.restoreFocus(focused, selectionStart, selectionEnd)) } constructor(view, container, id, html, streams, targetCID){ diff --git a/test/e2e/support/issues/issue_3448.ex b/test/e2e/support/issues/issue_3448.ex new file mode 100644 index 0000000000..2ddb878169 --- /dev/null +++ b/test/e2e/support/issues/issue_3448.ex @@ -0,0 +1,65 @@ +defmodule Phoenix.LiveViewTest.E2E.Issue3448Live do + # https://github.com/phoenixframework/phoenix_live_view/issues/3448 + + use Phoenix.LiveView + + alias Phoenix.LiveView.JS + + def mount(_params, _session, socket) do + form = to_form(%{"a" => []}) + + {:ok, assign_new(socket, :form, fn -> form end)} + end + + def render(assigns) do + ~H""" + <.form for={@form} id="my_form" phx-change="validate" class="flex flex-col gap-2"> + <.my_component> + <:left_content :for={value <- @form[:a].value || []}> +
<%= value %>
+ + + +
+ "[]"} + value="settings" + checked={"settings" in (@form[:a].value || [])} + phx-click={JS.dispatch("input") |> JS.focus(to: "#search")} + /> + + "[]"} + value="content" + checked={"content" in (@form[:a].value || [])} + phx-click={JS.dispatch("input") |> JS.focus(to: "#search")} + /> +
+ + """ + end + + def handle_event("validate", params, socket) do + {:noreply, assign(socket, form: to_form(params))} + end + + def handle_event("search", _params, socket) do + {:noreply, socket} + end + + slot :left_content + + defp my_component(assigns) do + ~H""" +
+
+ <%= render_slot(left_content) %> +
+ + +
+ """ + end +end diff --git a/test/e2e/test_helper.exs b/test/e2e/test_helper.exs index 44a2c9b0e9..a3bd917693 100644 --- a/test/e2e/test_helper.exs +++ b/test/e2e/test_helper.exs @@ -148,6 +148,7 @@ defmodule Phoenix.LiveViewTest.E2E.Router do live "/3194", Issue3194Live live "/3194/other", Issue3194Live.OtherLive live "/3378", Issue3378.HomeLive + live "/3448", Issue3448Live end end diff --git a/test/e2e/tests/issues/3448.spec.js b/test/e2e/tests/issues/3448.spec.js new file mode 100644 index 0000000000..ce2b2707e9 --- /dev/null +++ b/test/e2e/tests/issues/3448.spec.js @@ -0,0 +1,17 @@ +const { test, expect } = require("../../test-fixtures"); +const { syncLV } = require("../../utils"); + +// https://github.com/phoenixframework/phoenix_live_view/issues/3448 +test("focus is handled correctly when patching locked form", async ({ page }) => { + await page.goto("/issues/3448"); + await syncLV(page); + + await page.evaluate(() => window.liveSocket.enableLatencySim(500)); + + await page.locator("input[type=checkbox]").first().check(); + await expect(page.locator("input#search")).toBeFocused(); + await syncLV(page); + + // after the patch is applied, the input should still be focused + await expect(page.locator("input#search")).toBeFocused(); +});