diff --git a/hydration_context/src/ssr.rs b/hydration_context/src/ssr.rs
index 9986c695c..9a9594532 100644
--- a/hydration_context/src/ssr.rs
+++ b/hydration_context/src/ssr.rs
@@ -182,12 +182,18 @@ impl SharedContext for SsrSharedContext {
initial_chunk.push_str("__SERIALIZED_ERRORS=[");
for error in mem::take(&mut *self.errors.write().or_poisoned()) {
+ // Debug-format first to get a valid, quoted JS string literal
+ // (escaping `"`, `\`, control chars), then rewrite every remaining
+ // `<` to a single-backslash `<` JS unicode escape. Escaping
+ // *after* `{:?}` keeps it one backslash, so the HTML tokenizer
+ // never sees `` while the browser's JS string parser
+ // still decodes `<` straight back to `<` for the consumer.
+ let msg =
+ format!("{:?}", error.2.to_string()).replace('<', "\\u003c");
_ = write!(
initial_chunk,
- "[{}, {}, {:?}],",
- error.0 .0,
- error.1,
- error.2.to_string()
+ "[{}, {}, {}],",
+ error.0 .0, error.1, msg
);
}
initial_chunk.push_str("];");
@@ -293,12 +299,14 @@ impl Stream for AsyncDataStream {
let sealed = self.sealed_error_boundaries.read().or_poisoned();
for error in mem::take(&mut *self.errors.write().or_poisoned()) {
if !sealed.contains(&error.0) {
+ // see the initial-chunk path: Debug-format, then single-
+ // backslash-escape `<` so the JS parser decodes it back to `<`
+ let msg = format!("{:?}", error.2.to_string())
+ .replace('<', "\\u003c");
_ = write!(
resolved,
- "__SERIALIZED_ERRORS.push([{}, {}, {:?}]);",
- error.0 .0,
- error.1,
- error.2.to_string()
+ "__SERIALIZED_ERRORS.push([{}, {}, {}]);",
+ error.0 .0, error.1, msg
);
}
}
@@ -325,3 +333,101 @@ impl ResolvedData {
write!(buf, "{}: {:?}", id.0, ser).unwrap();
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use futures::{executor::block_on, StreamExt};
+ use std::fmt;
+
+ #[derive(Debug)]
+ struct CustomError(&'static str);
+
+ impl fmt::Display for CustomError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.0)
+ }
+ }
+
+ impl std::error::Error for CustomError {}
+
+ /// An error message containing `` must not be able to escape
+ /// the surrounding "),
+ "initial chunk must not contain a literal `` substring, \
+ got: {initial}"
+ );
+ assert!(
+ !initial.contains('<'),
+ "initial chunk must not contain a literal `<` character anywhere \
+ inside the serialized errors, got: {initial}"
+ );
+ assert!(
+ initial.contains("\\u003c") && !initial.contains("\\\\u003c"),
+ "expected a single-backslash `\\u003c` escape in place of `<` (a \
+ double backslash would be decoded to a literal `\\u003c` and \
+ shown raw to the user), got: {initial}"
+ );
+ }
+
+ /// The same escape must be applied to errors emitted later via the
+ /// async stream (AsyncDataStream::poll_next).
+ #[test]
+ fn error_in_async_stream_escapes_script_close_tag() {
+ let ctx = SsrSharedContext::new();
+
+ // park one async resource so AsyncDataStream emits a follow-up chunk
+ ctx.write_async(
+ SerializedDataId(1),
+ Box::pin(async { String::from("\"ok\"") }),
+ );
+
+ let mut stream = ctx.pending_data().expect("pending_data on ssr");
+ // skip the initial setup chunk; we want the next one
+ let _initial = block_on(stream.next()).expect("initial chunk");
+
+ // register an error after pending_data() has been called so it is
+ // serialized through the streaming path rather than the initial chunk
+ ctx.register_error(
+ SerializedDataId(2),
+ ErrorId::from(7_usize),
+ Error::from(CustomError("late")),
+ );
+
+ let mut saw_error = false;
+ while let Some(chunk) = block_on(stream.next()) {
+ if chunk.contains("__SERIALIZED_ERRORS.push") {
+ saw_error = true;
+ assert!(
+ !chunk.contains(""),
+ "streamed error chunk must not contain ``: \
+ {chunk}"
+ );
+ assert!(
+ chunk.contains("\\u003c") && !chunk.contains("\\\\u003c"),
+ "streamed error chunk should carry a single-backslash \
+ escaped `<`: {chunk}"
+ );
+ }
+ }
+ assert!(
+ saw_error,
+ "expected at least one streamed __SERIALIZED_ERRORS.push chunk"
+ );
+ }
+}
diff --git a/leptos/tests/ssr.rs b/leptos/tests/ssr.rs
index 2b8e62108..373c3221b 100644
--- a/leptos/tests/ssr.rs
+++ b/leptos/tests/ssr.rs
@@ -205,3 +205,39 @@ fn ssr_option() {
assert_eq!(rendered.to_html(), "");
}
+
+#[cfg(feature = "ssr")]
+#[test]
+fn ssr_textarea_escapes_static_content() {
+ use leptos::prelude::*;
+
+ // Nested (non-top-level) static textarea exercises the macro's inert
+ // HTML path; its content must be HTML-escaped.
+ let rendered: View> = view! {
+
+ };
+
+ assert_eq!(
+ rendered.to_html(),
+ ""
+ );
+}
+
+#[cfg(feature = "ssr")]
+#[test]
+fn ssr_textarea_escapes_dynamic_content() {
+ use leptos::prelude::*;
+
+ // A dynamic child makes the textarea non-inert, exercising the runtime
+ // render path; its content must also be HTML-escaped.
+ let untrusted = "".to_string();
+ let rendered: View> = view! {
+
+ };
+
+ assert_eq!(
+ rendered.to_html(),
+ ""
+ );
+}
diff --git a/leptos_macro/src/view/mod.rs b/leptos_macro/src/view/mod.rs
index 17bc1f8cd..d8e0f9adb 100644
--- a/leptos_macro/src/view/mod.rs
+++ b/leptos_macro/src/view/mod.rs
@@ -337,9 +337,7 @@ fn inert_element_to_tokens(
Node::Element(node) => {
let self_closing = is_self_closing(node);
let el_name = node.name().to_string();
- let escape = el_name != "script"
- && el_name != "style"
- && el_name != "textarea";
+ let escape = el_name != "script" && el_name != "style";
// opening tag
html.push('<');
@@ -451,9 +449,7 @@ fn inert_svg_element_to_tokens(
.map(str::to_string)
.unwrap_or(el_name);
- let escape = el_name != "script"
- && el_name != "style"
- && el_name != "textarea";
+ let escape = el_name != "script" && el_name != "style";
// opening tag
html.push('<');
@@ -740,9 +736,7 @@ fn node_to_tokens(
Node::Element(el_node) => {
if !top_level && is_inert {
let el_name = el_node.name().to_string();
- let escape = el_name != "script"
- && el_name != "style"
- && el_name != "textarea";
+ let escape = el_name != "script" && el_name != "style";
let el_name = el_node.name().to_string();
if is_svg_element(&el_name) && el_name != "svg" {
diff --git a/tachys/src/html/element/elements.rs b/tachys/src/html/element/elements.rs
index 6bf1250fb..ce0c29df3 100644
--- a/tachys/src/html/element/elements.rs
+++ b/tachys/src/html/element/elements.rs
@@ -403,7 +403,7 @@ html_elements! {
/// The `` HTML element is a mechanism for holding HTML that is not to be rendered immediately when a page is loaded but may be instantiated subsequently during runtime using JavaScript.
template HtmlTemplateElement [] true,
/// The `