Show a message when JS view data is not available (#1067)

* Multiplex initialization of JS views

* Show timeout message when JS view data fails to load

* Refactor JS view channel

* Update tests
This commit is contained in:
Jonatan Kłosko 2022-03-22 18:25:42 +01:00 committed by GitHub
parent 01b9ffe731
commit ac5a71bf85
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 100 additions and 29 deletions

View file

@ -238,4 +238,8 @@
.error-box {
@apply rounded-lg px-4 py-2 bg-red-100 text-red-400 font-medium;
}
.info-box {
@apply p-4 bg-gray-100 text-sm text-gray-500 font-medium rounded-lg;
}
}

View file

@ -1,5 +1,5 @@
import { getAttributeOrThrow, parseInteger } from "../lib/attribute";
import { randomToken } from "../lib/utils";
import { randomId, randomToken } from "../lib/utils";
import {
getChannel,
transportDecode,
@ -45,14 +45,21 @@ import { initializeIframeSource } from "./js_view/iframe";
* * `data-iframe-local-port` - the local port where the iframe is
* served
*
* * `data-timeout-message` - the message to show when the initial
* data does not load
*
*/
const JSView = {
mounted() {
this.props = this.getProps();
this.id = randomId();
this.childToken = randomToken();
this.childReadyPromise = null;
this.childReady = false;
this.initReceived = false;
this.initTimeout = setTimeout(() => this.handleInitTimeout(), 2_000);
this.channel = getChannel(this.props.sessionId);
@ -73,10 +80,13 @@ const JSView = {
// Channel events
const initRef = this.channel.on(`init:${this.props.ref}`, (raw) => {
const [, payload] = transportDecode(raw);
this.handleServerInit(payload);
});
const initRef = this.channel.on(
`init:${this.props.ref}:${this.id}`,
(raw) => {
const [, payload] = transportDecode(raw);
this.handleServerInit(payload);
}
);
const eventRef = this.channel.on(`event:${this.props.ref}`, (raw) => {
const [[event], payload] = transportDecode(raw);
@ -91,7 +101,7 @@ const JSView = {
);
this.unsubscribeFromChannelEvents = () => {
this.channel.off(`init:${this.props.ref}`, initRef);
this.channel.off(`init:${this.props.ref}:${this.id}`, initRef);
this.channel.off(`event:${this.props.ref}`, eventRef);
this.channel.off(`error:${this.props.ref}`, errorRef);
};
@ -99,6 +109,7 @@ const JSView = {
this.channel.push("connect", {
session_token: this.props.sessionToken,
ref: this.props.ref,
id: this.id,
});
},
@ -127,6 +138,7 @@ const JSView = {
"data-iframe-local-port",
parseInteger
),
timeoutMessage: getAttributeOrThrow(this.el, "data-timeout-message"),
};
},
@ -290,13 +302,35 @@ const JSView = {
}
},
handleInitTimeout() {
this.initTimeoutContainer = document.createElement("div");
this.initTimeoutContainer.classList.add("info-box");
this.el.prepend(this.initTimeoutContainer);
this.initTimeoutContainer.textContent = this.props.timeoutMessage;
},
clearInitTimeout() {
clearTimeout(this.initTimeout);
if (this.initTimeoutContainer) {
this.initTimeoutContainer.remove();
}
},
handleServerInit(payload) {
this.clearInitTimeout();
this.initReceived = true;
this.childReadyPromise.then(() => {
this.postMessage({ type: "init", data: payload });
});
},
handleServerEvent(event, payload) {
if (!this.initReceived) {
return;
}
this.childReadyPromise.then(() => {
this.postMessage({ type: "event", event, payload });
});

View file

@ -3,21 +3,23 @@ defmodule LivebookWeb.JSViewChannel do
@impl true
def join("js_view", %{"session_id" => session_id}, socket) do
{:ok, assign(socket, session_id: session_id, ref_with_pid: %{}, ref_with_count: %{})}
{:ok, assign(socket, session_id: session_id, ref_with_info: %{})}
end
@impl true
def handle_in("connect", %{"session_token" => session_token, "ref" => ref}, socket) do
def handle_in("connect", %{"session_token" => session_token, "ref" => ref, "id" => id}, socket) do
{:ok, data} = Phoenix.Token.verify(LivebookWeb.Endpoint, "js view", session_token)
%{pid: pid} = data
send(pid, {:connect, self(), %{origin: self(), ref: ref}})
ref_with_pid = Map.put(socket.assigns.ref_with_pid, ref, pid)
ref_with_count = Map.update(socket.assigns.ref_with_count, ref, 1, &(&1 + 1))
socket = assign(socket, ref_with_pid: ref_with_pid, ref_with_count: ref_with_count)
socket =
update_in(socket.assigns.ref_with_info[ref], fn
nil -> %{pid: pid, count: 1, connect_queue: [id]}
info -> %{info | count: info.count + 1, connect_queue: info.connect_queue ++ [id]}
end)
if socket.assigns.ref_with_count[ref] == 1 do
if socket.assigns.ref_with_info[ref].count == 1 do
Livebook.Session.subscribe_to_runtime_events(
socket.assigns.session_id,
"js_live",
@ -32,26 +34,24 @@ defmodule LivebookWeb.JSViewChannel do
def handle_in("event", raw, socket) do
{[event, ref], payload} = transport_decode!(raw)
pid = socket.assigns.ref_with_pid[ref]
pid = socket.assigns.ref_with_info[ref].pid
send(pid, {:event, event, payload, %{origin: self(), ref: ref}})
{:noreply, socket}
end
def handle_in("disconnect", %{"ref" => ref}, socket) do
socket =
if socket.assigns.ref_with_count[ref] == 1 do
if socket.assigns.ref_with_info[ref].count == 1 do
Livebook.Session.unsubscribe_from_runtime_events(
socket.assigns.session_id,
"js_live",
ref
)
{_, ref_with_count} = Map.pop!(socket.assigns.ref_with_count, ref)
{_, ref_with_pid} = Map.pop!(socket.assigns.ref_with_pid, ref)
assign(socket, ref_with_count: ref_with_count, ref_with_pid: ref_with_pid)
{_, socket} = pop_in(socket.assigns.ref_with_info[ref])
socket
else
ref_with_count = Map.update!(socket.assigns.ref_with_count, ref, &(&1 - 1))
assign(socket, ref_with_count: ref_with_count)
update_in(socket.assigns.ref_with_info[ref], &%{&1 | count: &1.count - 1})
end
{:noreply, socket}
@ -59,7 +59,16 @@ defmodule LivebookWeb.JSViewChannel do
@impl true
def handle_info({:connect_reply, payload, %{ref: ref}}, socket) do
with {:error, error} <- try_push(socket, "init:#{ref}", nil, payload) do
# Multiple connections for the same reference may be establish,
# the replies come sequentially and we dispatch them according
# to the clients queue
{id, socket} =
get_and_update_in(socket.assigns.ref_with_info[ref].connect_queue, fn [id | queue] ->
{id, queue}
end)
with {:error, error} <- try_push(socket, "init:#{ref}:#{id}", nil, payload) do
message = "Failed to serialize initial widget data, " <> error
push(socket, "error:#{ref}", %{"message" => message})
end

View file

@ -1,6 +1,14 @@
defmodule LivebookWeb.JSViewComponent do
use LivebookWeb, :live_component
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_new(:timeout_message, fn -> "Not available" end)}
end
@impl true
def render(assigns) do
~H"""
@ -12,7 +20,8 @@ defmodule LivebookWeb.JSViewComponent do
data-js-path={@js_view.assets.js_path}
data-session-token={session_token(@js_view.pid)}
data-session-id={@session_id}
data-iframe-local-port={LivebookWeb.IframeEndpoint.port()}>
data-iframe-local-port={LivebookWeb.IframeEndpoint.port()}
data-timeout-message={@timeout_message}>
</div>
"""
end

View file

@ -65,7 +65,8 @@ defmodule LivebookWeb.Output do
live_component(LivebookWeb.JSViewComponent,
id: id,
js_view: js_info.js_view,
session_id: session_id
session_id: session_id,
timeout_message: "Output data no longer available, please reevaluate this cell"
)
end

View file

@ -134,7 +134,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
</div>
<% :dead -> %>
<div class="p-4 bg-gray-100 text-sm text-gray-500 font-medium rounded-lg">
<div class="info-box">
Evaluate and install dependencies to show the contents of this Smart cell.
</div>

View file

@ -15,16 +15,29 @@ defmodule LivebookWeb.JSViewChannelTest do
end
test "loads initial data from the widget server and pushes to the client", %{socket: socket} do
push(socket, "connect", %{"session_token" => session_token(), "ref" => "1"})
push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"})
assert_receive {:connect, from, %{}}
send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}})
assert_push "init:1", %{"root" => [nil, [1, 2, 3]]}
assert_push "init:1:id1", %{"root" => [nil, [1, 2, 3]]}
end
test "loads initial data for multiple connections separately", %{socket: socket} do
push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"})
push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id2"})
assert_receive {:connect, from, %{}}
send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}})
assert_push "init:1:id1", %{"root" => [nil, [1, 2, 3]]}
assert_receive {:connect, from, %{}}
send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}})
assert_push "init:1:id2", %{"root" => [nil, [1, 2, 3]]}
end
test "sends client events to the corresponding widget server", %{socket: socket} do
push(socket, "connect", %{"session_token" => session_token(), "ref" => "1"})
push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"})
assert_receive {:connect, from, %{}}
send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}})
@ -36,17 +49,18 @@ defmodule LivebookWeb.JSViewChannelTest do
describe "binary payload" do
test "initial data", %{socket: socket} do
push(socket, "connect", %{"session_token" => session_token(), "ref" => "1"})
push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"})
assert_receive {:connect, from, %{}}
payload = {:binary, %{message: "hey"}, <<1, 2, 3>>}
send(from, {:connect_reply, payload, %{ref: "1"}})
assert_push "init:1", {:binary, <<24::size(32), "[null,{\"message\":\"hey\"}]", 1, 2, 3>>}
assert_push "init:1:id1",
{:binary, <<24::size(32), "[null,{\"message\":\"hey\"}]", 1, 2, 3>>}
end
test "form client to server", %{socket: socket} do
push(socket, "connect", %{"session_token" => session_token(), "ref" => "1"})
push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"})
assert_receive {:connect, from, %{}}
send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}})