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 { .error-box {
@apply rounded-lg px-4 py-2 bg-red-100 text-red-400 font-medium; @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 { getAttributeOrThrow, parseInteger } from "../lib/attribute";
import { randomToken } from "../lib/utils"; import { randomId, randomToken } from "../lib/utils";
import { import {
getChannel, getChannel,
transportDecode, transportDecode,
@ -45,14 +45,21 @@ import { initializeIframeSource } from "./js_view/iframe";
* * `data-iframe-local-port` - the local port where the iframe is * * `data-iframe-local-port` - the local port where the iframe is
* served * served
* *
* * `data-timeout-message` - the message to show when the initial
* data does not load
*
*/ */
const JSView = { const JSView = {
mounted() { mounted() {
this.props = this.getProps(); this.props = this.getProps();
this.id = randomId();
this.childToken = randomToken(); this.childToken = randomToken();
this.childReadyPromise = null; this.childReadyPromise = null;
this.childReady = false; this.childReady = false;
this.initReceived = false;
this.initTimeout = setTimeout(() => this.handleInitTimeout(), 2_000);
this.channel = getChannel(this.props.sessionId); this.channel = getChannel(this.props.sessionId);
@ -73,10 +80,13 @@ const JSView = {
// Channel events // Channel events
const initRef = this.channel.on(`init:${this.props.ref}`, (raw) => { const initRef = this.channel.on(
const [, payload] = transportDecode(raw); `init:${this.props.ref}:${this.id}`,
this.handleServerInit(payload); (raw) => {
}); const [, payload] = transportDecode(raw);
this.handleServerInit(payload);
}
);
const eventRef = this.channel.on(`event:${this.props.ref}`, (raw) => { const eventRef = this.channel.on(`event:${this.props.ref}`, (raw) => {
const [[event], payload] = transportDecode(raw); const [[event], payload] = transportDecode(raw);
@ -91,7 +101,7 @@ const JSView = {
); );
this.unsubscribeFromChannelEvents = () => { 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(`event:${this.props.ref}`, eventRef);
this.channel.off(`error:${this.props.ref}`, errorRef); this.channel.off(`error:${this.props.ref}`, errorRef);
}; };
@ -99,6 +109,7 @@ const JSView = {
this.channel.push("connect", { this.channel.push("connect", {
session_token: this.props.sessionToken, session_token: this.props.sessionToken,
ref: this.props.ref, ref: this.props.ref,
id: this.id,
}); });
}, },
@ -127,6 +138,7 @@ const JSView = {
"data-iframe-local-port", "data-iframe-local-port",
parseInteger 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) { handleServerInit(payload) {
this.clearInitTimeout();
this.initReceived = true;
this.childReadyPromise.then(() => { this.childReadyPromise.then(() => {
this.postMessage({ type: "init", data: payload }); this.postMessage({ type: "init", data: payload });
}); });
}, },
handleServerEvent(event, payload) { handleServerEvent(event, payload) {
if (!this.initReceived) {
return;
}
this.childReadyPromise.then(() => { this.childReadyPromise.then(() => {
this.postMessage({ type: "event", event, payload }); this.postMessage({ type: "event", event, payload });
}); });

View file

@ -3,21 +3,23 @@ defmodule LivebookWeb.JSViewChannel do
@impl true @impl true
def join("js_view", %{"session_id" => session_id}, socket) do 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 end
@impl true @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) {:ok, data} = Phoenix.Token.verify(LivebookWeb.Endpoint, "js view", session_token)
%{pid: pid} = data %{pid: pid} = data
send(pid, {:connect, self(), %{origin: self(), ref: ref}}) send(pid, {:connect, self(), %{origin: self(), ref: ref}})
ref_with_pid = Map.put(socket.assigns.ref_with_pid, ref, pid) socket =
ref_with_count = Map.update(socket.assigns.ref_with_count, ref, 1, &(&1 + 1)) update_in(socket.assigns.ref_with_info[ref], fn
socket = assign(socket, ref_with_pid: ref_with_pid, ref_with_count: ref_with_count) 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( Livebook.Session.subscribe_to_runtime_events(
socket.assigns.session_id, socket.assigns.session_id,
"js_live", "js_live",
@ -32,26 +34,24 @@ defmodule LivebookWeb.JSViewChannel do
def handle_in("event", raw, socket) do def handle_in("event", raw, socket) do
{[event, ref], payload} = transport_decode!(raw) {[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}}) send(pid, {:event, event, payload, %{origin: self(), ref: ref}})
{:noreply, socket} {:noreply, socket}
end end
def handle_in("disconnect", %{"ref" => ref}, socket) do def handle_in("disconnect", %{"ref" => ref}, socket) do
socket = 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( Livebook.Session.unsubscribe_from_runtime_events(
socket.assigns.session_id, socket.assigns.session_id,
"js_live", "js_live",
ref ref
) )
{_, ref_with_count} = Map.pop!(socket.assigns.ref_with_count, ref) {_, socket} = pop_in(socket.assigns.ref_with_info[ref])
{_, ref_with_pid} = Map.pop!(socket.assigns.ref_with_pid, ref) socket
assign(socket, ref_with_count: ref_with_count, ref_with_pid: ref_with_pid)
else else
ref_with_count = Map.update!(socket.assigns.ref_with_count, ref, &(&1 - 1)) update_in(socket.assigns.ref_with_info[ref], &%{&1 | count: &1.count - 1})
assign(socket, ref_with_count: ref_with_count)
end end
{:noreply, socket} {:noreply, socket}
@ -59,7 +59,16 @@ defmodule LivebookWeb.JSViewChannel do
@impl true @impl true
def handle_info({:connect_reply, payload, %{ref: ref}}, socket) do 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 message = "Failed to serialize initial widget data, " <> error
push(socket, "error:#{ref}", %{"message" => message}) push(socket, "error:#{ref}", %{"message" => message})
end end

View file

@ -1,6 +1,14 @@
defmodule LivebookWeb.JSViewComponent do defmodule LivebookWeb.JSViewComponent do
use LivebookWeb, :live_component 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 @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -12,7 +20,8 @@ defmodule LivebookWeb.JSViewComponent do
data-js-path={@js_view.assets.js_path} data-js-path={@js_view.assets.js_path}
data-session-token={session_token(@js_view.pid)} data-session-token={session_token(@js_view.pid)}
data-session-id={@session_id} 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> </div>
""" """
end end

View file

@ -65,7 +65,8 @@ defmodule LivebookWeb.Output do
live_component(LivebookWeb.JSViewComponent, live_component(LivebookWeb.JSViewComponent,
id: id, id: id,
js_view: js_info.js_view, 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 end

View file

@ -134,7 +134,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
</div> </div>
<% :dead -> %> <% :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. Evaluate and install dependencies to show the contents of this Smart cell.
</div> </div>

View file

@ -15,16 +15,29 @@ defmodule LivebookWeb.JSViewChannelTest do
end end
test "loads initial data from the widget server and pushes to the client", %{socket: socket} do 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, %{}} assert_receive {:connect, from, %{}}
send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}}) 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 end
test "sends client events to the corresponding widget server", %{socket: socket} do 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, %{}} assert_receive {:connect, from, %{}}
send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}}) send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}})
@ -36,17 +49,18 @@ defmodule LivebookWeb.JSViewChannelTest do
describe "binary payload" do describe "binary payload" do
test "initial data", %{socket: socket} 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, %{}} assert_receive {:connect, from, %{}}
payload = {:binary, %{message: "hey"}, <<1, 2, 3>>} payload = {:binary, %{message: "hey"}, <<1, 2, 3>>}
send(from, {:connect_reply, payload, %{ref: "1"}}) 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 end
test "form client to server", %{socket: socket} do 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, %{}} assert_receive {:connect, from, %{}}
send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}}) send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}})