mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-12-17 13:43:05 +08:00
Improve UX when loading JS output fails or takes long time (#2975)
This commit is contained in:
parent
79259a1c35
commit
0aa4013f3f
5 changed files with 79 additions and 46 deletions
|
|
@ -56,8 +56,8 @@ import { initializeIframeSource } from "./js_view/iframe";
|
||||||
*
|
*
|
||||||
* * `iframe-url` - an optional location to load the iframe from
|
* * `iframe-url` - an optional location to load the iframe from
|
||||||
*
|
*
|
||||||
* * `timeout-message` - the message to show when the initial
|
* * `unreachable-message` - the message to show when the initial
|
||||||
* data does not load
|
* data fails to load
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
const JSView = {
|
const JSView = {
|
||||||
|
|
@ -72,8 +72,6 @@ const JSView = {
|
||||||
this.syncCallbackQueue = [];
|
this.syncCallbackQueue = [];
|
||||||
this.pongCallbackQueue = [];
|
this.pongCallbackQueue = [];
|
||||||
|
|
||||||
this.initTimeout = setTimeout(() => this.handleInitTimeout(), 2_000);
|
|
||||||
|
|
||||||
this.channel = getChannel(this.props.sessionToken);
|
this.channel = getChannel(this.props.sessionToken);
|
||||||
|
|
||||||
this.iframeActions = this.createIframe();
|
this.iframeActions = this.createIframe();
|
||||||
|
|
@ -102,8 +100,13 @@ const JSView = {
|
||||||
const initRef = this.channel.on(
|
const initRef = this.channel.on(
|
||||||
`init:${this.props.ref}:${this.id}`,
|
`init:${this.props.ref}:${this.id}`,
|
||||||
(raw) => {
|
(raw) => {
|
||||||
const [, payload] = transportDecode(raw);
|
const [[ok], payload] = transportDecode(raw);
|
||||||
this.handleServerInit(payload);
|
this.removeSkeleton();
|
||||||
|
if (ok) {
|
||||||
|
this.handleServerInit(payload);
|
||||||
|
} else {
|
||||||
|
this.handleInitUnreachable();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -115,7 +118,10 @@ const JSView = {
|
||||||
const errorRef = this.channel.on(
|
const errorRef = this.channel.on(
|
||||||
`error:${this.props.ref}`,
|
`error:${this.props.ref}`,
|
||||||
({ message, init }) => {
|
({ message, init }) => {
|
||||||
this.handleServerError(message, init);
|
if (init) {
|
||||||
|
this.removeSkeleton();
|
||||||
|
}
|
||||||
|
this.handleServerError(message);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -187,7 +193,7 @@ const JSView = {
|
||||||
"connect-token",
|
"connect-token",
|
||||||
"iframe-port",
|
"iframe-port",
|
||||||
"iframe-url",
|
"iframe-url",
|
||||||
"timeout-message",
|
"unreachable-message",
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -416,23 +422,14 @@ const JSView = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleInitTimeout() {
|
handleInitUnreachable() {
|
||||||
this.initTimeoutContainer = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
this.initTimeoutContainer.classList.add("info-box");
|
container.classList.add("info-box");
|
||||||
this.el.prepend(this.initTimeoutContainer);
|
this.el.prepend(container);
|
||||||
this.initTimeoutContainer.textContent = this.props.timeoutMessage;
|
container.textContent = this.props.unreachableMessage;
|
||||||
},
|
|
||||||
|
|
||||||
clearInitTimeout() {
|
|
||||||
clearTimeout(this.initTimeout);
|
|
||||||
|
|
||||||
if (this.initTimeoutContainer) {
|
|
||||||
this.initTimeoutContainer.remove();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
handleServerInit(payload) {
|
handleServerInit(payload) {
|
||||||
this.clearInitTimeout();
|
|
||||||
this.initReceived = true;
|
this.initReceived = true;
|
||||||
|
|
||||||
this.childReadyPromise.then(() => {
|
this.childReadyPromise.then(() => {
|
||||||
|
|
@ -450,11 +447,7 @@ const JSView = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
handleServerError(message, init) {
|
handleServerError(message) {
|
||||||
if (init) {
|
|
||||||
this.clearInitTimeout();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.errorContainer) {
|
if (!this.errorContainer) {
|
||||||
this.errorContainer = document.createElement("div");
|
this.errorContainer = document.createElement("div");
|
||||||
this.errorContainer.classList.add("error-box", "mb-4");
|
this.errorContainer.classList.add("error-box", "mb-4");
|
||||||
|
|
@ -497,6 +490,11 @@ const JSView = {
|
||||||
parentFocusableId === focusableId,
|
parentFocusableId === focusableId,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
removeSkeleton() {
|
||||||
|
const skeletonEl = this.el.querySelector(`[data-el-skeleton]`);
|
||||||
|
skeletonEl.remove();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,12 @@ defmodule LivebookWeb.JSViewChannel do
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
update_in(socket.assigns.ref_with_info[ref], fn
|
update_in(socket.assigns.ref_with_info[ref], fn
|
||||||
nil -> %{pid: pid, count: 1, connect_queue: [id]}
|
nil ->
|
||||||
info -> %{info | count: info.count + 1, connect_queue: info.connect_queue ++ [id]}
|
monitor_ref = Process.monitor(pid, tag: {:connect_down, ref})
|
||||||
|
%{pid: pid, monitor_ref: monitor_ref, count: 1, connect_queue: [id]}
|
||||||
|
|
||||||
|
info ->
|
||||||
|
%{info | count: info.count + 1, connect_queue: info.connect_queue ++ [id]}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
if socket.assigns.ref_with_info[ref].count == 1 do
|
if socket.assigns.ref_with_info[ref].count == 1 do
|
||||||
|
|
@ -66,13 +70,10 @@ defmodule LivebookWeb.JSViewChannel do
|
||||||
def handle_in("disconnect", %{"ref" => ref}, socket) do
|
def handle_in("disconnect", %{"ref" => ref}, socket) do
|
||||||
socket =
|
socket =
|
||||||
case socket.assigns.ref_with_info do
|
case socket.assigns.ref_with_info do
|
||||||
%{^ref => %{count: 1}} ->
|
%{^ref => %{count: 1, monitor_ref: monitor_ref}} ->
|
||||||
Livebook.Session.unsubscribe_from_runtime_events(
|
Process.demonitor(monitor_ref, [:flush])
|
||||||
socket.assigns.session_id,
|
session_id = socket.assigns.session_id
|
||||||
"js_live",
|
Livebook.Session.unsubscribe_from_runtime_events(session_id, "js_live", ref)
|
||||||
ref
|
|
||||||
)
|
|
||||||
|
|
||||||
{_, socket} = pop_in(socket.assigns.ref_with_info[ref])
|
{_, socket} = pop_in(socket.assigns.ref_with_info[ref])
|
||||||
socket
|
socket
|
||||||
|
|
||||||
|
|
@ -97,7 +98,7 @@ defmodule LivebookWeb.JSViewChannel do
|
||||||
{id, queue}
|
{id, queue}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
with {:error, error} <- try_push(socket, "init:#{ref}:#{id}", nil, payload) do
|
with {:error, error} <- try_push(socket, "init:#{ref}:#{id}", [true], payload) do
|
||||||
message = "Failed to serialize initial widget data, " <> error
|
message = "Failed to serialize initial widget data, " <> error
|
||||||
push(socket, "error:#{ref}", %{"message" => message, "init" => true})
|
push(socket, "error:#{ref}", %{"message" => message, "init" => true})
|
||||||
end
|
end
|
||||||
|
|
@ -105,6 +106,17 @@ defmodule LivebookWeb.JSViewChannel do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_info({{:connect_down, ref}, _ref, :process, _pid, _reason}, socket) do
|
||||||
|
Livebook.Session.unsubscribe_from_runtime_events(socket.assigns.session_id, "js_live", ref)
|
||||||
|
{%{connect_queue: ids}, socket} = pop_in(socket.assigns.ref_with_info[ref])
|
||||||
|
|
||||||
|
for id <- ids do
|
||||||
|
:ok = try_push(socket, "init:#{ref}:#{id}", [false], nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_info({:event, event, payload, %{ref: ref}}, socket) do
|
def handle_info({:event, event, payload, %{ref: ref}}, socket) do
|
||||||
with {:error, error} <- try_push(socket, "event:#{ref}", [event], payload) do
|
with {:error, error} <- try_push(socket, "event:#{ref}", [event], payload) do
|
||||||
message = "Failed to serialize widget data, " <> error
|
message = "Failed to serialize widget data, " <> error
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ defmodule LivebookWeb.JSViewComponent do
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(assigns)
|
|> assign(assigns)
|
||||||
|> assign_new(:timeout_message, fn -> "Not available" end)}
|
|> assign_new(:unreachable_message, fn -> "Not available" end)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -26,8 +26,11 @@ defmodule LivebookWeb.JSViewComponent do
|
||||||
data-p-connect-token={hook_prop(connect_token(@js_view.pid))}
|
data-p-connect-token={hook_prop(connect_token(@js_view.pid))}
|
||||||
data-p-iframe-port={hook_prop(LivebookWeb.IframeEndpoint.port())}
|
data-p-iframe-port={hook_prop(LivebookWeb.IframeEndpoint.port())}
|
||||||
data-p-iframe-url={hook_prop(Livebook.Config.iframe_url())}
|
data-p-iframe-url={hook_prop(Livebook.Config.iframe_url())}
|
||||||
data-p-timeout-message={hook_prop(@timeout_message)}
|
data-p-unreachable-message={hook_prop(@unreachable_message)}
|
||||||
>
|
>
|
||||||
|
<div class="delay-200 py-2" data-el-skeleton>
|
||||||
|
<.content_skeleton empty={false} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ defmodule LivebookWeb.Output do
|
||||||
js_view={@js_view}
|
js_view={@js_view}
|
||||||
session_id={@session_id}
|
session_id={@session_id}
|
||||||
client_id={@client_id}
|
client_id={@client_id}
|
||||||
timeout_message="Output data no longer available, please reevaluate this cell"
|
unreachable_message="Output data no longer available, please reevaluate this cell"
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ defmodule LivebookWeb.JSViewChannelTest do
|
||||||
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:id1", %{"root" => [nil, [1, 2, 3]]}
|
assert_push "init:1:id1", %{"root" => [[true], [1, 2, 3]]}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "loads initial data for multiple connections separately", %{socket: socket} do
|
test "loads initial data for multiple connections separately", %{socket: socket} do
|
||||||
|
|
@ -29,11 +29,31 @@ defmodule LivebookWeb.JSViewChannelTest do
|
||||||
|
|
||||||
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:id1", %{"root" => [nil, [1, 2, 3]]}
|
assert_push "init:1:id1", %{"root" => [[true], [1, 2, 3]]}
|
||||||
|
|
||||||
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:id2", %{"root" => [nil, [1, 2, 3]]}
|
assert_push "init:1:id2", %{"root" => [[true], [1, 2, 3]]}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sends init failure when the widget server terminates", %{socket: socket} do
|
||||||
|
widget_server_pid =
|
||||||
|
spawn(fn ->
|
||||||
|
# Respond only to the first one and terminate
|
||||||
|
receive do
|
||||||
|
{:connect, from, %{}} ->
|
||||||
|
send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}})
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
connect_token = connect_token(widget_server_pid)
|
||||||
|
|
||||||
|
push(socket, "connect", %{"connect_token" => connect_token, "ref" => "1", "id" => "id1"})
|
||||||
|
push(socket, "connect", %{"connect_token" => connect_token, "ref" => "1", "id" => "id2"})
|
||||||
|
|
||||||
|
assert_push "init:1:id1", %{"root" => [[true], [1, 2, 3]]}
|
||||||
|
|
||||||
|
assert_push "init:1:id2", %{"root" => [[false], nil]}
|
||||||
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
|
||||||
|
|
@ -74,7 +94,7 @@ defmodule LivebookWeb.JSViewChannelTest do
|
||||||
send(from, {:connect_reply, payload, %{ref: "1"}})
|
send(from, {:connect_reply, payload, %{ref: "1"}})
|
||||||
|
|
||||||
assert_push "init:1:id1",
|
assert_push "init:1:id1",
|
||||||
{:binary, <<24::size(32), "[null,{\"message\":\"hey\"}]", 1, 2, 3>>}
|
{:binary, <<26::size(32), "[[true],{\"message\":\"hey\"}]", 1, 2, 3>>}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "form client to server", %{socket: socket} do
|
test "form client to server", %{socket: socket} do
|
||||||
|
|
@ -98,7 +118,7 @@ defmodule LivebookWeb.JSViewChannelTest do
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
defp connect_token() do
|
defp connect_token(pid \\ self()) do
|
||||||
Phoenix.Token.sign(LivebookWeb.Endpoint, "js-view-connect", %{pid: self()})
|
Phoenix.Token.sign(LivebookWeb.Endpoint, "js-view-connect", %{pid: pid})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue