Skip to content

Commit

Permalink
use regular DOM patching when unlocking cloned trees (#3652)
Browse files Browse the repository at this point in the history
* use regular DOM patching when unlocking cloned trees

Fixes #3647.
Fixes #3651.
Relates to: #3591.

Issue #3647 was caused by LV uploads relying on DOM attributes like
data-phx-active-refs="1,2,3" being in the DOM to track uploads. The problem
arises when the upload input is inside a form that is locked due to another,
unrelated change. The following would happen:

1. User clicks on a button to upload a file
2. A hook calls this.uploadTo(), which triggers a validate event and
   locks the form
3. The hook also changes another input in ANOTHER form, which also
   triggers a separate validate event and locks the form
4. The first validate completes, but the attributes are patched to the
   clone of the form, the real DOM does not contain it.
5. LiveView tries to start uploading, but does not find any active files.

This case is special in that the upload input belongs to a separate form
(<input form="form-id">), so it's not the upload input's form that is locked.

The fix for this is to only try to upload when the closest locked element
starting from the upload input is unlocked.

There was a separate problem though: LiveView relied on a separate DOM
patching mechanism when patching cloned trees that did not fully share
the same logic as the default DOMPatch. In this case, it did not merge
data-attributes on elements that are ignored
(phx-update="ignore" / data-phx-update="ignore"), therefore, the first
fix alone would not work.

Now, we use the same patching logic for regular DOM patches and element
unlocks.

This difference in DOM patching logic also caused other issues, notably:
   * #3591
   * #3651

* e2e tests
  • Loading branch information
SteffenDE authored Jan 26, 2025
1 parent 04aa842 commit 22ff924
Show file tree
Hide file tree
Showing 9 changed files with 369 additions and 50 deletions.
5 changes: 5 additions & 0 deletions assets/js/phoenix_live_view/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
PHX_PARENT_ID,
PHX_PRIVATE,
PHX_REF_SRC,
PHX_REF_LOCK,
PHX_PENDING_ATTRS,
PHX_ROOT_ID,
PHX_SESSION,
Expand Down Expand Up @@ -545,6 +546,10 @@ let DOM = {
if(!ops){ return }

ops.forEach(([name, op, _stashed]) => this.putSticky(el, name, op))
},

isLocked(el){
return el.hasAttribute && el.hasAttribute(PHX_REF_LOCK)
}
}

Expand Down
42 changes: 6 additions & 36 deletions assets/js/phoenix_live_view/dom_patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,40 +27,7 @@ import DOMPostMorphRestorer from "./dom_post_morph_restorer"
import morphdom from "morphdom"

