Synchronize smart cell source before evaluation (#1164)

* Synchronize smart cell source before evaluation

* Sync JS view only if present

* Synchronize with smart cell only when started

* Update references to kino packages

* Update the Smart cells notebook

* Rename add dependnecy to add package

* Update changelog
This commit is contained in:
Jonatan Kłosko 2022-05-03 15:09:37 +02:00 committed by GitHub
parent 96de866c34
commit 9a016e439d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 264 additions and 52 deletions

View file

@ -43,6 +43,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Restructured cell insert buttons ([#1073](https://github.com/livebook-dev/livebook/pull/1073)) - Restructured cell insert buttons ([#1073](https://github.com/livebook-dev/livebook/pull/1073))
- Allowed inserting images without specifying name in Markdown cells ([#1083](https://github.com/livebook-dev/livebook/pull/1083)) - Allowed inserting images without specifying name in Markdown cells ([#1083](https://github.com/livebook-dev/livebook/pull/1083))
- Unified authentication to always redirect to the initial URL ([#1104](https://github.com/livebook-dev/livebook/pull/1104) and [#1112](https://github.com/livebook-dev/livebook/pull/1112)) - Unified authentication to always redirect to the initial URL ([#1104](https://github.com/livebook-dev/livebook/pull/1104) and [#1112](https://github.com/livebook-dev/livebook/pull/1112))
- Erase outputs action to clear cell indicators ([#1160](https://github.com/livebook-dev/livebook/pull/1160))
### Removed ### Removed

View file

@ -83,6 +83,11 @@ const Cell = {
this.unsubscribeFromCellsEvents = globalPubSub.subscribe("cells", (event) => this.unsubscribeFromCellsEvents = globalPubSub.subscribe("cells", (event) =>
this.handleCellsEvent(event) this.handleCellsEvent(event)
); );
this.unsubscribeFromCellEvents = globalPubSub.subscribe(
`cells:${this.props.cellId}`,
(event) => this.handleCellEvent(event)
);
}, },
disconnected() { disconnected() {
@ -93,6 +98,7 @@ const Cell = {
destroyed() { destroyed() {
this.unsubscribeFromNavigationEvents(); this.unsubscribeFromNavigationEvents();
this.unsubscribeFromCellsEvents(); this.unsubscribeFromCellsEvents();
this.unsubscribeFromCellEvents();
}, },
updated() { updated() {
@ -114,6 +120,11 @@ const Cell = {
"data-evaluation-digest", "data-evaluation-digest",
null null
), ),
smartCellJSViewRef: getAttributeOrDefault(
this.el,
"data-smart-cell-js-view-ref",
null
),
}; };
}, },
@ -135,6 +146,12 @@ const Cell = {
} }
}, },
handleCellEvent(event) {
if (event.type === "dispatch_queue_evaluation") {
this.handleDispatchQueueEvaluation(event.dispatch);
}
},
handleElementFocused(focusableId, scroll) { handleElementFocused(focusableId, scroll) {
if (this.props.cellId === focusableId) { if (this.props.cellId === focusableId) {
this.isFocused = true; this.isFocused = true;
@ -325,6 +342,18 @@ const Cell = {
} }
}, },
handleDispatchQueueEvaluation(dispatch) {
if (this.props.type === "smart" && this.props.smartCellJSViewRef) {
// Ensure the smart cell UI is reflected on the server, before the evaluation
globalPubSub.broadcast(`js_views:${this.props.smartCellJSViewRef}`, {
type: "sync",
callback: dispatch,
});
} else {
dispatch();
}
},
handleLocationReport(client, report) { handleLocationReport(client, report) {
Object.entries(this.liveEditors).forEach(([tag, liveEditor]) => { Object.entries(this.liveEditors).forEach(([tag, liveEditor]) => {
if ( if (

View file

@ -1,5 +1,6 @@
import { getAttributeOrThrow, parseInteger } from "../lib/attribute"; import { getAttributeOrThrow, parseInteger } from "../lib/attribute";
import { isElementHidden, randomId, randomToken } from "../lib/utils"; import { isElementHidden, randomId, randomToken } from "../lib/utils";
import { globalPubSub } from "../lib/pub_sub";
import { import {
getChannel, getChannel,
transportDecode, transportDecode,
@ -58,6 +59,8 @@ const JSView = {
this.childReadyPromise = null; this.childReadyPromise = null;
this.childReady = false; this.childReady = false;
this.initReceived = false; this.initReceived = false;
this.syncCallbackQueue = [];
this.pongCallbackQueue = [];
this.initTimeout = setTimeout(() => this.handleInitTimeout(), 2_000); this.initTimeout = setTimeout(() => this.handleInitTimeout(), 2_000);
@ -104,12 +107,22 @@ const JSView = {
} }
); );
const pongRef = this.channel.on(`pong:${this.props.ref}`, () => {
this.handleServerPong();
});
this.unsubscribeFromChannelEvents = () => { this.unsubscribeFromChannelEvents = () => {
this.channel.off(`init:${this.props.ref}:${this.id}`, 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);
this.channel.off(`pong:${this.props.ref}`, pongRef);
}; };
this.unsubscribeFromJSViewEvents = globalPubSub.subscribe(
`js_views:${this.props.ref}`,
(event) => this.handleJSViewEvent(event)
);
this.channel.push( this.channel.push(
"connect", "connect",
{ {
@ -134,6 +147,8 @@ const JSView = {
this.unsubscribeFromChannelEvents(); this.unsubscribeFromChannelEvents();
this.channel.push("disconnect", { ref: this.props.ref }); this.channel.push("disconnect", { ref: this.props.ref });
this.unsubscribeFromJSViewEvents();
}, },
getProps() { getProps() {
@ -296,6 +311,9 @@ const JSView = {
const { event, payload } = message; const { event, payload } = message;
const raw = transportEncode([event, this.props.ref], payload); const raw = transportEncode([event, this.props.ref], payload);
this.channel.push("event", raw); this.channel.push("event", raw);
} else if (message.type === "syncReply") {
this.pongCallbackQueue.push(this.syncCallbackQueue.shift());
this.channel.push("ping", { ref: this.props.ref });
} }
} }
}, },
@ -361,6 +379,21 @@ const JSView = {
this.errorContainer.textContent = message; this.errorContainer.textContent = message;
}, },
handleServerPong() {
const callback = this.pongCallbackQueue.shift();
callback();
},
handleJSViewEvent(event) {
if (event.type === "sync") {
// First, we invoke optional synchronization callback in the iframe,
// that may send any deferred UI changes to the server. Then, we
// do a ping to synchronize with the server
this.syncCallbackQueue.push(event.callback);
this.postMessage({ type: "sync" });
}
},
}; };
export default JSView; export default JSView;

View file

@ -26,7 +26,7 @@ import { sha256Base64 } from "../../lib/utils";
// (2): https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox // (2): https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox
// (3): https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts // (3): https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
const IFRAME_SHA256 = "4gyeA71Bpb4SGj2M0BUdT1jtk6wjUqOf6Q8wVYp7htc="; const IFRAME_SHA256 = "fA00WeO9LAvgpbMz9vKEU0WTr4Uk5bTt/BxKHdweEz8=";
export function initializeIframeSource(iframe, iframePort) { export function initializeIframeSource(iframe, iframePort) {
const iframeUrl = getIframeUrl(iframePort); const iframeUrl = getIframeUrl(iframePort);

View file

@ -488,6 +488,14 @@ const Session = {
this.setInsertMode(true); this.setInsertMode(true);
} }
const evalButton = event.target.closest(
`[data-el-queue-cell-evaluation-button]`
);
if (evalButton) {
const cellId = evalButton.getAttribute("data-cell-id");
this.queueCellEvaluation(cellId);
}
const hash = window.location.hash; const hash = window.location.hash;
if (hash) { if (hash) {
@ -700,10 +708,16 @@ const Session = {
} }
}, },
queueCellEvaluation(cellId) {
this.dispatchQueueEvaluation(() => {
this.pushEvent("queue_cell_evaluation", { cell_id: cellId });
});
},
queueFocusedCellEvaluation() { queueFocusedCellEvaluation() {
if (this.focusedId && this.isCell(this.focusedId)) { if (this.focusedId && this.isCell(this.focusedId)) {
this.pushEvent("queue_cell_evaluation", { this.dispatchQueueEvaluation(() => {
cell_id: this.focusedId, this.pushEvent("queue_cell_evaluation", { cell_id: this.focusedId });
}); });
} }
}, },
@ -714,8 +728,10 @@ const Session = {
? [this.focusedId] ? [this.focusedId]
: []; : [];
this.pushEvent("queue_full_evaluation", { this.dispatchQueueEvaluation(() => {
forced_cell_ids: forcedCellIds, this.pushEvent("queue_full_evaluation", {
forced_cell_ids: forcedCellIds,
});
}); });
}, },
@ -724,13 +740,29 @@ const Session = {
const sectionId = this.getSectionIdByFocusableId(this.focusedId); const sectionId = this.getSectionIdByFocusableId(this.focusedId);
if (sectionId) { if (sectionId) {
this.pushEvent("queue_section_evaluation", { this.dispatchQueueEvaluation(() => {
section_id: sectionId, this.pushEvent("queue_section_evaluation", {
section_id: sectionId,
});
}); });
} }
} }
}, },
dispatchQueueEvaluation(dispatch) {
if (isEvaluable(this.focusedCellType())) {
// If an evaluable cell is focused, we forward the evaluation
// request to that cell, so it can synchronize itself before
// sending the request to the server
globalPubSub.broadcast(`cells:${this.focusedId}`, {
type: "dispatch_queue_evaluation",
dispatch,
});
} else {
dispatch();
}
},
cancelFocusedCellEvaluation() { cancelFocusedCellEvaluation() {
if (this.focusedId && this.isCell(this.focusedId)) { if (this.focusedId && this.isCell(this.focusedId)) {
this.pushEvent("cancel_cell_evaluation", { this.pushEvent("cancel_cell_evaluation", {

View file

@ -28,6 +28,7 @@
importPromise: null, importPromise: null,
eventHandlers: {}, eventHandlers: {},
eventQueue: [], eventQueue: [],
syncHandler: null,
}; };
function postMessage(message) { function postMessage(message) {
@ -75,6 +76,10 @@
document.head.appendChild(linkEl); document.head.appendChild(linkEl);
}); });
}, },
handleSync(callback) {
state.syncHandler = callback;
},
}; };
window.addEventListener("message", (event) => { window.addEventListener("message", (event) => {
@ -128,6 +133,12 @@
} else { } else {
state.eventQueue.push({ event, payload }); state.eventQueue.push({ event, payload });
} }
} else if (message.type === "sync") {
Promise.resolve(state.syncHandler && state.syncHandler()).then(
() => {
postMessage({ type: "syncReply" });
}
);
} }
} }

View file

@ -30,11 +30,11 @@ defmodule Livebook do
[ [
%{ %{
dependency: {:kino, "~> 0.6.0"}, dependency: {:kino, "~> 0.6.1"},
description: "Interactive widgets for Livebook", description: "Interactive widgets for Livebook",
name: "kino", name: "kino",
url: "https://hex.pm/packages/kino", url: "https://hex.pm/packages/kino",
version: "0.6.0" version: "0.6.1"
} }
] ]

View file

@ -56,7 +56,7 @@ double-click on "Notebook dependencies and setup", add and run the code.
```elixir ```elixir
Mix.install([ Mix.install([
{:kino, "~> 0.6.0"} {:kino, "~> 0.6.1"}
]) ])
``` ```

View file

@ -3,7 +3,7 @@
```elixir ```elixir
Mix.install([ Mix.install([
{:vega_lite, "~> 0.1.4"}, {:vega_lite, "~> 0.1.4"},
{:kino_vega_lite, "~> 0.1.0"} {:kino_vega_lite, "~> 0.1.1"}
]) ])
``` ```

View file

@ -2,7 +2,7 @@
```elixir ```elixir
Mix.install([ Mix.install([
{:kino, "~> 0.6.0"} {:kino, "~> 0.6.1"}
]) ])
``` ```

View file

@ -2,7 +2,7 @@
```elixir ```elixir
Mix.install([ Mix.install([
{:kino, "~> 0.6.0"} {:kino, "~> 0.6.1"}
]) ])
``` ```

View file

@ -2,7 +2,7 @@
```elixir ```elixir
Mix.install([ Mix.install([
{:kino, "~> 0.6.0"} {:kino, "~> 0.6.1"}
]) ])
``` ```

View file

@ -2,7 +2,7 @@
```elixir ```elixir
Mix.install([ Mix.install([
{:kino, "~> 0.6.0"} {:kino, "~> 0.6.1"}
]) ])
``` ```

View file

@ -2,7 +2,7 @@
```elixir ```elixir
Mix.install([ Mix.install([
{:kino, "~> 0.6.0"}, {:kino, "~> 0.6.1"},
{:jason, "~> 1.3"} {:jason, "~> 1.3"}
]) ])
``` ```
@ -90,9 +90,15 @@ defmodule KinoGuide.PrintCell do
textEl.value = text; textEl.value = text;
}); });
textEl.addEventListener("blur", (event) => { textEl.addEventListener("change", (event) => {
ctx.pushEvent("update_text", event.target.value); ctx.pushEvent("update_text", event.target.value);
}); });
ctx.handleSync(() => {
// Synchronously invokes change listeners
document.activeElement &&
document.activeElement.dispatchEvent(new Event("change"));
});
} }
""" """
end end
@ -125,15 +131,21 @@ so that Livebook picks it up. Note that in practice we would put the
Smart cell in a package and we would register it in `application.ex` Smart cell in a package and we would register it in `application.ex`
when starting the application. when starting the application.
Note that we register a synchronization handler on the client with
`ctx.handleSync(() => ...)`. This optional handler is invoked before
evaluation and it should flush any deferred UI changes to the server.
In our example we listen to input's "change" event, which is only
triggered on blur, so on synchronization we trigger it programmatically.
<!-- livebook:{"break_markdown":true} --> <!-- livebook:{"break_markdown":true} -->
Now let's try out the new cell! We already inserted one below, but you Now let's try out the new cell! We already inserted one below, but you
can add more with the <kbd>+ Smart</kbd> button. can add more with the <kbd>+ Smart</kbd> button.
<!-- livebook:{"attrs":{"text":"something"},"kind":"Elixir.KinoGuide.PrintCell","livebook_object":"smart_cell"} --> <!-- livebook:{"attrs":{"text":"Hello!"},"kind":"Elixir.KinoGuide.PrintCell","livebook_object":"smart_cell"} -->
```elixir ```elixir
IO.puts("something") IO.puts("Hello!")
``` ```
Focus the Smart cell and click the "Source" icon. You should see the Focus the Smart cell and click the "Source" icon. You should see the
@ -329,13 +341,19 @@ defmodule KinoGuide.JSONConverterCell do
const variableEl = ctx.root.querySelector(`[name="variable"]`); const variableEl = ctx.root.querySelector(`[name="variable"]`);
variableEl.value = payload.variable; variableEl.value = payload.variable;
variableEl.addEventListener("blur", (event) => { variableEl.addEventListener("change", (event) => {
ctx.pushEvent("update_variable", event.target.value); ctx.pushEvent("update_variable", event.target.value);
}); });
ctx.handleEvent("update_variable", (variable) => { ctx.handleEvent("update_variable", (variable) => {
variableEl.value = variable; variableEl.value = variable;
}); });
ctx.handleSync(() => {
// Synchronously invokes change listeners
document.activeElement &&
document.activeElement.dispatchEvent(new Event("change"));
});
} }
""" """
end end

