Skip to content

Commit 2692526

Browse files
authored
Add blocking: true to JS.dispatch (#3615)
* Add blocking: true to JS.dispatch Relates to: #3516 When integrating external animation libraries like motion.dev, the existing JS functions are not sufficient. Instead, the third party library needs to be triggered via JS. This has the downside of not being able to block the DOM until the animation is complete, which prevents this from working when elements are removed using `phx-remove`. This commit introduces a new `blocking: true` option to `JS.dispatch/3`, which injects a `done` function into the event's `detail` object. Using this with motion could look like this: ```elixir def render(assigns) do ~H""" <div :if={@show} phx-remove={JS.dispatch("motion:rotate", blocking: true)}> ... </div> """ end ``` ```javascript const { animate } = Motion window.addEventListener("motion:rotate", (e) => { animate(e.target, { rotate: [0, 360] }, { duration: 1 }).then(() => { if (e.detail.done) { e.detail.done() } }) }) ``` It is still necessary to block the DOM while the remove animation is running, as the remove can happen because of a navigation, where the animation would otherwise not run as the whole LiveView is just replaced. * add test for blocking: true * fail when done key is already used
1 parent 9347a8f commit 2692526

File tree

5 files changed

+83
-3
lines changed

5 files changed

+83
-3
lines changed

assets/js/phoenix_live_view/js.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,16 @@ const JS = {
7171
view,
7272
sourceEl,
7373
el,
74-
{ event, detail, bubbles },
74+
{ event, detail, bubbles, blocking },
7575
) {
7676
detail = detail || {};
7777
detail.dispatcher = sourceEl;
78+
if (blocking) {
79+
const promise = new Promise((resolve, _reject) => {
80+
detail.done = resolve;
81+
});
82+
view.liveSocket.asyncTransition(promise);
83+
}
7884
DOM.dispatchEvent(el, event, { detail, bubbles });
7985
},
8086

assets/js/phoenix_live_view/live_socket.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,10 @@ export default class LiveSocket {
265265
this.transitions.after(callback);
266266
}
267267

268+
asyncTransition(promise) {
269+
this.transitions.addAsyncTransition(promise);
270+
}
271+
268272
transition(time, onStart, onDone = function () {}) {
269273
this.transitions.addTransition(time, onStart, onDone);
270274
}
@@ -1206,6 +1210,7 @@ export default class LiveSocket {
12061210
class TransitionSet {
12071211
constructor() {
12081212
this.transitions = new Set();
1213+
this.promises = new Set();
12091214
this.pendingOps = [];
12101215
}
12111216

@@ -1214,6 +1219,7 @@ class TransitionSet {
12141219
clearTimeout(timer);
12151220
this.transitions.delete(timer);
12161221
});
1222+
this.promises.clear();
12171223
this.flushPendingOps();
12181224
}
12191225

@@ -1235,12 +1241,20 @@ class TransitionSet {
12351241
this.transitions.add(timer);
12361242
}
12371243

1244+
addAsyncTransition(promise) {
1245+
this.promises.add(promise);
1246+
promise.then(() => {
1247+
this.promises.delete(promise);
1248+
this.flushPendingOps();
1249+
});
1250+
}
1251+
12381252
pushPendingOp(op) {
12391253
this.pendingOps.push(op);
12401254
}
12411255

12421256
size() {
1243-
return this.transitions.size;
1257+
return this.transitions.size + this.promises.size;
12441258
}
12451259

12461260
flushPendingOps() {

assets/test/js_test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ describe("JS", () => {
447447
modal.addEventListener("click", () => done());
448448
JS.exec(event, "click", click.getAttribute("phx-click"), view, click);
449449
});
450+
450451
test("with details", (done) => {
451452
const view = setupView(`
452453
<div id="modal">modal</div>
@@ -492,6 +493,32 @@ describe("JS", () => {
492493

493494
JS.exec(event, "close", close.getAttribute("phx-click"), view, close);
494495
});
496+
497+
test("blocking blocks DOM updates until done", (done) => {
498+
let view = setupView(`
499+
<div id="modal">modal</div>
500+
<div id="click" phx-click='[["dispatch", {"to": "#modal", "event": "custom", "blocking": true}]]'></div>
501+
`);
502+
let modal = simulateVisibility(document.querySelector("#modal"));
503+
let click = document.querySelector("#click");
504+
let doneCalled = false;
505+
506+
modal.addEventListener("custom", (e) => {
507+
expect(e.detail).toEqual({
508+
done: expect.any(Function),
509+
dispatcher: click,
510+
});
511+
expect(view.liveSocket.transitions.size()).toBe(1);
512+
view.liveSocket.requestDOMUpdate(() => {
513+
expect(doneCalled).toBe(true);
514+
done();
515+
});
516+
// now we unblock the transition
517+
e.detail.done();
518+
doneCalled = true;
519+
});
520+
JS.exec(event, "click", click.getAttribute("phx-click"), view, click);
521+
});
495522
});
496523

497524
describe("exec_add_class and exec_remove_class", () => {

lib/phoenix_live_view/js.ex

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,9 @@ defmodule Phoenix.LiveView.JS do
266266
with the client event. The details will be available in the
267267
`event.detail` attribute for event listeners.
268268
* `:bubbles` – A boolean flag to bubble the event or not. Defaults to `true`.
269+
* `:blocking` - A boolean flag to block the UI until the event handler calls `event.detail.done()`.
270+
The done function is injected by LiveView and *must* be called eventually to unblock the UI.
271+
This is useful to integrate with third party JavaScript based animation libraries.
269272
270273
## Examples
271274
@@ -283,7 +286,7 @@ defmodule Phoenix.LiveView.JS do
283286

284287
@doc "See `dispatch/2`."
285288
def dispatch(%JS{} = js, event, opts) do
286-
opts = validate_keys(opts, :dispatch, [:to, :detail, :bubbles])
289+
opts = validate_keys(opts, :dispatch, [:to, :detail, :bubbles, :blocking])
287290
args = [event: event, to: opts[:to]]
288291

289292
args =
@@ -298,6 +301,21 @@ defmodule Phoenix.LiveView.JS do
298301
args
299302
end
300303

304+
if opts[:blocking] do
305+
case opts[:detail] do
306+
map when is_map(map) and (is_map_key(map, "done") or is_map_key(map, :done)) ->
307+
raise ArgumentError, """
308+
the detail map passed to JS.dispatch must not contain a `done` key
309+
when `blocking: true` is used!
310+
311+
Got: #{inspect(map)}
312+
"""
313+
314+
_ ->
315+
:ok
316+
end
317+
end
318+
301319
args =
302320
case {event, Keyword.fetch(opts, :detail)} do
303321
{"click", {:ok, _detail}} ->
@@ -322,6 +340,15 @@ defmodule Phoenix.LiveView.JS do
322340
args
323341
end
324342

343+
args =
344+
case Keyword.get(opts, :blocking) do
345+
true ->
346+
Keyword.put(args, :blocking, opts[:blocking])
347+
348+
_ ->
349+
args
350+
end
351+
325352
put_op(js, "dispatch", args)
326353
end
327354

test/phoenix_live_view/js_test.exs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,12 @@ defmodule Phoenix.LiveView.JSTest do
477477
assert js_to_string(JS.dispatch("click", to: ".foo")) ==
478478
"[[&quot;dispatch&quot;,{&quot;event&quot;:&quot;click&quot;,&quot;to&quot;:&quot;.foo&quot;}]]"
479479
end
480+
481+
test "raises when done is a details key and blocking is true" do
482+
assert_raise ArgumentError, ~r/must not contain a `done` key/, fn ->
483+
JS.dispatch("foo", detail: %{done: true}, blocking: true)
484+
end
485+
end
480486
end
481487

482488
describe "toggle" do

0 commit comments

Comments
 (0)