diff --git a/crates/webui-parser/benches/parser_bench.rs b/crates/webui-parser/benches/parser_bench.rs index 2767837b9..61b2e368b 100644 --- a/crates/webui-parser/benches/parser_bench.rs +++ b/crates/webui-parser/benches/parser_bench.rs @@ -625,6 +625,34 @@ fn client_template_directives_bench(c: &mut Criterion) { }); } +fn client_template_whitespace_bench(c: &mut Criterion) { + let lf = format!( + "
{}
", + "\n ".repeat(100) + ); + let mut group = c.benchmark_group("client_template_whitespace"); + for (name, input) in [ + ("lf_100", lf.clone()), + ("crlf_100", lf.replace('\n', "\r\n")), + ( + "entities_100", + lf.replace("{{label}}", "< {{label}} &"), + ), + ] { + group.throughput(Throughput::Bytes(input.len() as u64)); + group.bench_with_input(name, &input, |b, html| { + b.iter(|| { + webui_parser::plugin::webui::generate_compiled_template( + "bench-whitespace", + black_box(html), + ) + .unwrap_or_else(|error| panic!("client text compilation failed: {error}")) + }); + }); + } + group.finish(); +} + fn parser_isolated_bench(c: &mut Criterion) { let mut group = c.benchmark_group("parser_isolated"); for (name, input) in [ @@ -682,6 +710,7 @@ criterion_group!( parser_text_vs_directive_bench, parser_adversarial_bench, client_template_directives_bench, + client_template_whitespace_bench, parser_isolated_bench, css_token_analysis_bench ); diff --git a/crates/webui-parser/src/lib.rs b/crates/webui-parser/src/lib.rs index d739cb6be..1b0750035 100644 --- a/crates/webui-parser/src/lib.rs +++ b/crates/webui-parser/src/lib.rs @@ -3943,9 +3943,9 @@ impl HtmlParser { let parsed_fragments = self.handlebars_parser.parse(content)?; for fragment in parsed_fragments { if let Some(Fragment::Raw(raw)) = fragment.fragment.as_ref() { - if Self::should_emit_text_content(&raw.value) { - self.add_raw_fragment(&raw.value); - } + // Whitespace inside an emitted text run is part of the binding, + // not inter-element formatting. Client text parts retain it too. + self.add_raw_fragment(&raw.value); } else if let Some(Fragment::Signal(signal)) = fragment.fragment.as_ref() { if signal.raw && is_html_text_only_scope(self.boundary_parent_scope) { self.add_fragment( @@ -5761,6 +5761,28 @@ mod tests { ); } + #[test] + fn test_parse_preserves_whitespace_inside_bound_text_runs() -> Result<()> { + let mut parser = HtmlParser::new(); + parser.parse( + "test.html", + "\n \n
  {{details}}\n  
\n", + )?; + let records = parser.into_fragment_records(); + assert_stream!( + records, + "test.html", + [ + raw("
  "),
+                signal("details"),
+                raw("\n  
"), + ] + ); + Ok(()) + } + #[test] fn test_invalid_for_each_is_template_diagnostic() { let mut parser = HtmlParser::new(); @@ -6697,7 +6719,15 @@ mod tests { [for_loop("item", "category.items", "for-2"),] ); - assert_stream!(fragment_records, "for-2", [signal("item.title"),]); + assert_stream!( + fragment_records, + "for-2", + [ + raw("\n "), + signal("item.title"), + raw("\n "), + ] + ); } #[test] diff --git a/crates/webui-parser/src/plugin/webui.rs b/crates/webui-parser/src/plugin/webui.rs index a517ba837..63e63bc49 100644 --- a/crates/webui-parser/src/plugin/webui.rs +++ b/crates/webui-parser/src/plugin/webui.rs @@ -541,6 +541,7 @@ struct TemplateSource<'a> { source: &'a str, range: Range, repeat_key: Option, + trim_boundary_formatting: bool, } impl<'a> From<&'a str> for TemplateSource<'a> { @@ -549,6 +550,7 @@ impl<'a> From<&'a str> for TemplateSource<'a> { source, range: 0..source.len(), repeat_key: None, + trim_boundary_formatting: false, } } } @@ -563,15 +565,17 @@ impl<'a> TemplateSource<'a> { source: self.source, range: self.range.start + range.start..self.range.start + range.end, repeat_key: self.repeat_key, + trim_boundary_formatting: false, } } - fn trimmed(mut self) -> Self { + fn trim_boundary_formatting(mut self) -> Self { let html = self.html(); let trimmed = html.trim_start(); - let leading = html.len() - trimmed.len(); - self.range.start += leading; - self.range.end = self.range.start + trimmed.trim_end().len(); + if trimmed.is_empty() || trimmed.starts_with('<') { + self.range.start += html.len() - trimmed.len(); + } + self.trim_boundary_formatting = true; self } @@ -794,8 +798,8 @@ fn generate_compiled_template_with_root_source( shadow_dom: bool, scriptless: bool, ) -> Result { - let source = TemplateSource::from(html_content).trimmed(); - let trimmed = source.html(); + let source = TemplateSource::from(html_content); + let trimmed = html_content.trim(); let shadow_body = shadow_template_body_range(trimmed); let authored_shadow_root = shadow_body.is_some(); let mut root_events = if authored_shadow_root { @@ -810,7 +814,13 @@ fn generate_compiled_template_with_root_source( { root_events = extract_root_events(tag_name, raw_root)?; } - let body = shadow_body.map_or_else(|| source.clone(), |range| source.slice(range)); + let body = shadow_body.map_or_else( + || source.clone().trim_boundary_formatting(), + |range| { + let leading = html_content.len() - html_content.trim_start().len(); + source.slice(leading + range.start..leading + range.end) + }, + ); let meta = compile_to_metadata(tag_name, body, root_events)?; let build_meta = collect_template_build_metadata(&meta); if scriptless && build_meta.has_events { @@ -1647,6 +1657,7 @@ fn compile_section( let bytes = input.as_bytes(); let len = bytes.len(); let mut i = 0; + let mut last_markup_end = 0; while i < len { if !input.is_char_boundary(i) { @@ -1666,6 +1677,7 @@ fn compile_section( if remaining.starts_with("") { i += close + 3; + last_markup_end = i; continue; } } @@ -1682,6 +1694,7 @@ fn compile_section( meta.conditionals.push((cond, block_index)); meta.html.push_str(&format!("")); i += consumed; + last_markup_end = i; continue; } } @@ -1706,6 +1719,7 @@ fn compile_section( }); meta.html.push_str(&format!("")); i += repeat.consumed; + last_markup_end = i; continue; } } @@ -1715,6 +1729,7 @@ fn compile_section( compile_style_content(&input[open_end..close_start], &mut meta); meta.html.push_str(&input[close_start..close_end]); i = close_end; + last_markup_end = i; continue; } @@ -1724,6 +1739,7 @@ fn compile_section( if let Some(consumed) = find_element_end(remaining, "outlet") { meta.html.push_str(""); i += consumed; + last_markup_end = i; continue; } } @@ -1736,6 +1752,7 @@ fn compile_section( { meta.html.push_str(&tag_html); i += consumed; + last_markup_end = i; continue; } } @@ -1765,6 +1782,10 @@ fn compile_section( } } + if source.trim_boundary_formatting && input[last_markup_end..].trim().is_empty() { + let trailing = len - last_markup_end; + meta.html.truncate(meta.html.len() - trailing); + } Ok(meta) } @@ -1915,6 +1936,7 @@ fn finalize_template_section(meta: &mut TemplateSectionMeta) { process_fragment_children( &nodes, 0, + false, &text_bindings, &mut finalized_html, &mut text_runs, @@ -1941,6 +1963,7 @@ fn finalize_template_section(meta: &mut TemplateSectionMeta) { fn process_fragment_children( nodes: &[FragmentNode], parent_index: usize, + ignore_initial_lf: bool, text_bindings: &[(String, bool)], out: &mut String, text_runs: &mut Vec<(SlotLocator, Vec, bool)>, @@ -1974,16 +1997,7 @@ fn process_fragment_children( // (e.g. `>` → `>`). Static-only text runs are // baked into `meta.h` and parsed via innerHTML which // handles entity decoding natively. - let decoded_parts = parts - .into_iter() - .map(|part| match part { - CompiledAttrPart::Static(s) => { - let decoded = html_escape::decode_html_entities(&s); - CompiledAttrPart::Static(decoded.into_owned()) - } - other => other, - }) - .collect(); + let decoded_parts = decode_text_parts(parts, ignore_initial_lf && index == 0); let order = slot_orders.get(&child_index).copied().unwrap_or(0); slot_orders.insert(child_index, order + 1); text_runs.push(( @@ -2082,6 +2096,53 @@ fn process_fragment_children( ssr_layout.finish_children(parent_index, &ssr_children, text_runs.len()); } +fn decode_text_parts( + mut parts: Vec, + ignore_initial_lf: bool, +) -> Vec { + for part in &mut parts { + if let CompiledAttrPart::Static(text) = part { + normalize_text_newlines(text); + if let Cow::Owned(decoded) = html_escape::decode_html_entities(text.as_str()) { + *text = decoded; + } + } + } + // HTML parsing ignores the first LF after pre/textarea/listing. Client + // binding assignment bypasses HTML parsing, so mirror it in static parts. + if ignore_initial_lf { + if let Some(CompiledAttrPart::Static(text)) = parts.first_mut() { + if text.starts_with('\n') { + text.remove(0); + } + } + } + parts +} + +fn normalize_text_newlines(text: &mut String) { + let Some(mut carriage_return) = text.find('\r') else { + return; + }; + let first_is_crlf = text[carriage_return..].starts_with("\r\n"); + let mut normalized = String::with_capacity(text.len() - usize::from(first_is_crlf)); + let mut remaining = text.as_str(); + loop { + normalized.push_str(&remaining[..carriage_return]); + normalized.push('\n'); + remaining = &remaining[carriage_return + 1..]; + if let Some(after_lf) = remaining.strip_prefix('\n') { + remaining = after_lf; + } + let Some(next) = remaining.find('\r') else { + break; + }; + carriage_return = next; + } + normalized.push_str(remaining); + *text = normalized; +} + #[allow(clippy::too_many_arguments)] fn serialize_fragment_element( element: &FragmentElement, @@ -2151,6 +2212,9 @@ fn serialize_fragment_element( process_fragment_children( &element.children, element_index, + element.tag_name.eq_ignore_ascii_case("pre") + || element.tag_name.eq_ignore_ascii_case("textarea") + || element.tag_name.eq_ignore_ascii_case("listing"), text_bindings, out, text_runs, @@ -2719,7 +2783,9 @@ fn parse_if_block( let (body_end, close_end) = find_matching_end(input, tag.name, body_start)?; Some(( condition, - source.slice(body_start..body_end).trimmed(), + source + .slice(body_start..body_end) + .trim_boundary_formatting(), close_end, )) } @@ -2778,7 +2844,9 @@ fn parse_for_block<'a>( }; let (item_var, collection) = crate::parse_for_each(component, each_val)?; - let mut body = source.slice(tag.close + 1..body_end).trimmed(); + let mut body = source + .slice(tag.close + 1..body_end) + .trim_boundary_formatting(); let repeat_key = first_child_repeat_key(component, item_var, body.html())?; body.repeat_key = repeat_key .as_ref() @@ -3540,6 +3608,86 @@ mod whitespace_tests; mod tests { use super::*; + #[test] + fn decoding_plain_text_reuses_owned_buffers() { + let text = String::from("\n plain text \n"); + let text_pointer = text.as_ptr(); + let parts = vec![ + CompiledAttrPart::Static(text), + CompiledAttrPart::Dynamic("label".to_string()), + ]; + let parts_pointer = parts.as_ptr(); + let decoded = decode_text_parts(parts, false); + assert_eq!(decoded.as_ptr(), parts_pointer); + let CompiledAttrPart::Static(text) = &decoded[0] else { + panic!("expected static text"); + }; + assert_eq!(text, "\n plain text \n"); + assert_eq!(text.as_ptr(), text_pointer); + } + + #[test] + fn source_newline_normalization_handles_mixed_runs() { + for (input, expected) in [ + ("", ""), + ("plain\ntext", "plain\ntext"), + ("\r", "\n"), + ("\r\n", "\n"), + ("\r\r\n\r", "\n\n\n"), + ("a\r\nb\rc\n", "a\nb\nc\n"), + ("é\r\n中\r🙂", "é\n中\n🙂"), + ] { + let mut text = input.to_string(); + normalize_text_newlines(&mut text); + assert_eq!(text, expected); + } + } + + #[test] + fn boundary_formatting_stays_separate_from_stripped_comments() { + for text in [ + "plain text", + "{{label}}", + "{{{label}}}", + "{{label}}", + ] { + let source = format!("\n{text}\n"); + assert_eq!( + generate_compiled_template("text-comments", &source), + generate_compiled_template("text-comments", text), + ); + } + } + + #[test] + fn text_run_edges_survive_component_and_block_boundaries() { + let text = "\n {{label}}\n "; + let metadata = generate_compiled_template("text-root", text); + assert!(metadata.contains(r#"["\n ",["label"],"\n "]"#)); + for source in [ + format!("{text}"), + format!("{text}"), + ] { + let metadata = generate_compiled_template("text-block", &source); + assert!(metadata.contains(r#"["\n ",["label"],"\n "]"#)); + } + } + + #[test] + fn text_parts_follow_html_source_newline_rules() { + let button = + generate_compiled_template("text-newlines", ""); + assert!(button.contains(r#"["\n ",["label"],"\n"]"#)); + for tag in ["pre", "textarea", "listing"] { + let source = format!("<{tag}>\r\n {{{{label}}}}\r\n"); + let metadata = generate_compiled_template("text-newlines", &source); + assert!(metadata.contains(r#"[" ",["label"],"\n"]"#)); + } + let entity = + generate_compiled_template("text-newlines", " {{label}} "); + assert!(entity.contains(r#"["\r",["label"],"\n"]"#)); + } + #[test] fn boolean_literal_conditions_have_no_state_dependencies() { for value in ["true", "false"] { diff --git a/crates/webui-parser/src/plugin/webui/named_repeats.rs b/crates/webui-parser/src/plugin/webui/named_repeats.rs index 89dd31724..75bcc999b 100644 --- a/crates/webui-parser/src/plugin/webui/named_repeats.rs +++ b/crates/webui-parser/src/plugin/webui/named_repeats.rs @@ -79,6 +79,7 @@ impl CompileBlocks { source: repeat.body.source, range: repeat.offset..repeat.offset, repeat_key: None, + trim_boundary_formatting: false, }; source.locate_error(error) })?; diff --git a/docs/guide/concepts/hydration.md b/docs/guide/concepts/hydration.md index 279ffe80d..1396319f3 100644 --- a/docs/guide/concepts/hydration.md +++ b/docs/guide/concepts/hydration.md @@ -15,6 +15,12 @@ compiler and framework versions. SSR hydration requires the compiler's current metadata; stale artifacts fail with an error asking you to rebuild rather than being reinterpreted in the browser. +Text bindings retain their surrounding authored whitespace on the server and +during browser updates. Values are not trimmed; ordinary HTML whitespace +collapsing and CSS such as `white-space: pre-wrap` determine their appearance. +Native HTML rules still apply, including ignoring the first newline immediately +after an opening `
` or `
+  
+    {{details}}
+  
+ +
+ {{details}} +
+
+ {{item}} +
+ diff --git a/packages/webui-framework/tests/fixtures/hydration-mismatch/src/whitespace-slot/whitespace-slot.html b/packages/webui-framework/tests/fixtures/hydration-mismatch/src/whitespace-slot/whitespace-slot.html new file mode 100644 index 000000000..a1bec3143 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/hydration-mismatch/src/whitespace-slot/whitespace-slot.html @@ -0,0 +1 @@ + diff --git a/packages/webui-framework/tests/fixtures/hydration-mismatch/state.json b/packages/webui-framework/tests/fixtures/hydration-mismatch/state.json index cd76a59cc..b15b8d36d 100644 --- a/packages/webui-framework/tests/fixtures/hydration-mismatch/state.json +++ b/packages/webui-framework/tests/fixtures/hydration-mismatch/state.json @@ -1,4 +1,8 @@ { "seededShow": true, - "seededValue": "3" + "seededValue": "3", + "headerTitle": "Downloads", + "details": "Open file", + "padded": " data \n ", + "items": ["First", "Second"] } diff --git a/specs/hydration.md b/specs/hydration.md index 2e95ed231..0b19f8a23 100644 --- a/specs/hydration.md +++ b/specs/hydration.md @@ -97,6 +97,14 @@ HTML binding. The client counterparts are defined in Ordinary escaped text bindings, attribute bindings, and event handlers are **not** marked. They are located via compiled element indices. +An emitted text run preserves all of its static pieces, including indentation +around bindings. SSR fragments and client text parts must agree on those +pieces; formatting-only text between elements is not a reason to discard +whitespace within a binding's text node. State values are never trimmed. +Static text parts follow browser HTML parsing: source CR/CRLF becomes LF, +character references are decoded, and the initial LF after +`pre`/`textarea`/`listing` is ignored before client text assignment. + Whole-value dynamic attributes omit missing and null values during SSR and client binding updates. Empty strings, zero, and false remain values; mixed attributes retain their static text. Omitting an attribute does not remove an