View file

@ -2,8 +2,8 @@
```elixir ```elixir
Mix.install([ Mix.install([
{:kino, "~> 0.6.0"}, {:kino, "~> 0.6.1"},
{:kino_vega_lite, "~> 0.1.0"} {:kino_vega_lite, "~> 0.1.1"}
]) ])
alias VegaLite, as: Vl alias VegaLite, as: Vl

View file

@ -411,7 +411,7 @@ defprotocol Livebook.Runtime do
to the runtime owner whenever attrs and the generated source code to the runtime owner whenever attrs and the generated source code
change. change.
* `{:runtime_smart_cell_update, ref, attrs, source}` * `{:runtime_smart_cell_update, ref, attrs, source, %{reevaluate: boolean()}}`
The attrs are persisted and may be used to restore the smart cell The attrs are persisted and may be used to restore the smart cell
state later. Note that for persistence they get serialized and state later. Note that for persistence they get serialized and

View file

@ -17,8 +17,8 @@ defmodule Livebook.Runtime.ElixirStandalone do
server_pid: pid() | nil server_pid: pid() | nil
} }
kino_vega_lite = %{name: "kino_vega_lite", dependency: {:kino_vega_lite, "~> 0.1.0"}} kino_vega_lite = %{name: "kino_vega_lite", dependency: {:kino_vega_lite, "~> 0.1.1"}}
kino_db = %{name: "kino_db", dependency: {:kino_db, "~> 0.1.0"}} kino_db = %{name: "kino_db", dependency: {:kino_db, "~> 0.1.1"}}
@extra_smart_cell_definitions [ @extra_smart_cell_definitions [
%{ %{

View file

@ -1001,6 +1001,18 @@ defmodule Livebook.Session do
end end
end end
def handle_info({:pong, {:smart_cell_evaluation, cell_id}, _info}, state) do
state =
with {:ok, cell, section} <- Notebook.fetch_cell_and_section(state.data.notebook, cell_id),
:evaluating <- state.data.cell_infos[cell.id].eval.status do
start_evaluation(state, cell, section)
else
_ -> state
end
{:noreply, state}
end
def handle_info(_message, state), do: {:noreply, state} def handle_info(_message, state), do: {:noreply, state}
@impl true @impl true
@ -1308,28 +1320,20 @@ defmodule Livebook.Session do
end end
defp handle_action(state, {:start_evaluation, cell, section}) do defp handle_action(state, {:start_evaluation, cell, section}) do
path = info = state.data.cell_infos[cell.id]
case state.data.file do
nil -> ""
file -> file.path
end
file = path <> "#cell" if is_struct(cell, Cell.Smart) and info.status == :started do
# We do a ping and start evaluation only once we get a reply,
# this way we make sure we received all relevant source changes
send(
cell.js_view.pid,
{:ping, self(), {:smart_cell_evaluation, cell.id}, %{ref: cell.js_view.ref}}
)
smart_cell_ref = state
case cell do else
%Cell.Smart{} -> cell.id start_evaluation(state, cell, section)
_ -> nil end
end
opts = [file: file, smart_cell_ref: smart_cell_ref]
locator = {container_ref_for_section(section), cell.id}
base_locator = find_base_locator(state.data, cell, section)
Runtime.evaluate_code(state.data.runtime, cell.source, locator, base_locator, opts)
evaluation_digest = :erlang.md5(cell.source)
handle_operation(state, {:evaluation_started, self(), cell.id, evaluation_digest})
end end
defp handle_action(state, {:stop_evaluation, section}) do defp handle_action(state, {:stop_evaluation, section}) do
@ -1384,6 +1388,31 @@ defmodule Livebook.Session do
defp handle_action(state, _action), do: state defp handle_action(state, _action), do: state
defp start_evaluation(state, cell, section) do
path =
case state.data.file do
nil -> ""
file -> file.path
end
file = path <> "#cell"
smart_cell_ref =
case cell do
%Cell.Smart{} -> cell.id
_ -> nil
end
opts = [file: file, smart_cell_ref: smart_cell_ref]
locator = {container_ref_for_section(section), cell.id}
base_locator = find_base_locator(state.data, cell, section)
Runtime.evaluate_code(state.data.runtime, cell.source, locator, base_locator, opts)
evaluation_digest = :erlang.md5(cell.source)
handle_operation(state, {:evaluation_started, self(), cell.id, evaluation_digest})
end
defp broadcast_operation(session_id, operation) do defp broadcast_operation(session_id, operation) do
broadcast_message(session_id, {:operation, operation}) broadcast_message(session_id, {:operation, operation})
end end

View file

@ -39,6 +39,12 @@ defmodule LivebookWeb.JSViewChannel do
{:noreply, socket} {:noreply, socket}
end end
def handle_in("ping", %{"ref" => ref}, socket) do
pid = socket.assigns.ref_with_info[ref].pid
send(pid, {:ping, self(), nil, %{ref: ref}})
{:noreply, socket}
end
def handle_in("disconnect", %{"ref" => ref}, socket) do def handle_in("disconnect", %{"ref" => ref}, socket) do
socket = socket =
if socket.assigns.ref_with_info[ref].count == 1 do if socket.assigns.ref_with_info[ref].count == 1 do
@ -76,6 +82,11 @@ defmodule LivebookWeb.JSViewChannel do
{:noreply, socket} {:noreply, socket}
end end
def handle_info({:pong, _, %{ref: ref}}, socket) do
push(socket, "pong:#{ref}", %{})
{:noreply, socket}
end
def handle_info({:encoding_error, error, {:event, _event, _payload, %{ref: ref}}}, socket) do def handle_info({:encoding_error, error, {:event, _event, _payload, %{ref: ref}}}, socket) do
message = "Failed to serialize widget data, " <> error message = "Failed to serialize widget data, " <> error
push(socket, "error:#{ref}", %{"message" => message}) push(socket, "error:#{ref}", %{"message" => message})

View file

@ -14,7 +14,8 @@ defmodule LivebookWeb.SessionLive.CellComponent do
data-session-path={Routes.session_path(@socket, :page, @session_id)} data-session-path={Routes.session_path(@socket, :page, @session_id)}
data-evaluation-digest={get_in(@cell_view, [:eval, :evaluation_digest])} data-evaluation-digest={get_in(@cell_view, [:eval, :evaluation_digest])}
data-eval-validity={get_in(@cell_view, [:eval, :validity])} data-eval-validity={get_in(@cell_view, [:eval, :validity])}
data-js-empty={empty?(@cell_view.source_view)}> data-js-empty={empty?(@cell_view.source_view)}
data-smart-cell-js-view-ref={smart_cell_js_view_ref(@cell_view)}>
<%= render_cell(assigns) %> <%= render_cell(assigns) %>
</div> </div>
""" """
@ -272,8 +273,8 @@ defmodule LivebookWeb.SessionLive.CellComponent do
defp cell_evaluation_button(%{status: :ready} = assigns) do defp cell_evaluation_button(%{status: :ready} = assigns) do
~H""" ~H"""
<button class="text-gray-600 hover:text-gray-800 focus:text-gray-800 flex space-x-1 items-center" <button class="text-gray-600 hover:text-gray-800 focus:text-gray-800 flex space-x-1 items-center"
phx-click="queue_cell_evaluation" data-el-queue-cell-evaluation-button
phx-value-cell_id={@cell_id}> data-cell-id={@cell_id}>
<.remix_icon icon="play-circle-fill" class="text-xl" /> <.remix_icon icon="play-circle-fill" class="text-xl" />
<span class="text-sm font-medium"> <span class="text-sm font-medium">
<%= if(@validity == :evaluated, do: "Reevaluate", else: "Evaluate") %> <%= if(@validity == :evaluated, do: "Reevaluate", else: "Evaluate") %>
@ -298,8 +299,8 @@ defmodule LivebookWeb.SessionLive.CellComponent do
defp setup_cell_evaluation_button(%{status: :ready} = assigns) do defp setup_cell_evaluation_button(%{status: :ready} = assigns) do
~H""" ~H"""
<button class="text-gray-600 hover:text-gray-800 focus:text-gray-800 flex space-x-1 items-center" <button class="text-gray-600 hover:text-gray-800 focus:text-gray-800 flex space-x-1 items-center"
phx-click="queue_cell_evaluation" data-el-queue-cell-evaluation-button
phx-value-cell_id={@cell_id}> data-cell-id={@cell_id}>
<%= if @validity == :fresh do %> <%= if @validity == :fresh do %>
<.remix_icon icon="play-circle-fill" class="text-xl" /> <.remix_icon icon="play-circle-fill" class="text-xl" />
<span class="text-sm font-medium">Setup</span> <span class="text-sm font-medium">Setup</span>
@ -388,10 +389,10 @@ defmodule LivebookWeb.SessionLive.CellComponent do
</button> </button>
</span> </span>
<% else %> <% else %>
<span class="tooltip top" data-tooltip="Add dependency (sp)"> <span class="tooltip top" data-tooltip="Add package (sp)">
<%= live_patch to: Routes.session_path(@socket, :package_search, @session_id), <%= live_patch to: Routes.session_path(@socket, :package_search, @session_id),
class: "icon-button", class: "icon-button",
aria_label: "add dependency", aria_label: "add package",
role: "button", role: "button",
data_btn_package_search: true do %> data_btn_package_search: true do %>
<.remix_icon icon="play-list-add-line" class="text-xl" /> <.remix_icon icon="play-list-add-line" class="text-xl" />
@ -611,4 +612,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
end end
defp evaluated_label(_time_ms), do: nil defp evaluated_label(_time_ms), do: nil
defp smart_cell_js_view_ref(%{type: :smart, js_view: %{ref: ref}}), do: ref
defp smart_cell_js_view_ref(_cell_view), do: nil
end end

View file

@ -676,6 +676,50 @@ defmodule Livebook.SessionTest do
assert_receive {:editor_source, "content!"} assert_receive {:editor_source, "content!"}
end end
test "pings the smart cell before evaluation to await all incoming messages" do
smart_cell = %{Notebook.Cell.new(:smart) | kind: "text", source: "1"}
notebook = %{Notebook.new() | sections: [%{Notebook.Section.new() | cells: [smart_cell]}]}
session = start_session(notebook: notebook)
runtime = connected_noop_runtime()
Session.set_runtime(session.pid, runtime)
send(
session.pid,
{:runtime_smart_cell_definitions, [%{kind: "text", name: "Text", requirement: nil}]}
)
Session.subscribe(session.id)
send(
session.pid,
{:runtime_smart_cell_started, smart_cell.id,
%{source: "1", js_view: %{pid: self(), ref: "ref"}, editor: nil}}
)
Session.queue_cell_evaluation(session.pid, smart_cell.id)
send(
session.pid,
{:runtime_evaluation_response, "setup", {:ok, ""}, %{evaluation_time_ms: 10}}
)
session_pid = session.pid
assert_receive {:ping, ^session_pid, metadata, %{ref: "ref"}}
# Update the source before replying to ping
send(
session.pid,
{:runtime_smart_cell_update, smart_cell.id, %{}, "2", %{reevaluate: false}}
)
send(session_pid, {:pong, metadata, %{ref: "ref"}})
cell_id = smart_cell.id
new_digest = :erlang.md5("2")
assert_receive {:operation, {:evaluation_started, ^session_pid, ^cell_id, ^new_digest}}
end
end end
describe "find_base_locator/3" do describe "find_base_locator/3" do