Skip to content

Commit d12366d

Browse files
authored
Support pushing JS commands via events (#4060)
* Add JS.to_encodable/1 function for pushing JS commands via events The new `JS.to_encodable/1` function transforms the `Phoenix.LiveView.JS` struct into an opaque JSON-serializable value, such that it can be transmitted outside of HEEx templates. See discussion https://elixirforum.com/t/make-js-t-a-public-data-structure-or-json-serializable/53870?u=rhcarvalho. * Conditionally implement Jason.Encoder and JSON.Encoder for Phoenix.LiveView.JS Makes it more convenient to use JS commands in `push_event` payloads. * Reword `JS.to_encodable/1` doc - No need to link to hexdocs.pm, ExDoc will do that automatically - Use the same wording as Phoenix, "JSON library" instead of "JSON encoder" - More concise wording * Add to_encodable/1 tests * Add Jason.Encoder and JSON.Encoder tests * Simplify js.exec test 1. Remove done callback, which is meant to be used with callbacks/asynchronous code. https://jestjs.io/docs/asynchronous#callbacks 2. Use jest.runAllTimers(), reflecting typical usage when we don't care about intermediate timer states. https://jestjs.io/docs/jest-object#jestrunalltimers https://jestjs.io/docs/jest-object#jestadvancetimersbytimemstorun * Add JS test coverage for execJS, js().exec and hook.js().exec * Conditionally define JSON.Encoder test To avoid warnings on older Elixir versions where JSON.Encoder is not available. When the JSON module is not available, we define a skipped test with the same name for visibility in test reports.
1 parent 9a756ca commit d12366d

File tree

9 files changed

+296
-40
lines changed

9 files changed

+296
-40
lines changed

assets/js/phoenix_live_view/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { ViewHook } from "./view_hook";
1212
import View from "./view";
1313
import { logError } from "./utils";
1414

15-
import type { LiveSocketJSCommands } from "./js_commands";
15+
import type { EncodedJS, LiveSocketJSCommands } from "./js_commands";
1616
import type { Hook, HooksOptions } from "./view_hook";
1717
import type { Socket as PhoenixSocket } from "phoenix";
1818

@@ -266,7 +266,11 @@ export interface LiveSocketInstanceInterface {
266266
*
267267
* See [`Phoenix.LiveView.JS`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.JS.html) for more information.
268268
*/
269-
execJS(el: HTMLElement, encodedJS: string, eventType?: string | null): void;
269+
execJS(
270+
el: HTMLElement,
271+
encodedJS: EncodedJS,
272+
eventType?: string | null,
273+
): void;
270274
/**
271275
* Returns an object with methods to manipulate the DOM and execute JavaScript.
272276
* The applied changes integrate with server DOM patching.

assets/js/phoenix_live_view/js.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ const JS = {
1111
null,
1212
{ callback: defaults && defaults.callback },
1313
];
14-
const commands =
15-
phxEvent.charAt(0) === "["
14+
const commands = Array.isArray(phxEvent)
15+
? phxEvent
16+
: typeof phxEvent === "string" && phxEvent.startsWith("[")
1617
? JSON.parse(phxEvent)
1718
: [[defaultKind, defaultArgs]];
1819

assets/js/phoenix_live_view/js_commands.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import JS from "./js";
22
import LiveSocket from "./live_socket";
33

4+
/**
5+
* An encoded JS command. Use functions in the `Phoenix.LiveView.JS` module on
6+
* the server to create and compose JS commands.
7+
*
8+
* The underlying primitive type is considered opaque, and may change in future
9+
* versions.
10+
*/
11+
export type EncodedJS = string | Array<any>;
12+
413
type Transition = string | string[];
514

615
// Base options for commands involving transitions and timing
@@ -76,13 +85,13 @@ type NavigationOpts = {
7685
*/
7786
interface AllJSCommands {
7887
/**
79-
* Executes encoded JavaScript in the context of the element.
88+
* Executes an encoded JS command in the context of an element.
8089
* This version is for general use via liveSocket.js().
8190
*
82-
* @param el - The element in whose context to execute the JavaScript.
83-
* @param encodedJS - The encoded JavaScript string to execute.
91+
* @param el - The element in whose context to execute the JS command.
92+
* @param encodedJS - The encoded JS command with operations to execute.
8493
*/
85-
exec(el: HTMLElement, encodedJS: string): void;
94+
exec(el: HTMLElement, encodedJS: EncodedJS): void;
8695

8796
/**
8897
* Shows an element.
@@ -382,9 +391,9 @@ export type LiveSocketJSCommands = AllJSCommands;
382391
*/
383392
export interface HookJSCommands extends Omit<AllJSCommands, "exec"> {
384393
/**
385-
* Executes encoded JavaScript in the context of the hook's element.
394+
* Executes a JS command in the context of the hook's element.
386395
*
387-
* @param {string} encodedJS - The encoded JavaScript string to execute.
396+
* @param encodedJS - The encoded JS command with operations to execute.
388397
*/
389-
exec(encodedJS: string): void;
398+
exec(encodedJS: EncodedJS): void;
390399
}

assets/js/phoenix_live_view/view_hook.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import jsCommands, { HookJSCommands } from "./js_commands";
1+
import jsCommands, { EncodedJS, HookJSCommands } from "./js_commands";
22
import DOM from "./dom";
33
import LiveSocket from "./live_socket";
44
import View from "./view";
@@ -380,7 +380,7 @@ export class ViewHook<E extends HTMLElement = HTMLElement>
380380
js(): HookJSCommands {
381381
return {
382382
...jsCommands(this.__view().liveSocket, "hook"),
383-
exec: (encodedJS: string) => {
383+
exec: (encodedJS: EncodedJS) => {
384384
this.__view().liveSocket.execJS(this.el, encodedJS, "hook");
385385
},
386386
};

assets/test/js_test.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,20 @@ describe("JS", () => {
3838
js = hook.js();
3939
});
4040

41-
test("exec", (done) => {
41+
test("exec", () => {
4242
simulateVisibility(modal);
4343
expect(modal.style.display).toBe("");
4444
js.exec('[["toggle", {"to": "#modal"}]]');
45-
jest.advanceTimersByTime(100);
45+
jest.runAllTimers();
46+
expect(modal.style.display).toBe("none");
47+
});
48+
49+
test("exec with command array", () => {
50+
simulateVisibility(modal);
51+
expect(modal.style.display).toBe("");
52+
js.exec([["toggle", { to: "#modal" }]]);
53+
jest.runAllTimers();
4654
expect(modal.style.display).toBe("none");
47-
done();
4855
});
4956

5057
test("show and hide", (done) => {
@@ -1147,6 +1154,19 @@ describe("JS", () => {
11471154
JS.exec(event, "exec", click.getAttribute("phx-click"), view, click);
11481155
});
11491156

1157+
test("with command array", (done) => {
1158+
const view = setupView(`
1159+
<div id="modal" phx-remove='[["push", {"event": "clicked"}]]'>modal</div>
1160+
`);
1161+
const modal = document.querySelector("#modal")!;
1162+
view.pushEvent = (eventType, sourceEl, targetCtx, event, _meta) => {
1163+
expect(eventType).toBe("exec");
1164+
expect(event).toBe("clicked");
1165+
done();
1166+
};
1167+
JS.exec(event, "exec", [["exec", { attr: "phx-remove" }]], view, modal);
1168+
});
1169+
11501170
test("with no selector", () => {
11511171
const view = setupView(`
11521172
<div

assets/test/live_socket_test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,58 @@ describe("LiveSocket", () => {
241241
// liveSocket constructor reads nav history position from sessionStorage
242242
expect(getItemCalls).toEqual(2);
243243
});
244+
245+
describe("execJS", () => {
246+
let view, liveSocket;
247+
248+
beforeEach(() => {
249+
global.document.body.innerHTML = "";
250+
prepareLiveViewDOM(global.document);
251+
jest.useFakeTimers();
252+
253+
liveSocket = new LiveSocket("/live", Socket);
254+
view = simulateJoinedView(
255+
document.getElementById("container1"),
256+
liveSocket,
257+
);
258+
});
259+
260+
afterEach(() => {
261+
liveSocket && liveSocket.destroyAllViews();
262+
liveSocket = null;
263+
jest.useRealTimers();
264+
});
265+
266+
afterAll(() => {
267+
global.document.body.innerHTML = "";
268+
});
269+
270+
test("accepts JSON-encoded command string", () => {
271+
const el = document.createElement("div");
272+
el.setAttribute("id", "test-exec");
273+
el.setAttribute(
274+
"data-test",
275+
'[["toggle_attr", {"attr": ["open", "true"]}]]',
276+
);
277+
view.el.appendChild(el);
278+
279+
expect(el.getAttribute("open")).toBeNull();
280+
liveSocket.execJS(el, el.getAttribute("data-test"));
281+
jest.runAllTimers();
282+
expect(el.getAttribute("open")).toEqual("true");
283+
});
284+
285+
test("accepts command array", () => {
286+
const el = document.createElement("div");
287+
el.setAttribute("id", "test-exec-array");
288+
view.el.appendChild(el);
289+
290+
expect(el.getAttribute("open")).toBeNull();
291+
liveSocket.execJS(el, [["toggle_attr", { attr: ["open", "true"] }]]);
292+
jest.runAllTimers();
293+
expect(el.getAttribute("open")).toEqual("true");
294+
});
295+
});
244296
});
245297

246298
describe("liveSocket.js()", () => {
@@ -284,6 +336,17 @@ describe("liveSocket.js()", () => {
284336
expect(el.getAttribute("open")).toEqual("true");
285337
});
286338

339+
test("exec with command array", () => {
340+
const el = document.createElement("div");
341+
el.setAttribute("id", "test-exec-array");
342+
view.el.appendChild(el);
343+
344+
expect(el.getAttribute("open")).toBeNull();
345+
js.exec(el, [["toggle_attr", { attr: ["open", "true"] }]]);
346+
jest.runAllTimers();
347+
expect(el.getAttribute("open")).toEqual("true");
348+
});
349+
287350
test("show and hide", (done) => {
288351
const el = document.createElement("div");
289352
el.setAttribute("id", "test-visibility");

guides/client/js-interop.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ The `liveSocket` instance exposes the following methods:
3434
- `disableDebug()` - turns off debug logging
3535
- `enableLatencySim(milliseconds)` - turns on latency simulation, see [Simulating latency](#simulating-latency)
3636
- `disableLatencySim()` - turns off latency simulation
37-
- `execJS(el, encodedJS)` - executes encoded JavaScript in the context of the element
38-
- `js()` - returns an object with methods to manipulate the DOM and execute JavaScript. The applied changes integrate with server DOM patching. See [JS commands](#js-commands).
37+
- `execJS(el, encodedJS)` - executes encoded JS command in the context of the element
38+
- `js()` - returns an object with methods to manipulate the DOM and execute JS commands. The applied changes integrate with server DOM patching. See [JS commands](#js-commands).
3939

4040
## Debugging client events
4141

@@ -477,5 +477,5 @@ The command interface returned by `js()` above offers the following functions:
477477
- `push(el, type, opts = {})` - pushes an event to the server. To target a LiveComponent by its ID, pass a separate `target` in the options. Options: `target`, `loading`, `page_loading`, `value`. For more details, see `Phoenix.LiveView.JS.push/1`.
478478
- `navigate(href, opts = {})` - sends a navigation event to the server and updates the browser's pushState history. Options: `replace`. For more details, see `Phoenix.LiveView.JS.navigate/1`.
479479
- `patch(href, opts = {})` - sends a patch event to the server and updates the browser's pushState history. Options: `replace`. For more details, see `Phoenix.LiveView.JS.patch/1`.
480-
- `exec(encodedJS)` - *only via Client hook `this.js()`*: executes encoded JavaScript in the context of the hook's root node. The encoded JS command should be constructed via `Phoenix.LiveView.JS` and is usually stored as an HTML attribute. Example: `this.js().exec(this.el.getAttribute('phx-remove'))`.
481-
- `exec(el, encodedJS)` - *only via `liveSocket.js()`*: executes encoded JavaScript in the context of any element.
480+
- `exec(encodedJS)` - *only via Client hook `this.js()`*: executes encoded JS command in the context of the hook's root node. The encoded JS command should be constructed via `Phoenix.LiveView.JS` and is usually stored as an HTML attribute. Example: `this.js().exec(this.el.getAttribute('phx-remove'))`.
481+
- `exec(el, encodedJS)` - *only via `liveSocket.js()`*: executes encoded JS command in the context of any element.

lib/phoenix_live_view/js.ex

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,10 +218,120 @@ defmodule Phoenix.LiveView.JS do
218218

219219
defimpl Phoenix.HTML.Safe, for: Phoenix.LiveView.JS do
220220
def to_iodata(%Phoenix.LiveView.JS{} = js) do
221-
Phoenix.HTML.Engine.html_escape(Phoenix.json_library().encode!(js.ops))
221+
js
222+
|> JS.to_encodable()
223+
|> Phoenix.json_library().encode!()
224+
|> Phoenix.HTML.Engine.html_escape()
222225
end
223226
end
224227

228+
if Code.ensure_loaded?(Jason.Encoder) do
229+
defimpl Jason.Encoder, for: Phoenix.LiveView.JS do
230+
def encode(%Phoenix.LiveView.JS{} = js, opts) do
231+
Jason.Encode.list(JS.to_encodable(js), opts)
232+
end
233+
end
234+
end
235+
236+
if Code.ensure_loaded?(JSON.Encoder) do
237+
defimpl JSON.Encoder, for: Phoenix.LiveView.JS do
238+
def encode(%Phoenix.LiveView.JS{} = js, encoder) do
239+
JSON.Encoder.encode(JS.to_encodable(js), encoder)
240+
end
241+
end
242+
end
243+
244+
@doc ~S"""
245+
Returns a JSON-encodable opaque intermediate representation of the JS command.
246+
247+
Most of the time you will not need to call this function directly, as
248+
JS commands are automatically encoded where they are typically used: in
249+
[HEEx templates](assigns-eex.md) or within the payload of
250+
`Phoenix.LiveView.push_event/3`.
251+
252+
This function is useful when you use a custom JSON library. JS commands
253+
implement the `Jason.Encoder` and `JSON.Encoder` protocols, such that they
254+
are automatically encoded when you use either of those JSON libraries.
255+
256+
## Examples
257+
258+
On the server, dynamically compute some JS commands and push them to the
259+
client:
260+
261+
```elixir
262+
socket
263+
|> push_event("myapp:exec_js", %{
264+
to: "#items-#{item.id}",
265+
js: js_commands_for(item) |> JS.to_encodable()
266+
})
267+
```
268+
269+
> #### Automatic encoding {: .tip}
270+
>
271+
> Note that you don't need to call `to_encodable/1` if you are using `Jason` or
272+
> `JSON`, instead you can pass the JS commands directly:
273+
>
274+
> ```elixir
275+
> socket
276+
> |> push_event("myapp:exec_js", %{
277+
> to: "#items-#{item.id}",
278+
> js: JS.show()
279+
> })
280+
> ```
281+
282+
On the client, handle the event and execute the commands:
283+
284+
```javascript
285+
window.addEventListener("phx:myapp:exec_js", e => {
286+
const {to, js} = e.detail;
287+
const el = document.querySelector(to);
288+
if (el && js) {
289+
window.liveSocket.execJS(el, js);
290+
}
291+
});
292+
```
293+
294+
The common case, though, is having the JS commands stored in an HTML attribute
295+
(`phx-*` or `data-*`), such that client-side JavaScript can refer to them
296+
later. For example, in a LiveView template:
297+
298+
```heex
299+
<div id={"items-#{item.id}"} data-js={JS.show()}>
300+
Hello!
301+
</div>
302+
```
303+
304+
Now the server can push an event that refers to the `data-js` attribute:
305+
306+
```elixir
307+
socket
308+
|> push_event("myapp:exec_attr", %{
309+
to: "#items-#{item.id}",
310+
attr: "data-js"
311+
})
312+
```
313+
314+
Finally, on the client, you can read and execute the commands:
315+
316+
```javascript
317+
window.addEventListener("phx:myapp:exec_attr", e => {
318+
const {to, attr} = e.detail;
319+
const el = document.querySelector(to);
320+
const js = el && attr && el.getAttribute(attr);
321+
if (el && js) {
322+
window.liveSocket.execJS(el, js);
323+
}
324+
});
325+
```
326+
327+
Note how in the code above we didn't need to encode JS commands explicitly,
328+
nor to pass them in the event payload, thanks to rendering them in the
329+
template.
330+
331+
"""
332+
@spec to_encodable(js :: JS.t()) :: internal()
333+
def to_encodable(%JS{} = js), do: js.ops
334+
225335
@doc """
226336
Pushes an event to the server.
227337

0 commit comments

Comments
 (0)