Quellcode durchsuchen

Fix playwright tests (#2695)

Evan Almloff vor 11 Monaten
Ursprung
Commit
bd484842bd

+ 3 - 6
packages/cli/src/builder/mod.rs

@@ -117,6 +117,7 @@ impl BuildResult {
         &self,
         serve: &ServeArguments,
         fullstack_address: Option<SocketAddr>,
+        workspace: &std::path::Path,
     ) -> std::io::Result<Option<Child>> {
         if self.web {
             return Ok(None);
@@ -124,12 +125,8 @@ impl BuildResult {
 
         let arguments = RuntimeCLIArguments::new(serve.address.address(), fullstack_address);
         let executable = self.executable.canonicalize()?;
-        // This is the /dist folder generally
-        let output_folder = executable.parent().unwrap();
-        // This is the workspace folder
-        let workspace_folder = output_folder.parent().unwrap();
         Ok(Some(
-            Command::new(&executable)
+            Command::new(executable)
                 // When building the fullstack server, we need to forward the serve arguments (like port) to the fullstack server through env vars
                 .env(
                     dioxus_cli_config::__private::SERVE_ENV,
@@ -138,7 +135,7 @@ impl BuildResult {
                 .stderr(Stdio::piped())
                 .stdout(Stdio::piped())
                 .kill_on_drop(true)
-                .current_dir(workspace_folder)
+                .current_dir(workspace)
                 .spawn()?,
         ))
     }

+ 1 - 1
packages/cli/src/serve/mod.rs

@@ -117,7 +117,7 @@ pub async fn serve_all(serve: Serve, dioxus_crate: DioxusCrate) -> Result<()> {
 
                         // If we have a build result, open it
                         for build_result in results.iter() {
-                            let child = build_result.open(&serve.server_arguments, server.fullstack_address());
+                            let child = build_result.open(&serve.server_arguments, server.fullstack_address(), &dioxus_crate.workspace_dir());
                             match child {
                                 Ok(Some(child_proc)) => builder.children.push((build_result.platform,child_proc)),
                                 Err(_e) => break,

+ 10 - 2
packages/cli/src/serve/server.rs

@@ -21,6 +21,7 @@ use axum_server::tls_rustls::RustlsConfig;
 use dioxus_cli_config::{Platform, WebHttpsConfig};
 use dioxus_hot_reload::{DevserverMsg, HotReloadMsg};
 use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender};
+use futures_util::stream;
 use futures_util::{stream::FuturesUnordered, StreamExt};
 use hyper::header::ACCEPT;
 use hyper::HeaderMap;
@@ -577,7 +578,9 @@ pub(crate) fn open_browser(base_path: Option<String>, address: SocketAddr, https
 }
 
 fn get_available_port(address: IpAddr) -> Option<u16> {
-    (8000..9000).find(|port| TcpListener::bind((address, *port)).is_ok())
+    TcpListener::bind((address, 0))
+        .map(|listener| listener.local_addr().unwrap().port())
+        .ok()
 }
 
 /// Middleware that intercepts html requests if the status is "Building" and returns a loading page instead
@@ -598,7 +601,12 @@ async fn build_status_middleware(
             let html = include_str!("../../assets/loading.html");
             return axum::response::Response::builder()
                 .status(StatusCode::OK)
-                .body(Body::from(html))
+                // Load the html loader then keep loading forever
+                // We never close the stream so any headless testing framework (like playwright) will wait until the real build is done
+                .body(Body::from_stream(
+                    stream::once(async move { Ok::<_, std::convert::Infallible>(html) })
+                        .chain(stream::pending()),
+                ))
                 .unwrap();
         }
     }

+ 249 - 231
packages/core/src/suspense/component.rs

@@ -276,7 +276,6 @@ impl SuspenseBoundaryProps {
         to: Option<&mut M>,
     ) -> usize {
         let mut scope_id = ScopeId(dom.mounts[mount.0].mounted_dynamic_nodes[idx]);
-
         // If the ScopeId is a placeholder, we need to load up a new scope for this vcomponent. If it's already mounted, then we can just use that
         if scope_id.is_placeholder() {
             {
@@ -300,78 +299,80 @@ impl SuspenseBoundaryProps {
             // Store the scope id for the next render
             dom.mounts[mount.0].mounted_dynamic_nodes[idx] = scope_id.0;
         }
+        dom.runtime.clone().with_scope_on_stack(scope_id, || {
+            let scope_state = &mut dom.scopes[scope_id.0];
+            let props = Self::downcast_from_props(&mut *scope_state.props).unwrap();
+            let suspense_context =
+                SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id)
+                    .unwrap();
 
-        let scope_state = &mut dom.scopes[scope_id.0];
-        let props = Self::downcast_from_props(&mut *scope_state.props).unwrap();
-        let suspense_context =
-            SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id).unwrap();
+            let children = RenderReturn {
+                node: props
+                    .children
+                    .as_ref()
+                    .map(|node| node.clone_mounted())
+                    .map_err(Clone::clone),
+            };
 
-        let children = RenderReturn {
-            node: props
-                .children
-                .as_ref()
-                .map(|node| node.clone_mounted())
-                .map_err(Clone::clone),
-        };
-
-        // First always render the children in the background. Rendering the children may cause this boundary to suspend
-        suspense_context.under_suspense_boundary(&dom.runtime(), || {
-            children.create(dom, parent, None::<&mut M>);
-        });
-
-        // Store the (now mounted) children back into the scope state
-        let scope_state = &mut dom.scopes[scope_id.0];
-        let props = Self::downcast_from_props(&mut *scope_state.props).unwrap();
-        props.children = children.clone().node;
-
-        let scope_state = &mut dom.scopes[scope_id.0];
-        let suspense_context = scope_state
-            .state()
-            .suspense_location()
-            .suspense_context()
-            .unwrap()
-            .clone();
-        // If there are suspended futures, render the fallback
-        let nodes_created = if !suspense_context.suspended_futures().is_empty() {
-            let (node, nodes_created) =
-                suspense_context.in_suspense_placeholder(&dom.runtime(), || {
-                    let scope_state = &mut dom.scopes[scope_id.0];
-                    let props = Self::downcast_from_props(&mut *scope_state.props).unwrap();
-                    let suspense_context = SuspenseContext::downcast_suspense_boundary_from_scope(
-                        &dom.runtime,
-                        scope_id,
-                    )
-                    .unwrap();
-                    suspense_context.set_suspended_nodes(children.into());
-                    let suspense_placeholder = props.fallback.call(suspense_context);
-                    let node = RenderReturn {
-                        node: suspense_placeholder,
-                    };
-                    let nodes_created = node.create(dom, parent, to);
-                    (node, nodes_created)
-                });
+            // First always render the children in the background. Rendering the children may cause this boundary to suspend
+            suspense_context.under_suspense_boundary(&dom.runtime(), || {
+                children.create(dom, parent, None::<&mut M>);
+            });
 
+            // Store the (now mounted) children back into the scope state
             let scope_state = &mut dom.scopes[scope_id.0];
-            scope_state.last_rendered_node = Some(node);
+            let props = Self::downcast_from_props(&mut *scope_state.props).unwrap();
+            props.children = children.clone().node;
 
-            nodes_created
-        } else {
-            // Otherwise just render the children in the real dom
-            debug_assert!(children.mount.get().mounted());
-            let nodes_created = suspense_context
-                .under_suspense_boundary(&dom.runtime(), || children.create(dom, parent, to));
             let scope_state = &mut dom.scopes[scope_id.0];
-            scope_state.last_rendered_node = Some(children);
-            let suspense_context =
-                SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id)
-                    .unwrap();
-            suspense_context.take_suspended_nodes();
-            mark_suspense_resolved(dom, scope_id);
+            let suspense_context = scope_state
+                .state()
+                .suspense_location()
+                .suspense_context()
+                .unwrap()
+                .clone();
+            // If there are suspended futures, render the fallback
+            let nodes_created = if !suspense_context.suspended_futures().is_empty() {
+                let (node, nodes_created) =
+                    suspense_context.in_suspense_placeholder(&dom.runtime(), || {
+                        let scope_state = &mut dom.scopes[scope_id.0];
+                        let props = Self::downcast_from_props(&mut *scope_state.props).unwrap();
+                        let suspense_context =
+                            SuspenseContext::downcast_suspense_boundary_from_scope(
+                                &dom.runtime,
+                                scope_id,
+                            )
+                            .unwrap();
+                        suspense_context.set_suspended_nodes(children.into());
+                        let suspense_placeholder = props.fallback.call(suspense_context);
+                        let node = RenderReturn {
+                            node: suspense_placeholder,
+                        };
+                        let nodes_created = node.create(dom, parent, to);
+                        (node, nodes_created)
+                    });
 
-            nodes_created
-        };
+                let scope_state = &mut dom.scopes[scope_id.0];
+                scope_state.last_rendered_node = Some(node);
+
+                nodes_created
+            } else {
+                // Otherwise just render the children in the real dom
+                debug_assert!(children.mount.get().mounted());
+                let nodes_created = suspense_context
+                    .under_suspense_boundary(&dom.runtime(), || children.create(dom, parent, to));
+                let scope_state = &mut dom.scopes[scope_id.0];
+                scope_state.last_rendered_node = Some(children);
+                let suspense_context =
+                    SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id)
+                        .unwrap();
+                suspense_context.take_suspended_nodes();
+                mark_suspense_resolved(dom, scope_id);
 
-        nodes_created
+                nodes_created
+            };
+            nodes_created
+        })
     }
 
     #[doc(hidden)]
@@ -385,65 +386,69 @@ impl SuspenseBoundaryProps {
         only_write_templates: impl FnOnce(&mut M),
         replace_with: usize,
     ) {
-        let _runtime = RuntimeGuard::new(dom.runtime());
-        let Some(scope_state) = dom.scopes.get_mut(scope_id.0) else {
-            return;
-        };
+        dom.runtime.clone().with_scope_on_stack(scope_id, || {
+            let _runtime = RuntimeGuard::new(dom.runtime());
+            let Some(scope_state) = dom.scopes.get_mut(scope_id.0) else {
+                return;
+            };
+
+            // Reset the suspense context
+            let suspense_context = scope_state
+                .state()
+                .suspense_location()
+                .suspense_context()
+                .unwrap()
+                .clone();
+            suspense_context.inner.suspended_tasks.borrow_mut().clear();
+
+            // Get the parent of the suspense boundary to later create children with the right parent
+            let currently_rendered = scope_state.last_rendered_node.as_ref().unwrap().clone();
+            let mount = currently_rendered.mount.get();
+            let parent = dom
+                .mounts
+                .get(mount.0)
+                .expect("suspense placeholder is not mounted")
+                .parent;
+
+            let props = Self::downcast_from_props(&mut *scope_state.props).unwrap();
+
+            // Unmount any children to reset any scopes under this suspense boundary
+            let children = props
+                .children
+                .as_ref()
+                .map(|node| node.clone_mounted())
+                .map_err(Clone::clone);
+            let suspense_context =
+                SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id)
+                    .unwrap();
+            let suspended = suspense_context.suspended_nodes();
+            if let Some(node) = suspended {
+                node.remove_node(&mut *dom, None::<&mut M>, None);
+            }
+            // Replace the rendered nodes with resolved nodes
+            currently_rendered.remove_node(&mut *dom, Some(to), Some(replace_with));
 
-        // Reset the suspense context
-        let suspense_context = scope_state
-            .state()
-            .suspense_location()
-            .suspense_context()
-            .unwrap()
-            .clone();
-        suspense_context.inner.suspended_tasks.borrow_mut().clear();
-
-        // Get the parent of the suspense boundary to later create children with the right parent
-        let currently_rendered = scope_state.last_rendered_node.as_ref().unwrap().clone();
-        let mount = currently_rendered.mount.get();
-        let parent = dom
-            .mounts
-            .get(mount.0)
-            .expect("suspense placeholder is not mounted")
-            .parent;
-
-        let props = Self::downcast_from_props(&mut *scope_state.props).unwrap();
-
-        // Unmount any children to reset any scopes under this suspense boundary
-        let children = props
-            .children
-            .as_ref()
-            .map(|node| node.clone_mounted())
-            .map_err(Clone::clone);
-        let suspense_context =
-            SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id).unwrap();
-        let suspended = suspense_context.suspended_nodes();
-        if let Some(node) = suspended {
-            node.remove_node(&mut *dom, None::<&mut M>, None);
-        }
-        // Replace the rendered nodes with resolved nodes
-        currently_rendered.remove_node(&mut *dom, Some(to), Some(replace_with));
-
-        // Switch to only writing templates
-        only_write_templates(to);
-
-        let children = RenderReturn { node: children };
-        children.mount.take();
-
-        // First always render the children in the background. Rendering the children may cause this boundary to suspend
-        suspense_context.under_suspense_boundary(&dom.runtime(), || {
-            children.create(dom, parent, Some(to));
-        });
-
-        // Store the (now mounted) children back into the scope state
-        let scope_state = &mut dom.scopes[scope_id.0];
-        let props = Self::downcast_from_props(&mut *scope_state.props).unwrap();
-        props.children = children.clone().node;
-        scope_state.last_rendered_node = Some(children);
-        let suspense_context =
-            SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id).unwrap();
-        suspense_context.take_suspended_nodes();
+            // Switch to only writing templates
+            only_write_templates(to);
+
+            let children = RenderReturn { node: children };
+            children.mount.take();
+
+            // First always render the children in the background. Rendering the children may cause this boundary to suspend
+            suspense_context.under_suspense_boundary(&dom.runtime(), || {
+                children.create(dom, parent, Some(to));
+            });
+
+            // Store the (now mounted) children back into the scope state
+            let scope_state = &mut dom.scopes[scope_id.0];
+            let props = Self::downcast_from_props(&mut *scope_state.props).unwrap();
+            props.children = children.clone().node;
+            scope_state.last_rendered_node = Some(children);
+            let suspense_context =
+                SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id)
+                    .unwrap();
+            suspense_context.take_suspended_nodes();
+        })
     }
 
     pub(crate) fn diff<M: WriteMutations>(
@@ -451,127 +456,140 @@ impl SuspenseBoundaryProps {
         dom: &mut VirtualDom,
         to: Option<&mut M>,
     ) {
-        let scope = &mut dom.scopes[scope_id.0];
-        let myself = Self::downcast_from_props(&mut *scope.props)
-            .unwrap()
-            .clone();
-
-        let last_rendered_node = scope.last_rendered_node.as_ref().unwrap().clone_mounted();
-
-        let Self {
-            fallback, children, ..
-        } = myself;
-
-        let suspense_context = scope.state().suspense_boundary().unwrap().clone();
-        let suspended_nodes = suspense_context.suspended_nodes();
-        let suspended = !suspense_context.suspended_futures().is_empty();
-        match (suspended_nodes, suspended) {
-            // We already have suspended nodes that still need to be suspended
-            // Just diff the normal and suspended nodes
-            (Some(suspended_nodes), true) => {
-                let new_suspended_nodes: VNode = RenderReturn { node: children }.into();
-
-                // Diff the placeholder nodes in the dom
-                let new_placeholder =
-                    suspense_context.in_suspense_placeholder(&dom.runtime(), || {
-                        let old_placeholder = last_rendered_node;
-                        let new_placeholder = RenderReturn {
-                            node: fallback.call(suspense_context.clone()),
-                        };
-
-                        old_placeholder.diff_node(&new_placeholder, dom, to);
-                        new_placeholder
+        dom.runtime.clone().with_scope_on_stack(scope_id, || {
+            let scope = &mut dom.scopes[scope_id.0];
+            let myself = Self::downcast_from_props(&mut *scope.props)
+                .unwrap()
+                .clone();
+
+            let last_rendered_node = scope.last_rendered_node.as_ref().unwrap().clone_mounted();
+
+            let Self {
+                fallback, children, ..
+            } = myself;
+
+            let suspense_context = scope.state().suspense_boundary().unwrap().clone();
+            let suspended_nodes = suspense_context.suspended_nodes();
+            let suspended = !suspense_context.suspended_futures().is_empty();
+            match (suspended_nodes, suspended) {
+                // We already have suspended nodes that still need to be suspended
+                // Just diff the normal and suspended nodes
+                (Some(suspended_nodes), true) => {
+                    let new_suspended_nodes: VNode = RenderReturn { node: children }.into();
+
+                    // Diff the placeholder nodes in the dom
+                    let new_placeholder =
+                        suspense_context.in_suspense_placeholder(&dom.runtime(), || {
+                            let old_placeholder = last_rendered_node;
+                            let new_placeholder = RenderReturn {
+                                node: fallback.call(suspense_context.clone()),
+                            };
+
+                            old_placeholder.diff_node(&new_placeholder, dom, to);
+                            new_placeholder
+                        });
+
+                    // Set the last rendered node to the placeholder
+                    dom.scopes[scope_id.0].last_rendered_node = Some(new_placeholder);
+
+                    // Diff the suspended nodes in the background
+                    suspense_context.under_suspense_boundary(&dom.runtime(), || {
+                        suspended_nodes.diff_node(&new_suspended_nodes, dom, None::<&mut M>);
                     });
 
-                // Set the last rendered node to the placeholder
-                dom.scopes[scope_id.0].last_rendered_node = Some(new_placeholder);
-
-                // Diff the suspended nodes in the background
-                suspense_context.under_suspense_boundary(&dom.runtime(), || {
-                    suspended_nodes.diff_node(&new_suspended_nodes, dom, None::<&mut M>);
-                });
-
-                let suspense_context =
-                    SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id)
-                        .unwrap();
-                suspense_context.set_suspended_nodes(new_suspended_nodes);
-            }
-            // We have no suspended nodes, and we are not suspended. Just diff the children like normal
-            (None, false) => {
-                let old_children = last_rendered_node;
-                let new_children = RenderReturn { node: children };
-
-                suspense_context.under_suspense_boundary(&dom.runtime(), || {
-                    old_children.diff_node(&new_children, dom, to);
-                });
+                    let suspense_context = SuspenseContext::downcast_suspense_boundary_from_scope(
+                        &dom.runtime,
+                        scope_id,
+                    )
+                    .unwrap();
+                    suspense_context.set_suspended_nodes(new_suspended_nodes);
+                }
+                // We have no suspended nodes, and we are not suspended. Just diff the children like normal
+                (None, false) => {
+                    let old_children = last_rendered_node;
+                    let new_children = RenderReturn { node: children };
+
+                    suspense_context.under_suspense_boundary(&dom.runtime(), || {
+                        old_children.diff_node(&new_children, dom, to);
+                    });
 
-                // Set the last rendered node to the new children
-                dom.scopes[scope_id.0].last_rendered_node = Some(new_children);
-            }
-            // We have no suspended nodes, but we just became suspended. Move the children to the background
-            (None, true) => {
-                let old_children = last_rendered_node;
-                let new_children: VNode = RenderReturn { node: children }.into();
-
-                let new_placeholder = RenderReturn {
-                    node: fallback.call(suspense_context.clone()),
-                };
-
-                // Move the children to the background
-                let mount = old_children.mount.get();
-                let mount = dom.mounts.get(mount.0).expect("mount should exist");
-                let parent = mount.parent;
-
-                suspense_context.in_suspense_placeholder(&dom.runtime(), || {
-                    old_children.move_node_to_background(
-                        std::slice::from_ref(&*new_placeholder),
-                        parent,
-                        dom,
-                        to,
-                    );
-                });
+                    // Set the last rendered node to the new children
+                    dom.scopes[scope_id.0].last_rendered_node = Some(new_children);
+                }
+                // We have no suspended nodes, but we just became suspended. Move the children to the background
+                (None, true) => {
+                    let old_children = last_rendered_node;
+                    let new_children: VNode = RenderReturn { node: children }.into();
 
-                // Then diff the new children in the background
-                suspense_context.under_suspense_boundary(&dom.runtime(), || {
-                    old_children.diff_node(&new_children, dom, None::<&mut M>);
-                });
+                    let new_placeholder = RenderReturn {
+                        node: fallback.call(suspense_context.clone()),
+                    };
 
-                // Set the last rendered node to the new suspense placeholder
-                dom.scopes[scope_id.0].last_rendered_node = Some(new_placeholder);
+                    // Move the children to the background
+                    let mount = old_children.mount.get();
+                    let mount = dom.mounts.get(mount.0).expect("mount should exist");
+                    let parent = mount.parent;
 
-                let suspense_context =
-                    SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id)
-                        .unwrap();
-                suspense_context.set_suspended_nodes(new_children);
+                    suspense_context.in_suspense_placeholder(&dom.runtime(), || {
+                        old_children.move_node_to_background(
+                            std::slice::from_ref(&*new_placeholder),
+                            parent,
+                            dom,
+                            to,
+                        );
+                    });
 
-                un_resolve_suspense(dom, scope_id);
-            } // We have suspended nodes, but we just got out of suspense. Move the suspended nodes to the foreground
-            (Some(old_suspended_nodes), false) => {
-                let old_placeholder = last_rendered_node;
-                let new_children = RenderReturn { node: children };
+                    // Then diff the new children in the background
+                    suspense_context.under_suspense_boundary(&dom.runtime(), || {
+                        old_children.diff_node(&new_children, dom, None::<&mut M>);
+                    });
 
-                // First diff the two children nodes in the background
-                suspense_context.under_suspense_boundary(&dom.runtime(), || {
-                    old_suspended_nodes.diff_node(&new_children, dom, None::<&mut M>);
+                    // Set the last rendered node to the new suspense placeholder
+                    dom.scopes[scope_id.0].last_rendered_node = Some(new_placeholder);
 
-                    // Then replace the placeholder with the new children
-                    let mount = old_placeholder.mount.get();
-                    let mount = dom.mounts.get(mount.0).expect("mount should exist");
-                    let parent = mount.parent;
-                    old_placeholder.replace(std::slice::from_ref(&*new_children), parent, dom, to);
-                });
+                    let suspense_context = SuspenseContext::downcast_suspense_boundary_from_scope(
+                        &dom.runtime,
+                        scope_id,
+                    )
+                    .unwrap();
+                    suspense_context.set_suspended_nodes(new_children);
+
+                    un_resolve_suspense(dom, scope_id);
+                } // We have suspended nodes, but we just got out of suspense. Move the suspended nodes to the foreground
+                (Some(old_suspended_nodes), false) => {
+                    let old_placeholder = last_rendered_node;
+                    let new_children = RenderReturn { node: children };
+
+                    // First diff the two children nodes in the background
+                    suspense_context.under_suspense_boundary(&dom.runtime(), || {
+                        old_suspended_nodes.diff_node(&new_children, dom, None::<&mut M>);
+
+                        // Then replace the placeholder with the new children
+                        let mount = old_placeholder.mount.get();
+                        let mount = dom.mounts.get(mount.0).expect("mount should exist");
+                        let parent = mount.parent;
+                        old_placeholder.replace(
+                            std::slice::from_ref(&*new_children),
+                            parent,
+                            dom,
+                            to,
+                        );
+                    });
 
-                // Set the last rendered node to the new children
-                dom.scopes[scope_id.0].last_rendered_node = Some(new_children);
+                    // Set the last rendered node to the new children
+                    dom.scopes[scope_id.0].last_rendered_node = Some(new_children);
 
-                let suspense_context =
-                    SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id)
-                        .unwrap();
-                suspense_context.take_suspended_nodes();
+                    let suspense_context = SuspenseContext::downcast_suspense_boundary_from_scope(
+                        &dom.runtime,
+                        scope_id,
+                    )
+                    .unwrap();
+                    suspense_context.take_suspended_nodes();
 
-                mark_suspense_resolved(dom, scope_id);
+                    mark_suspense_resolved(dom, scope_id);
+                }
             }
-        }
+        })
     }
 }
 

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

