Pārlūkot izejas kodu

Merge pull request #2586 from DogeDark/backport

Backport To v0.5
Jonathan Kelley 11 mēneši atpakaļ
vecāks
revīzija
3f8c52f89e

+ 11 - 0
Cargo.lock

@@ -2256,6 +2256,7 @@ dependencies = [
  "rustc-hash",
  "serde",
  "slab",
+ "slotmap",
  "tokio",
  "tracing",
  "tracing-fluent-assertions",
@@ -8280,6 +8281,16 @@ dependencies = [
  "rustc-hash",
 ]
 
+[[package]]
+name = "slotmap"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a"
+dependencies = [
+ "serde",
+ "version_check",
+]
+
 [[package]]
 name = "smallvec"
 version = "1.13.2"

+ 1 - 0
Cargo.toml

@@ -83,6 +83,7 @@ tracing-futures = "0.2.5"
 toml = "0.8"
 tokio = "1.28"
 slab = "0.4.2"
+slotmap = { version = "1.0.7", features = ["serde"] }
 futures-channel = "0.3.21"
 futures-util = { version = "0.3", default-features = false }
 rustc-hash = "1.1.0"

+ 51 - 14
packages/cli/src/builder.rs

@@ -15,7 +15,7 @@ use manganis_cli_support::{AssetManifest, ManganisSupportGuard};
 use std::{
     env,
     fs::{copy, create_dir_all, File},
-    io::Read,
+    io::{self, IsTerminal, Read},
     panic,
     path::PathBuf,
     process::Command,
@@ -437,18 +437,56 @@ struct CargoBuildResult {
     output_location: Option<PathBuf>,
 }
 
+struct Outputter {
+    progress_bar: Option<ProgressBar>,
+}
+
+impl Outputter {
+    pub fn new() -> Self {
+        let stdout = io::stdout().lock();
+
+        let mut myself = Self { progress_bar: None };
+
+        if stdout.is_terminal() {
+            let mut pb = ProgressBar::new_spinner();
+            pb.enable_steady_tick(Duration::from_millis(200));
+            pb = PROGRESS_BARS.add(pb);
+            pb.set_style(
+                ProgressStyle::with_template("{spinner:.dim.bold} {wide_msg}")
+                    .unwrap()
+                    .tick_chars("/|\\- "),
+            );
+
+            myself.progress_bar = Some(pb);
+        }
+
+        myself
+    }
+
+    pub fn println(&self, msg: impl ToString) {
+        let msg = msg.to_string();
+        if let Some(pb) = &self.progress_bar {
+            pb.set_message(msg)
+        } else {
+            println!("{msg}");
+        }
+    }
+
+    pub fn finish_with_message(&self, msg: impl ToString) {
+        let msg = msg.to_string();
+        if let Some(pb) = &self.progress_bar {
+            pb.finish_with_message(msg)
+        } else {
+            println!("{msg}");
+        }
+    }
+}
+
 fn prettier_build(cmd: subprocess::Exec) -> anyhow::Result<CargoBuildResult> {
     let mut warning_messages: Vec<Diagnostic> = vec![];
 
-    let mut pb = ProgressBar::new_spinner();
-    pb.enable_steady_tick(Duration::from_millis(200));
-    pb = PROGRESS_BARS.add(pb);
-    pb.set_style(
-        ProgressStyle::with_template("{spinner:.dim.bold} {wide_msg}")
-            .unwrap()
-            .tick_chars("/|\\- "),
-    );
-    pb.set_message("💼 Waiting to start building the project...");
+    let output = Outputter::new();
+    output.println("💼 Waiting to start building the project...");
 
     let stdout = cmd.detached().stream_stdout()?;
     let reader = std::io::BufReader::new(stdout);
@@ -473,8 +511,7 @@ fn prettier_build(cmd: subprocess::Exec) -> anyhow::Result<CargoBuildResult> {
                 }
             }
             Message::CompilerArtifact(artifact) => {
-                pb.set_message(format!("⚙ Compiling {} ", artifact.package_id));
-                pb.tick();
+                output.println(format!("⚙ Compiling {} ", artifact.package_id));
                 if let Some(executable) = artifact.executable {
                     output_location = Some(executable.into());
                 }
@@ -484,9 +521,9 @@ fn prettier_build(cmd: subprocess::Exec) -> anyhow::Result<CargoBuildResult> {
             }
             Message::BuildFinished(finished) => {
                 if finished.success {
-                    pb.finish_with_message("👑 Build done.");
+                    output.finish_with_message("👑 Build done.");
                 } else {
-                    pb.finish_with_message("❌ Build failed.");
+                    output.finish_with_message("❌ Build failed.");
                     return Err(anyhow::anyhow!("Build failed"));
                 }
             }

+ 26 - 5
packages/cli/src/cli/bundle.rs

@@ -1,6 +1,6 @@
 use core::panic;
 use dioxus_cli_config::ExecutableType;
-use std::{fs::create_dir_all, str::FromStr};
+use std::{env::current_dir, fs::create_dir_all, str::FromStr};
 
 use tauri_bundler::{BundleSettings, PackageSettings, SettingsBuilder};
 
@@ -153,10 +153,31 @@ impl Bundle {
 
         let static_asset_output_dir = static_asset_output_dir.display().to_string();
         println!("Adding assets from {} to bundle", static_asset_output_dir);
-        if let Some(resources) = &mut bundle_settings.resources {
-            resources.push(static_asset_output_dir);
-        } else {
-            bundle_settings.resources = Some(vec![static_asset_output_dir]);
+
+        // Don't copy the executable or the old bundle directory
+        let ignored_files = [
+            crate_config.out_dir().join("bundle"),
+            crate_config.out_dir().join(name),
+        ];
+
+        for entry in std::fs::read_dir(&static_asset_output_dir)?.flatten() {
+            let path = entry.path().canonicalize()?;
+            if ignored_files.iter().any(|f| path.starts_with(f)) {
+                continue;
+            }
+
+            // Tauri bundle will add a __root__ prefix if the input path is absolute even though the output path is relative?
+            // We strip the prefix here to make sure the input path is relative so that the bundler puts the output path in the right place
+            let path = path
+                .strip_prefix(&current_dir()?)
+                .unwrap()
+                .display()
+                .to_string();
+            if let Some(resources) = &mut bundle_settings.resources_map {
+                resources.insert(path, "".to_string());
+            } else {
+                bundle_settings.resources_map = Some([(path, "".to_string())].into());
+            }
         }
 
         let mut settings = SettingsBuilder::new()

+ 1 - 0
packages/core/Cargo.toml

@@ -17,6 +17,7 @@ futures-util = { workspace = true, default-features = false, features = [
     "std",
 ] }
 slab = { workspace = true }
+slotmap = { workspace = true }
 futures-channel = { workspace = true }
 tracing = { workspace = true }
 serde = { version = "1", features = ["derive"], optional = true }

+ 18 - 2
packages/core/src/arena.rs

@@ -80,11 +80,27 @@ impl VirtualDom {
 }
 
 impl ElementPath {
-    pub(crate) fn is_decendant(&self, small: &&[u8]) -> bool {
-        small.len() <= self.path.len() && *small == &self.path[..small.len()]
+    pub(crate) fn is_decendant(&self, small: &[u8]) -> bool {
+        small.len() <= self.path.len() && small == &self.path[..small.len()]
     }
 }
 
+#[test]
+fn is_decendant() {
+    let event_path = ElementPath {
+        path: &[1, 2, 3, 4, 5],
+    };
+
+    assert!(event_path.is_decendant(&[1, 2, 3, 4, 5]));
+    assert!(event_path.is_decendant(&[1, 2, 3, 4]));
+    assert!(event_path.is_decendant(&[1, 2, 3]));
+    assert!(event_path.is_decendant(&[1, 2]));
+    assert!(event_path.is_decendant(&[1]));
+
+    assert!(!event_path.is_decendant(&[1, 2, 3, 4, 5, 6]));
+    assert!(!event_path.is_decendant(&[2, 3, 4]));
+}
+
 impl PartialEq<&[u8]> for ElementPath {
     fn eq(&self, other: &&[u8]) -> bool {
         self.path.eq(*other)

+ 6 - 53
packages/core/src/diff/node.rs

@@ -561,8 +561,12 @@ impl VNode {
 
         // If this is a debug build, we need to check that the paths are in the correct order because hot reloading can cause scrambled states
         #[cfg(debug_assertions)]
-        let (attrs_sorted, nodes_sorted) =
-            { (sort_bfs(template.attr_paths), sort_bfs(template.node_paths)) };
+        let (attrs_sorted, nodes_sorted) = {
+            (
+                crate::nodes::sort_bfo(template.attr_paths),
+                crate::nodes::sort_bfo(template.node_paths),
+            )
+        };
         #[cfg(debug_assertions)]
         let (mut attrs, mut nodes) = {
             (
@@ -959,54 +963,3 @@ fn matching_components<'a>(
         })
         .collect()
 }
-
-#[cfg(debug_assertions)]
-fn sort_bfs(paths: &[&'static [u8]]) -> Vec<(usize, &'static [u8])> {
-    let mut with_indecies = paths.iter().copied().enumerate().collect::<Vec<_>>();
-    with_indecies.sort_unstable_by(|(_, a), (_, b)| {
-        let mut a = a.iter();
-        let mut b = b.iter();
-        loop {
-            match (a.next(), b.next()) {
-                (Some(a), Some(b)) => {
-                    if a != b {
-                        return a.cmp(b);
-                    }
-                }
-                // The shorter path goes first
-                (None, Some(_)) => return std::cmp::Ordering::Less,
-                (Some(_), None) => return std::cmp::Ordering::Greater,
-                (None, None) => return std::cmp::Ordering::Equal,
-            }
-        }
-    });
-    with_indecies
-}
-
-#[test]
-#[cfg(debug_assertions)]
-fn sorting() {
-    let r: [(usize, &[u8]); 5] = [
-        (0, &[0, 1]),
-        (1, &[0, 2]),
-        (2, &[1, 0]),
-        (3, &[1, 0, 1]),
-        (4, &[1, 2]),
-    ];
-    assert_eq!(
-        sort_bfs(&[&[0, 1,], &[0, 2,], &[1, 0,], &[1, 0, 1,], &[1, 2,],]),
-        r
-    );
-    let r: [(usize, &[u8]); 6] = [
-        (0, &[0]),
-        (1, &[0, 1]),
-        (2, &[0, 1, 2]),
-        (3, &[1]),
-        (4, &[1, 2]),
-        (5, &[2]),
-    ];
-    assert_eq!(
-        sort_bfs(&[&[0], &[0, 1], &[0, 1, 2], &[1], &[1, 2], &[2],]),
-        r
-    );
-}

+ 67 - 0
packages/core/src/nodes.rs

@@ -424,6 +424,22 @@ impl Template {
             .iter()
             .all(|root| matches!(root, Dynamic { .. } | DynamicText { .. }))
     }
+
+    /// Iterate over the attribute paths in order along with the original indexes for each path
+    pub(crate) fn breadth_first_attribute_paths(
+        &self,
+    ) -> impl Iterator<Item = (usize, &'static [u8])> {
+        // In release mode, hot reloading is disabled and everything is in breadth first order already
+        #[cfg(not(debug_assertions))]
+        {
+            self.attr_paths.iter().copied().enumerate()
+        }
+        // If we are in debug mode, hot reloading may have messed up the order of the paths. We need to sort them
+        #[cfg(debug_assertions)]
+        {
+            sort_bfo(self.attr_paths).into_iter()
+        }
+    }
 }
 
 /// A statically known node in a layout.
@@ -1012,3 +1028,54 @@ pub trait HasAttributes {
         volatile: bool,
     ) -> Self;
 }
+
+#[cfg(debug_assertions)]
+pub(crate) fn sort_bfo(paths: &[&'static [u8]]) -> Vec<(usize, &'static [u8])> {
+    let mut with_indecies = paths.iter().copied().enumerate().collect::<Vec<_>>();
+    with_indecies.sort_unstable_by(|(_, a), (_, b)| {
+        let mut a = a.iter();
+        let mut b = b.iter();
+        loop {
+            match (a.next(), b.next()) {
+                (Some(a), Some(b)) => {
+                    if a != b {
+                        return a.cmp(b);
+                    }
+                }
+                // The shorter path goes first
+                (None, Some(_)) => return std::cmp::Ordering::Less,
+                (Some(_), None) => return std::cmp::Ordering::Greater,
+                (None, None) => return std::cmp::Ordering::Equal,
+            }
+        }
+    });
+    with_indecies
+}
+
+#[test]
+#[cfg(debug_assertions)]
+fn sorting() {
+    let r: [(usize, &[u8]); 5] = [
+        (0, &[0, 1]),
+        (1, &[0, 2]),
+        (2, &[1, 0]),
+        (3, &[1, 0, 1]),
+        (4, &[1, 2]),
+    ];
+    assert_eq!(
+        sort_bfo(&[&[0, 1,], &[0, 2,], &[1, 0,], &[1, 0, 1,], &[1, 2,],]),
+        r
+    );
+    let r: [(usize, &[u8]); 6] = [
+        (0, &[0]),
+        (1, &[0, 1]),
+        (2, &[0, 1, 2]),
+        (3, &[1]),
+        (4, &[1, 2]),
+        (5, &[2]),
+    ];
+    assert_eq!(
+        sort_bfo(&[&[0], &[0, 1], &[0, 1, 2], &[1], &[1, 2], &[2],]),
+        r
+    );
+}

+ 3 - 1
packages/core/src/runtime.rs

@@ -1,3 +1,5 @@
+use slotmap::DefaultKey;
+
 use crate::innerlude::Effect;
 use crate::{
     innerlude::{LocalTask, SchedulerMsg},
@@ -27,7 +29,7 @@ pub struct Runtime {
     pub(crate) current_task: Cell<Option<Task>>,
 
     /// Tasks created with cx.spawn
-    pub(crate) tasks: RefCell<slab::Slab<Rc<LocalTask>>>,
+    pub(crate) tasks: RefCell<slotmap::SlotMap<DefaultKey, Rc<LocalTask>>>,
 
     // Currently suspended tasks
     pub(crate) suspended_tasks: Cell<usize>,

+ 24 - 18
packages/core/src/tasks.rs

@@ -3,6 +3,7 @@ use crate::innerlude::ScopeOrder;
 use crate::innerlude::{remove_future, spawn, Runtime};
 use crate::ScopeId;
 use futures_util::task::ArcWake;
+use slotmap::DefaultKey;
 use std::sync::Arc;
 use std::task::Waker;
 use std::{cell::Cell, future::Future};
@@ -14,7 +15,7 @@ use std::{pin::Pin, task::Poll};
 /// `Task` is a unique identifier for a task that has been spawned onto the runtime. It can be used to cancel the task
 #[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
 #[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
-pub struct Task(pub(crate) usize);
+pub struct Task(pub(crate) slotmap::DefaultKey);
 
 impl Task {
     /// Start a new future on the same thread as the rest of the VirtualDom.
@@ -139,24 +140,29 @@ impl Runtime {
         let (task, task_id) = {
             let mut tasks = self.tasks.borrow_mut();
 
-            let entry = tasks.vacant_entry();
-            let task_id = Task(entry.key());
-
-            let task = Rc::new(LocalTask {
-                scope,
-                active: Cell::new(true),
-                parent: self.current_task(),
-                task: RefCell::new(Box::pin(task)),
-                waker: futures_util::task::waker(Arc::new(LocalTaskHandle {
-                    id: task_id,
-                    tx: self.sender.clone(),
-                })),
-                ty: Cell::new(ty),
+            let mut task_id = Task(DefaultKey::default());
+            let mut local_task = None;
+            tasks.insert_with_key(|key| {
+                task_id = Task(key);
+
+                let new_task = Rc::new(LocalTask {
+                    scope,
+                    active: Cell::new(true),
+                    parent: self.current_task(),
+                    task: RefCell::new(Box::pin(task)),
+                    waker: futures_util::task::waker(Arc::new(LocalTaskHandle {
+                        id: task_id,
+                        tx: self.sender.clone(),
+                    })),
+                    ty: Cell::new(ty),
+                });
+
+                local_task = Some(new_task.clone());
+
+                new_task
             });
 
-            entry.insert(task.clone());
-
-            (task, task_id)
+            (local_task.unwrap(), task_id)
         };
 
         // Get a borrow on the task, holding no borrows on the tasks map
@@ -244,7 +250,7 @@ impl Runtime {
     ///
     /// This does not abort the task, so you'll want to wrap it in an abort handle if that's important to you
     pub(crate) fn remove_task(&self, id: Task) -> Option<Rc<LocalTask>> {
-        let task = self.tasks.borrow_mut().try_remove(id.0);
+        let task = self.tasks.borrow_mut().remove(id.0);
         if let Some(task) = &task {
             if task.suspended() {
                 self.suspended_tasks.set(self.suspended_tasks.get() - 1);

+ 10 - 13
packages/core/src/virtual_dom.rs

@@ -429,7 +429,7 @@ impl VirtualDom {
 
         if let Some(Some(parent_path)) = self.elements.get(element.0).copied() {
             if bubbles {
-                self.handle_bubbling_event(Some(parent_path), name, Event::new(data, bubbles));
+                self.handle_bubbling_event(parent_path, name, Event::new(data, bubbles));
             } else {
                 self.handle_non_bubbling_event(parent_path, name, Event::new(data, bubbles));
             }
@@ -799,14 +799,10 @@ impl VirtualDom {
         level = "trace",
         name = "VirtualDom::handle_bubbling_event"
     )]
-    fn handle_bubbling_event(
-        &mut self,
-        mut parent: Option<ElementRef>,
-        name: &str,
-        uievent: Event<dyn Any>,
-    ) {
+    fn handle_bubbling_event(&mut self, parent: ElementRef, name: &str, uievent: Event<dyn Any>) {
         // If the event bubbles, we traverse through the tree until we find the target element.
         // Loop through each dynamic attribute (in a depth first order) in this template before moving up to the template's parent.
+        let mut parent = Some(parent);
         while let Some(path) = parent {
             let mut listeners = vec![];
 
@@ -815,13 +811,13 @@ impl VirtualDom {
             let target_path = path.path;
 
             // Accumulate listeners into the listener list bottom to top
-            for (idx, attrs) in el_ref.dynamic_attrs.iter().enumerate() {
-                let this_path = node_template.attr_paths[idx];
+            for (idx, this_path) in node_template.breadth_first_attribute_paths() {
+                let attrs = &*el_ref.dynamic_attrs[idx];
 
                 for attr in attrs.iter() {
                     // Remove the "on" prefix if it exists, TODO, we should remove this and settle on one
                     if attr.name.trim_start_matches("on") == name
-                        && target_path.is_decendant(&this_path)
+                        && target_path.is_decendant(this_path)
                     {
                         listeners.push(&attr.value);
 
@@ -842,6 +838,7 @@ impl VirtualDom {
                 "Calling {} listeners",
                 listeners.len()
             );
+            tracing::info!("Listeners: {:?}", listeners);
             for listener in listeners.into_iter().rev() {
                 if let AttributeValue::Listener(listener) = listener {
                     self.runtime.rendering.set(false);
@@ -870,10 +867,10 @@ impl VirtualDom {
         let node_template = el_ref.template.get();
         let target_path = node.path;
 
-        for (idx, attr) in el_ref.dynamic_attrs.iter().enumerate() {
-            let this_path = node_template.attr_paths[idx];
+        for (idx, this_path) in node_template.breadth_first_attribute_paths() {
+            let attrs = &*el_ref.dynamic_attrs[idx];
 
-            for attr in attr.iter() {
+            for attr in attrs.iter() {
                 // Remove the "on" prefix if it exists, TODO, we should remove this and settle on one
                 // Only call the listener if this is the exact target element.
                 if attr.name.trim_start_matches("on") == name && target_path == this_path {

+ 45 - 2
packages/desktop/src/config.rs

@@ -2,6 +2,7 @@ use std::borrow::Cow;
 use std::path::PathBuf;
 use tao::window::{Icon, WindowBuilder};
 use wry::http::{Request as HttpRequest, Response as HttpResponse};
+use wry::RequestAsyncResponder;
 
 use crate::menubar::{default_menu_bar, DioxusMenu};
 
@@ -22,6 +23,7 @@ pub struct Config {
     pub(crate) window: WindowBuilder,
     pub(crate) menu: Option<DioxusMenu>,
     pub(crate) protocols: Vec<WryProtocol>,
+    pub(crate) asynchronous_protocols: Vec<AsyncWryProtocol>,
     pub(crate) pre_rendered: Option<String>,
     pub(crate) disable_context_menu: bool,
     pub(crate) resource_dir: Option<PathBuf>,
@@ -38,6 +40,11 @@ pub(crate) type WryProtocol = (
     Box<dyn Fn(HttpRequest<Vec<u8>>) -> HttpResponse<Cow<'static, [u8]>> + 'static>,
 );
 
+pub(crate) type AsyncWryProtocol = (
+    String,
+    Box<dyn Fn(HttpRequest<Vec<u8>>, RequestAsyncResponder) + 'static>,
+);
+
 impl Config {
     /// Initializes a new `WindowBuilder` with default values.
     #[inline]
@@ -56,6 +63,7 @@ impl Config {
             window,
             menu: Some(default_menu_bar()),
             protocols: Vec::new(),
+            asynchronous_protocols: Vec::new(),
             pre_rendered: None,
             disable_context_menu: !cfg!(debug_assertions),
             resource_dir: None,
@@ -109,11 +117,46 @@ impl Config {
     }
 
     /// Set a custom protocol
-    pub fn with_custom_protocol<F>(mut self, name: String, handler: F) -> Self
+    pub fn with_custom_protocol<F>(mut self, name: impl ToString, handler: F) -> Self
     where
         F: Fn(HttpRequest<Vec<u8>>) -> HttpResponse<Cow<'static, [u8]>> + 'static,
     {
-        self.protocols.push((name, Box::new(handler)));
+        self.protocols.push((name.to_string(), Box::new(handler)));
+        self
+    }
+
+    /// Set an asynchronous custom protocol
+    ///
+    /// **Example Usage**
+    /// ```rust
+    /// # use wry::http::response::Response as HTTPResponse;
+    /// # use std::borrow::Cow;
+    /// # use dioxus_desktop::Config;
+    /// #
+    /// # fn main() {
+    /// let cfg = Config::new()
+    ///     .with_asynchronous_custom_protocol("asset", |request, responder| {
+    ///         tokio::spawn(async move {
+    ///             responder.respond(
+    ///                 HTTPResponse::builder()
+    ///                     .status(404)
+    ///                     .body(Cow::Borrowed("404 - Not Found".as_bytes()))
+    ///                     .unwrap()
+    ///             );
+    ///         });
+    ///     });
+    /// # }
+    /// ```
+    /// note a key difference between Dioxus and Wry, the protocol name doesn't explicitly need to be a
+    /// [`String`], but needs to implement [`ToString`].
+    ///
+    /// See [`wry`](wry::WebViewBuilder::with_asynchronous_custom_protocol) for more details on implementation
+    pub fn with_asynchronous_custom_protocol<F>(mut self, name: impl ToString, handler: F) -> Self
+    where
+        F: Fn(HttpRequest<Vec<u8>>, RequestAsyncResponder) + 'static,
+    {
+        self.asynchronous_protocols
+            .push((name.to_string(), Box::new(handler)));
         self
     }
 

+ 32 - 19
packages/desktop/src/protocol.rs

@@ -2,7 +2,7 @@ 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};
+use std::path::{Component, Path, PathBuf};
 use wry::{
     http::{status::StatusCode, Request, Response},
     RequestAsyncResponder, Result,
@@ -82,14 +82,8 @@ fn assets_head() -> Option<String> {
         target_os = "openbsd"
     ))]
     {
-        let root = crate::protocol::get_asset_root_or_default();
-        let assets_head_path = "__assets_head.html";
-        let mut head = root.join(assets_head_path);
-        // If we can't find it, add the dist directory and try again
-        // When bundling we currently copy the whole dist directory to the output directory instead of the individual files because of a limitation of cargo bundle2
-        if !head.exists() {
-            head = root.join("dist").join(assets_head_path);
-        }
+        let assets_head_path = PathBuf::from("__assets_head.html");
+        let head = resolve_resource(&assets_head_path);
         match std::fs::read_to_string(&head) {
             Ok(s) => Some(s),
             Err(err) => {
@@ -112,6 +106,27 @@ fn assets_head() -> Option<String> {
     }
 }
 
+fn resolve_resource(path: &Path) -> PathBuf {
+    let mut base_path = get_asset_root_or_default();
+    if running_in_dev_mode() {
+        base_path.push(path);
+    } else {
+        let mut resource_path = PathBuf::new();
+        for component in path.components() {
+            // Tauri-bundle inserts special path segments for abnormal component paths
+            match component {
+                Component::Prefix(_) => {}
+                Component::RootDir => resource_path.push("_root_"),
+                Component::CurDir => {}
+                Component::ParentDir => resource_path.push("_up_"),
+                Component::Normal(p) => resource_path.push(p),
+            }
+        }
+        base_path.push(resource_path);
+    }
+    base_path
+}
+
 /// Handle a request from the webview
 ///
 /// - Tries to stream edits if they're requested.
@@ -157,19 +172,13 @@ pub(super) fn desktop_handler(
 
 fn serve_from_fs(path: PathBuf) -> Result<Response<Vec<u8>>> {
     // If the path is relative, we'll try to serve it from the assets directory.
-    let mut asset = get_asset_root_or_default().join(&path);
+    let mut asset = resolve_resource(&path);
 
     // If we can't find it, make it absolute and try again
     if !asset.exists() {
         asset = PathBuf::from("/").join(&path);
     }
 
-    // If we can't find it, add the dist directory and try again
-    // When bundling we currently copy the whole dist directory to the output directory instead of the individual files because of a limitation of cargo bundle2
-    if !asset.exists() {
-        asset = get_asset_root_or_default().join("dist").join(&path);
-    }
-
     if !asset.exists() {
         return Ok(Response::builder()
             .status(StatusCode::NOT_FOUND)
@@ -226,6 +235,12 @@ fn get_asset_root_or_default() -> PathBuf {
     get_asset_root().unwrap_or_else(|| std::env::current_dir().unwrap())
 }
 
+fn running_in_dev_mode() -> bool {
+    // If running under cargo, there's no bundle!
+    // There might be a smarter/more resilient way of doing this
+    std::env::var_os("CARGO").is_some()
+}
+
 /// Get the asset directory, following tauri/cargo-bundles directory discovery approach
 ///
 /// Currently supports:
@@ -237,9 +252,7 @@ fn get_asset_root_or_default() -> PathBuf {
 /// - [ ] Android
 #[allow(unreachable_code)]
 fn get_asset_root() -> Option<PathBuf> {
-    // If running under cargo, there's no bundle!
-    // There might be a smarter/more resilient way of doing this
-    if std::env::var_os("CARGO").is_some() {
+    if running_in_dev_mode() {
         return dioxus_cli_config::CURRENT_CONFIG
             .as_ref()
             .map(|c| c.out_dir())

+ 4 - 0
packages/desktop/src/webview.rs

@@ -169,6 +169,10 @@ impl WebviewInstance {
             webview = webview.with_custom_protocol(name, handler);
         }
 
+        for (name, handler) in cfg.asynchronous_protocols.drain(..) {
+            webview = webview.with_asynchronous_custom_protocol(name, handler);
+        }
+
         const INITIALIZATION_SCRIPT: &str = r#"
         if (document.addEventListener) {
             document.addEventListener('contextmenu', function(e) {

+ 11 - 2
packages/hooks/src/use_resource.rs

@@ -68,15 +68,18 @@ use std::{cell::Cell, future::Future, rc::Rc};
 /// }
 /// ```
 #[must_use = "Consider using `cx.spawn` to run a future without reading its value"]
+#[track_caller]
 pub fn use_resource<T, F>(mut future: impl FnMut() -> F + 'static) -> Resource<T>
 where
     T: 'static,
     F: Future<Output = T> + 'static,
 {
+    let location = std::panic::Location::caller();
+
     let mut value = use_signal(|| None);
     let mut state = use_signal(|| UseResourceState::Pending);
     let (rc, changed) = use_hook(|| {
-        let (rc, changed) = ReactiveContext::new();
+        let (rc, changed) = ReactiveContext::new_with_origin(location);
         (rc, Rc::new(Cell::new(Some(changed))))
     });
 
@@ -92,7 +95,13 @@ where
 
             // Run each poll in the context of the reactive scope
             // This ensures the scope is properly subscribed to the future's dependencies
-            let res = future::poll_fn(|cx| rc.run_in(|| fut.poll_unpin(cx))).await;
+            let res = future::poll_fn(|cx| {
+                rc.run_in(|| {
+                    tracing::trace_span!("polling resource", location = %location)
+                        .in_scope(|| fut.poll_unpin(cx))
+                })
+            })
+            .await;
 
             // Set the value and state
             state.set(UseResourceState::Ready);

+ 5 - 1
packages/html/src/eval.rs

@@ -55,6 +55,7 @@ pub fn eval(script: &str) -> UseEval {
             struct DummyProvider;
             impl EvalProvider for DummyProvider {
                 fn new_evaluator(&self, _js: String) -> GenerationalBox<Box<dyn Evaluator>> {
+                    tracing::error!("Eval is not supported on this platform. If you are using dioxus fullstack, you can wrap your code with `client! {{}}` to only include the code that runs eval in the client bundle.");
                     UnsyncStorage::owner().insert(Box::new(DummyEvaluator))
                 }
             }
@@ -98,7 +99,10 @@ impl UseEval {
 
     /// Sends a [`serde_json::Value`] to the evaluated JavaScript.
     pub fn send(&self, data: serde_json::Value) -> Result<(), EvalError> {
-        self.evaluator.read().send(data)
+        match self.evaluator.try_read() {
+            Ok(evaluator) => evaluator.send(data),
+            Err(_) => Err(EvalError::Finished),
+        }
     }
 
     /// Gets an UnboundedReceiver to receive messages from the evaluated JavaScript.

+ 1 - 1
packages/interpreter/src/js/core.js

@@ -1 +1 @@
-function setAttributeInner(node,field,value,ns){if(ns==="style"){node.style.setProperty(field,value);return}if(ns){node.setAttributeNS(ns,field,value);return}switch(field){case"value":if(node.value!==value)node.value=value;break;case"initial_value":node.defaultValue=value;break;case"checked":node.checked=truthy(value);break;case"initial_checked":node.defaultChecked=truthy(value);break;case"selected":node.selected=truthy(value);break;case"initial_selected":node.defaultSelected=truthy(value);break;case"dangerous_inner_html":node.innerHTML=value;break;default:if(!truthy(value)&&isBoolAttr(field))node.removeAttribute(field);else node.setAttribute(field,value)}}var truthy=function(val){return val==="true"||val===!0},isBoolAttr=function(field){switch(field){case"allowfullscreen":case"allowpaymentrequest":case"async":case"autofocus":case"autoplay":case"checked":case"controls":case"default":case"defer":case"disabled":case"formnovalidate":case"hidden":case"ismap":case"itemscope":case"loop":case"multiple":case"muted":case"nomodule":case"novalidate":case"open":case"playsinline":case"readonly":case"required":case"reversed":case"selected":case"truespeed":case"webkitdirectory":return!0;default:return!1}};class BaseInterpreter{global;local;root;handler;nodes;stack;templates;m;constructor(){}initialize(root,handler=null){if(this.global={},this.local={},this.root=root,this.nodes=[root],this.stack=[root],this.templates={},handler)this.handler=handler}createListener(event_name,element,bubbles){if(bubbles)if(this.global[event_name]===void 0)this.global[event_name]={active:1,callback:this.handler},this.root.addEventListener(event_name,this.handler);else this.global[event_name].active++;else{const id=element.getAttribute("data-dioxus-id");if(!this.local[id])this.local[id]={};element.addEventListener(event_name,this.handler)}}removeListener(element,event_name,bubbles){if(bubbles)this.removeBubblingListener(event_name);else this.removeNonBubblingListener(element,event_name)}removeBubblingListener(event_name){if(this.global[event_name].active--,this.global[event_name].active===0)this.root.removeEventListener(event_name,this.global[event_name].callback),delete this.global[event_name]}removeNonBubblingListener(element,event_name){const id=element.getAttribute("data-dioxus-id");if(delete this.local[id][event_name],Object.keys(this.local[id]).length===0)delete this.local[id];element.removeEventListener(event_name,this.handler)}removeAllNonBubblingListeners(element){const id=element.getAttribute("data-dioxus-id");delete this.local[id]}getNode(id){return this.nodes[id]}appendChildren(id,many){const root=this.nodes[id],els=this.stack.splice(this.stack.length-many);for(let k=0;k<many;k++)root.appendChild(els[k])}loadChild(ptr,len){let node=this.stack[this.stack.length-1],ptr_end=ptr+len;for(;ptr<ptr_end;ptr++){let end=this.m.getUint8(ptr);for(node=node.firstChild;end>0;end--)node=node.nextSibling}return node}saveTemplate(nodes,tmpl_id){this.templates[tmpl_id]=nodes}hydrate(ids){const hydrateNodes=document.querySelectorAll("[data-node-hydration]");for(let i=0;i<hydrateNodes.length;i++){const hydrateNode=hydrateNodes[i],split=hydrateNode.getAttribute("data-node-hydration").split(","),id=ids[parseInt(split[0])];if(this.nodes[id]=hydrateNode,split.length>1){hydrateNode.listening=split.length-1,hydrateNode.setAttribute("data-dioxus-id",id.toString());for(let j=1;j<split.length;j++){const split2=split[j].split(":"),event_name=split2[0],bubbles=split2[1]==="1";this.createListener(event_name,hydrateNode,bubbles)}}}const treeWalker=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT);let currentNode=treeWalker.nextNode();while(currentNode){const split=currentNode.textContent.split("node-id");if(split.length>1)this.nodes[ids[parseInt(split[1])]]=currentNode.nextSibling;currentNode=treeWalker.nextNode()}}setAttributeInner(node,field,value,ns){setAttributeInner(node,field,value,ns)}}export{BaseInterpreter};
+function setAttributeInner(node,field,value,ns){if(ns==="style"){node.style.setProperty(field,value);return}if(ns){node.setAttributeNS(ns,field,value);return}switch(field){case"value":if(node.value!==value)node.value=value;break;case"initial_value":node.defaultValue=value;break;case"checked":node.checked=truthy(value);break;case"initial_checked":node.defaultChecked=truthy(value);break;case"selected":node.selected=truthy(value);break;case"initial_selected":node.defaultSelected=truthy(value);break;case"dangerous_inner_html":node.innerHTML=value;break;default:if(!truthy(value)&&isBoolAttr(field))node.removeAttribute(field);else node.setAttribute(field,value)}}var truthy=function(val){return val==="true"||val===!0},isBoolAttr=function(field){switch(field){case"allowfullscreen":case"allowpaymentrequest":case"async":case"autofocus":case"autoplay":case"checked":case"controls":case"default":case"defer":case"disabled":case"formnovalidate":case"hidden":case"ismap":case"itemscope":case"loop":case"multiple":case"muted":case"nomodule":case"novalidate":case"open":case"playsinline":case"readonly":case"required":case"reversed":case"selected":case"truespeed":case"webkitdirectory":return!0;default:return!1}};class BaseInterpreter{global;local;root;handler;nodes;stack;templates;m;constructor(){}initialize(root,handler=null){if(this.global={},this.local={},this.root=root,this.nodes=[root],this.stack=[root],this.templates={},handler)this.handler=handler}createListener(event_name,element,bubbles){if(bubbles)if(this.global[event_name]===void 0)this.global[event_name]={active:1,callback:this.handler},this.root.addEventListener(event_name,this.handler);else this.global[event_name].active++;else{const id=element.getAttribute("data-dioxus-id");if(!this.local[id])this.local[id]={};element.addEventListener(event_name,this.handler)}}removeListener(element,event_name,bubbles){if(bubbles)this.removeBubblingListener(event_name);else this.removeNonBubblingListener(element,event_name)}removeBubblingListener(event_name){if(this.global[event_name].active--,this.global[event_name].active===0)this.root.removeEventListener(event_name,this.global[event_name].callback),delete this.global[event_name]}removeNonBubblingListener(element,event_name){const id=element.getAttribute("data-dioxus-id");if(delete this.local[id][event_name],Object.keys(this.local[id]).length===0)delete this.local[id];element.removeEventListener(event_name,this.handler)}removeAllNonBubblingListeners(element){const id=element.getAttribute("data-dioxus-id");delete this.local[id]}getNode(id){return this.nodes[id]}appendChildren(id,many){const root=this.nodes[id],els=this.stack.splice(this.stack.length-many);for(let k=0;k<many;k++)root.appendChild(els[k])}loadChild(ptr,len){let node=this.stack[this.stack.length-1],ptr_end=ptr+len;for(;ptr<ptr_end;ptr++){let end=this.m.getUint8(ptr);for(node=node.firstChild;end>0;end--)node=node.nextSibling}return node}saveTemplate(nodes,tmpl_id){this.templates[tmpl_id]=nodes}hydrate(ids){const hydrateNodes=document.querySelectorAll("[data-node-hydration]");for(let i=0;i<hydrateNodes.length;i++){const hydrateNode=hydrateNodes[i],split=hydrateNode.getAttribute("data-node-hydration").split(","),id=ids[parseInt(split[0])];if(this.nodes[id]=hydrateNode,split.length>1){hydrateNode.listening=split.length-1,hydrateNode.setAttribute("data-dioxus-id",id.toString());for(let j=1;j<split.length;j++){const split2=split[j].split(":"),event_name=split2[0],bubbles=split2[1]==="1";this.createListener(event_name,hydrateNode,bubbles)}}}const treeWalker=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT);let currentNode=treeWalker.nextNode();while(currentNode){const split=currentNode.textContent.split("node-id");if(split.length>1){let next=currentNode.nextSibling;if(next.nodeType===Node.COMMENT_NODE)next=next.parentElement.insertBefore(document.createTextNode(""),next);this.nodes[ids[parseInt(split[1])]]=next}currentNode=treeWalker.nextNode()}}setAttributeInner(node,field,value,ns){setAttributeInner(node,field,value,ns)}}export{BaseInterpreter};

+ 1 - 1
packages/interpreter/src/js/hash.txt

@@ -1 +1 @@
-5713307201725207733
+5244713076016553709

+ 32 - 17
packages/interpreter/src/ts/core.ts

@@ -8,13 +8,13 @@ export type NodeId = number;
 export class BaseInterpreter {
   // non bubbling events listen at the element the listener was created at
   global: {
-    [key: string]: { active: number, callback: EventListener }
+    [key: string]: { active: number; callback: EventListener };
   };
   // bubbling events can listen at the root element
   local: {
     [key: string]: {
-      [key: string]: EventListener
-    }
+      [key: string]: EventListener;
+    };
   };
 
   root: HTMLElement;
@@ -22,13 +22,13 @@ export class BaseInterpreter {
   nodes: Node[];
   stack: Node[];
   templates: {
-    [key: number]: Node[]
+    [key: number]: Node[];
   };
 
   // sledgehammer is generating this...
   m: any;
 
-  constructor() { }
+  constructor() {}
 
   initialize(root: HTMLElement, handler: EventListener | null = null) {
     this.global = {};
@@ -72,7 +72,10 @@ export class BaseInterpreter {
   removeBubblingListener(event_name: string) {
     this.global[event_name].active--;
     if (this.global[event_name].active === 0) {
-      this.root.removeEventListener(event_name, this.global[event_name].callback);
+      this.root.removeEventListener(
+        event_name,
+        this.global[event_name].callback
+      );
       delete this.global[event_name];
     }
   }
@@ -123,12 +126,12 @@ export class BaseInterpreter {
   }
 
   hydrate(ids: { [key: number]: number }) {
-    const hydrateNodes = document.querySelectorAll('[data-node-hydration]');
+    const hydrateNodes = document.querySelectorAll("[data-node-hydration]");
 
     for (let i = 0; i < hydrateNodes.length; i++) {
       const hydrateNode = hydrateNodes[i] as HTMLElement;
-      const hydration = hydrateNode.getAttribute('data-node-hydration');
-      const split = hydration!.split(',');
+      const hydration = hydrateNode.getAttribute("data-node-hydration");
+      const split = hydration!.split(",");
       const id = ids[parseInt(split[0])];
 
       this.nodes[id] = hydrateNode;
@@ -136,12 +139,12 @@ export class BaseInterpreter {
       if (split.length > 1) {
         // @ts-ignore
         hydrateNode.listening = split.length - 1;
-        hydrateNode.setAttribute('data-dioxus-id', id.toString());
+        hydrateNode.setAttribute("data-dioxus-id", id.toString());
         for (let j = 1; j < split.length; j++) {
           const listener = split[j];
-          const split2 = listener.split(':');
+          const split2 = listener.split(":");
           const event_name = split2[0];
-          const bubbles = split2[1] === '1';
+          const bubbles = split2[1] === "1";
           this.createListener(event_name, hydrateNode, bubbles);
         }
       }
@@ -149,25 +152,37 @@ export class BaseInterpreter {
 
     const treeWalker = document.createTreeWalker(
       document.body,
-      NodeFilter.SHOW_COMMENT,
+      NodeFilter.SHOW_COMMENT
     );
 
     let currentNode = treeWalker.nextNode();
 
     while (currentNode) {
       const id = currentNode.textContent!;
-      const split = id.split('node-id');
+      const split = id.split("node-id");
 
       if (split.length > 1) {
-        this.nodes[ids[parseInt(split[1])]] = currentNode.nextSibling;
+        let next = currentNode.nextSibling;
+        // If we are hydrating an empty text node, we may see two comment nodes in a row instead of a comment node, text node and then comment node
+        if (next.nodeType === Node.COMMENT_NODE) {
+          next = next.parentElement.insertBefore(
+            document.createTextNode(""),
+            next
+          );
+        }
+        this.nodes[ids[parseInt(split[1])]] = next;
       }
 
       currentNode = treeWalker.nextNode();
     }
   }
 
-  setAttributeInner(node: HTMLElement, field: string, value: string, ns: string) {
+  setAttributeInner(
+    node: HTMLElement,
+    field: string,
+    value: string,
+    ns: string
+  ) {
     setAttributeInner(node, field, value, ns);
   }
 }
-

+ 1 - 0
packages/interpreter/src/unified_bindings.rs

@@ -98,6 +98,7 @@ mod js {
                 switch (field) {
                     case "value":
                         node.value = "";
+                        node.removeAttribute("value");
                         break;
                     case "checked":
                         node.checked = false;

+ 4 - 1
packages/router/src/components/link.rs

@@ -195,7 +195,7 @@ impl Debug for LinkProps {
 /// # vdom.rebuild_in_place();
 /// # assert_eq!(
 /// #     dioxus_ssr::render(&vdom),
-/// #     r#"<a href="/" dioxus-prevent-default="" class="link_class active" rel="link_rel" target="_blank" id="link_id">A fully configured link</a>"#
+/// #     r#"<a href="/" dioxus-prevent-default="" class="link_class active" rel="link_rel" target="_blank" aria-current="page" id="link_id">A fully configured link</a>"#
 /// # );
 /// ```
 #[allow(non_snake_case)]
@@ -252,6 +252,8 @@ pub fn Link(props: LinkProps) -> Element {
         Some(class_)
     };
 
+    let aria_current = (href == current_url).then_some("page");
+
     let tag_target = new_tab.then_some("_blank");
 
     let is_external = matches!(parsed_route, NavigationTarget::External(_));
@@ -286,6 +288,7 @@ pub fn Link(props: LinkProps) -> Element {
             class,
             rel,
             target: tag_target,
+            aria_current,
             ..attributes,
             {children}
         }

+ 7 - 6
packages/router/src/router_cfg.rs

@@ -17,7 +17,7 @@ use std::sync::Arc;
 ///     #[route("/")]
 ///     Index {},
 /// }
-/// let cfg = RouterConfig::default().history(WebHistory::<Route>::default());
+/// let cfg = RouterConfig::default().history(MemoryHistory::<Route>::default());
 /// ```
 pub struct RouterConfig<R: Routable> {
     pub(crate) failure_external_navigation: fn() -> Element,
@@ -46,13 +46,14 @@ where
     <R as std::str::FromStr>::Err: std::fmt::Display,
 {
     pub(crate) fn take_history(&mut self) -> Box<dyn AnyHistoryProvider> {
-        #[allow(unused)]
-        let initial_route = self.initial_route.clone().unwrap_or("/".parse().unwrap_or_else(|err|
-            panic!("index route does not exist:\n{}\n use MemoryHistory::with_initial_path or RouterConfig::initial_route to set a custom path", err)
-        ));
         self.history
             .take()
-            .unwrap_or_else(|| default_history(initial_route))
+            .unwrap_or_else(|| {
+                let initial_route = self.initial_route.clone().unwrap_or_else(|| "/".parse().unwrap_or_else(|err|
+                    panic!("index route does not exist:\n{}\n use MemoryHistory::with_initial_path or RouterConfig::initial_route to set a custom path", err)
+                ));
+                default_history(initial_route)
+    })
     }
 }
 

+ 2 - 1
packages/router/tests/via_ssr/link.rs

@@ -173,10 +173,11 @@ fn with_active_class_active() {
     }
 
     let expected = format!(
-        "<h1>App</h1><a {href} {default} {class}>Link</a>",
+        "<h1>App</h1><a {href} {default} {class} {aria}>Link</a>",
         href = r#"href="/""#,
         default = r#"dioxus-prevent-default="onclick""#,
         class = r#"class="test_class active_class""#,
+        aria = r#"aria-current="page""#,
     );
 
     assert_eq!(prepare::<Route>(), expected);

+ 1 - 0
packages/router/tests/via_ssr/main.rs

@@ -1,2 +1,3 @@
 mod link;
 mod outlet;
+mod without_index;

+ 41 - 0
packages/router/tests/via_ssr/without_index.rs

@@ -0,0 +1,41 @@
+use dioxus::prelude::*;
+
+// Tests for regressions of <https://github.com/DioxusLabs/dioxus/issues/2468>
+#[test]
+fn router_without_index_route_parses() {
+    let mut vdom = VirtualDom::new_with_props(
+        App,
+        AppProps {
+            path: Route::Test {},
+        },
+    );
+    vdom.rebuild_in_place();
+    let as_string = dioxus_ssr::render(&vdom);
+    assert_eq!(as_string, "<div>router with no index route renders</div>")
+}
+
+#[derive(Routable, Clone, Copy, PartialEq, Debug)]
+enum Route {
+    #[route("/test")]
+    Test {},
+}
+
+#[component]
+fn Test() -> Element {
+    rsx! {
+        div {
+            "router with no index route renders"
+        }
+    }
+}
+
+#[component]
+fn App(path: Route) -> Element {
+    rsx! {
+        Router::<Route> {
+            config: {
+                move || RouterConfig::default().history(MemoryHistory::with_initial_path(path))
+            }
+        }
+    }
+}

+ 15 - 8
packages/signals/src/memo.rs

@@ -5,7 +5,6 @@ use crate::{CopyValue, ReadOnlySignal};
 use std::{
     cell::RefCell,
     ops::Deref,
-    panic::Location,
     sync::{atomic::AtomicBool, Arc},
 };
 
@@ -36,7 +35,18 @@ where
 impl<T: 'static> Memo<T> {
     /// Create a new memo
     #[track_caller]
-    pub fn new(mut f: impl FnMut() -> T + 'static) -> Self
+    pub fn new(f: impl FnMut() -> T + 'static) -> Self
+    where
+        T: PartialEq,
+    {
+        Self::new_with_location(f, std::panic::Location::caller())
+    }
+
+    /// Create a new memo with an explicit location
+    pub fn new_with_location(
+        mut f: impl FnMut() -> T + 'static,
+        location: &'static std::panic::Location<'static>,
+    ) -> Self
     where
         T: PartialEq,
     {
@@ -50,11 +60,8 @@ impl<T: 'static> Memo<T> {
                 let _ = tx.unbounded_send(());
             }
         };
-        let rc = ReactiveContext::new_with_callback(
-            callback,
-            current_scope_id().unwrap(),
-            Location::caller(),
-        );
+        let rc =
+            ReactiveContext::new_with_callback(callback, current_scope_id().unwrap(), location);
 
         // Create a new signal in that context, wiring up its dependencies and subscribers
         let mut recompute = move || rc.run_in(&mut f);
@@ -64,7 +71,7 @@ impl<T: 'static> Memo<T> {
             dirty,
             callback: recompute,
         });
-        let state: Signal<T> = Signal::new(value);
+        let state: Signal<T> = Signal::new_with_caller(value, location);
 
         let memo = Memo {
             inner: state,

+ 10 - 0
packages/signals/src/signal.rs

@@ -91,6 +91,16 @@ impl<T: PartialEq + 'static> Signal<T> {
     pub fn memo(f: impl FnMut() -> T + 'static) -> Memo<T> {
         Memo::new(f)
     }
+
+    /// Creates a new unsync Selector with an explicit location. The selector will be run immediately and whenever any signal it reads changes.
+    ///
+    /// Selectors can be used to efficiently compute derived data from signals.
+    pub fn memo_with_location(
+        f: impl FnMut() -> T + 'static,
+        location: &'static std::panic::Location<'static>,
+    ) -> Memo<T> {
+        Memo::new_with_location(f, location)
+    }
 }
 
 impl<T: 'static, S: Storage<SignalData<T>>> Signal<T, S> {