diff --git a/Cargo.lock b/Cargo.lock index c1dffb9b6a..93e82454d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3501,6 +3501,7 @@ dependencies = [ "jni 0.21.1", "lazy-js-bundle", "libc", + "manganis", "muda", "ndk", "ndk-context", diff --git a/notes/architecture/06-RENDERERS.md b/notes/architecture/06-RENDERERS.md index 2538960085..746cff014d 100644 --- a/notes/architecture/06-RENDERERS.md +++ b/notes/architecture/06-RENDERERS.md @@ -97,6 +97,8 @@ Implements WriteMutations for wry-based rendering: - Delegates to `WryQueue` managing mutation batch - WebSocket server on random port for mutation transmission - Binary protocol via Sledgehammer interpreter +- Each served page is numbered. It reports `initialize` with its number and opens the socket when the host answers, and the host ignores a page a newer one is replacing +- A later `initialize` from the same webview is a reloaded page, such as the one Android loads into a recreated activity. The queue moves to a fresh socket id and the window is redrawn with `VirtualDom::remount_render_target`, keeping component state ### IPC (Interprocess Communication) ``` diff --git a/packages/cli/assets/android/gen/app/src/main/AndroidManifest.xml.hbs b/packages/cli/assets/android/gen/app/src/main/AndroidManifest.xml.hbs index 469f4b758b..c10f391672 100644 --- a/packages/cli/assets/android/gen/app/src/main/AndroidManifest.xml.hbs +++ b/packages/cli/assets/android/gen/app/src/main/AndroidManifest.xml.hbs @@ -25,7 +25,7 @@ {{#if uses_cleartext_traffic}}android:usesCleartextTraffic="{{ uses_cleartext_traffic }}"{{/if}} {{#if large_heap}}android:largeHeap="{{ large_heap }}"{{/if}} android:networkSecurityConfig="@xml/network_security_config"> - diff --git a/packages/core/src/lib.rs b/packages/core/src/lib.rs index dfbbacbf03..2d0587557a 100644 --- a/packages/core/src/lib.rs +++ b/packages/core/src/lib.rs @@ -43,6 +43,7 @@ mod nodes; mod portal; mod properties; mod reactive_context; +mod remount; mod render_driver; mod render_error; mod root_wrapper; diff --git a/packages/core/src/portal.rs b/packages/core/src/portal.rs index f05c49b922..408872893d 100644 --- a/packages/core/src/portal.rs +++ b/packages/core/src/portal.rs @@ -214,6 +214,11 @@ pub fn Portal(_: PortalProps) -> Element { /// target instead of mounting at the scope's slot. struct PortalDriver; +/// Whether `driver` renders a [`Portal`] scope. +pub(crate) fn is_portal_driver(driver: &dyn RenderDriver) -> bool { + driver.as_any().is::() +} + fn portal_props(dom: &VirtualDom, scope_id: ScopeId) -> (RenderTargetId, LastRenderedNode) { let props = dom.scopes[scope_id.index()] .props diff --git a/packages/core/src/remount.rs b/packages/core/src/remount.rs new file mode 100644 index 0000000000..b810e2cabe --- /dev/null +++ b/packages/core/src/remount.rs @@ -0,0 +1,133 @@ +use crate::{ + RenderTargetId, ScopeId, VirtualDom, + arena::{ElementId, RenderTargetState}, + innerlude::{MountId, MultiWriter}, + mount::MountedParent, + mutations::TargetRouter, + portal::is_portal_driver, + runtime::RuntimeGuard, + scopes::MountedOutput, +}; + +/// A scope whose rendered output sits at the root element of a render target. +struct TargetRoot { + scope: ScopeId, + logical_parent: Option, + render_parent: Option, +} + +/// Every writer of the host except the one for `skipped`. +struct WithoutTarget<'a, M> { + to: &'a mut M, + skipped: RenderTargetId, +} + +impl MultiWriter for WithoutTarget<'_, M> { + type Writer = M::Writer; + + fn writer_for(&mut self, id: RenderTargetId) -> Option<&mut M::Writer> { + if id == self.skipped { + None + } else { + self.to.writer_for(id) + } + } +} + +impl VirtualDom { + /// Write the whole mounted tree of `target` into `to`, between render passes, for a renderer + /// that lost the target's nodes, such as a webview that reloaded its page. + /// + /// Components keep their state and do not run. The [`ElementId`]s of `target` are reassigned, + /// and portals inside it rewrite their own targets through `to`. + pub fn remount_render_target(&mut self, target: RenderTargetId, to: &mut impl MultiWriter) { + let _runtime = RuntimeGuard::new(self.runtime.clone()); + let roots = self.target_roots(target); + if roots.is_empty() { + return; + } + + { + // The renderer of `target` has no nodes to remove, the other renderers do. + let mut others = WithoutTarget { + to: &mut *to, + skipped: target, + }; + let mut router = TargetRouter::new(&mut others, self.runtime.clone()); + for root in &roots { + self.remove_component_node(Some(&mut router), false, root.scope); + } + } + self.reset_render_target(target); + + let mut router = TargetRouter::new(to, self.runtime.clone()); + for root in roots { + if root.scope == ScopeId::ROOT { + self.rebuild_with_writer(&mut router); + continue; + } + let driver = self.runtime.get_state(root.scope).render_driver(); + self.runtime.clone().while_rendering(|| { + driver.create(self, root.scope, root.logical_parent, Some(&mut router)) + }); + if let Some(render_parent) = root.render_parent { + let root_mount = self.scopes[root.scope.index()] + .last_rendered_node + .as_ref() + .map(MountedOutput::root_mount) + .expect("a remounted scope has rendered output"); + self.set_mounted_render_parent(root_mount, render_parent); + } + } + } + + /// The outermost rendered scopes that place their output at the root element of `target`, in + /// scope id order. Their subtrees hold every node of `target`. + fn target_roots(&self, target: RenderTargetId) -> Vec { + let places_at_target_root = |scope: ScopeId| { + self.runtime.try_get_state(scope).is_some_and(|state| { + state.target_id() == target + && (scope == ScopeId::ROOT || is_portal_driver(&*state.render_driver())) + }) + }; + let parent = |scope: &ScopeId| self.runtime.get_state(*scope).parent_id(); + + self.scopes + .iter() + .map(|(index, _)| ScopeId::new(index)) + .filter(|&scope| places_at_target_root(scope)) + .filter(|scope| { + std::iter::successors(parent(scope), parent).all(|id| !places_at_target_root(id)) + }) + .filter(|&scope| self.runtime.scope_should_render(scope)) + .filter_map(|scope| { + let root_mount = self.scopes[scope.index()] + .last_rendered_node + .as_ref()? + .root_mount(); + Some(TargetRoot { + scope, + logical_parent: self.mounted_logical_parent(root_mount), + render_parent: self.mounted_render_parent(root_mount), + }) + }) + .collect() + } + + /// Forget the renderer-local state of `target` once no mount holds one of its elements. + fn reset_render_target(&mut self, target: RenderTargetId) { + let mut targets = self.runtime.render_targets.borrow_mut(); + let state = targets + .get_mut(target.index()) + .expect("a remounted target is registered"); + debug_assert!( + state + .elements + .iter() + .all(|(index, _)| index == ElementId::ROOT.index() + || state.template_roots.values().any(|id| id.index() == index)), + "every mounted element of the target is reclaimed before it is reset" + ); + *state = RenderTargetState::new(); + } +} diff --git a/packages/core/src/virtual_dom.rs b/packages/core/src/virtual_dom.rs index 581c437497..9a22cd2e90 100644 --- a/packages/core/src/virtual_dom.rs +++ b/packages/core/src/virtual_dom.rs @@ -589,7 +589,7 @@ impl VirtualDom { } } - fn rebuild_with_writer(&mut self, to: &mut dyn WriteMutations) { + pub(crate) fn rebuild_with_writer(&mut self, to: &mut dyn WriteMutations) { let driver = self.runtime.get_state(ScopeId::ROOT).render_driver(); append_children_to(to, ElementId::ROOT, self.runtime.clone(), |to| { self.runtime.clone().while_rendering(|| { diff --git a/packages/core/tests/render_targets.rs b/packages/core/tests/render_targets.rs index d619361bb4..09449d2765 100644 --- a/packages/core/tests/render_targets.rs +++ b/packages/core/tests/render_targets.rs @@ -1,10 +1,12 @@ use dioxus::prelude::*; use dioxus_core::{ ElementId, MultiWriter, Mutation, Mutations, Portal, RenderTargetId, Runtime, VirtualDom, + WriteMutations, }; +use dioxus_renderer_oracle::{RendererOracle, SnapshotNode}; use std::{ any::Any, - cell::Cell, + cell::{Cell, RefCell}, collections::BTreeMap, rc::Rc, sync::atomic::{AtomicUsize, Ordering}, @@ -26,24 +28,25 @@ fn render_immediate_to_targeted_vec(dom: &mut VirtualDom) -> BTreeMap, +/// One writer per render target, created the first time the diff writes to it. +struct CollectingTargetWriter { + targets: BTreeMap, } -impl CollectingTargetWriter { +impl CollectingTargetWriter { fn new() -> Self { Self { targets: BTreeMap::new() } } - fn into_targets(self) -> BTreeMap { + fn into_targets(self) -> BTreeMap { self.targets } } -impl MultiWriter for CollectingTargetWriter { - type Writer = Mutations; +impl MultiWriter for CollectingTargetWriter { + type Writer = W; - fn writer_for(&mut self, id: RenderTargetId) -> Option<&mut Mutations> { + fn writer_for(&mut self, id: RenderTargetId) -> Option<&mut W> { Some(self.targets.entry(id).or_default()) } } @@ -910,3 +913,228 @@ fn portal_under_suspense_keeps_state_and_updates_target_on_resolve() { assert_eq!(PORTAL_STATE_INITS.load(Ordering::SeqCst), 1); }); } + +// A renderer that lost a target's nodes, such as a reloaded webview page, gets it back from +// `remount_render_target` without any component running. + +fn all_text(nodes: &[SnapshotNode]) -> Vec { + fn walk(nodes: &[SnapshotNode], out: &mut Vec) { + for node in nodes { + match node { + SnapshotNode::Element { children, .. } => walk(children, out), + SnapshotNode::Text(text) => out.push(text.clone()), + } + } + } + let mut out = Vec::new(); + walk(nodes, &mut out); + out +} + +fn assert_stacks_clean(oracles: &CollectingTargetWriter) { + for oracle in oracles.targets.values() { + oracle.assert_stack_clean(); + } +} + +thread_local! { + static RENDERS: Cell = const { Cell::new(0) }; +} + +#[component] +fn Counter(label: &'static str) -> Element { + RENDERS.set(RENDERS.get() + 1); + let mut count = use_signal(|| 0); + rsx! { + h1 { "{label} {count}" } + button { id: "{label}", onclick: move |_| count += 1, "inc" } + ul { + for i in 0..count() { + li { key: "{i}", "item {i}" } + } + } + if count() % 2 == 0 { + p { "even" } + } + } +} + +fn root_app() -> Element { + rsx! { + Counter { label: "root" } + "trailing text" + } +} + +#[test] +fn remounting_the_root_target_restores_the_tree_and_keeps_state() { + set_event_converter(Box::new(dioxus::html::SerializedHtmlEventConverter)); + let mut dom = VirtualDom::new(root_app); + let mut lost = RendererOracle::new(); + lost.rebuild(&mut dom); + + let button = lost.element_id_by_attr("id", "root"); + dom.runtime().handle_event("click", click_event(), button); + lost.render(&mut dom); + assert!(all_text(&lost.snapshot()).contains(&"root 1".to_string())); + + let renders = RENDERS.get(); + let mut fresh = RendererOracle::new(); + dom.remount_render_target(RenderTargetId::ROOT, &mut fresh); + fresh.assert_stack_clean(); + assert_eq!( + RENDERS.get(), + renders, + "remounting must not re-run components" + ); + assert_eq!(fresh.snapshot(), lost.snapshot()); + fresh.assert_matches_vdom(&dom); + + // Listeners and later diffs address the ids the fresh renderer was given. + let button = fresh.element_id_by_attr("id", "root"); + dom.runtime().handle_event("click", click_event(), button); + fresh.render(&mut dom); + fresh.assert_matches_vdom(&dom); + let text = all_text(&fresh.snapshot()); + assert!(text.contains(&"root 2".to_string()), "{text:?}"); + assert!(text.contains(&"item 1".to_string()), "{text:?}"); + assert!(text.contains(&"even".to_string()), "{text:?}"); +} + +thread_local! { + static TARGETS: Cell> = const { Cell::new(None) }; +} + +fn window_app() -> Element { + let (window, nested) = use_hook(|| { + let runtime = Runtime::current(); + let targets = ( + runtime.create_render_target(), + runtime.create_render_target(), + ); + TARGETS.set(Some(targets)); + targets + }); + rsx! { + p { "outside the window" } + Portal { target: window, + Counter { label: "window" } + Portal { target: nested, + span { "nested" } + Counter { label: "nested" } + } + } + } +} + +#[test] +fn remounting_a_portal_target_leaves_other_targets_consistent() { + set_event_converter(Box::new(dioxus::html::SerializedHtmlEventConverter)); + let mut dom = VirtualDom::new(window_app); + let mut oracles = CollectingTargetWriter::new(); + dom.rebuild(&mut oracles); + assert_stacks_clean(&oracles); + let (window, nested) = TARGETS.get().unwrap(); + + for (target, label) in [(window, "window"), (nested, "nested")] { + let button = oracles.targets[&target].element_id_by_attr("id", label); + dom.runtime() + .handle_event_for_target(target, "click", click_event(), button); + dom.render_immediate(&mut oracles); + } + assert_stacks_clean(&oracles); + + let root_before = oracles.targets[&RenderTargetId::ROOT].snapshot(); + let root_edits_before = oracles.targets[&RenderTargetId::ROOT].last_edit_summary(); + let window_before = oracles.targets[&window].snapshot(); + let nested_before = oracles.targets[&nested].snapshot(); + + // The window's renderer lost its nodes. The other renderers keep theirs. + let lost = oracles + .targets + .insert(window, RendererOracle::new()) + .unwrap(); + let renders = RENDERS.get(); + dom.remount_render_target(window, &mut oracles); + assert_stacks_clean(&oracles); + assert_eq!( + RENDERS.get(), + renders, + "remounting must not re-run components" + ); + assert_eq!(oracles.targets[&window].snapshot(), lost.snapshot()); + assert_eq!(oracles.targets[&window].snapshot(), window_before); + assert_eq!(oracles.targets[&nested].snapshot(), nested_before); + assert_eq!( + oracles.targets[&RenderTargetId::ROOT].last_edit_summary(), + root_edits_before, + "the root target must not receive writes" + ); + assert_eq!( + oracles.targets[&RenderTargetId::ROOT].snapshot(), + root_before + ); + + for (target, label, expected) in [ + (window, "window", "window 2"), + (nested, "nested", "nested 2"), + ] { + let button = oracles.targets[&target].element_id_by_attr("id", label); + dom.runtime() + .handle_event_for_target(target, "click", click_event(), button); + dom.render_immediate(&mut oracles); + assert_stacks_clean(&oracles); + let text = all_text(&oracles.targets[&target].snapshot()); + assert!(text.contains(&expected.to_string()), "{text:?}"); + assert!(text.contains(&"item 1".to_string()), "{text:?}"); + } +} + +thread_local! { + static RESUME: RefCell>> = + const { RefCell::new(None) }; +} + +#[component] +fn Suspended() -> Element { + let resumed = use_resource(|| async { + let resume = RESUME.take().expect("the test hands over a receiver"); + let _ = resume.await; + }); + resumed.suspend()?; + rsx! { Counter { label: "resolved" } } +} + +fn suspense_app() -> Element { + rsx! { + h2 { "outside" } + SuspenseBoundary { fallback: |_| rsx! { "loading" }, + Suspended {} + } + } +} + +#[tokio::test] +async fn remounting_a_suspended_boundary_keeps_the_fallback_until_it_resolves() { + set_event_converter(Box::new(dioxus::html::SerializedHtmlEventConverter)); + let (resume, receiver) = tokio::sync::oneshot::channel(); + RESUME.set(Some(receiver)); + let mut dom = VirtualDom::new(suspense_app); + let mut lost = RendererOracle::new(); + lost.rebuild(&mut dom); + assert!(all_text(&lost.snapshot()).contains(&"loading".to_string())); + + let mut fresh = RendererOracle::new(); + dom.remount_render_target(RenderTargetId::ROOT, &mut fresh); + fresh.assert_stack_clean(); + assert_eq!(fresh.snapshot(), lost.snapshot()); + + resume.send(()).unwrap(); + fresh.wait_and_render(&mut dom).await; + fresh.assert_matches_vdom(&dom); + let button = fresh.element_id_by_attr("id", "resolved"); + dom.runtime().handle_event("click", click_event(), button); + fresh.render(&mut dom); + fresh.assert_matches_vdom(&dom); + assert!(all_text(&fresh.snapshot()).contains(&"resolved 1".to_string())); +} diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index 729c0f91ef..213e710ddc 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -82,6 +82,7 @@ jni = "0.21.1" ndk = { version = "0.9.0" } ndk-sys = { version = "0.6.0" } ndk-context = { version = "0.1.1" } +manganis = { workspace = true } # use native tls on other platforms [target.'cfg(not(target_os = "android"))'.dependencies] diff --git a/packages/desktop/src/app.rs b/packages/desktop/src/app.rs index c51cceee2a..096fe7f425 100644 --- a/packages/desktop/src/app.rs +++ b/packages/desktop/src/app.rs @@ -279,14 +279,27 @@ impl App { } } - /// The webview is finally loaded. Rebuild once, then start polling the - /// shared VDOM. - pub fn handle_initialize_msg(&mut self, id: WindowId) { - if !self.webviews.contains_key(&id) { + /// A page finished loading in a webview. The first page of the app triggers the initial + /// rebuild and a page that replaced an earlier one is redrawn from the live VirtualDom, then + /// the page is told where to read its edits. A page being replaced, or already initialized, is ignored. + pub fn handle_initialize_msg(&mut self, msg: IpcMessage, id: WindowId) { + #[derive(serde::Deserialize)] + struct Params { + page: u32, + } + let Ok(Params { page }) = serde_json::from_value(msg.params()) else { + return; + }; + let Some(app_webview) = self.webviews.get_mut(&id) else { + return; + }; + if app_webview.edits.is_replaced(page) || app_webview.initialized_page == Some(page) { return; } - if !self.initial_dom_rebuild_done { + if app_webview.initialized_page.replace(page).is_some() { + self.redraw_reloaded_page(id); + } else if !self.initial_dom_rebuild_done { self.rebuild_dom(); self.send_touched_edits(); @@ -298,9 +311,26 @@ impl App { } } + self.webviews[&id].connect_initialized_page(); + self.schedule_poll(); } + /// Give a reloaded page, such as the one Android loads into a recreated activity, the + /// webview's head elements and whole tree again. Component state is kept. + fn redraw_reloaded_page(&mut self, id: WindowId) { + let app_webview = &self.webviews[&id]; + app_webview.edits.wry_queue.start_new_page(); + app_webview.desktop_context.replay_head_elements(); + let target_id = app_webview.target_id(); + + #[cfg(target_os = "android")] + let _lock = crate::android_sync_lock::android_runtime_lock(); + let mut writer = self.dom_writer(); + self.dom.remount_render_target(target_id, &mut writer); + self.send_touched_edits(); + } + pub fn handle_query_msg(&mut self, msg: IpcMessage, id: WindowId) { let Ok(result) = serde_json::from_value::(msg.params()) else { return; @@ -515,14 +545,7 @@ impl App { .poll_new_edits_location(cx) .is_ready() { - _ = app_webview - .desktop_context - .webview - .evaluate_script(&format!( - "window.interpreter.waitForRequest(\"{edits_path}\", \"{expected_key}\");", - edits_path = app_webview.edits.wry_queue.edits_path(), - expected_key = app_webview.edits.wry_queue.required_server_key() - )); + app_webview.connect_initialized_page(); } if app_webview diff --git a/packages/desktop/src/desktop_state.rs b/packages/desktop/src/desktop_state.rs index 6022292bb1..229df586cb 100644 --- a/packages/desktop/src/desktop_state.rs +++ b/packages/desktop/src/desktop_state.rs @@ -96,6 +96,8 @@ pub struct DesktopWindowContext { pub(crate) query: QueryEngine, pub(crate) close_behaviour: Cell, component_window_callbacks: RefCell>, + /// Scripts that created head elements, run again in a reloaded page. + head_elements: RefCell>, #[cfg(target_os = "ios")] pub(crate) views: RefCell>>, @@ -119,6 +121,7 @@ impl DesktopWindowContext { query: QueryEngine::default(), close_behaviour: Cell::new(close_behaviour), component_window_callbacks: RefCell::new(None), + head_elements: RefCell::new(Vec::new()), #[cfg(target_os = "ios")] views: RefCell::new(Vec::new()), } @@ -165,6 +168,19 @@ impl DesktopWindowContext { (callbacks.on_destroyed)(); } } + + /// Run `script`, which creates a head element, and keep it for a page that replaces this one. + pub(crate) fn create_head_element(&self, script: String) { + _ = self.webview.evaluate_script(&script); + self.head_elements.borrow_mut().push(script); + } + + /// Recreate every recorded head element in a page that replaced the one they were made in. + pub(crate) fn replay_head_elements(&self) { + for script in self.head_elements.borrow().iter() { + _ = self.webview.evaluate_script(script); + } + } } /// Expose the underlying native window so its [`tao`] methods can be called directly on a diff --git a/packages/desktop/src/document.rs b/packages/desktop/src/document.rs index daedf13407..6f4ccb3488 100644 --- a/packages/desktop/src/document.rs +++ b/packages/desktop/src/document.rs @@ -21,6 +21,12 @@ impl DesktopDocument { let desktop_ctx = std::rc::Rc::downgrade(&desktop_ctx); Self { desktop_ctx } } + + fn create_head_element(&self, script: String) { + if let Some(ctx) = self.desktop_ctx.upgrade() { + ctx.create_head_element(script); + } + } } impl Document for DesktopDocument { @@ -43,7 +49,7 @@ impl Document for DesktopDocument { fn create_meta(&self, props: MetaProps) { let myself = self.clone(); queue_effect(move || { - myself.eval(create_element_in_head("meta", &props.attributes(), None)); + myself.create_head_element(create_element_in_head("meta", &props.attributes(), None)); }); } @@ -51,7 +57,7 @@ impl Document for DesktopDocument { fn create_script(&self, props: ScriptProps) { let myself = self.clone(); queue_effect(move || { - myself.eval(create_element_in_head( + myself.create_head_element(create_element_in_head( "script", &props.attributes(), props.script_contents().ok(), @@ -63,7 +69,7 @@ impl Document for DesktopDocument { fn create_style(&self, props: StyleProps) { let myself = self.clone(); queue_effect(move || { - myself.eval(create_element_in_head( + myself.create_head_element(create_element_in_head( "style", &props.attributes(), props.style_contents().ok(), @@ -75,7 +81,7 @@ impl Document for DesktopDocument { fn create_link(&self, props: LinkProps) { let myself = self.clone(); queue_effect(move || { - myself.eval(create_element_in_head("link", &props.attributes(), None)); + myself.create_head_element(create_element_in_head("link", &props.attributes(), None)); }); } } diff --git a/packages/desktop/src/edits.rs b/packages/desktop/src/edits.rs index 35be5562af..92aab8f00e 100644 --- a/packages/desktop/src/edits.rs +++ b/packages/desktop/src/edits.rs @@ -145,8 +145,29 @@ impl WryQueue { poll } + /// The script that makes `page` open its edits connection, and does nothing in any other page. + pub(crate) fn connect_script(&self, page: u32) -> String { + format!( + "if (window.dioxusPage === {page}) window.interpreter.waitForRequest(\"{}\", \"{}\");", + self.edits_path(), + self.required_server_key() + ) + } + + /// Serve a page that replaced the previous one, on a fresh connection id and mutation channel + /// because the new page cannot read the old page's pending edits or interned strings. + pub(crate) fn start_new_page(&self) { + let mut inner = self.inner.borrow_mut(); + let old_webview_id = inner.location.webview_id; + inner.location.webview_id = inner.websocket.next_webview_id(); + inner.websocket.forget_webview(old_webview_id); + inner.mutation_state = MutationState::default(); + inner.edits_in_progress = None; + inner.touched = false; + } + /// Get the websocket path that the webview should connect to in order to receive edits - pub(crate) fn edits_path(&self) -> String { + fn edits_path(&self) -> String { let WebviewWebsocketLocation { webview_id, server, .. } = &self.inner.borrow().location; @@ -158,7 +179,7 @@ impl WryQueue { } /// Get the key the client should expect from the server when connecting to the websocket. - pub(crate) fn required_server_key(&self) -> String { + fn required_server_key(&self) -> String { let server = &self.inner.borrow().location.server; let server = server.lock().unwrap(); encode_key_string(&server.server_key) @@ -400,14 +421,14 @@ impl EditWebsocket { _ = msg.response.send(()); } tracing::trace!("Webview {} closed the connection", location.webview_id); - let mut connection = WebviewConnectionState::default(); - if let Some(msg) = queued_message { - connection.add_message_pair(msg); + let mut connections = connections_.write().unwrap(); + // A forgotten webview has no page left to reconnect and take the unacknowledged edits. + if let Some(connection) = connections.get_mut(&location.webview_id) { + *connection = WebviewConnectionState::default(); + if let Some(msg) = queued_message { + connection.add_message_pair(msg); + } } - connections_ - .write() - .unwrap() - .insert(location.webview_id, connection); }); let mut connections = connections.write().unwrap(); @@ -438,10 +459,19 @@ impl EditWebsocket { ); } + /// Allocate an id no webview connection has used. + fn next_webview_id(&self) -> u32 { + self.max_webview_id + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + } + + /// Drop the queued edits and the connection of a webview whose page is gone. + fn forget_webview(&self, webview: u32) { + self.connections.write().unwrap().remove(&webview); + } + pub(crate) fn create_queue(&self) -> WryQueue { - let webview_id = self - .max_webview_id - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let webview_id = self.next_webview_id(); let server = self.current_location.clone(); let server_location = self.server_location.clone(); WryQueue { diff --git a/packages/desktop/src/launch.rs b/packages/desktop/src/launch.rs index 93eaed58c3..5433d0a60d 100644 --- a/packages/desktop/src/launch.rs +++ b/packages/desktop/src/launch.rs @@ -132,7 +132,7 @@ pub fn launch_virtual_dom_blocking(virtual_dom: VirtualDom, mut desktop_config: } UserWindowEvent::Ipc { id, msg } => match msg.method() { - IpcMethod::Initialize => app.handle_initialize_msg(id), + IpcMethod::Initialize => app.handle_initialize_msg(msg, id), IpcMethod::UserEvent => {} IpcMethod::Query => app.handle_query_msg(msg, id), IpcMethod::BrowserOpen => app.handle_browser_open(msg), diff --git a/packages/desktop/src/mobile.rs b/packages/desktop/src/mobile.rs index 2bea03e6eb..499384c9bd 100644 --- a/packages/desktop/src/mobile.rs +++ b/packages/desktop/src/mobile.rs @@ -18,34 +18,39 @@ pub extern "C" fn start_app() { use std::any::Any; // tao 0.35 dropped its automatic `ndk_context::initialize_android_context` call - // (see https://github.com/tauri-apps/tao/issues/1220). Many android-aware crates — - // including parts of wry itself — call `ndk_context::android_context()` and panic if - // it's uninitialized, which then poisons wry's static mutexes and turns the original - // panic into a confusing `PoisonError` at the next JNI callback. Initialize it here - // before handing off to wry's own setup. - // - // Guarded by `Once` because `WryActivity.onCreate` (and therefore this setup) runs - // again on activity re-creation — rotation, theme changes, back/foreground cycles — - // and `ndk_context::initialize_android_context` asserts `previous.is_none()`, which - // would abort the process on every re-entry. The global only needs the JavaVM + an - // activity-like Context pointer for consumers to attach a JNI thread; we don't need - // to refresh it per-activity. + // (see https://github.com/tauri-apps/tao/issues/1220), and many android-aware crates panic + // without it. It gets the Application context because activities are recreated while the + // process lives. unsafe fn android_setup( package: &str, - env: ::wry::prelude::JNIEnv<'_>, + mut env: ::wry::prelude::JNIEnv<'_>, looper: &::ndk::looper::ThreadLooper, activity: ::wry::prelude::GlobalRef, ) { - static NDK_CONTEXT_INIT: std::sync::Once = std::sync::Once::new(); - NDK_CONTEXT_INIT.call_once(|| { + static APPLICATION: std::sync::OnceLock<::wry::prelude::GlobalRef> = + std::sync::OnceLock::new(); + APPLICATION.get_or_init(|| { + let application = env + .call_method( + activity.as_obj(), + "getApplicationContext", + "()Landroid/content/Context;", + &[], + ) + .and_then(|context| context.l()) + .and_then(|context| env.new_global_ref(context)) + .expect("an activity has an Application context"); let vm = env.get_java_vm().unwrap(); + // SAFETY: `APPLICATION` keeps this global reference alive for the rest of the process. unsafe { ::ndk_context::initialize_android_context( vm.get_java_vm_pointer() as *mut _, - activity.as_obj().as_raw() as *mut _, + application.as_obj().as_raw() as *mut _, ); } + application }); + ::manganis::android::set_current_activity(activity.clone()); unsafe { wry::android_setup(package, env, looper, activity); } diff --git a/packages/desktop/src/protocol.rs b/packages/desktop/src/protocol.rs index be0f70de6a..e07b23c9bf 100644 --- a/packages/desktop/src/protocol.rs +++ b/packages/desktop/src/protocol.rs @@ -13,13 +13,13 @@ use wry::{ }; #[cfg(target_os = "android")] -const BASE_URI: &str = "https://dioxus.index.html/"; +pub(crate) const BASE_URI: &str = "https://dioxus.index.html/"; #[cfg(target_os = "windows")] -const BASE_URI: &str = "http://dioxus.index.html/"; +pub(crate) const BASE_URI: &str = "http://dioxus.index.html/"; #[cfg(not(any(target_os = "android", target_os = "windows")))] -const BASE_URI: &str = "dioxus://index.html/"; +pub(crate) const BASE_URI: &str = "dioxus://index.html/"; #[cfg(debug_assertions)] static DEFAULT_INDEX: &str = include_str!("./assets/dev.index.html"); @@ -127,7 +127,7 @@ fn index_request( // Might want to document this index.insert_str( index.find("").expect("Body element to exist"), - &module_loader(root_name, headless, edit_state), + &module_loader(root_name, headless, edit_state.serve_page()), ); Response::builder() @@ -143,13 +143,11 @@ fn index_request( /// - root_name: the root element (by Id) that we stream edits into /// - headless: is this page being loaded but invisible? Important because not all windows are visible and the /// interpreter can't connect until the window is ready. -/// - port: the port that the websocket server is listening on for edits -/// - webview_id: the id of the webview that we're loading this into. This is used to differentiate between -/// multiple webviews in the same application, so that we can send edits to the correct one. -fn module_loader(root_id: &str, headless: bool, edit_state: &WebviewEdits) -> String { - let edits_path = edit_state.wry_queue.edits_path(); - let expected_key = edit_state.wry_queue.required_server_key(); - +/// - page: the number of this document in its webview +/// +/// The page reports `initialize` with its number once loaded, and the host answers with the edits +/// connection to open. +fn module_loader(root_id: &str, headless: bool, page: u32) -> String { format!( r#"