@@ -22,6 +22,10 @@ impl Document for DesktopDocument {
     fn set_title(&self, title: String) {
         self.desktop_ctx.window.set_title(&title);
     }
+
+    fn as_any(&self) -> &dyn std::any::Any {
+        self
+    }
 }
 
 /// Represents a desktop-target's JavaScript evaluator.

+ 1 - 1
packages/fullstack/Cargo.toml

@@ -104,7 +104,7 @@ server = [
     "dep:parking_lot",
     "dioxus-interpreter-js",
     "dep:clap",
-    "dioxus-cli-config/read-from-args"
+    "dioxus-cli-config/read-from-args",
 ]
 
 [package.metadata.docs.rs]

+ 5 - 1
packages/fullstack/src/document/mod.rs

@@ -1,4 +1,8 @@
+//! This module contains the document providers for the fullstack platform.
+
 #[cfg(feature = "server")]
 pub(crate) mod server;
-#[cfg(feature = "web")]
+#[cfg(feature = "server")]
+pub use server::ServerDocument;
+#[cfg(all(feature = "web", feature = "document"))]
 pub(crate) mod web;

+ 26 - 22
packages/fullstack/src/document/server.rs

@@ -7,6 +7,10 @@ use std::cell::RefCell;
 use dioxus_lib::{html::document::*, prelude::*};
 use dioxus_ssr::Renderer;
 use generational_box::GenerationalBox;
