switch to a Document trait and introduce Script/Head/Style/Meta components (#2635)

* switch to a Document trait and introduce Script/Head/Style/Meta components

* Fix desktop title

* Insert special elements into the head during the inital SSR render

* Make all head component attributes optional

* hydrate head elements

* improve the server streaming head warning

* Document fullstack head hydration approach

* deduplicate head elements by href

* move Link into head::Link

* document head components

* add meta and title examples

* Fix a few doc examples

* fix formatting

* Add title to playwright tests

* serde is optional on web, but it is enabled by hydrate

* remove leftover console log
This commit is contained in:
Evan Almloff authored and GitHub committed 2024-07-17 20:54:03 -05:00
1 parent a490a94ae5
commit 176e67e5b7
65 files changed
+1703 -495

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
/// Code for the Dioxus channel used to communicate between the dioxus and javascript code
#[cfg(feature = "native-bind")]
pub const NATIVE_EVAL_JS: &str = include_str!("../js/native_eval.js");
#[cfg(feature = "wasm-bind")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub struct JSOwner {
_owner: Box<dyn std::any::Any>,
}
#[cfg(feature = "wasm-bind")]
impl JSOwner {
pub fn new(owner: impl std::any::Any) -> Self {
Self {
_owner: Box::new(owner),
}
}
}
#[cfg(feature = "wasm-bind")]
#[wasm_bindgen::prelude::wasm_bindgen(module = "/src/js/eval.js")]
extern "C" {
pub type WebDioxusChannel;
#[wasm_bindgen(constructor)]
pub fn new(owner: JSOwner) -> WebDioxusChannel;
#[wasm_bindgen(method, js_name = "rustSend")]
pub fn rust_send(this: &WebDioxusChannel, value: wasm_bindgen::JsValue);
#[wasm_bindgen(method, js_name = "rustRecv")]
pub async fn rust_recv(this: &WebDioxusChannel) -> wasm_bindgen::JsValue;
#[wasm_bindgen(method)]
pub fn send(this: &WebDioxusChannel, value: wasm_bindgen::JsValue);
#[wasm_bindgen(method)]
pub async fn recv(this: &WebDioxusChannel) -> wasm_bindgen::JsValue;
#[wasm_bindgen(method)]
pub fn weak(this: &WebDioxusChannel) -> WeakDioxusChannel;
pub type WeakDioxusChannel;
#[wasm_bindgen(method, js_name = "rustSend")]
pub fn rust_send(this: &WeakDioxusChannel, value: wasm_bindgen::JsValue);
#[wasm_bindgen(method, js_name = "rustRecv")]
pub async fn rust_recv(this: &WeakDioxusChannel) -> wasm_bindgen::JsValue;
}
+119
View File
@@ -0,0 +1,119 @@
#![allow(clippy::await_holding_refcell_ref)]
#![doc = include_str!("../../docs/eval.md")]
use dioxus_core::prelude::*;
use generational_box::GenerationalBox;
use std::future::{poll_fn, Future, IntoFuture};
use std::pin::Pin;
use std::rc::Rc;
use std::task::{Context, Poll};
use super::document;
/// The platform's evaluator.
pub trait Evaluator {
/// Sends a message to the evaluated JavaScript.
fn send(&self, data: serde_json::Value) -> Result<(), EvalError>;
/// Receive any queued messages from the evaluated JavaScript.
fn poll_recv(
&mut self,
context: &mut Context<'_>,
) -> Poll<Result<serde_json::Value, EvalError>>;
/// Gets the return value of the JavaScript
fn poll_join(
&mut self,
context: &mut Context<'_>,
) -> Poll<Result<serde_json::Value, EvalError>>;
}
type EvalCreator = Rc<dyn Fn(&str) -> UseEval>;
/// Get a struct that can execute any JavaScript.
///
/// # Safety
///
/// Please be very careful with this function. A script with too many dynamic
/// parts is practically asking for a hacker to find an XSS vulnerability in
/// it. **This applies especially to web targets, where the JavaScript context
/// has access to most, if not all of your application data.**
#[must_use]
pub fn eval_provider() -> EvalCreator {
let eval_provider = document();
Rc::new(move |script: &str| UseEval::new(eval_provider.new_evaluator(script.to_string())))
as Rc<dyn Fn(&str) -> UseEval>
}
#[doc = include_str!("../../docs/eval.md")]
#[doc(alias = "javascript")]
pub fn eval(script: &str) -> UseEval {
let document = use_hook(document);
UseEval::new(document.new_evaluator(script.to_string()))
}
/// A wrapper around the target platform's evaluator that lets you send and receive data from JavaScript spawned by [`eval`].
///
#[doc = include_str!("../../docs/eval.md")]
#[derive(Clone, Copy)]
pub struct UseEval {
evaluator: GenerationalBox<Box<dyn Evaluator>>,
}
impl UseEval {
/// Creates a new UseEval
pub fn new(evaluator: GenerationalBox<Box<dyn Evaluator + 'static>>) -> Self {
Self { evaluator }
}
/// Sends a [`serde_json::Value`] to the evaluated JavaScript.
pub fn send(&self, data: serde_json::Value) -> Result<(), EvalError> {
match self.evaluator.try_read() {
Ok(evaluator) => evaluator.send(data),
Err(_) => Err(EvalError::Finished),
}
}
/// Gets an UnboundedReceiver to receive messages from the evaluated JavaScript.
pub async fn recv(&mut self) -> Result<serde_json::Value, EvalError> {
poll_fn(|cx| match self.evaluator.try_write() {
Ok(mut evaluator) => evaluator.poll_recv(cx),
Err(_) => Poll::Ready(Err(EvalError::Finished)),
})
.await
}
/// Gets the return value of the evaluated JavaScript.
pub async fn join(self) -> Result<serde_json::Value, EvalError> {
poll_fn(|cx| match self.evaluator.try_write() {
Ok(mut evaluator) => evaluator.poll_join(cx),
Err(_) => Poll::Ready(Err(EvalError::Finished)),
})
.await
}
}
impl IntoFuture for UseEval {
type Output = Result<serde_json::Value, EvalError>;
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output>>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(self.join())
}
}
/// Represents an error when evaluating JavaScript
#[derive(Debug)]
#[non_exhaustive]
pub enum EvalError {
/// The platform does not support evaluating JavaScript.
Unsupported,
/// The provided JavaScript has already been ran.
Finished,
/// The provided JavaScript is not valid and can't be ran.
InvalidJs(String),
/// Represents an error communicating between JavaScript and Rust.
Communication(String),
}
+520
View File
@@ -0,0 +1,520 @@
#![doc = include_str!("../../docs/head.md")]
use std::{cell::RefCell, collections::HashSet, rc::Rc};
use dioxus_core::{prelude::*, DynamicNode};
use dioxus_core_macro::*;
/// Warn the user if they try to change props on a element that is injected into the head
#[allow(unused)]
fn use_update_warning<T: PartialEq + Clone + 'static>(value: &T, name: &'static str) {
#[cfg(debug_assertions)]
{
let cloned_value = value.clone();
let initial = use_hook(move || value.clone());
if initial != cloned_value {
tracing::warn!("Changing the props of `{name}` is not supported ");
}
}
}
fn extract_single_text_node(children: &Element, component: &str) -> Option<String> {
let vnode = match children {
Element::Ok(vnode) => vnode,
Element::Err(err) => {
tracing::error!("Error while rendering {component}: {err}");
return None;
}
};
// The title's children must be in one of two forms:
// 1. rsx! { "static text" }
// 2. rsx! { "title: {dynamic_text}" }
match vnode.template.get() {
// rsx! { "static text" }
Template {
roots: &[TemplateNode::Text { text }],
node_paths: &[],
attr_paths: &[],
..
} => Some(text.to_string()),
// rsx! { "title: {dynamic_text}" }
Template {
roots: &[TemplateNode::Dynamic { id }],
node_paths: &[&[0]],
attr_paths: &[],
..
} => {
let node = &vnode.dynamic_nodes[id];
match node {
DynamicNode::Text(text) => Some(text.value.clone()),
_ => {
tracing::error!("Error while rendering {component}: The children of {component} must be a single text node. It cannot be a component, if statement, loop, or a fragment");
None
}
}
}
_ => {
tracing::error!(
"Error while rendering title: The children of title must be a single text node"
);
None
}
}
}
#[derive(Clone, Props, PartialEq)]
pub struct TitleProps {
/// The contents of the title tag. The children must be a single text node.
children: Element,
}
/// Render the title of the page. On web renderers, this will set the [title](crate::elements::title) in the head. On desktop, it will set the window title.
///
/// Unlike most head components, the Title can be modified after the first render. Only the latest update to the title will be reflected if multiple title components are rendered.
///
///
/// The children of the title component must be a single static or formatted string. If there are more children or the children contain components, conditionals, loops, or fragments, the title will not be updated.
///
/// # Example
///
/// ```rust, no_run
/// # use dioxus::prelude::*;
/// fn App() -> Element {
/// rsx! {
/// // You can use the Title component to render a title tag into the head of the page or window
/// Title { "My Page" }
/// }
/// }
/// ```
#[component]
pub fn Title(props: TitleProps) -> Element {
let children = props.children;
let Some(text) = extract_single_text_node(&children, "Title") else {
return rsx! {};
};
// Update the title as it changes. NOTE: We don't use use_effect here because we need this to run on the server
let document = use_hook(document);
let last_text = use_hook(|| {
// Set the title initially
document.set_title(text.clone());
Rc::new(RefCell::new(text.clone()))
});
// If the text changes, update the title
let mut last_text = last_text.borrow_mut();
if text != *last_text {
document.set_title(text.clone());
*last_text = text;
}
rsx! {}
}
/// Props for the [`Meta`] component
#[derive(Clone, Props, PartialEq)]
pub struct MetaProps {
pub property: Option<String>,
pub name: Option<String>,
pub charset: Option<String>,
pub http_equiv: Option<String>,
pub content: Option<String>,
}
impl MetaProps {
pub(crate) fn attributes(&self) -> Vec<(&'static str, String)> {
let mut attributes = Vec::new();
if let Some(property) = &self.property {
attributes.push(("property", property.clone()));
}
if let Some(name) = &self.name {
attributes.push(("name", name.clone()));
}
if let Some(charset) = &self.charset {
attributes.push(("charset", charset.clone()));
}
if let Some(http_equiv) = &self.http_equiv {
attributes.push(("http-equiv", http_equiv.clone()));
}
if let Some(content) = &self.content {
attributes.push(("content", content.clone()));
}
attributes
}
}
/// Render a [`meta`](crate::elements::meta) tag into the head of the page.
///
/// # Example
///
/// ```rust, no_run
/// # use dioxus::prelude::*;
/// fn RedirectToDioxusHomepageWithoutJS() -> Element {
/// rsx! {
/// // You can use the meta component to render a meta tag into the head of the page
/// // This meta tag will redirect the user to the dioxuslabs homepage in 10 seconds
/// Meta {
/// http_equiv: "refresh",
/// content: "10;url=https://dioxuslabs.com",
/// }
/// }
/// }
/// ```
///
/// <div class="warning">
///
/// Any updates to the props after the first render will not be reflected in the head.
///
/// </div>
#[component]
pub fn Meta(props: MetaProps) -> Element {
use_update_warning(&props, "Meta {}");
use_hook(|| {
let document = document();
document.create_meta(props);
});
rsx! {}
}
#[derive(Clone, Props, PartialEq)]
pub struct ScriptProps {
/// The contents of the script tag. If present, the children must be a single text node.
pub children: Element,
/// Scripts are deduplicated by their src attribute
pub src: Option<String>,
pub defer: Option<bool>,
pub crossorigin: Option<String>,
pub fetchpriority: Option<String>,
pub integrity: Option<String>,
pub nomodule: Option<bool>,
pub nonce: Option<String>,
pub referrerpolicy: Option<String>,
pub r#type: Option<String>,
}
impl ScriptProps {
pub(crate) fn attributes(&self) -> Vec<(&'static str, String)> {
let mut attributes = Vec::new();
if let Some(defer) = &self.defer {
attributes.push(("defer", defer.to_string()));
}
if let Some(crossorigin) = &self.crossorigin {
attributes.push(("crossorigin", crossorigin.clone()));
}
if let Some(fetchpriority) = &self.fetchpriority {
attributes.push(("fetchpriority", fetchpriority.clone()));
}
if let Some(integrity) = &self.integrity {
attributes.push(("integrity", integrity.clone()));
}
if let Some(nomodule) = &self.nomodule {
attributes.push(("nomodule", nomodule.to_string()));
}
if let Some(nonce) = &self.nonce {
attributes.push(("nonce", nonce.clone()));
}
if let Some(referrerpolicy) = &self.referrerpolicy {
attributes.push(("referrerpolicy", referrerpolicy.clone()));
}
if let Some(r#type) = &self.r#type {
attributes.push(("type", r#type.clone()));
}
attributes
}
pub fn script_contents(&self) -> Option<String> {
extract_single_text_node(&self.children, "Script")
}
}
/// Render a [`script`](crate::elements::script) tag into the head of the page.
///
///
/// If present, the children of the script component must be a single static or formatted string. If there are more children or the children contain components, conditionals, loops, or fragments, the script will not be added.
///
///
/// Any scripts you add will be deduplicated by their `src` attribute (if present).
///
/// # Example
/// ```rust, no_run
/// # use dioxus::prelude::*;
/// fn LoadScript() -> Element {
/// rsx! {
/// // You can use the Script component to render a script tag into the head of the page
/// Script {
/// src: manganis::mg!(file("./assets/script.js")),
/// }
/// }
/// }
/// ```
///
/// <div class="warning">
///
/// Any updates to the props after the first render will not be reflected in the head.
///
/// </div>
#[component]
pub fn Script(props: ScriptProps) -> Element {
use_update_warning(&props, "Script {}");
use_hook(|| {
if let Some(src) = &props.src {
if !should_insert_script(src) {
return;
}
}
let document = document();
document.create_script(props);
});
rsx! {}
}
#[derive(Clone, Props, PartialEq)]
pub struct StyleProps {
/// Styles are deduplicated by their href attribute
pub href: Option<String>,
pub media: Option<String>,
pub nonce: Option<String>,
pub title: Option<String>,
/// The contents of the style tag. If present, the children must be a single text node.
pub children: Element,
}
impl StyleProps {
pub(crate) fn attributes(&self) -> Vec<(&'static str, String)> {
let mut attributes = Vec::new();
if let Some(href) = &self.href {
attributes.push(("href", href.clone()));
}
if let Some(media) = &self.media {
attributes.push(("media", media.clone()));
}
if let Some(nonce) = &self.nonce {
attributes.push(("nonce", nonce.clone()));
}
if let Some(title) = &self.title {
attributes.push(("title", title.clone()));
}
attributes
}
pub fn style_contents(&self) -> Option<String> {
extract_single_text_node(&self.children, "Title")
}
}
/// Render a [`style`](crate::elements::style) tag into the head of the page.
///
/// If present, the children of the style component must be a single static or formatted string. If there are more children or the children contain components, conditionals, loops, or fragments, the style will not be added.
///
/// # Example
/// ```rust, no_run
/// # use dioxus::prelude::*;
/// fn RedBackground() -> Element {
/// rsx! {
/// // You can use the style component to render a style tag into the head of the page
/// // This style tag will set the background color of the page to red
/// Style {
/// r#"
/// body {{
/// background-color: red;
/// }}
/// "#,
/// }
/// }
/// }
/// ```
///
/// <div class="warning">
///
/// Any updates to the props after the first render will not be reflected in the head.
///
/// </div>
#[component]
pub fn Style(props: StyleProps) -> Element {
use_update_warning(&props, "Style {}");
use_hook(|| {
if let Some(href) = &props.href {
if !should_insert_style(href) {
return;
}
}
let document = document();
document.create_style(props);
});
rsx! {}
}
use super::*;
#[derive(Clone, Props, PartialEq)]
pub struct LinkProps {
pub rel: Option<String>,
pub media: Option<String>,
pub title: Option<String>,
pub disabled: Option<bool>,
pub r#as: Option<String>,
pub sizes: Option<String>,
/// Links are deduplicated by their href attribute
pub href: Option<String>,
pub crossorigin: Option<String>,
pub referrerpolicy: Option<String>,
pub fetchpriority: Option<String>,
pub hreflang: Option<String>,
pub integrity: Option<String>,
pub r#type: Option<String>,
pub blocking: Option<String>,
}
impl LinkProps {
pub(crate) fn attributes(&self) -> Vec<(&'static str, String)> {
let mut attributes = Vec::new();
if let Some(rel) = &self.rel {
attributes.push(("rel", rel.clone()));
}
if let Some(media) = &self.media {
attributes.push(("media", media.clone()));
}
if let Some(title) = &self.title {
attributes.push(("title", title.clone()));
}
if let Some(disabled) = &self.disabled {
attributes.push(("disabled", disabled.to_string()));
}
if let Some(r#as) = &self.r#as {
attributes.push(("as", r#as.clone()));
}
if let Some(sizes) = &self.sizes {
attributes.push(("sizes", sizes.clone()));
}
if let Some(href) = &self.href {
attributes.push(("href", href.clone()));
}
if let Some(crossorigin) = &self.crossorigin {
attributes.push(("crossOrigin", crossorigin.clone()));
}
if let Some(referrerpolicy) = &self.referrerpolicy {
attributes.push(("referrerPolicy", referrerpolicy.clone()));
}
if let Some(fetchpriority) = &self.fetchpriority {
attributes.push(("fetchPriority", fetchpriority.clone()));
}
if let Some(hreflang) = &self.hreflang {
attributes.push(("hrefLang", hreflang.clone()));
}
if let Some(integrity) = &self.integrity {
attributes.push(("integrity", integrity.clone()));
}
if let Some(r#type) = &self.r#type {
attributes.push(("type", r#type.clone()));
}
if let Some(blocking) = &self.blocking {
attributes.push(("blocking", blocking.clone()));
}
attributes
}
}
/// Render a [`link`](crate::elements::link) tag into the head of the page.
///
/// > The [Link](https://docs.rs/dioxus-router/latest/dioxus_router/components/fn.Link.html) component in dioxus router and this component are completely different.
/// > This component links resources in the head of the page, while the router component creates clickable links in the body of the page.
///
/// # Example
/// ```rust, no_run
/// # use dioxus::prelude::*;
/// fn RedBackground() -> Element {
/// rsx! {
/// // You can use the meta component to render a meta tag into the head of the page
/// // This meta tag will redirect the user to the dioxuslabs homepage in 10 seconds
/// head::Link {
/// href: manganis::mg!(file("./assets/style.css")),
/// rel: "stylesheet",
/// }
/// }
/// }
/// ```
///
/// <div class="warning">
///
/// Any updates to the props after the first render will not be reflected in the head.
///
/// </div>
#[doc(alias = "<link>")]
#[component]
pub fn Link(props: LinkProps) -> Element {
use_update_warning(&props, "Link {}");
use_hook(|| {
if let Some(href) = &props.href {
if !should_insert_link(href) {
return;
}
}
let document = document();
document.create_link(props);
});
rsx! {}
}
fn get_or_insert_root_context<T: Default + Clone + 'static>() -> T {
match ScopeId::ROOT.has_context::<T>() {
Some(context) => context,
None => {
let context = T::default();
ScopeId::ROOT.provide_context(context.clone());
context
}
}
}
#[derive(Default, Clone)]
struct LinkContext(DeduplicationContext);
fn should_insert_link(href: &str) -> bool {
get_or_insert_root_context::<LinkContext>()
.0
.should_insert(href)
}
#[derive(Default, Clone)]
struct ScriptContext(DeduplicationContext);
fn should_insert_script(src: &str) -> bool {
get_or_insert_root_context::<ScriptContext>()
.0
.should_insert(src)
}
#[derive(Default, Clone)]
struct StyleContext(DeduplicationContext);
fn should_insert_style(href: &str) -> bool {
get_or_insert_root_context::<StyleContext>()
.0
.should_insert(href)
}
#[derive(Default, Clone)]
struct DeduplicationContext(Rc<RefCell<HashSet<String>>>);
impl DeduplicationContext {
fn should_insert(&self, href: &str) -> bool {
let mut set = self.0.borrow_mut();
let present = set.contains(href);
if !present {
set.insert(href.to_string());
true
} else {
false
}
}
}
+114
View File
@@ -0,0 +1,114 @@
// API inspired by Reacts implementation of head only elements. We use components here instead of elements to simplify internals.
use std::{
rc::Rc,
task::{Context, Poll},
};
use generational_box::{AnyStorage, GenerationalBox, UnsyncStorage};
mod bindings;
#[allow(unused)]
pub use bindings::*;
mod eval;
pub use eval::*;
pub mod head;
pub use head::{Meta, MetaProps, Script, ScriptProps, Style, StyleProps, Title, TitleProps};
fn format_attributes(attributes: &[(&str, String)]) -> String {
let mut formatted = String::from("[");
for (key, value) in attributes {
formatted.push_str(&format!("[{key:?}, {value:?}],"));
}
if formatted.ends_with(',') {
formatted.pop();
}
formatted.push(']');
formatted
}
fn create_element_in_head(
tag: &str,
attributes: &[(&str, String)],
children: Option<String>,
) -> String {
let helpers = include_str!("../js/head.js");
let attributes = format_attributes(attributes);
let children = children
.map(|c| format!("\"{c}\""))
.unwrap_or("null".to_string());
format!(r#"{helpers};window.createElementInHead("{tag}", {attributes}, {children});"#)
}
/// A provider for document-related functionality. By default most methods are driven through [`eval`].
pub trait Document {
/// Create a new evaluator for the document that evaluates JavaScript and facilitates communication between JavaScript and Rust.
fn new_evaluator(&self, js: String) -> GenerationalBox<Box<dyn Evaluator>>;
/// Set the title of the document
fn set_title(&self, title: String) {
self.new_evaluator(format!("document.title = {title:?};"));
}
fn create_meta(&self, props: MetaProps) {
let attributes = props.attributes();
let js = create_element_in_head("meta", &attributes, None);
self.new_evaluator(js);
}
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);
}
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);
}
fn create_link(&self, props: head::LinkProps) {
let attributes = props.attributes();
let js = create_element_in_head("link", &attributes, None);
self.new_evaluator(js);
}
}
/// The default No-Op document
pub struct NoOpDocument;
impl Document for NoOpDocument {
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(NoOpEvaluator))
}
}
struct NoOpEvaluator;
impl Evaluator for NoOpEvaluator {
fn send(&self, _data: serde_json::Value) -> Result<(), EvalError> {
Err(EvalError::Unsupported)
}
fn poll_recv(
&mut self,
_context: &mut Context<'_>,
) -> Poll<Result<serde_json::Value, EvalError>> {
Poll::Ready(Err(EvalError::Unsupported))
}
fn poll_join(
&mut self,
_context: &mut Context<'_>,
) -> Poll<Result<serde_json::Value, EvalError>> {
Poll::Ready(Err(EvalError::Unsupported))
}
}
/// Get the document provider for the current platform or a no-op provider if the platform doesn't document functionality.
pub fn document() -> Rc<dyn Document> {
dioxus_core::prelude::try_consume_context::<Rc<dyn Document>>()
// Create a NoOp provider that always logs an error when trying to evaluate
// That way, we can still compile and run the code without a real provider
.unwrap_or_else(|| Rc::new(NoOpDocument) as Rc<dyn Document>)
}