Skip to content

Commit 62174d3

Browse files
authored
Add JS.ignore_attributes (#3765)
* Add `JS.ignore_attributes` Sometimes it is useful to ignore updates to specific attributes. One famous example is the "new"-ish HTML `dialog` element that relies on an `open` attribute to determine if it is open or closed. The same applies to `details` elements. Importantly, when opened by a user, the browser sets this attribute by itself and LiveView should not overwrite it on patches, otherwise it would accidentally close it again. Previously, the recommended way to handle such cases was to add a function to the `onBeforeElUpdated` dom option of the `liveSocket`. This is cumbersome though and especially for libraries leads to more friction, as more steps are necessary to install them. Now, one can use `JS.ignore_attributes` in instead, for example: ```heex <details phx-mounted={JS.ignore_attributes(["open"])}> <summary>...</summary> ... </details> ```` And then the details element will always retain the previous open state, regardless of what the server does. Relates to: #3741 Relates to: #2349 Relates to: #3574 * add test for wildcard * accept single attribute * fix precedence * add ignoreAttributes to this.js()
1 parent 71ac58b commit 62174d3

File tree

7 files changed

+171
-2
lines changed

7 files changed

+171
-2
lines changed

assets/js/phoenix_live_view/js.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ let JS = {
142142
this.toggleAttr(el, attr, val1, val2)
143143
},
144144

145+
exec_ignore_attrs(e, eventType, phxEvent, view, sourceEl, el, {attrs}){
146+
this.ignoreAttrs(el, attrs)
147+
},
148+
145149
exec_transition(e, eventType, phxEvent, view, sourceEl, el, {time, transition, blocking}){
146150
this.addOrRemoveClasses(el, [], [], transition, time, view, blocking)
147151
},
@@ -166,6 +170,23 @@ let JS = {
166170
this.setOrRemoveAttrs(el, [], [attr])
167171
},
168172

173+
ignoreAttrs(el, attrs){
174+
DOM.putPrivate(el, "JS:ignore_attrs", {apply: (fromEl, toEl) => {
175+
Array.from(fromEl.attributes).forEach(attr => {
176+
if(attrs.some(toIgnore => attr.name == toIgnore || toIgnore.includes("*") && attr.name.match(toIgnore) != null)){
177+
toEl.setAttribute(attr.name, attr.value)
178+
}
179+
})
180+
}})
181+
},
182+
183+
onBeforeElUpdated(fromEl, toEl){
184+
const ignoreAttrs = DOM.private(fromEl, "JS:ignore_attrs")
185+
if(ignoreAttrs){
186+
ignoreAttrs.apply(fromEl, toEl)
187+
}
188+
},
189+
169190
// utils for commands
170191

171192
show(eventType, view, el, display, transition, time, blocking){

assets/js/phoenix_live_view/js_commands.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,14 @@ export default (liveSocket, eventType) => {
230230
patch(href, opts = {}){
231231
let e = new CustomEvent("phx:exec")
232232
liveSocket.pushHistoryPatch(e, href, opts.replace ? "replace" : "push", null)
233-
}
233+
},
234+
235+
/**
236+
* Mark attributes as ignored, skipping them when patching the DOM.
237+
*
238+
* @param {HTMLElement} el - The element to toggle the attribute on.
239+
* @param {Array<string>|string} attrs - The attribute name or names to ignore.
240+
*/
241+
ignoreAttributes(el, attrs){ JS.ignoreAttrs(el, attrs) }
234242
}
235243
}

assets/js/phoenix_live_view/view.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,8 @@ export default class View {
504504
patch.before("updated", (fromEl, toEl) => {
505505
let hook = this.triggerBeforeUpdateHook(fromEl, toEl)
506506
if(hook){ updatedHookIds.add(fromEl.id) }
507+
// trigger JS specific update logic (for example for JS.ignore_attributes)
508+
JS.onBeforeElUpdated(fromEl, toEl)
507509
})
508510

509511
patch.after("updated", el => {

assets/test/view_test.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,71 @@ describe("View + DOM", function(){
727727
expect(childIds()).toEqual([1])
728728
})
729729
})
730+
731+
describe("JS integration", () => {
732+
test("ignore_attributes skips attributes on update", () => {
733+
let liveSocket = new LiveSocket("/live", Socket)
734+
let el = liveViewDOM()
735+
let updateDiff = {
736+
"0": " phx-mounted=\"[[&quot;ignore_attrs&quot;,{&quot;attrs&quot;:[&quot;open&quot;]}]]\"",
737+
"1": "0",
738+
"s": [
739+
"<details",
740+
">\n <summary>A</summary>\n <span>",
741+
"</span></details>"
742+
]
743+
}
744+
745+
let view = simulateJoinedView(el, liveSocket)
746+
view.applyDiff("update", updateDiff, ({diff, events}) => view.update(diff, events))
747+
748+
expect(view.el.firstChild.tagName).toBe("DETAILS")
749+
expect(view.el.firstChild.open).toBe(false)
750+
view.el.firstChild.open = true
751+
view.el.firstChild.setAttribute("data-foo", "bar")
752+
753+
// now update, the HTML patch would normally reset the open attribute
754+
view.applyDiff("update", {"1": "1"}, ({diff, events}) => view.update(diff, events))
755+
// open is ignored, so it is kept as is
756+
expect(view.el.firstChild.open).toBe(true)
757+
// foo is not ignored, so it is reset
758+
expect(view.el.firstChild.getAttribute("data-foo")).toBe(null)
759+
expect(view.el.firstChild.textContent.replace(/\s+/g, "")).toEqual("A1")
760+
})
761+
762+
test("ignore_attributes wildcard", () => {
763+
let liveSocket = new LiveSocket("/live", Socket)
764+
let el = liveViewDOM()
765+
let updateDiff = {
766+
"0": " phx-mounted=\"[[&quot;ignore_attrs&quot;,{&quot;attrs&quot;:[&quot;open&quot;,&quot;data-*&quot;]}]]\"",
767+
"1": " data-foo=\"foo\" data-bar=\"bar\"",
768+
"2": "0",
769+
"s": [
770+
"<details",
771+
"",
772+
">\n <summary>A</summary>\n <span>",
773+
"</span></details>"
774+
]
775+
}
776+
777+
let view = simulateJoinedView(el, liveSocket)
778+
view.applyDiff("update", updateDiff, ({diff, events}) => view.update(diff, events))
779+
780+
expect(view.el.firstChild.tagName).toBe("DETAILS")
781+
expect(view.el.firstChild.open).toBe(false)
782+
view.el.firstChild.open = true
783+
view.el.firstChild.setAttribute("data-foo", "bar")
784+
view.el.firstChild.setAttribute("data-other", "also kept")
785+
// apply diff
786+
view.applyDiff("update", {"1": "data-foo=\"foo\" data-bar=\"bar\" data-new=\"new\"", "2": "1"}, ({diff, events}) => view.update(diff, events))
787+
expect(view.el.firstChild.open).toBe(true)
788+
expect(view.el.firstChild.getAttribute("data-foo")).toBe("bar")
789+
expect(view.el.firstChild.getAttribute("data-bar")).toBe("bar")
790+
expect(view.el.firstChild.getAttribute("data-other")).toBe("also kept")
791+
expect(view.el.firstChild.getAttribute("data-new")).toBe("new")
792+
expect(view.el.firstChild.textContent.replace(/\s+/g, "")).toEqual("A1")
793+
})
794+
})
730795
})
731796

732797
let submitBefore

lib/phoenix_live_view/js.ex

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ defmodule Phoenix.LiveView.JS do
2525
* `set_attribute` - Set an attribute on elements
2626
* `remove_attribute` - Remove an attribute from elements
2727
* `toggle_attribute` - Sets or removes element attribute based on attribute presence.
28+
* `ignore_attributes` - Marks attributes as ignored, skipping them when patching the DOM.
2829
* `show` - Show elements, with optional transitions
2930
* `hide` - Hide elements, with optional transitions
3031
* `toggle` - Shows or hides elements based on visibility, with optional transitions
@@ -828,6 +829,56 @@ defmodule Phoenix.LiveView.JS do
828829
put_op(js, "toggle_attr", to: opts[:to], attr: [attr, val1, val2])
829830
end
830831

832+
@doc """
833+
Mark attributes as ignored, skipping them when patching the DOM.
834+
835+
Accepts a single attribute name or a list of attribute names.
836+
An asterisk `*` can be used as a wildcard.
837+
838+
Once set, the given attributes will not be patched across LiveView updates.
839+
This includes attributes that are removed by the server.
840+
841+
If you need to "unmark" an attribute, you need to call `ignore_attributes/1` again
842+
with an updated list of attributes.
843+
844+
This is mostly useful in combination with the `phx-mounted` binding, for example:
845+
846+
```heex
847+
<dialog phx-mounted={JS.ignore_attributes("open")}>
848+
...
849+
</dialog>
850+
```
851+
852+
## Options
853+
854+
* `:to` - An optional DOM selector to select the target element.
855+
Defaults to the interacted element. See the `DOM selectors`
856+
section for details.
857+
858+
## Examples
859+
860+
JS.ignore_attributes(["open", "data-*"], to: "#my-dialog")
861+
862+
"""
863+
864+
def ignore_attributes(attrs) when is_list(attrs) or is_binary(attrs),
865+
do: ignore_attributes(%JS{}, attrs, [])
866+
867+
def ignore_attributes(attrs, opts) when (is_list(attrs) or is_binary(attrs)) and is_list(opts),
868+
do: ignore_attributes(%JS{}, attrs, opts)
869+
870+
def ignore_attributes(%JS{} = js, attrs, opts)
871+
when (is_list(attrs) or is_binary(attrs)) and is_list(opts) do
872+
attrs =
873+
case attrs do
874+
attr when is_binary(attr) -> [attr]
875+
attrs when is_list(attrs) -> attrs
876+
end
877+
878+
opts = validate_keys(opts, :ignore_attributes, [:attrs, :to])
879+
put_op(js, "ignore_attrs", to: opts[:to], attrs: attrs)
880+
end
881+
831882
@doc """
832883
Sends focus to a selector.
833884

test/e2e/support/js_live.ex

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ defmodule Phoenix.LiveViewTest.E2E.JsLive do
55

66
@impl Phoenix.LiveView
77
def mount(_params, _session, socket) do
8-
{:ok, socket}
8+
{:ok, assign(socket, count: 0)}
99
end
1010

1111
@impl Phoenix.LiveView
@@ -36,6 +36,16 @@ defmodule Phoenix.LiveViewTest.E2E.JsLive do
3636
}>
3737
toggle modal
3838
</button>
39+
40+
<details phx-mounted={JS.ignore_attributes(["open"])}>
41+
<summary>Details</summary>
42+
<button phx-click="increment">{@count}</button>
43+
</details>
3944
"""
4045
end
46+
47+
@impl Phoenix.LiveView
48+
def handle_event("increment", _params, socket) do
49+
{:noreply, update(socket, :count, &(&1 + 1))}
50+
end
4151
end

test/e2e/tests/js.spec.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,15 @@ test("set and remove_attribute", async ({page}) => {
8080
await expect(page.locator("#my-modal")).not.toHaveAttribute("open")
8181
await expect(page.locator("#my-modal")).toBeHidden()
8282
})
83+
84+
test("ignore_attributes", async ({page}) => {
85+
await page.goto("/js")
86+
await syncLV(page)
87+
await expect(page.locator("details")).not.toHaveAttribute("open")
88+
await page.locator("details").click()
89+
await expect(page.locator("details")).toHaveAttribute("open")
90+
// without ignore_attributes, the open attribute would be reset to false
91+
await page.locator("details button").click()
92+
await syncLV(page)
93+
await expect(page.locator("details")).toHaveAttribute("open")
94+
})

0 commit comments

Comments
 (0)