+use once_cell::sync::Lazy;
+use parking_lot::RwLock;
+
+static RENDERER: Lazy<RwLock<Renderer>> = Lazy::new(|| RwLock::new(Renderer::new()));
 
 #[derive(Default)]
 struct ServerDocumentInner {
@@ -19,35 +23,27 @@ struct ServerDocumentInner {
 
 /// A Document provider that collects all contents injected into the head for SSR rendering.
 #[derive(Default)]
-pub(crate) struct ServerDocument(RefCell<ServerDocumentInner>);
+pub struct ServerDocument(RefCell<ServerDocumentInner>);
 
 impl ServerDocument {
-    pub(crate) fn render(
-        &self,
-        to: &mut impl std::fmt::Write,
-        renderer: &mut Renderer,
-    ) -> std::fmt::Result {
-        fn lazy_app(props: Element) -> Element {
-            props
-        }
+    pub(crate) fn title(&self) -> Option<String> {
+        let myself = self.0.borrow();
+        myself.title.as_ref().map(|title| {
+            RENDERER
+                .write()
+                .render_element(rsx! { title { "{title}" } })
+        })
+    }
+
+    pub(crate) fn render(&self, to: &mut impl std::fmt::Write) -> std::fmt::Result {
         let myself = self.0.borrow();
         let element = rsx! {
-            if let Some(title) = myself.title.as_ref() {
-                title { title: "{title}" }
-            }
             {myself.meta.iter().map(|m| rsx! { {m} })}
             {myself.link.iter().map(|l| rsx! { {l} })}
             {myself.script.iter().map(|s| rsx! { {s} })}
         };
 
-        let mut dom = VirtualDom::new_with_props(lazy_app, element);
-        dom.rebuild_in_place();
-
-        // We don't hydrate the head, so we can set the pre_render flag to false to save a few bytes
-        let was_pre_rendering = renderer.pre_render;
-        renderer.pre_render = false;
-        renderer.render_to(to, &dom)?;
-        renderer.pre_render = was_pre_rendering;
+        RENDERER.write().render_element_to(to, element)?;
 
         Ok(())
     }
@@ -65,8 +61,12 @@ impl ServerDocument {
     /// Write the head element into the serialized context for hydration
     /// We write true if the head element was written to the DOM during server side rendering
     pub(crate) fn serialize_for_hydration(&self) {
-        let serialize = crate::html_storage::serialize_context();
-        serialize.push(&!self.0.borrow().streaming);
+        // We only serialize the head elements if the web document feature is enabled
+        #[cfg(feature = "document")]
+        {
+            let serialize = crate::html_storage::serialize_context();
+            serialize.push(&!self.0.borrow().streaming);
+        }
     }
 }
 
@@ -137,4 +137,8 @@ impl Document for ServerDocument {
             }
         })
     }
+
+    fn as_any(&self) -> &dyn std::any::Any {
+        self
+    }
 }

