mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-05 04:24:21 +08:00
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:
parent
01b9ffe731
commit
ac5a71bf85
7 changed files with 100 additions and 29 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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"}})
|
||||
|
|
Loading…
Add table
Reference in a new issue