diff --git a/Cargo.lock b/Cargo.lock index a03b0aaae..53df0ed5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2507,6 +2507,7 @@ dependencies = [ "sledgehammer_bindgen", "sledgehammer_utils", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] diff --git a/Cargo.toml b/Cargo.toml index a9f93939a..dd9d8bb32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,7 @@ futures-channel = "0.3.21" futures-util = { version = "0.3", default-features = false } rustc-hash = "1.1.0" wasm-bindgen = "0.2.92" +wasm-bindgen-futures = "0.4.42" html_parser = "0.7.0" thiserror = "1.0.40" prettyplease = { version = "0.2.16", features = ["verbatim"] } diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index da8cce399..a8de85151 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -17,7 +17,7 @@ dioxus-html = { workspace = true, features = [ "mounted", "eval", ] } -dioxus-interpreter-js = { workspace = true, features = ["binary-protocol"] } +dioxus-interpreter-js = { workspace = true, features = ["binary-protocol", "eval"] } dioxus-cli-config = { workspace = true, features = ["read-config"] } generational-box = { workspace = true } @@ -96,3 +96,8 @@ harness = false name = "check_rendering" path = "headless_tests/rendering.rs" harness = false + +[[test]] +name = "check_eval" +path = "headless_tests/eval.rs" +harness = false diff --git a/packages/desktop/headless_tests/eval.rs b/packages/desktop/headless_tests/eval.rs new file mode 100644 index 000000000..3d61dba76 --- /dev/null +++ b/packages/desktop/headless_tests/eval.rs @@ -0,0 +1,64 @@ +use dioxus::prelude::*; +use dioxus_desktop::window; +use serde::Deserialize; + +#[path = "./utils.rs"] +mod utils; + +pub fn main() { + utils::check_app_exits(app); +} + +static EVALS_RECEIVED: GlobalSignal = Signal::global(|| 0); +static EVALS_RETURNED: GlobalSignal = Signal::global(|| 0); + +fn app() -> Element { + // Double 100 values in the value + use_future(|| async { + let mut eval = eval( + r#"for (let i = 0; i < 100; i++) { + let value = await dioxus.recv(); + dioxus.send(value*2); + }"#, + ); + for i in 0..100 { + eval.send(serde_json::Value::from(i)).unwrap(); + let value = eval.recv().await.unwrap(); + assert_eq!(value, serde_json::Value::from(i * 2)); + EVALS_RECEIVED.with_mut(|x| *x += 1); + } + }); + + // Make sure returning no value resolves the future + use_future(|| async { + let eval = eval(r#"return;"#); + + eval.await.unwrap(); + EVALS_RETURNED.with_mut(|x| *x += 1); + }); + + // Return a value from the future + use_future(|| async { + let eval = eval( + r#" + return [1, 2, 3]; + "#, + ); + + assert_eq!( + Vec::::deserialize(&eval.await.unwrap()).unwrap(), + vec![1, 2, 3] + ); + EVALS_RETURNED.with_mut(|x| *x += 1); + }); + + use_memo(|| { + println!("expected 100 evals received found {}", EVALS_RECEIVED()); + println!("expected 2 eval returned found {}", EVALS_RETURNED()); + if EVALS_RECEIVED() == 100 && EVALS_RETURNED() == 2 { + window().close(); + } + }); + + None +} diff --git a/packages/desktop/headless_tests/rendering.rs b/packages/desktop/headless_tests/rendering.rs index fa64b4e1e..ace615a02 100644 --- a/packages/desktop/headless_tests/rendering.rs +++ b/packages/desktop/headless_tests/rendering.rs @@ -18,7 +18,7 @@ fn use_inner_html(id: &'static str) -> Option { let res = eval(&format!( r#"let element = document.getElementById('{}'); - return element.innerHTML"#, + return element.innerHTML"#, id )) .await diff --git a/packages/desktop/src/eval.rs b/packages/desktop/src/eval.rs index f920a508c..1f69d1b47 100644 --- a/packages/desktop/src/eval.rs +++ b/packages/desktop/src/eval.rs @@ -3,7 +3,7 @@ use generational_box::{AnyStorage, GenerationalBox, UnsyncStorage}; use crate::{query::Query, DesktopContext}; -/// Reprents the desktop-target's provider of evaluators. +/// Represents the desktop-target's provider of evaluators. pub struct DesktopEvalProvider { pub(crate) desktop_ctx: DesktopContext, } diff --git a/packages/desktop/src/protocol.rs b/packages/desktop/src/protocol.rs index b294fe377..93b97a2be 100644 --- a/packages/desktop/src/protocol.rs +++ b/packages/desktop/src/protocol.rs @@ -1,4 +1,5 @@ use crate::{assets::*, edits::EditQueue}; +use dioxus_interpreter_js::eval::NATIVE_EVAL_JS; use dioxus_interpreter_js::unified_bindings::SLEDGEHAMMER_JS; use dioxus_interpreter_js::NATIVE_JS; use std::path::{Path, PathBuf}; @@ -196,7 +197,7 @@ fn module_loader(root_id: &str, headless: bool) -> String { // And then extend it with our native bindings {NATIVE_JS} - // The nativeinterprerter extends the sledgehammer interpreter with a few extra methods that we use for IPC + // The native interpreter extends the sledgehammer interpreter with a few extra methods that we use for IPC window.interpreter = new NativeInterpreter("{EDITS_PATH}"); // Wait for the page to load before sending the initialize message @@ -209,6 +210,10 @@ fn module_loader(root_id: &str, headless: bool) -> String { window.interpreter.waitForRequest({headless}); }} + "# ) } diff --git a/packages/desktop/src/query.rs b/packages/desktop/src/query.rs index e56be1bf7..15d625380 100644 --- a/packages/desktop/src/query.rs +++ b/packages/desktop/src/query.rs @@ -7,45 +7,6 @@ use slab::Slab; use std::{cell::RefCell, rc::Rc}; use thiserror::Error; -/* -todo: -- write this in the interpreter itself rather than in blobs of inline javascript... -- it could also be simpler, probably? -*/ -const DIOXUS_CODE: &str = r#" -let dioxus = { - recv: function () { - return new Promise((resolve, _reject) => { - // Ever 50 ms check for new data - let timeout = setTimeout(() => { - let __msg = null; - while (true) { - let __data = _message_queue.shift(); - if (__data) { - __msg = __data; - break; - } - } - clearTimeout(timeout); - resolve(__msg); - }, 50); - }); - }, - - send: function (value) { - window.ipc.postMessage( - JSON.stringify({ - "method":"query", - "params": { - "id": _request_id, - "data": value, - "returned_value": false - } - }) - ); - } -}"#; - /// Tracks what query ids are currently active pub(crate) struct SharedSlab { pub slab: Rc>>, @@ -69,12 +30,10 @@ impl Default for SharedSlab { pub(crate) struct QueryEntry { channel_sender: futures_channel::mpsc::UnboundedSender, - return_sender: Option>, + return_sender: Option>>, pub owner: Option, } -const QUEUE_NAME: &str = "__msg_queues"; - /// Handles sending and receiving arbitrary queries from the webview. Queries can be resolved non-sequentially, so we use ids to track them. #[derive(Clone, Default)] pub(crate) struct QueryEngine { @@ -100,40 +59,51 @@ impl QueryEngine { // We embed the return of the eval in a function so we can send it back to the main thread if let Err(err) = context.webview.evaluate_script(&format!( r#"(function(){{ - (async (resolve, _reject) => {{ - {DIOXUS_CODE} - if (!window.{QUEUE_NAME}) {{ - window.{QUEUE_NAME} = []; - }} - - let _request_id = {request_id}; - - if (!window.{QUEUE_NAME}[{request_id}]) {{ - window.{QUEUE_NAME}[{request_id}] = []; - }} - let _message_queue = window.{QUEUE_NAME}[{request_id}]; - - {script} - }})().then((result)=>{{ + let dioxus = window.createQuery({request_id}); + let post_error = function(err) {{ let returned_value = {{ - "method":"query", + "method": "query", "params": {{ "id": {request_id}, - "data": result, - "returned_value": true + "data": {{ + "data": err, + "method": "return_error" + }} }} }}; window.ipc.postMessage( JSON.stringify(returned_value) ); - }}) + }}; + try {{ + const AsyncFunction = async function () {{}}.constructor; + let promise = (new AsyncFunction("dioxus", {script:?}))(dioxus); + promise + .then((result)=>{{ + let returned_value = {{ + "method": "query", + "params": {{ + "id": {request_id}, + "data": {{ + "data": result, + "method": "return" + }} + }} + }}; + window.ipc.postMessage( + JSON.stringify(returned_value) + ); + }}) + .catch(err => post_error(`Error running JS: ${{err}}`)); + }} catch (error) {{ + post_error(`Invalid JS: ${{error}}`); + }} }})();"# )) { tracing::warn!("Query error: {err}"); } Query { - slab: self.active_requests.clone(), id: request_id, receiver: rx, return_receiver: Some(return_rx), @@ -144,19 +114,26 @@ impl QueryEngine { /// Send a query channel message to the correct query pub fn send(&self, data: QueryResult) { - let QueryResult { - id, - data, - returned_value, - } = data; + let QueryResult { id, data } = data; let mut slab = self.active_requests.slab.borrow_mut(); if let Some(entry) = slab.get_mut(id) { - if returned_value { - if let Some(sender) = entry.return_sender.take() { - let _ = sender.send(data); + match data { + QueryResultData::Return { data } => { + if let Some(sender) = entry.return_sender.take() { + let _ = sender.send(Ok(data.unwrap_or_default())); + } + } + QueryResultData::ReturnError { data } => { + if let Some(sender) = entry.return_sender.take() { + let _ = sender.send(Err(data.to_string())); + } + } + QueryResultData::Drop => { + slab.remove(id); + } + QueryResultData::Send { data } => { + let _ = entry.channel_sender.unbounded_send(data); } - } else { - let _ = entry.channel_sender.unbounded_send(data); } } } @@ -164,9 +141,8 @@ impl QueryEngine { pub(crate) struct Query { desktop: DesktopContext, - slab: SharedSlab, receiver: futures_channel::mpsc::UnboundedReceiver, - return_receiver: Option>, + return_receiver: Option>>, pub id: usize, phantom: std::marker::PhantomData, } @@ -183,18 +159,7 @@ impl Query { let queue_id = self.id; let data = message.to_string(); - let script = format!( - r#" - if (!window.{QUEUE_NAME}) {{ - window.{QUEUE_NAME} = []; - }} - - if (!window.{QUEUE_NAME}[{queue_id}]) {{ - window.{QUEUE_NAME}[{queue_id}] = []; - }} - window.{QUEUE_NAME}[{queue_id}].push({data}); - "# - ); + let script = format!(r#"window.getQuery({queue_id}).rustSend({data});"#); self.desktop .webview @@ -211,13 +176,17 @@ impl Query { ) -> std::task::Poll> { self.receiver .poll_next_unpin(cx) - .map(|result| result.ok_or(QueryError::Recv)) + .map(|result| result.ok_or(QueryError::Recv(String::from("Receive channel closed")))) } /// Receive the result of the query pub async fn result(&mut self) -> Result { match self.return_receiver.take() { - Some(receiver) => receiver.await.map_err(|_| QueryError::Recv), + Some(receiver) => match receiver.await { + Ok(Ok(data)) => Ok(data), + Ok(Err(err)) => Err(QueryError::Recv(err)), + Err(err) => Err(QueryError::Recv(err.to_string())), + }, None => Err(QueryError::Finished), } } @@ -228,36 +197,21 @@ impl Query { cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { match self.return_receiver.as_mut() { - Some(receiver) => receiver.poll_unpin(cx).map_err(|_| QueryError::Recv), + Some(receiver) => receiver.poll_unpin(cx).map(|result| match result { + Ok(Ok(data)) => Ok(data), + Ok(Err(err)) => Err(QueryError::Recv(err)), + Err(err) => Err(QueryError::Recv(err.to_string())), + }), None => std::task::Poll::Ready(Err(QueryError::Finished)), } } } -impl Drop for Query { - fn drop(&mut self) { - self.slab.slab.borrow_mut().remove(self.id); - let queue_id = self.id; - - _ = self.desktop.webview.evaluate_script(&format!( - r#" - if (!window.{QUEUE_NAME}) {{ - window.{QUEUE_NAME} = []; - }} - - if (window.{QUEUE_NAME}[{queue_id}]) {{ - window.{QUEUE_NAME}[{queue_id}] = []; - }} - "# - )); - } -} - #[derive(Error, Debug)] #[non_exhaustive] pub enum QueryError { - #[error("Error receiving query result.")] - Recv, + #[error("Error receiving query result: {0}")] + Recv(String), #[error("Error sending message to query: {0}")] Send(String), #[error("Error deserializing query result: {0}")] @@ -269,7 +223,18 @@ pub enum QueryError { #[derive(Clone, Debug, Deserialize)] pub(crate) struct QueryResult { id: usize, - data: Value, - #[serde(default)] - returned_value: bool, + data: QueryResultData, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "method")] +enum QueryResultData { + #[serde(rename = "return")] + Return { data: Option }, + #[serde(rename = "return_error")] + ReturnError { data: Value }, + #[serde(rename = "send")] + Send { data: Value }, + #[serde(rename = "drop")] + Drop, } diff --git a/packages/interpreter/Cargo.toml b/packages/interpreter/Cargo.toml index 7c56cdf68..ea4ec8650 100644 --- a/packages/interpreter/Cargo.toml +++ b/packages/interpreter/Cargo.toml @@ -12,6 +12,7 @@ keywords = ["dom", "ui", "gui", "react", "wasm"] [dependencies] wasm-bindgen = { workspace = true, optional = true } +wasm-bindgen-futures = { workspace = true, optional = true } js-sys = { version = "0.3.56", optional = true } web-sys = { version = "0.3.56", optional = true, features = [ "Element", @@ -34,9 +35,11 @@ sledgehammer = ["sledgehammer_bindgen", "sledgehammer_utils"] webonly = [ "sledgehammer", "wasm-bindgen", + "wasm-bindgen-futures", "js-sys", "web-sys", "sledgehammer_bindgen/web", ] binary-protocol = ["sledgehammer", "dioxus-core", "dioxus-html"] minimal_bindings = [] +eval = [] diff --git a/packages/interpreter/build.rs b/packages/interpreter/build.rs index aee7cb526..4e80dc39f 100644 --- a/packages/interpreter/build.rs +++ b/packages/interpreter/build.rs @@ -1,4 +1,5 @@ -use std::process::Command; +use std::collections::hash_map::DefaultHasher; +use std::{hash::Hasher, process::Command}; fn main() { // If any TS changes, re-run the build script @@ -7,6 +8,8 @@ fn main() { println!("cargo:rerun-if-changed=src/ts/serialize.ts"); println!("cargo:rerun-if-changed=src/ts/set_attribute.ts"); println!("cargo:rerun-if-changed=src/ts/common.ts"); + println!("cargo:rerun-if-changed=src/ts/eval.ts"); + println!("cargo:rerun-if-changed=src/ts/native_eval.ts"); // Compute the hash of the ts files let hash = hash_ts_files(); @@ -22,34 +25,30 @@ fn main() { gen_bindings("common", "common"); gen_bindings("native", "native"); gen_bindings("core", "core"); + gen_bindings("eval", "eval"); + gen_bindings("native_eval", "native_eval"); std::fs::write("src/js/hash.txt", hash.to_string()).unwrap(); } /// Hashes the contents of a directory -fn hash_ts_files() -> u128 { - let mut out = 0; - +fn hash_ts_files() -> u64 { let files = [ include_str!("src/ts/common.ts"), include_str!("src/ts/native.ts"), include_str!("src/ts/core.ts"), + include_str!("src/ts/eval.ts"), + include_str!("src/ts/native_eval.ts"), ]; - // Let's make the dumbest hasher by summing the bytes of the files - // The location is multiplied by the byte value to make sure that the order of the bytes matters - let mut idx = 0; + let mut hash = DefaultHasher::new(); for file in files { // windows + git does a weird thing with line endings, so we need to normalize them for line in file.lines() { - idx += 1; - for byte in line.bytes() { - idx += 1; - out += (byte as u128) * (idx as u128); - } + hash.write(line.as_bytes()); } } - out + hash.finish() } // okay...... so tsc might fail if the user doesn't have it installed diff --git a/packages/interpreter/src/eval.rs b/packages/interpreter/src/eval.rs new file mode 100644 index 000000000..81f2e0f97 --- /dev/null +++ b/packages/interpreter/src/eval.rs @@ -0,0 +1,49 @@ +/// Code for the Dioxus channel used to communicate between the dioxus and javascript code +pub const NATIVE_EVAL_JS: &str = include_str!("./js/native_eval.js"); + +#[cfg(feature = "webonly")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub struct JSOwner { + _owner: Box, +} + +#[cfg(feature = "webonly")] +impl JSOwner { + pub fn new(owner: impl std::any::Any) -> Self { + Self { + _owner: Box::new(owner), + } + } +} + +#[cfg(feature = "webonly")] +#[wasm_bindgen::prelude::wasm_bindgen(module = "/src/js/eval.js")] +extern "C" { + pub type WebDioxusChannel; + + #[wasm_bindgen(constructor)] + pub fn new(owner: JSOwner) -> WebDioxusChannel; + + #[wasm_bindgen(method, js_name = "rustSend")] + pub fn rust_send(this: &WebDioxusChannel, value: wasm_bindgen::JsValue); + + #[wasm_bindgen(method, js_name = "rustRecv")] + pub async fn rust_recv(this: &WebDioxusChannel) -> wasm_bindgen::JsValue; + + #[wasm_bindgen(method)] + pub fn send(this: &WebDioxusChannel, value: wasm_bindgen::JsValue); + + #[wasm_bindgen(method)] + pub async fn recv(this: &WebDioxusChannel) -> wasm_bindgen::JsValue; + + #[wasm_bindgen(method)] + pub fn weak(this: &WebDioxusChannel) -> WeakDioxusChannel; + + pub type WeakDioxusChannel; + + #[wasm_bindgen(method, js_name = "rustSend")] + pub fn rust_send(this: &WeakDioxusChannel, value: wasm_bindgen::JsValue); + + #[wasm_bindgen(method, js_name = "rustRecv")] + pub async fn rust_recv(this: &WeakDioxusChannel) -> wasm_bindgen::JsValue; +} diff --git a/packages/interpreter/src/js/eval.js b/packages/interpreter/src/js/eval.js new file mode 100644 index 000000000..216f11c9d --- /dev/null +++ b/packages/interpreter/src/js/eval.js @@ -0,0 +1 @@ +class Channel{pending;waiting;constructor(){this.pending=[],this.waiting=[]}send(data){if(this.waiting.length>0){this.waiting.shift()(data);return}this.pending.push(data)}async recv(){return new Promise((resolve,_reject)=>{if(this.pending.length>0){resolve(this.pending.shift());return}this.waiting.push(resolve)})}}class WeakDioxusChannel{inner;constructor(channel){this.inner=new WeakRef(channel)}rustSend(data){let channel=this.inner.deref();if(channel)channel.rustSend(data)}async rustRecv(){let channel=this.inner.deref();if(channel)return await channel.rustRecv()}}class DioxusChannel{weak(){return new WeakDioxusChannel(this)}}class WebDioxusChannel extends DioxusChannel{js_to_rust;rust_to_js;owner;constructor(owner){super();this.owner=owner,this.js_to_rust=new Channel,this.rust_to_js=new Channel}weak(){return new WeakDioxusChannel(this)}async recv(){return await this.rust_to_js.recv()}send(data){this.js_to_rust.send(data)}rustSend(data){this.rust_to_js.send(data)}async rustRecv(){return await this.js_to_rust.recv()}}export{WebDioxusChannel,WeakDioxusChannel,DioxusChannel,Channel}; diff --git a/packages/interpreter/src/js/hash.txt b/packages/interpreter/src/js/hash.txt index 1cf1f0798..a543d1781 100644 --- a/packages/interpreter/src/js/hash.txt +++ b/packages/interpreter/src/js/hash.txt @@ -1 +1 @@ -14148301494 \ No newline at end of file +2770005544568683192 \ No newline at end of file diff --git a/packages/interpreter/src/js/native_eval.js b/packages/interpreter/src/js/native_eval.js new file mode 100644 index 000000000..50c6026ab --- /dev/null +++ b/packages/interpreter/src/js/native_eval.js @@ -0,0 +1 @@ +class Channel{pending;waiting;constructor(){this.pending=[],this.waiting=[]}send(data){if(this.waiting.length>0){this.waiting.shift()(data);return}this.pending.push(data)}async recv(){return new Promise((resolve,_reject)=>{if(this.pending.length>0){resolve(this.pending.shift());return}this.waiting.push(resolve)})}}class WeakDioxusChannel{inner;constructor(channel){this.inner=new WeakRef(channel)}rustSend(data){let channel=this.inner.deref();if(channel)channel.rustSend(data)}async rustRecv(){let channel=this.inner.deref();if(channel)return await channel.rustRecv()}}class DioxusChannel{weak(){return new WeakDioxusChannel(this)}}class QueryParams{id;data;constructor(id,method,data){this.id=id,this.data={method,data}}}window.__msg_queues=window.__msg_queues||[];window.finalizationRegistry=window.finalizationRegistry||new FinalizationRegistry(({id})=>{window.ipc.postMessage(JSON.stringify({method:"query",params:new QueryParams(id,"drop")}))});window.getQuery=function(request_id){return window.__msg_queues[request_id]};window.createQuery=function(request_id){return new NativeDioxusChannel(request_id)};class NativeDioxusChannel extends DioxusChannel{rust_to_js;request_id;constructor(request_id){super();this.rust_to_js=new Channel,this.request_id=request_id,window.__msg_queues[request_id]=this.weak(),window.finalizationRegistry.register(this,{id:request_id})}async recv(){return await this.rust_to_js.recv()}send(data){window.ipc.postMessage(JSON.stringify({method:"query",params:new QueryParams(this.request_id,"send",data)}))}rustSend(data){this.rust_to_js.send(data)}async rustRecv(){}}export{NativeDioxusChannel}; diff --git a/packages/interpreter/src/lib.rs b/packages/interpreter/src/lib.rs index 6d21f0b5b..45b85eec5 100644 --- a/packages/interpreter/src/lib.rs +++ b/packages/interpreter/src/lib.rs @@ -20,6 +20,9 @@ pub mod unified_bindings; #[cfg(feature = "sledgehammer")] pub use unified_bindings::*; +#[cfg(feature = "eval")] +pub mod eval; + // Common bindings for minimal usage. #[cfg(all(feature = "minimal_bindings", feature = "webonly"))] pub mod minimal_bindings { diff --git a/packages/interpreter/src/ts/eval.ts b/packages/interpreter/src/ts/eval.ts new file mode 100644 index 000000000..44ce99d67 --- /dev/null +++ b/packages/interpreter/src/ts/eval.ts @@ -0,0 +1,108 @@ +// Handle communication between rust and evaluating javascript + +export class Channel { + pending: any[]; + waiting: ((data: any) => void)[]; + + constructor() { + this.pending = []; + this.waiting = []; + } + + send(data: any) { + // If there's a waiting callback, call it + if (this.waiting.length > 0) { + this.waiting.shift()(data); + return; + } + // Otherwise queue the data + this.pending.push(data); + } + + async recv(): Promise { + return new Promise((resolve, _reject) => { + // If data already exists, resolve immediately + if (this.pending.length > 0) { + resolve(this.pending.shift()); + return; + } + // Otherwise queue the resolve callback + this.waiting.push(resolve); + }); + } +} + +export class WeakDioxusChannel { + inner: WeakRef; + + constructor(channel: DioxusChannel) { + this.inner = new WeakRef(channel); + } + + // Send data from rust to javascript + rustSend(data: any) { + let channel = this.inner.deref(); + if (channel) { + channel.rustSend(data); + } + } + + // Receive data sent from javascript in rust + async rustRecv(): Promise { + let channel = this.inner.deref(); + if (channel) { + return await channel.rustRecv(); + } + } +} + +export abstract class DioxusChannel { + // Return a weak reference to this channel + weak(): WeakDioxusChannel { + return new WeakDioxusChannel(this); + } + + // Send data from rust to javascript + abstract rustSend(data: any): void; + + // Receive data sent from javascript in rust + abstract rustRecv(): Promise; +} + +export class WebDioxusChannel extends DioxusChannel { + js_to_rust: Channel; + rust_to_js: Channel; + owner: any; + + constructor(owner: any) { + super(); + this.owner = owner; + this.js_to_rust = new Channel(); + this.rust_to_js = new Channel(); + } + + // Return a weak reference to this channel + weak(): WeakDioxusChannel { + return new WeakDioxusChannel(this); + } + + // Receive message from Rust + async recv() { + return await this.rust_to_js.recv(); + } + + // Send message to rust. + send(data: any) { + this.js_to_rust.send(data); + } + + // Send data from rust to javascript + rustSend(data: any) { + this.rust_to_js.send(data); + } + + // Receive data sent from javascript in rust + async rustRecv(): Promise { + return await this.js_to_rust.recv(); + } +} diff --git a/packages/interpreter/src/ts/native_eval.ts b/packages/interpreter/src/ts/native_eval.ts new file mode 100644 index 000000000..b4152074b --- /dev/null +++ b/packages/interpreter/src/ts/native_eval.ts @@ -0,0 +1,86 @@ +import { Channel, DioxusChannel, WeakDioxusChannel } from "./eval"; + +// In dioxus desktop, eval needs to use the window object to store global state because we evaluate separate snippets of javascript in the browser +declare global { + interface Window { + __msg_queues: WeakDioxusChannel[]; + finalizationRegistry: FinalizationRegistry<{ id: number }>; + + getQuery(request_id: number): WeakDioxusChannel; + + createQuery(request_id: number): NativeDioxusChannel; + } +} + +// A message that can be sent to the desktop renderer about a query +class QueryParams { + id: number; + data: { method: "drop" | "send"; data?: any }; + + constructor(id: number, method: "drop" | "send", data?: any) { + this.id = id; + this.data = { method, data }; + } +} + +window.__msg_queues = window.__msg_queues || []; +// In dioxus desktop, eval is copy so we cannot run a drop handler. Instead, the drop handler is run after the channel is garbage collected in the javascript side +window.finalizationRegistry = + window.finalizationRegistry || + new FinalizationRegistry(({ id }) => { + // @ts-ignore - wry gives us this + window.ipc.postMessage( + JSON.stringify({ + method: "query", + params: new QueryParams(id, "drop"), + }) + ); + }); + +// Get a query from the global state +window.getQuery = function (request_id: number): WeakDioxusChannel { + return window.__msg_queues[request_id]; +}; + +// Create a new query (and insert it into the global state) +window.createQuery = function (request_id: number): NativeDioxusChannel { + return new NativeDioxusChannel(request_id); +}; + +export class NativeDioxusChannel extends DioxusChannel { + rust_to_js: Channel; + request_id: number; + + constructor(request_id: number) { + super(); + this.rust_to_js = new Channel(); + this.request_id = request_id; + + window.__msg_queues[request_id] = this.weak(); + window.finalizationRegistry.register(this, { id: request_id }); + } + + // Receive message from Rust + async recv() { + return await this.rust_to_js.recv(); + } + + // Send message to rust. + send(data: any) { + // @ts-ignore - wry gives us this + window.ipc.postMessage( + JSON.stringify({ + method: "query", + params: new QueryParams(this.request_id, "send", data), + }) + ); + } + + // Send data from rust to javascript + rustSend(data: any) { + this.rust_to_js.send(data); + } + + // Receive data sent from javascript in rust. This is a no-op in the native interpreter because the rust code runs remotely + async rustRecv(): Promise {} +} diff --git a/packages/interpreter/tsconfig.json b/packages/interpreter/tsconfig.json index 6a9dd215a..11cda68e3 100644 --- a/packages/interpreter/tsconfig.json +++ b/packages/interpreter/tsconfig.json @@ -5,7 +5,8 @@ "ES2015", "DOM", "dom", - "dom.iterable" + "dom.iterable", + "ESNext" ], "noImplicitAny": true, "removeComments": true, diff --git a/packages/playwright-tests/web/src/main.rs b/packages/playwright-tests/web/src/main.rs index 3ac275eb1..58a0ec174 100644 --- a/packages/playwright-tests/web/src/main.rs +++ b/packages/playwright-tests/web/src/main.rs @@ -26,10 +26,22 @@ fn app() -> Element { let mut eval = eval( r#" window.document.title = 'Hello from Dioxus Eval!'; + // Receive and multiply 10 numbers + for (let i = 0; i < 10; i++) { + let value = await dioxus.recv(); + dioxus.send(value*2); + } dioxus.send("returned eval value"); "#, ); + // Send 10 numbers + for i in 0..10 { + eval.send(serde_json::Value::from(i)).unwrap(); + let value = eval.recv().await.unwrap(); + assert_eq!(value, serde_json::Value::from(i * 2)); + } + let result = eval.recv().await; if let Ok(serde_json::Value::String(string)) = result { eval_result.set(string); diff --git a/packages/web/Cargo.toml b/packages/web/Cargo.toml index 56a419d17..7e80a76e8 100644 --- a/packages/web/Cargo.toml +++ b/packages/web/Cargo.toml @@ -63,7 +63,7 @@ file_engine = [ "async-trait", ] hot_reload = ["web-sys/MessageEvent", "web-sys/WebSocket", "web-sys/Location"] -eval = ["dioxus-html/eval", "serde-wasm-bindgen", "async-trait"] +eval = ["dioxus-html/eval", "dioxus-interpreter-js/eval", "serde-wasm-bindgen", "async-trait"] [dev-dependencies] dioxus = { workspace = true } diff --git a/packages/web/src/eval.js b/packages/web/src/eval.js deleted file mode 100644 index 959d422eb..000000000 --- a/packages/web/src/eval.js +++ /dev/null @@ -1,41 +0,0 @@ -export class Dioxus { - constructor(sendCallback, returnCallback) { - this.sendCallback = sendCallback; - this.returnCallback = returnCallback; - this.promiseResolve = null; - this.received = []; - } - - // Receive message from Rust - recv() { - return new Promise((resolve, _reject) => { - // If data already exists, resolve immediately - let data = this.received.shift(); - if (data) { - resolve(data); - return; - } - - // Otherwise set a resolve callback - this.promiseResolve = resolve; - }); - } - - // Send message to rust. - send(data) { - this.sendCallback(data); - } - - // Internal rust send - rustSend(data) { - // If a promise is waiting for data, resolve it, and clear the resolve callback - if (this.promiseResolve) { - this.promiseResolve(data); - this.promiseResolve = null; - return; - } - - // Otherwise add the data to a queue - this.received.push(data); - } -} \ No newline at end of file diff --git a/packages/web/src/eval.rs b/packages/web/src/eval.rs index 66711b394..c4a8a61a4 100644 --- a/packages/web/src/eval.rs +++ b/packages/web/src/eval.rs @@ -1,8 +1,10 @@ use dioxus_html::prelude::{EvalError, EvalProvider, Evaluator}; -use futures_util::StreamExt; +use dioxus_interpreter_js::eval::{JSOwner, WeakDioxusChannel, WebDioxusChannel}; use generational_box::{AnyStorage, GenerationalBox, UnsyncStorage}; use js_sys::Function; use serde_json::Value; +use std::future::Future; +use std::pin::Pin; use std::{rc::Rc, str::FromStr}; use wasm_bindgen::prelude::*; @@ -26,42 +28,35 @@ const PROMISE_WRAPPER: &str = r#" {JS_CODE} resolve(null); }); - "#; +"#; + +type NextPoll = Pin>>>; /// Represents a web-target's JavaScript evaluator. struct WebEvaluator { - dioxus: Dioxus, - channel_receiver: futures_channel::mpsc::UnboundedReceiver, + channels: WeakDioxusChannel, + next_future: Option, result: Option>, } impl WebEvaluator { /// Creates a new evaluator for web-based targets. fn create(js: String) -> GenerationalBox> { - let (mut channel_sender, channel_receiver) = futures_channel::mpsc::unbounded(); let owner = UnsyncStorage::owner(); - let invalid = owner.invalid(); - // This Rc cloning mess hurts but it seems to work.. - let recv_value = Closure::::new(move |data| { - // Drop the owner when the sender is dropped. - let _ = &owner; - match serde_wasm_bindgen::from_value::(data) { - Ok(data) => _ = channel_sender.start_send(data), - Err(e) => { - // Can't really do much here. - tracing::error!("failed to serialize JsValue to serde_json::Value (eval communication) - {}", e); - } - } - }); + let generational_box = owner.invalid(); - let dioxus = Dioxus::new(recv_value.as_ref().unchecked_ref()); - recv_value.forget(); + // add the drop handler to DioxusChannel so that it gets dropped when the channel is dropped in js + let channels = WebDioxusChannel::new(JSOwner::new(owner)); + + // The Rust side of the channel is a weak reference to the DioxusChannel + let weak_channels = channels.weak(); // Wrap the evaluated JS in a promise so that wasm can continue running (send/receive data from js) let code = PROMISE_WRAPPER.replace("{JS_CODE}", &js); - let result = match Function::new_with_args("dioxus", &code).call1(&JsValue::NULL, &dioxus) { + let result = match Function::new_with_args("dioxus", &code).call1(&JsValue::NULL, &channels) + { Ok(result) => { if let Ok(stringified) = js_sys::JSON::stringify(&result) { if !stringified.is_undefined() && stringified.is_valid_utf16() { @@ -85,13 +80,13 @@ impl WebEvaluator { )), }; - invalid.set(Box::new(Self { - dioxus, - channel_receiver, + generational_box.set(Box::new(Self { + channels: weak_channels, result: Some(result), - }) as Box); + next_future: None, + }) as Box); - invalid + generational_box } } @@ -115,7 +110,7 @@ impl Evaluator for WebEvaluator { Err(e) => return Err(EvalError::Communication(e.to_string())), }; - self.dioxus.rustSend(data); + self.channels.rust_send(data); Ok(()) } @@ -124,21 +119,22 @@ impl Evaluator for WebEvaluator { &mut self, context: &mut std::task::Context<'_>, ) -> std::task::Poll> { - self.channel_receiver.poll_next_unpin(context).map(|poll| { - poll.ok_or_else(|| { - EvalError::Communication("failed to receive data from js".to_string()) - }) - }) + if self.next_future.is_none() { + let channels: WebDioxusChannel = self.channels.clone().into(); + let pinned = Box::pin(async move { + let fut = channels.rust_recv(); + let data = fut.await; + serde_wasm_bindgen::from_value::(data) + .map_err(|err| EvalError::Communication(err.to_string())) + }); + self.next_future = Some(pinned); + } + let fut = self.next_future.as_mut().unwrap(); + let mut pinned = std::pin::pin!(fut); + let result = pinned.as_mut().poll(context); + if result.is_ready() { + self.next_future = None; + } + result } } - -#[wasm_bindgen(module = "/src/eval.js")] -extern "C" { - pub type Dioxus; - - #[wasm_bindgen(constructor)] - pub fn new(recv_callback: &Function) -> Dioxus; - - #[wasm_bindgen(method)] - pub fn rustSend(this: &Dioxus, data: JsValue); -} diff --git a/packages/web/src/rehydrate.rs b/packages/web/src/rehydrate.rs index 5c425de79..7412ca36d 100644 --- a/packages/web/src/rehydrate.rs +++ b/packages/web/src/rehydrate.rs @@ -2,7 +2,7 @@ use crate::dom::WebsysDom; use dioxus_core::prelude::*; use dioxus_core::AttributeValue; use dioxus_core::WriteMutations; -use dioxus_core::{DynamicNode, ElementId, ScopeState, TemplateNode, VNode, VirtualDom}; +use dioxus_core::{DynamicNode, ElementId}; #[derive(Debug)] #[non_exhaustive]