+ 4 - 0
packages/fullstack/src/document/web.rs

@@ -55,4 +55,8 @@ impl Document for FullstackWebDocument {
         }
         WebDocument.create_link(props);
     }
+
+    fn as_any(&self) -> &dyn std::any::Any {
+        self
+    }
 }

+ 3 - 2
packages/fullstack/src/launch.rs

@@ -62,8 +62,9 @@ pub fn launch(
     #[cfg(feature = "document")]
     let factory = move || {
         let mut vdom = factory();
-        vdom.provide_root_context(std::rc::Rc::new(crate::document::web::FullstackWebDocument)
-            as std::rc::Rc<dyn dioxus_lib::prelude::document::Document>);
+        let document = std::rc::Rc::new(crate::document::web::FullstackWebDocument)
+            as std::rc::Rc<dyn dioxus_lib::prelude::document::Document>;
+        vdom.provide_root_context(document);
         vdom
     };
 

+ 1 - 2
packages/fullstack/src/lib.rs

@@ -18,8 +18,7 @@ pub mod launch;
 
 pub use config::*;
 
-#[cfg(feature = "document")]
-mod document;
+pub mod document;
 #[cfg(feature = "server")]
 mod render;
 #[cfg(feature = "server")]

+ 63 - 38
packages/fullstack/src/render.rs

@@ -7,9 +7,9 @@ use dioxus_ssr::{
 };
 use futures_channel::mpsc::Sender;
 use futures_util::{Stream, StreamExt};
+use std::sync::Arc;
 use std::sync::RwLock;
 use std::{collections::HashMap, future::Future};
-use std::{fmt::Write, sync::Arc};
 use tokio::task::JoinHandle;
 
 use crate::prelude::*;
@@ -160,9 +160,7 @@ impl SsrRendererPool {
 
         let join_handle = spawn_platform(move || async move {
             let mut virtual_dom = virtual_dom_factory();
-            #[cfg(feature = "document")]
             let document = std::rc::Rc::new(crate::document::server::ServerDocument::default());
-            #[cfg(feature = "document")]
             virtual_dom.provide_root_context(document.clone() as std::rc::Rc<dyn Document>);
 
             // poll the future, which may call server_context()
@@ -171,34 +169,11 @@ impl SsrRendererPool {
 
             let mut pre_body = String::new();
 
-            if let Err(err) = wrapper.render_head(&mut pre_body) {
+            if let Err(err) = wrapper.render_head(&mut pre_body, &virtual_dom) {
                 _ = into.start_send(Err(err));
                 return;
             }
 
-            #[cfg(feature = "document")]
-            {
-                // Collect any head content from the document provider and inject that into the head
-                if let Err(err) = document.render(&mut pre_body, &mut renderer) {
-                    _ = into.start_send(Err(err.into()));
-                    return;
-                }
-
-                // Enable a warning when inserting contents into the head during streaming
-                document.start_streaming();
-            }
-
-            if let Err(err) = wrapper.render_before_body(&mut pre_body) {
-                _ = into.start_send(Err(err));
-                return;
-            }
-            if let Err(err) = write!(&mut pre_body, "<script>{INITIALIZE_STREAMING_JS}</script>") {
-                _ = into.start_send(Err(
-                    dioxus_ssr::incremental::IncrementalRendererError::RenderError(err),
-                ));
-                return;
-            }
-
             let stream = Arc::new(StreamingRenderer::new(pre_body, into));
             let scope_to_mount_mapping = Arc::new(RwLock::new(HashMap::new()));
 
@@ -237,15 +212,8 @@ impl SsrRendererPool {
             // Render the initial frame with loading placeholders
             let mut initial_frame = renderer.render(&virtual_dom);
 
-            // Collect the initial server data from the root node. For most apps, no use_server_futures will be resolved initially, so this will be full on `None`s.
-            // Sending down those Nones are still important to tell the client not to run the use_server_futures that are already running on the backend
-            let resolved_data = serialize_server_data(&virtual_dom, ScopeId::ROOT);
-            initial_frame.push_str(&format!(
-                r#"<script>window.initial_dioxus_hydration_data="{resolved_data}";</script>"#,
-            ));
-
             // Along with the initial frame, we render the html after the main element, but before the body tag closes. This should include the script that starts loading the wasm bundle.
-            if let Err(err) = wrapper.render_after_main(&mut initial_frame) {
+            if let Err(err) = wrapper.render_after_main(&mut initial_frame, &virtual_dom) {
                 throw_error!(err);
             }
             stream.render(initial_frame);
@@ -311,7 +279,7 @@ impl SsrRendererPool {
             // If incremental rendering is enabled, add the new render to the cache without the streaming bits
             if let Some(incremental) = &self.incremental_cache {
                 let mut cached_render = String::new();
-                if let Err(err) = wrapper.render_before_body(&mut cached_render) {
+                if let Err(err) = wrapper.render_head(&mut cached_render, &virtual_dom) {
                     throw_error!(err);
                 }
                 cached_render.push_str(&post_streaming);
@@ -401,16 +369,48 @@ impl FullstackHTMLTemplate {
     pub fn render_head<R: std::fmt::Write>(
         &self,
         to: &mut R,
+        virtual_dom: &VirtualDom,
     ) -> Result<(), dioxus_ssr::incremental::IncrementalRendererError> {
         let ServeConfig { index, .. } = &self.cfg;
 
-        to.write_str(&index.head)?;
+        let title = {
+            let document: Option<std::rc::Rc<dyn dioxus_lib::prelude::document::Document>> =
+                virtual_dom.in_runtime(|| ScopeId::ROOT.consume_context());
+            let document: Option<&crate::document::server::ServerDocument> = document
+                .as_ref()
+                .and_then(|document| document.as_any().downcast_ref());
+            // Collect any head content from the document provider and inject that into the head
+            document.and_then(|document| document.title())
+        };
+
+        to.write_str(&index.head_before_title)?;
+        if let Some(title) = title {
+            to.write_str(&title)?;
+        } else {
+            to.write_str(&index.title)?;
+        }
+        to.write_str(&index.head_after_title)?;
+
+        let document: Option<std::rc::Rc<dyn dioxus_lib::prelude::document::Document>> =
+            virtual_dom.in_runtime(|| ScopeId::ROOT.consume_context());
+        let document: Option<&crate::document::server::ServerDocument> = document
+            .as_ref()
+            .and_then(|document| document.as_any().downcast_ref());
+        if let Some(document) = document {
+            // Collect any head content from the document provider and inject that into the head
+            document.render(to)?;
+
+            // Enable a warning when inserting contents into the head during streaming
+            document.start_streaming();
+        }
+
+        self.render_before_body(to)?;
 
         Ok(())
     }
 
     /// Render any content before the body of the page.
-    pub fn render_before_body<R: std::fmt::Write>(
+    fn render_before_body<R: std::fmt::Write>(
         &self,
         to: &mut R,
     ) -> Result<(), dioxus_ssr::incremental::IncrementalRendererError> {
@@ -418,6 +418,8 @@ impl FullstackHTMLTemplate {
 
         to.write_str(&index.close_head)?;
 
+        write!(to, "<script>{INITIALIZE_STREAMING_JS}</script>")?;
+
         Ok(())
     }
 
@@ -425,9 +427,17 @@ impl FullstackHTMLTemplate {
     pub fn render_after_main<R: std::fmt::Write>(
         &self,
         to: &mut R,
+        virtual_dom: &VirtualDom,
     ) -> Result<(), dioxus_ssr::incremental::IncrementalRendererError> {
         let ServeConfig { index, .. } = &self.cfg;
 
+        // Collect the initial server data from the root node. For most apps, no use_server_futures will be resolved initially, so this will be full on `None`s.
+        // Sending down those Nones are still important to tell the client not to run the use_server_futures that are already running on the backend
+        let resolved_data = serialize_server_data(virtual_dom, ScopeId::ROOT);
+        write!(
+            to,
+            r#"<script>window.initial_dioxus_hydration_data="{resolved_data}";</script>"#,
+        )?;
         to.write_str(&index.post_main)?;
 
         Ok(())
@@ -444,6 +454,21 @@ impl FullstackHTMLTemplate {
 
         Ok(())
     }
+
+    /// Wrap a body in the template
+    pub fn wrap_body<R: std::fmt::Write>(
+        &self,
+        to: &mut R,
+        virtual_dom: &VirtualDom,
+        body: impl std::fmt::Display,
+    ) -> Result<(), dioxus_ssr::incremental::IncrementalRendererError> {
+        self.render_head(to, virtual_dom)?;
+        write!(to, "{body}")?;
+        self.render_after_main(to, virtual_dom)?;
+        self.render_after_body(to)?;
+
+        Ok(())
+    }
 }
 
 fn pre_renderer() -> Renderer {

+ 19 - 2
packages/fullstack/src/serve_config.rs

@@ -118,8 +118,23 @@ fn load_index_html(contents: String, root_id: &'static str) -> IndexHtml {
             panic!("Failed to find closing </body> tag after id=\"{root_id}\" in index.html.")
         });
 
+    // Strip out the head if it exists
+    let mut head_before_title = String::new();
+    let mut head_after_title = head;
+    let mut title = String::new();
+    if let Some((new_head_before_title, new_title)) = head_after_title.split_once("<title>") {
+        let (new_title, new_head_after_title) = new_title
+            .split_once("</title>")
+            .expect("Failed to find closing </title> tag after <title> in index.html.");
+        title = format!("<title>{new_title}</title>");
+        head_before_title = new_head_before_title.to_string();
+        head_after_title = new_head_after_title.to_string();
+    }
+
     IndexHtml {
-        head,
+        head_before_title,
+        head_after_title,
+        title,
         close_head,
         post_main: post_main.to_string(),
         after_closing_body_tag: "</body>".to_string() + after_closing_body_tag,
@@ -128,7 +143,9 @@ fn load_index_html(contents: String, root_id: &'static str) -> IndexHtml {
 
 #[derive(Clone)]
 pub(crate) struct IndexHtml {
-    pub(crate) head: String,
+    pub(crate) head_before_title: String,
+    pub(crate) head_after_title: String,
+    pub(crate) title: String,
     pub(crate) close_head: String,
     pub(crate) post_main: String,
     pub(crate) after_closing_body_tag: String,

+ 11 - 0
packages/html/src/document/mod.rs

@@ -51,29 +51,36 @@ pub trait Document {
         self.new_evaluator(format!("document.title = {title:?};"));
     }
 
+    /// Create a new meta tag
     fn create_meta(&self, props: MetaProps) {
         let attributes = props.attributes();
         let js = create_element_in_head("meta", &attributes, None);
         self.new_evaluator(js);
     }
 
+    /// Create a new script tag
     fn create_script(&self, props: ScriptProps) {
         let attributes = props.attributes();
         let js = create_element_in_head("script", &attributes, props.script_contents());
         self.new_evaluator(js);
     }
 
+    /// Create a new style tag
     fn create_style(&self, props: StyleProps) {
         let attributes = props.attributes();
         let js = create_element_in_head("style", &attributes, props.style_contents());
         self.new_evaluator(js);
     }
 
+    /// Create a new link tag
     fn create_link(&self, props: head::LinkProps) {
         let attributes = props.attributes();
         let js = create_element_in_head("link", &attributes, None);
         self.new_evaluator(js);
     }
+
+    /// Get a reference to the document as `dyn Any`
+    fn as_any(&self) -> &dyn std::any::Any;
 }
 
 /// The default No-Op document
@@ -84,6 +91,10 @@ impl Document for NoOpDocument {
         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(NoOpEvaluator))
     }
+
+    fn as_any(&self) -> &dyn std::any::Any {
+        self
+    }
 }
 
 struct NoOpEvaluator;

+ 4 - 0
packages/liveview/src/eval.rs

@@ -21,6 +21,10 @@ impl Document for LiveviewDocument {
     fn new_evaluator(&self, js: String) -> GenerationalBox<Box<dyn Evaluator>> {
         LiveviewEvaluator::create(self.query.clone(), js)
     }
+
+    fn as_any(&self) -> &dyn std::any::Any {
+        self
+    }
 }
 
 /// Represents a liveview-target's JavaScript evaluator.

+ 9 - 16
packages/playwright-tests/fullstack.spec.js

@@ -1,12 +1,14 @@
 // @ts-check
 const { test, expect } = require("@playwright/test");
 
-test("button click", async ({ page }) => {
+test("hydration", async ({ page }) => {
   await page.goto("http://localhost:3333");
-  await page.waitForTimeout(1000);
 
-  // Expect the page to contain the counter text.
+  // Expect the page to contain the pending text.
   const main = page.locator("#main");
+  await expect(main).toContainText("Server said: ...");
+
+  // Expect the page to contain the counter text.
   await expect(main).toContainText("hello axum! 12345");
   // Expect the title to contain the counter text.
   await expect(page).toHaveTitle("hello axum! 12345");
@@ -15,23 +17,14 @@ test("button click", async ({ page }) => {
   let button = page.locator("button.increment-button");
   await button.click();
 
+  // Click the server button.
+  let serverButton = page.locator("button.server-button");
+  await serverButton.click();
+
   // Expect the page to contain the updated counter text.
   await expect(main).toContainText("hello axum! 12346");
   // Expect the title to contain the updated counter text.
   await expect(page).toHaveTitle("hello axum! 12346");
-});
-
-test("fullstack communication", async ({ page }) => {
-  await page.goto("http://localhost:3333");
-  await page.waitForTimeout(1000);
-
-  // Expect the page to contain the counter text.
-  const main = page.locator("#main");
-  await expect(main).toContainText("Server said: ...");
-
-  // Click the increment button.
-  let button = page.locator("button.server-button");
-  await button.click();
 
   // Expect the page to contain the updated counter text.
   await expect(main).toContainText("Server said: Hello from the server!");

+ 15 - 1
packages/playwright-tests/nested-suspense-no-js.spec.js

@@ -4,9 +4,23 @@ const { test, expect } = require("@playwright/test");
 test.use({ javaScriptEnabled: false });
 
 test("text appears in the body without javascript", async ({ page }) => {
+  await page.goto("http://localhost:5050", { waitUntil: "commit" });
+  // Wait for the page to finish building. Reload until it's ready
+  for (let i = 0; i < 50; i++) {
+    // If the page doesn't contain #building or "Backend connection failed", we're ready
+    let building_count = await page.locator("#building").count();
+    building_count += await page
+      .locator("body", { hasText: "backend connection failed" })
+      .count();
+    if (building_count === 0) {
+      break;
+    }
+    await page.waitForTimeout(1000);
+    await page.goto("http://localhost:5050", { waitUntil: "commit" });
+  }
   // If we wait until the whole page loads, the content of the site should still be in the body even if javascript is disabled
   // It will not be visible, and may not be in the right order/location, but SEO should still work
-  await page.goto("http://localhost:5050");
+  await page.waitForLoadState("load");
 
   const body = page.locator("body");
   const textExpected = [

+ 4 - 0
packages/playwright-tests/nested-suspense.spec.js

@@ -1,7 +1,11 @@
 // @ts-check
 const { test, expect } = require("@playwright/test");
+const { timeout } = require("./playwright.config");
 
 test("nested suspense resolves", async ({ page }) => {
+  // Wait for the dev server to reload
+  await page.goto("http://localhost:5050");
+  // Then wait for the page to start loading
   await page.goto("http://localhost:5050", { waitUntil: "commit" });
 
   // On the client, we should see some loading text

+ 4 - 0
packages/playwright-tests/playwright.config.js

@@ -30,8 +30,12 @@ module.exports = defineConfig({
 
     /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
     trace: "on-first-retry",
+    // Increase the timeout for navigations to give dx time to build the project
+    navigationTimeout: 50 * 60 * 1000,
   },
 
+  timeout: 50 * 60 * 1000,
+
   /* Configure projects for major browsers */
   projects: [
     {

+ 1 - 1
packages/playwright-tests/static-generation/src/main.rs

@@ -18,7 +18,7 @@ fn app() -> Element {
     rsx! {
         h1 { "hello axum! {count}" }
         button { class: "increment-button", onclick: move |_| count += 1, "Increment" }
-        "Server said: {server_data:?}"
+        "Server said: {server_data().unwrap():?}"
     }
 }
 

+ 3 - 0
packages/playwright-tests/suspense-carousel.spec.js

@@ -2,6 +2,9 @@
 const { test, expect } = require("@playwright/test");
 
 test("suspense resolves on server", async ({ page }) => {
+  // Wait for the dev server to reload
+  await page.goto("http://localhost:4040");
+  // Then wait for the page to start loading
   await page.goto("http://localhost:4040", { waitUntil: "commit" });
 
   // On the client, we should see some loading text

+ 1 - 0
packages/rsx/src/element.rs

@@ -558,6 +558,7 @@ fn merges_attributes() {
 /// - merging two expressions together
 /// - merging two literals together
 /// - merging a literal and an expression together
+///
 /// etc
 ///
 /// We really only want to merge formatted things together

+ 1 - 8
packages/ssr/src/lib.rs

@@ -17,14 +17,7 @@ pub use crate::renderer::Renderer;
 ///
 /// For advanced rendering, create a new `SsrRender`.
 pub fn render_element(element: Element) -> String {
-    fn lazy_app(props: Element) -> Element {
-        props
-    }
-
-    let mut dom = VirtualDom::new_with_props(lazy_app, element);
-    dom.rebuild_in_place();
-
-    Renderer::new().render(&dom)
+    Renderer::new().render_element(element)
 }
 
 /// A convenience function to render an existing VirtualDom to a string

+ 21 - 0
packages/ssr/src/renderer.rs

@@ -62,6 +62,27 @@ impl Renderer {
         self.render_scope(buf, dom, ScopeId::ROOT)
     }
 
+    /// Render an element to a string
+    pub fn render_element(&mut self, element: Element) -> String {
+        let mut buf = String::new();
+        self.render_element_to(&mut buf, element).unwrap();
+        buf
+    }
+
+    /// Render an element to the buffer
+    pub fn render_element_to<W: Write + ?Sized>(
+        &mut self,
+        buf: &mut W,
+        element: Element,
+    ) -> std::fmt::Result {
+        fn lazy_app(props: Element) -> Element {
+            props
+        }
+        let mut dom = VirtualDom::new_with_props(lazy_app, element);
+        dom.rebuild_in_place();
+        self.render_to(buf, &dom)
+    }
+
     /// Reset the renderer hydration state
     pub fn reset_hydration(&mut self) {
         self.dynamic_node_id = 0;

+ 14 - 3
packages/static-generation/src/launch.rs

@@ -42,11 +42,22 @@ pub fn launch(
 
             // Serve the program if we are running with cargo
             if std::env::var_os("CARGO").is_some() || std::env::var_os("DIOXUS_ACTIVE").is_some() {
+                // Get the address the server should run on. If the CLI is running, the CLI proxies static generation into the main address
+                // and we use the generated address the CLI gives us
+                let cli_args = dioxus_cli_config::RuntimeCLIArguments::from_cli();
+                let address = cli_args
+                    .as_ref()
+                    .map(|args| args.fullstack_address().address())
+                    .unwrap_or_else(|| std::net::SocketAddr::from(([127, 0, 0, 1], 8080)));
+
+                // Point the user to the CLI address if the CLI is running or the fullstack address if not
+                let serve_address = cli_args
+                    .map(|args| args.cli_address())
+                    .unwrap_or_else(|| address);
                 println!(
-                    "Serving static files from {} at http://127.0.0.1:8080",
+                    "Serving static files from {} at http://{serve_address}",
                     path.display()
                 );
-                let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080));
 
                 let mut serve_dir =
                     ServeDir::new(path.clone()).call_fallback_on_method_not_allowed(true);
@@ -66,7 +77,7 @@ pub fn launch(
                     })))
                 };
 
-                let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
+                let listener = tokio::net::TcpListener::bind(address).await.unwrap();
                 axum::serve(listener, router.into_make_service())
                     .await
                     .unwrap();

+ 4 - 1
packages/static-generation/src/ssg.rs

@@ -111,6 +111,8 @@ async fn prerender_route(
     let context = server_context_for_route(&route);
     let wrapper = config.fullstack_template();
     let mut virtual_dom = VirtualDom::new(app);
+    let document = std::rc::Rc::new(dioxus_fullstack::document::ServerDocument::default());
+    virtual_dom.provide_root_context(document.clone() as std::rc::Rc<dyn Document>);
     with_server_context(context.clone(), || {
         tokio::task::block_in_place(|| virtual_dom.rebuild_in_place());
     });
@@ -119,10 +121,11 @@ async fn prerender_route(
     let mut wrapped = String::new();
 
     // Render everything before the body
-    wrapper.render_before_body(&mut wrapped)?;
+    wrapper.render_head(&mut wrapped, &virtual_dom)?;
 
     renderer.render_to(&mut wrapped, &virtual_dom)?;
 
+    wrapper.render_after_main(&mut wrapped, &virtual_dom)?;
     wrapper.render_after_body(&mut wrapped)?;
 
     cache.cache(route, wrapped)

+ 4 - 0
packages/web/src/document.rs

@@ -25,6 +25,10 @@ impl Document for WebDocument {
     fn new_evaluator(&self, js: String) -> GenerationalBox<Box<dyn Evaluator>> {
         WebEvaluator::create(js)
     }
+
+    fn as_any(&self) -> &dyn std::any::Any {
+        self
+    }
 }
 
 /// Required to avoid blocking the Rust WASM thread.

+ 1 - 1
packages/web/src/launch.rs

@@ -30,5 +30,5 @@ pub fn launch_virtual_dom(vdom: VirtualDom, platform_config: Config) {
 
 /// Launch the web application with the given root component and config
 pub fn launch_cfg(root: fn() -> Element, platform_config: Config) {
-    launch_virtual_dom(VirtualDom::new(root), platform_config)
+    launch(root, Vec::new(), platform_config);
 }