export default class DOMPatch {
static patchWithClonedTree(container, clonedTree, liveSocket){
let focused = liveSocket.getActiveElement()
let {selectionStart, selectionEnd} = focused && DOM.hasSelectionRange(focused) ? focused : {}
let phxUpdate = liveSocket.binding(PHX_UPDATE)
let externalFormTriggered = null

morphdom(container, clonedTree, {
childrenOnly: false,
onBeforeElUpdated: (fromEl, toEl) => {
DOM.syncPendingAttrs(fromEl, toEl)
// we cannot morph locked children
if(!container.isSameNode(fromEl) && fromEl.hasAttribute(PHX_REF_LOCK)){ return false }
if(DOM.isIgnored(fromEl, phxUpdate)){ return false }
if(focused && focused.isSameNode(fromEl) && DOM.isFormInput(fromEl)){
DOM.mergeFocusedInput(fromEl, toEl)
return false
}
if(DOM.isNowTriggerFormExternal(toEl, liveSocket.binding(PHX_TRIGGER_ACTION))){
externalFormTriggered = toEl
}
}
})

if(externalFormTriggered){
liveSocket.unload()
// use prototype's submit in case there's a form control with name or id of "submit"
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit
Object.getPrototypeOf(externalFormTriggered).submit.call(externalFormTriggered)
}

liveSocket.silenceEvents(() => DOM.restoreFocus(focused, selectionStart, selectionEnd))
}

constructor(view, container, id, html, streams, targetCID){
constructor(view, container, id, html, streams, targetCID, opts={}){
this.view = view
this.liveSocket = view.liveSocket
this.container = container
Expand All @@ -80,6 +47,8 @@ export default class DOMPatch {
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) }
Expand Down Expand Up @@ -116,7 +85,7 @@ export default class DOMPatch {

let externalFormTriggered = null

function morph(targetContainer, source, withChildren=false){
function morph(targetContainer, source, withChildren=this.withChildren){
let morphCallbacks = {
// normally, we are running with childrenOnly, as the patch HTML for a LV
// does not include the LV attrs (data-phx-session, etc.)
Expand Down Expand Up @@ -248,7 +217,8 @@ export default class DOMPatch {
// apply any changes that happened while the element was locked.
let isFocusedFormEl = focused && fromEl.isSameNode(focused) && DOM.isFormInput(fromEl)
let focusedSelectChanged = isFocusedFormEl && this.isChangedSelect(fromEl, toEl)
if(fromEl.hasAttribute(PHX_REF_SRC)){
// only perform the clone step if this is not a patch that unlocks
if(fromEl.hasAttribute(PHX_REF_SRC) && fromEl.getAttribute(PHX_REF_LOCK) != this.undoRef){
if(DOM.isUploadInput(fromEl)){
DOM.mergeAttrs(fromEl, toEl, {isIgnored: true})
this.trackBefore("updated", fromEl, toEl)
Expand Down
9 changes: 9 additions & 0 deletions assets/js/phoenix_live_view/element_ref.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ import {
import DOM from "./dom"

export default class ElementRef {
static onUnlock(el, callback){
if(!DOM.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
Expand Down
32 changes: 19 additions & 13 deletions assets/js/phoenix_live_view/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -974,11 +974,12 @@ export default class View {
let elRef = new ElementRef(el)

elRef.maybeUndo(ref, phxEvent, clonedTree => {
let hook = this.triggerBeforeUpdateHook(el, clonedTree)
DOMPatch.patchWithClonedTree(el, clonedTree, this.liveSocket)
// we need to perform a full patch on unlocked elements
// to perform all the necessary logic (like calling updated for hooks, etc.)
let patch = new DOMPatch(this, el, this.id, clonedTree, [], null, {undoRef: ref})
const phxChildrenAdded = this.performPatch(patch, true)
DOM.all(el, `[${PHX_REF_SRC}="${this.refSrc()}"]`, child => this.undoElRef(child, ref, phxEvent))
this.execNewMounted(el)
if(hook){ hook.__updated() }
if(phxChildrenAdded){ this.joinNewChildren() }
})
}

Expand Down Expand Up @@ -1185,15 +1186,20 @@ export default class View {
}
this.pushWithReply(refGenerator, "event", event).then(({resp}) => {
if(DOM.isUploadInput(inputEl) && DOM.isAutoUpload(inputEl)){
if(LiveUploader.filesAwaitingPreflight(inputEl).length > 0){
let [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)
})
}
// the element could be inside a locked parent for other unrelated changes;
// we can only start uploads when the tree is unlocked and the
// necessary data attributes are set in the real DOM
ElementRef.onUnlock(inputEl, () => {
if(LiveUploader.filesAwaitingPreflight(inputEl).length > 0){
let [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)
}
Expand Down
186 changes: 186 additions & 0 deletions test/e2e/support/issues/issue_3647.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
defmodule Phoenix.LiveViewTest.E2E.Issue3647Live do
# https://github.com/phoenixframework/phoenix_live_view/issues/3647
#
# The above issue was caused by LV uploads relying on DOM attributes like
# data-phx-active-refs="1,2,3" being in the DOM to track uploads. The problem
# arises when the upload input is inside a form that is locked due to another,
# unrelated change. The following would happen:
#
# 1. User clicks on a button to upload a file
# 2. A hook calls this.uploadTo(), which triggers a validate event and locks the form
# 3. The hook also changes another input in ANOTHER form, which also triggers a separate validate
# event and locks the form
# 4. The first validate completes, but the attributes are patched to the clone of the form,
# the real DOM does not contain it.
# 5. LiveView tries to start uploading, but does not find any active files.
#
# This case is special in that the upload input belongs to a separate form (<input form="form-id">),
# so it's not the upload input's form that is locked.
#
# The fix for this is to only try to upload when the closest locked element starting from
# the upload input is unlocked.
#
# There was a separate problem though: LiveView relied on a separate DOM patching mechanism
# when patching cloned trees that did not fully share the same logic as the default DOMPatch.
# In this case, it did not merge data-attributes on elements that are ignored (phx-update="ignore" / data-phx-update="ignore"),
# therefore, the first fix alone would not work.
# Now, we use the same patching logic for regular DOM patches and element unlocks.
#
# This difference in DOM patching logic also caused other issues, notably:
# * https://github.com/phoenixframework/phoenix_live_view/issues/3591
# * https://github.com/phoenixframework/phoenix_live_view/issues/3651
use Phoenix.LiveView

defmodule User do
import Ecto.Changeset
use Ecto.Schema

schema "users" do
field(:name)
end

def change_user(user, params \\ %{}) do
user |> cast(params, [:name])
end
end

def render("live.html", assigns) do
~H"""
<meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} />
<script src="/assets/phoenix/phoenix.min.js">
</script>
<script type="module">
import {LiveSocket} from "/assets/phoenix_live_view/phoenix_live_view.esm.js"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
let liveSocket = new LiveSocket("/live", window.Phoenix.Socket, {params: {_csrf_token: csrfToken}, hooks: {
JsUpload: {
mounted() {
this.el.addEventListener("click", () => {
const fillBefore = "before" in this.el.dataset
if (fillBefore) this.fill_input()
this.js_upload()
if (!fillBefore) this.fill_input()
})
},
js_upload() {
const content = "x".repeat(1024).repeat(1024)
const file = new File([content], "1mb_of_x.txt", { type: "text/plain" })
const input = document.querySelector("input[type=file]")
this.uploadTo(input.form, input.name, [file])
},
fill_input() {
const input = document.querySelector("input[type=text]")
input.value = input.value + input.value.length
const event = new Event("input", { bubbles: true })
input.dispatchEvent(event)
}
}
}})
liveSocket.connect()
window.liveSocket = liveSocket
</script>
<style>
* { font-size: 1.1em; }
</style>
<main>{@inner_content}</main>
"""
end

@impl Phoenix.LiveView
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(form: to_form(User.change_user(%User{})))
|> assign(:uploaded_files, [])
|> allow_upload(:avatar,
accept: ~w(.txt .md),
max_entries: 2,
auto_upload: true,
progress: &handle_progress/3
), layout: {__MODULE__, :live}}
end

# with auto_upload: true we can consume files here
defp handle_progress(:avatar, entry, socket) do
if entry.done? do
uuid =
consume_uploaded_entry(socket, entry, fn _meta ->
{:ok, entry.uuid}
end)

{:noreply, update(socket, :uploaded_files, &[uuid | &1])}
else
{:noreply, socket}
end
end

@impl Phoenix.LiveView
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end

@impl Phoenix.LiveView
def handle_event("validate-user", %{"user" => params}, socket) do
form =
%User{}
|> User.change_user(params)
|> to_form(action: :validate)

{:noreply, assign(socket, form: form)}
end

def handle_event("validate", _params, socket) do
{:noreply, socket}
end

@impl Phoenix.LiveView
def handle_event("cancel-upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :avatar, ref)}
end

@impl Phoenix.LiveView
def render(assigns) do
~H"""
<.form for={@form} phx-change="validate-user" id="user-form">
<input id={@form[:name].id} name={@form[:name].name} value={@form[:name].value} type="text" />
<button id="x" type="button" phx-hook="JsUpload">
Upload then Input
</button>
<button id="y" type="button" phx-hook="JsUpload" data-before>
Input then Upload
</button>
<.live_file_input upload={@uploads.avatar} form="auto-form" />
</.form>
<form id="auto-form" phx-change="validate"></form>
<section class="pending-uploads" phx-drop-target={@uploads.avatar.ref} style="min-height: 100%;">
<h3>Pending Uploads ({length(@uploads.avatar.entries)})</h3>
<%= for entry <- @uploads[:avatar].entries do %>
<div>
<progress value={entry.progress} max="100">{entry.progress}%</progress>
<div>
{entry.uuid}<br />
<a
href="#"
phx-click="cancel-upload"
phx-value-ref={entry.ref}
class="upload-entry__cancel"
>
Cancel Upload
</a>
</div>
</div>
<% end %>
</section>
<ul>
<li :for={file <- @uploaded_files}><a href={file}>{Path.basename(file)}</a></li>
</ul>
"""
end
end
Loading

0 comments on commit 22ff924

Please sign in to comment.