Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

⚡️ AbstractPlutoDingetjes.Display.with_js_link to request calculations and data from Julia dynamically #2726

Merged
merged 24 commits into from
Feb 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/components/CellOutput.js
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,11 @@ const execute_scripttags = async ({ root_node, script_nodes, previous_results_ma
// @ts-ignore
getPublishedObject: (id) => cell.getPublishedObject(id),

_internal_getJSLinkResponse: (cell_id, link_id) => (input) =>
pluto_actions.request_js_link_response(cell_id, link_id, input).then(([success, result]) => {
if (success) return result
throw result
}),
getBoundElementValueLikePluto: get_input_value,
setBoundElementValueLikePluto: set_input_value,
getBoundElementEventNameLikePluto: eventof,
Expand Down
13 changes: 13 additions & 0 deletions frontend/components/Editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,19 @@ export class Editor extends Component {
false
)
},
request_js_link_response: (cell_id, link_id, input) => {
return this.client
.send(
"request_js_link_response",
{
cell_id,
link_id,
input,
},
{ notebook_id: this.state.notebook.notebook_id }
)
.then((r) => r.message)
},
/** This actions avoids pushing selected cells all the way down, which is too heavy to handle! */
get_selected_cells: (cell_id, /** @type {boolean} */ allow_other_selected_cells) =>
allow_other_selected_cells ? this.state.selected_cells : [cell_id],
Expand Down
5 changes: 3 additions & 2 deletions src/evaluation/Run.jl
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,9 @@ function run_reactive_core!(
new_errable = keys(new_order.errable)
to_delete_vars = union!(to_delete_vars, defined_variables(new_topology, new_errable)...)
to_delete_funcs = union!(to_delete_funcs, defined_functions(new_topology, new_errable)...)

cells_to_macro_invalidate = Set{UUID}(c.cell_id for c in cells_with_deleted_macros(old_topology, new_topology))
cells_to_js_link_invalidate = Set{UUID}(c.cell_id for c in union!(Set{Cell}(), to_run, new_errable, indirectly_deactivated))

module_imports_to_move = reduce(all_cells(new_topology); init=Set{Expr}()) do module_imports_to_move, c
c ∈ to_run && return module_imports_to_move
Expand All @@ -156,7 +157,7 @@ function run_reactive_core!(

if will_run_code(notebook)
to_delete_funcs_simple = Set{Tuple{UUID,Tuple{Vararg{Symbol}}}}((id, name.parts) for (id,name) in to_delete_funcs)
deletion_hook((session, notebook), old_workspace_name, nothing, to_delete_vars, to_delete_funcs_simple, module_imports_to_move, cells_to_macro_invalidate; to_run) # `deletion_hook` defaults to `WorkspaceManager.move_vars`
deletion_hook((session, notebook), old_workspace_name, nothing, to_delete_vars, to_delete_funcs_simple, module_imports_to_move, cells_to_macro_invalidate, cells_to_js_link_invalidate; to_run) # `deletion_hook` defaults to `WorkspaceManager.move_vars`
end

foreach(v -> delete!(notebook.bonds, v), to_delete_vars)
Expand Down
3 changes: 2 additions & 1 deletion src/evaluation/RunBonds.jl
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function set_bond_values_reactive(;
bond_value_pairs = zip(syms_to_set, new_values)

syms_to_set_set = Set{Symbol}(syms_to_set)
function custom_deletion_hook((session, notebook)::Tuple{ServerSession,Notebook}, old_workspace_name, new_workspace_name, to_delete_vars::Set{Symbol}, methods_to_delete, module_imports_to_move, cells_to_macro_invalidate; to_run)
function custom_deletion_hook((session, notebook)::Tuple{ServerSession,Notebook}, old_workspace_name, new_workspace_name, to_delete_vars::Set{Symbol}, methods_to_delete, module_imports_to_move, cells_to_macro_invalidate, cells_to_js_link_invalidate; to_run)
to_delete_vars = union(to_delete_vars, syms_to_set_set) # also delete the bound symbols
WorkspaceManager.move_vars(
(session, notebook),
Expand All @@ -51,6 +51,7 @@ function set_bond_values_reactive(;
methods_to_delete,
module_imports_to_move,
cells_to_macro_invalidate,
cells_to_js_link_invalidate,
syms_to_set_set,
)
set_bond_value_pairs!(session, notebook, zip(syms_to_set, new_values))
Expand Down
10 changes: 7 additions & 3 deletions src/evaluation/WorkspaceManager.jl
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ function move_vars(
methods_to_delete::Set{Tuple{UUID,Tuple{Vararg{Symbol}}}},
module_imports_to_move::Set{Expr},
cells_to_macro_invalidate::Set{UUID},
cells_to_js_link_invalidate::Set{UUID},
keep_registered::Set{Symbol}=Set{Symbol}();
kwargs...
)
Expand All @@ -570,6 +571,7 @@ function move_vars(
$methods_to_delete,
$module_imports_to_move,
$cells_to_macro_invalidate,
$cells_to_js_link_invalidate,
$keep_registered,
)
end)
Expand All @@ -580,16 +582,18 @@ function move_vars(
to_delete::Set{Symbol},
methods_to_delete::Set{Tuple{UUID,Tuple{Vararg{Symbol}}}},
module_imports_to_move::Set{Expr},
cells_to_macro_invalidate::Set{UUID};
cells_to_macro_invalidate::Set{UUID},
cells_to_js_link_invalidate::Set{UUID};
kwargs...
)
move_vars(
session_notebook,
bump_workspace_module(session_notebook)...,
to_delete,
to_delete,
methods_to_delete,
module_imports_to_move,
cells_to_macro_invalidate;
cells_to_macro_invalidate,
cells_to_js_link_invalidate;
kwargs...
)
end
Expand Down
107 changes: 91 additions & 16 deletions src/runner/PlutoRunner/src/PlutoRunner.jl
Original file line number Diff line number Diff line change
Expand Up @@ -283,14 +283,9 @@ function try_macroexpand(mod::Module, notebook_id::UUID, cell_id::UUID, expr; ca
Expr(:block, expr)
end

logger = get!(() -> PlutoCellLogger(notebook_id, cell_id), pluto_cell_loggers, cell_id)
if logger.workspace_count < moduleworkspace_count[]
logger = pluto_cell_loggers[cell_id] = PlutoCellLogger(notebook_id, cell_id)
end
capture_logger = CaptureLogger(nothing, get_cell_logger(notebook_id, cell_id), Dict[])

capture_logger = CaptureLogger(nothing, logger, Dict[])

expanded_expr, elapsed_ns = with_logger_and_io_to_logs(capture_logger; capture_stdout, stdio_loglevel=stdout_log_level) do
expanded_expr, elapsed_ns = with_logger_and_io_to_logs(capture_logger; capture_stdout) do
elapsed_ns = time_ns()
expanded_expr = macroexpand(mod, expr_not_toplevel)::Expr
elapsed_ns = time_ns() - elapsed_ns
Expand Down Expand Up @@ -531,17 +526,17 @@ function run_expression(
old_currently_running_cell_id = currently_running_cell_id[]
currently_running_cell_id[] = cell_id

logger = get!(() -> PlutoCellLogger(notebook_id, cell_id), pluto_cell_loggers, cell_id)
if logger.workspace_count < moduleworkspace_count[]
logger = pluto_cell_loggers[cell_id] = PlutoCellLogger(notebook_id, cell_id)
end
logger = get_cell_logger(notebook_id, cell_id)

# reset published objects
cell_published_objects[cell_id] = Dict{String,Any}()

# reset registered bonds
cell_registered_bond_names[cell_id] = Set{Symbol}()

# reset JS links
unregister_js_link(cell_id)

# If the cell contains macro calls, we want those macro calls to preserve their identity,
# so we macroexpand this earlier (during expression explorer stuff), and then we find it here.
# NOTE Turns out sometimes there is no macroexpanded version even though the expression contains macro calls...
Expand Down Expand Up @@ -593,7 +588,7 @@ function run_expression(
throw("Expression still contains macro calls!!")
end

result, runtime = with_logger_and_io_to_logs(logger; capture_stdout, stdio_loglevel=stdout_log_level) do # about 200ns + 3ms overhead
result, runtime = with_logger_and_io_to_logs(logger; capture_stdout) do # about 200ns + 3ms overhead
if function_wrapped_info === nothing
toplevel_expr = Expr(:toplevel, expr)
wrapped = timed_expr(toplevel_expr)
Expand Down Expand Up @@ -691,6 +686,7 @@ function move_vars(
methods_to_delete::Set{Tuple{UUID,Tuple{Vararg{Symbol}}}},
module_imports_to_move::Set{Expr},
cells_to_macro_invalidate::Set{UUID},
cells_to_js_link_invalidate::Set{UUID},
keep_registered::Set{Symbol},
)
old_workspace = getfield(Main, old_workspace_name)
Expand All @@ -701,7 +697,8 @@ function move_vars(
for cell_id in cells_to_macro_invalidate
delete!(cell_expanded_exprs, cell_id)
end

foreach(unregister_js_link, cells_to_js_link_invalidate)

# TODO: delete
Core.eval(new_workspace, :(import ..($(old_workspace_name))))

Expand Down Expand Up @@ -929,8 +926,7 @@ function formatted_result_of(
errored = ans isa CapturedException

output_formatted = if (!ends_with_semicolon || errored)
logger = get!(() -> PlutoCellLogger(notebook_id, cell_id), pluto_cell_loggers, cell_id)
with_logger_and_io_to_logs(logger; capture_stdout, stdio_loglevel=stdout_log_level) do
with_logger_and_io_to_logs(get_cell_logger(notebook_id, cell_id); capture_stdout) do
format_output(ans; context=IOContext(
default_iocontext,
:extra_items=>extra_items,
Expand Down Expand Up @@ -1002,6 +998,7 @@ const default_iocontext = IOContext(devnull,
:is_pluto => true,
:pluto_supported_integration_features => supported_integration_features,
:pluto_published_to_js => (io, x) -> core_published_to_js(io, x),
:pluto_with_js_link => (io, callback, on_cancellation) -> core_with_js_link(io, callback, on_cancellation),
)

const default_stdout_iocontext = IOContext(devnull,
Expand Down Expand Up @@ -1757,6 +1754,9 @@ const integrations = Integration[
if isdefined(AbstractPlutoDingetjes.Display, :published_to_js)
supported!(AbstractPlutoDingetjes.Display.published_to_js)
end
if isdefined(AbstractPlutoDingetjes.Display, :with_js_link)
supported!(AbstractPlutoDingetjes.Display.with_js_link)
end
end

end,
Expand Down Expand Up @@ -2540,6 +2540,73 @@ function Base.show(io::IO, m::MIME"text/html", e::DivElement)
Base.show(io, m, embed_display(e))
end


###
# JS LINK
###

struct JSLink
callback::Function
on_cancellation::Union{Nothing,Function}
cancelled_ref::Ref{Bool}
end

const cell_js_links = Dict{UUID,Dict{String,JSLink}}()

function core_with_js_link(io, callback, on_cancellation)

_cell_id = get(io, :pluto_cell_id, currently_running_cell_id[])::UUID

link_id = String(rand('a':'z', 16))

links = get!(() -> Dict{String,JSLink}(), cell_js_links, _cell_id)
links[link_id] = JSLink(callback, on_cancellation, Ref(false))

write(io, "/* See the documentation for AbstractPlutoDingetjes.Display.with_js_link */ _internal_getJSLinkResponse(\"$(_cell_id)\", \"$(link_id)\")")
end

function unregister_js_link(cell_id::UUID)
# cancel old links
old_links = get!(() -> Dict{String,JSLink}(), cell_js_links, cell_id)
for (name, link) in old_links
link.cancelled_ref[] = true
end
for (name, link) in old_links
c = link.on_cancellation
c === nothing || c()
end

# clear
cell_js_links[cell_id] = Dict{String,JSLink}()
end

function evaluate_js_link(notebook_id::UUID, cell_id::UUID, link_id::String, input::Any)
links = get(() -> Dict{String,JSLink}(), cell_js_links, cell_id)
link = get(links, link_id, nothing)

with_logger_and_io_to_logs(get_cell_logger(notebook_id, cell_id); capture_stdout=false) do
if link === nothing
@warn "🚨 AbstractPlutoDingetjes: JS link not found." link_id

(false, "link not found")
elseif link.cancelled_ref[]
@warn "🚨 AbstractPlutoDingetjes: JS link has already been invalidated." link_id

(false, "link has been invalidated")
else
try
result = link.callback(input)
assertpackable(result)

(true, result)
catch ex
@error "🚨 AbstractPlutoDingetjes.Display.with_js_link: Exception while evaluating Julia callback." input exception=(ex, catch_backtrace())
(false, "exception in Julia callback:\n\n$(ex)")
end
end
end
end

###
# LOGGING
###
Expand Down Expand Up @@ -2581,6 +2648,14 @@ end
const pluto_cell_loggers = Dict{UUID,PlutoCellLogger}() # One logger per cell
const pluto_log_channels = Dict{UUID,Channel{Any}}() # One channel per notebook

function get_cell_logger(notebook_id, cell_id)
logger = get!(() -> PlutoCellLogger(notebook_id, cell_id), pluto_cell_loggers, cell_id)
if logger.workspace_count < moduleworkspace_count[]
logger = pluto_cell_loggers[cell_id] = PlutoCellLogger(notebook_id, cell_id)
end
logger
end

function Logging.shouldlog(logger::PlutoCellLogger, level, _module, _...)
# Accept logs
# - Only if the logger is the latest for this cell using the increasing workspace_count tied to each logger
Expand Down Expand Up @@ -2743,7 +2818,7 @@ function with_io_to_logs(f::Function; enabled::Bool=true, loglevel::Logging.LogL
result
end

function with_logger_and_io_to_logs(f, logger; capture_stdout=true, stdio_loglevel=Logging.LogLevel(1))
function with_logger_and_io_to_logs(f, logger; capture_stdout=true, stdio_loglevel=stdout_log_level)
Logging.with_logger(logger) do
with_io_to_logs(f; enabled=capture_stdout, loglevel=stdio_loglevel)
end
Expand Down
23 changes: 23 additions & 0 deletions src/webserver/Dynamic.jl
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,29 @@ responses[:reshow_cell] = function response_reshow_cell(🙋::ClientRequest)
send_notebook_changes!(🙋 |> without_initiator)
end

responses[:request_js_link_response] = function response_request_js_link_response(🙋::ClientRequest)
require_notebook(🙋)
@assert will_run_code(🙋.notebook)

Threads.@spawn try
result = WorkspaceManager.eval_fetch_in_workspace(
(🙋.session, 🙋.notebook),
quote
PlutoRunner.evaluate_js_link(
$(🙋.notebook.notebook_id),
$(UUID(🙋.body["cell_id"])),
$(🙋.body["link_id"]),
$(🙋.body["input"]),
)
end
)

putclientupdates!(🙋.session, 🙋.initiator, UpdateMessage(:🐤, result, nothing, nothing, 🙋.initiator))
catch ex
@error "Error in request_js_link_response" exception=(ex, stacktrace(catch_backtrace()))
end
end

responses[:nbpkg_available_versions] = function response_nbpkg_available_versions(🙋::ClientRequest)
# require_notebook(🙋)
all_versions = PkgCompat.package_versions(🙋.body["package_name"])
Expand Down
8 changes: 4 additions & 4 deletions test/frontend/__tests__/javascript_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,15 @@ describe("JavaScript API", () => {
page,
`# ╔═╡ 90cfa9a0-114d-49bf-8dea-e97d58fa2442
html"""<script>
const div = document.createElement("div")
const div = document.createElement("find-me")
div.innerHTML = "${expected}"
return div;
</script>"""
`
)
await runAllChanged(page)
await waitForPlutoToCalmDown(page, { polling: 100 })
const initialLastCellContent = await waitForContentToBecome(page, `pluto-cell:last-child pluto-output`, expected)
const initialLastCellContent = await waitForContentToBecome(page, `pluto-cell:last-child pluto-output find-me`, expected)
expect(initialLastCellContent).toBe(expected)
})

Expand All @@ -69,7 +69,7 @@ describe("JavaScript API", () => {
)
await runAllChanged(page)
await waitForPlutoToCalmDown(page, { polling: 100 })
let initialLastCellContent = await waitForContentToBecome(page, `pluto-cell:last-child pluto-output`, expected)
let initialLastCellContent = await waitForContentToBecome(page, `pluto-cell:last-child pluto-output span`, expected)
expect(initialLastCellContent).toBe(expected)

await paste(
Expand All @@ -84,7 +84,7 @@ describe("JavaScript API", () => {
)
await runAllChanged(page)
await waitForPlutoToCalmDown(page, { polling: 100 })
initialLastCellContent = await waitForContentToBecome(page, `pluto-cell:last-child pluto-output`, expected)
initialLastCellContent = await waitForContentToBecome(page, `pluto-cell:last-child pluto-output span`, expected)
expect(initialLastCellContent).toBe(expected)
})

Expand Down
2 changes: 1 addition & 1 deletion test/frontend/__tests__/slide_controls.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe("slideControls", () => {
await importNotebook(page, "slides.jl", { permissionToRunCode: false })
const plutoCellIds = await getCellIds(page)
const content = await waitForContent(page, `pluto-cell[id="${plutoCellIds[1]}"] pluto-output`)
expect(content).toBe("Slide 2")
expect(content).toBe("Slide 2\n")

const slide_1_title = await page.$(`pluto-cell[id="${plutoCellIds[0]}"] pluto-output h1`)
const slide_2_title = await page.$(`pluto-cell[id="${plutoCellIds[1]}"] pluto-output h1`)
Expand Down
4 changes: 2 additions & 2 deletions test/frontend/__tests__/wind_directions.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,14 @@ describe("wind_directions", () => {
).toBe(expected)
}

await expect_chosen_directions('chosen_directions_copy\n"North"')
await expect_chosen_directions('chosen_directions_copyString1"North"')

expect(await page.evaluate((sel) => document.querySelector(sel).checked, checkbox_selector(0))).toBe(true)

await page.click(checkbox_selector(2))
await waitForPlutoToCalmDown(page)

await expect_chosen_directions('chosen_directions_copy\n"North"\n"South"')
await expect_chosen_directions('chosen_directions_copyString1"North"2"South"')

expect(await page.evaluate((sel) => document.querySelector(sel).checked, checkbox_selector(0))).toBe(true)
expect(await page.evaluate((sel) => document.querySelector(sel).checked, checkbox_selector(1))).toBe(false)
Expand Down
Loading
Loading