mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-12-11 14:16:44 +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 {
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"}})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue