Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
286 changes: 286 additions & 0 deletions src/jsc/ConsoleObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1974,6 +1974,7 @@
NativeCode,

JSX,
DOMNode,
Event,

GetterSetter,
Expand Down Expand Up @@ -2010,6 +2011,7 @@
| Tag::Error
| Tag::Class
| Tag::Event
| Tag::DOMNode
)
}
}
Expand Down Expand Up @@ -2044,6 +2046,7 @@
ToJSON,
NativeCode,
JSX,
DOMNode,
Event,
GetterSetter,
CustomGetterSetter,
Expand Down Expand Up @@ -2090,6 +2093,7 @@
TagPayload::ToJSON => Tag::ToJSON,
TagPayload::NativeCode => Tag::NativeCode,
TagPayload::JSX => Tag::JSX,
TagPayload::DOMNode => Tag::DOMNode,
TagPayload::Event => Tag::Event,
TagPayload::GetterSetter => Tag::GetterSetter,
TagPayload::CustomGetterSetter => Tag::CustomGetterSetter,
Expand Down Expand Up @@ -2135,6 +2139,7 @@
Tag::ToJSON => TagPayload::ToJSON,
Tag::NativeCode => TagPayload::NativeCode,
Tag::JSX => TagPayload::JSX,
Tag::DOMNode => TagPayload::DOMNode,
Tag::Event => TagPayload::Event,
Tag::GetterSetter => TagPayload::GetterSetter,
Tag::CustomGetterSetter => TagPayload::CustomGetterSetter,
Expand Down Expand Up @@ -2330,6 +2335,16 @@
}
}

// Is this a DOM node (jsdom / happy-dom)?
if matches!(js_type, jsc::JSType::Object | jsc::JSType::FinalObject)
&& is_dom_node(global_this, value)?
{
return Ok(TagResult {
tag: TagPayload::DOMNode,
cell: js_type,
});
}

use jsc::JSType as T;
let tag = match js_type {
T::ErrorInstance => TagPayload::Error,
Expand Down Expand Up @@ -3304,6 +3319,67 @@
Ok(None)
}

const DOM_ELEMENT_NODE: i32 = 1;
const DOM_TEXT_NODE: i32 = 3;
const DOM_COMMENT_NODE: i32 = 8;
const DOM_FRAGMENT_NODE: i32 = 11;

/// Mirrors the pretty-format `DOMElement` plugin test: given a constructor
/// name, return the `nodeType` value that would confirm the object is a DOM
/// node of that kind, or `None` if the name does not look DOM-like.
Comment thread
robobun marked this conversation as resolved.
Outdated
pub fn dom_node_type_for_class_name(name: &[u8]) -> Option<i32> {
use bun_core::strings;
// /^((HTML|SVG)\w*)?Element$/
if strings::has_suffix_comptime(name, b"Element")
&& (name.len() == b"Element".len()
|| strings::has_prefix_comptime(name, b"HTML")
|| strings::has_prefix_comptime(name, b"SVG"))
{
return Some(DOM_ELEMENT_NODE);
}
match name {
b"Text" => Some(DOM_TEXT_NODE),
b"Comment" => Some(DOM_COMMENT_NODE),
b"DocumentFragment" => Some(DOM_FRAGMENT_NODE),
_ => None,
}
}

/// DOM node detection (jsdom / happy-dom). Restricted to values whose
/// class name matches a known DOM pattern so the `nodeType` prototype
/// getter is never invoked on unrelated objects.
Comment thread
robobun marked this conversation as resolved.
Outdated
#[inline(never)]
pub fn is_dom_node(global_this: &JSGlobalObject, value: JSValue) -> JsResult<bool> {
// Fast bail for plain `{}` / `Object.create(null)` / one-level classes:
// DOM nodes inherit through at least `Node` → `EventTarget`, so an
// object whose prototype is `Object.prototype` (grand-proto is null)
// cannot be one and skips the `calculatedClassName` walk below.
Comment thread
robobun marked this conversation as resolved.
Outdated
let proto = value.get_prototype(global_this);
if proto.is_empty_or_undefined_or_null() {
return Ok(false);
}
let grand = proto.get_prototype(global_this);
if grand.is_empty_or_undefined_or_null() {
return Ok(false);
}
Comment thread
claude[bot] marked this conversation as resolved.

let mut name_str = ZigString::init(b"");
value.get_class_name(global_this, &mut name_str)?;
let name_slice = name_str.to_slice();
let Some(expected) = dom_node_type_for_class_name(name_slice.slice()) else {
return Ok(false);
};
let confirmed = match value.get(global_this, "nodeType") {
Ok(Some(n)) if n.is_int32() => n.to_int32() == expected,
Ok(_) => false,
Err(_) => {
global_this.clear_exception_except_termination();
false
}
};
Ok(confirmed)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// `JSGlobalObject` is an opaque `UnsafeCell`-backed ZST handle; remaining
// params are by-value `JSValue`/scalars → `safe fn`.
unsafe extern "C" {
Expand Down Expand Up @@ -3470,6 +3546,7 @@
&mut remove_before_recurse,
),
Tag::JSX => self.print_jsx::<ENABLE_ANSI_COLORS>(writer_, value),
Tag::DOMNode => self.print_dom_node::<ENABLE_ANSI_COLORS>(writer_, value),
Tag::Object => self.print_object::<ENABLE_ANSI_COLORS>(writer_, value, js_type),
Tag::TypedArray => {
self.print_typed_array::<ENABLE_ANSI_COLORS>(writer_, value, js_type)
Expand Down Expand Up @@ -5449,6 +5526,215 @@
Ok(())
}

/// Serializes a DOM node (jsdom / happy-dom) as markup, mirroring the
/// `pretty-format` `DOMElement` plugin so jest-dom matcher messages and
/// snapshots render `<button id="x">text</button>` instead of the full
/// object graph.
Comment thread
robobun marked this conversation as resolved.
Outdated
#[inline(never)]
fn print_dom_node<const C: bool>(
&mut self,
writer_: &mut dyn bun_io::Write,
value: JSValue,
) -> JsResult<()> {
macro_rules! pf {
($s:literal) => {
pfmt!($s, C)
};
}
macro_rules! get_swallow {
($v:expr, $name:literal) => {
match $v.get(self.global_this, $name) {
Ok(v) => v,
Err(_) => {
self.global_this.clear_exception_except_termination();
None
}
}
};
}

let node_type = get_swallow!(value, "nodeType")
.filter(|v| v.is_int32())
.map(|v| v.to_int32())
.unwrap_or(0);

if matches!(node_type, DOM_TEXT_NODE | DOM_COMMENT_NODE) {
let data = get_swallow!(value, "data");
let text = match data {
Some(v) if v.is_string() => {
bun_core::OwnedString::new(v.to_bun_string(self.global_this)?)
}
_ => bun_core::OwnedString::new(bun_core::String::empty()),
};
if node_type == DOM_COMMENT_NODE {
let _ = write!(writer_, "{}<!--{}-->{}", pf!("<r><d>"), text, pf!("<r>"));
} else {
let _ = write!(writer_, "{}", text);
}
return Ok(());
}
Comment thread
robobun marked this conversation as resolved.

let (tag_utf8, is_fragment) = if node_type == DOM_FRAGMENT_NODE {
(
bun_core::ZigStringSlice::from_utf8_never_free(b"DocumentFragment"),
true,
)
} else {
let tag_name = get_swallow!(value, "tagName");
match tag_name {
Some(v) if v.is_string() => {
let s = v.get_zig_string(self.global_this)?;
let slice = s.to_slice();
let mut owned = slice.slice().to_vec();
owned.make_ascii_lowercase();
(bun_core::ZigStringSlice::init_owned(owned), false)
}
_ => (
bun_core::ZigStringSlice::from_utf8_never_free(b"unknown"),
false,
),
}
};
let tag_bytes = tag_utf8.slice();

if self.depth >= self.max_depth {
let _ = write!(
writer_,
"{}<{}{} \u{2026} {}/>{}",
pf!("<r><green>"),
bstr::BStr::new(tag_bytes),
pf!("<r>"),
pf!("<green>"),
pf!("<r>"),
);
return Ok(());
}

let _ = writer_.write_all(pf!("<r><green>").as_bytes());
let _ = writer_.write_all(b"<");
let _ = writer_.write_all(tag_bytes);
let _ = writer_.write_all(pf!("<r>").as_bytes());

let mut attrs_multiline = false;
if !is_fragment {
if let Some(attrs) = get_swallow!(value, "attributes") {
if attrs.is_cell() && attrs.is_object() {
if let Some(len_v) = get_swallow!(attrs, "length") {
if len_v.is_int32() {
let n = len_v.to_int32().max(0) as u32;
let mut pairs: Vec<(Vec<u8>, bun_core::OwnedString)> =
Vec::with_capacity(n.min(64) as usize);
for i in 0..n {
Comment thread
robobun marked this conversation as resolved.
let Ok(attr) = attrs.get_index(self.global_this, i) else {
self.global_this.clear_exception_except_termination();
continue;
};
if !attr.is_cell() || !attr.is_object() {
continue;
}
let Some(name) = get_swallow!(attr, "name") else {
continue;
};
if !name.is_string() {
continue;
}
let name_s = name.get_zig_string(self.global_this)?;
let name_owned = name_s.to_slice().slice().to_vec();
let val = get_swallow!(attr, "value")
.filter(|v| v.is_string())
.map(|v| v.to_bun_string(self.global_this))
.transpose()?
.map(bun_core::OwnedString::new)
.unwrap_or_else(|| {
bun_core::OwnedString::new(bun_core::String::empty())
});
pairs.push((name_owned, val));
}
pairs.sort_by(|a, b| a.0.cmp(&b.0));
Comment thread
claude[bot] marked this conversation as resolved.
attrs_multiline =
!self.single_line && !pairs.is_empty() && pairs.len() > 1;
for (name, val) in &pairs {
if attrs_multiline {
let _ = writer_.write_all(b"\n");
let _ = write_indent_n(self.indent + 1, writer_);
} else {
let _ = writer_.write_all(b" ");
}
let _ = write!(
writer_,
"{}{}{}={}\"{}\"{}",
pf!("<blue>"),
bstr::BStr::new(name),
pf!("<r><d>"),
pf!("<r><green>"),
val,
pf!("<r>"),
);

Check warning on line 5672 in src/jsc/ConsoleObject.rs

View check run for this annotation

Claude / Claude Code Review

Attribute values are not escaped in DOM node serialization

Nit: attribute values are written raw with no escaping, so `el.setAttribute("title", 'Say "Hi"')` renders as `<div title="Say "Hi"" />` — pretty-format's `DOMElement` plugin (which this claims to mirror) routes each prop through `printBasicValue` and emits `<div title="Say \"Hi\"" />`, and `print_jsx` in this file already does the equivalent by recursing through `self.format` with `quote_strings = true`. Same at `pretty_format.rs:1221-1230`. Consider escaping `\` and `"` in `val` (or routing it
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
}
if attrs_multiline {
let _ = writer_.write_all(b"\n");
let _ = write_indent_n(self.indent, writer_);
}
}
}
}
}
}

let children =
get_swallow!(value, "childNodes").filter(|v| v.is_cell() && v.is_object());
let child_len = children
.and_then(|c| get_swallow!(c, "length"))
.filter(|v| v.is_int32())
.map(|v| v.to_int32().max(0) as u32)
.unwrap_or(0);

if child_len == 0 {
if attrs_multiline {
let _ = write!(writer_, "{}/>{}", pf!("<green>"), pf!("<r>"));
} else {
let _ = write!(writer_, "{} />{}", pf!("<green>"), pf!("<r>"));
}
return Ok(());
}

let _ = write!(writer_, "{}>{}", pf!("<green>"), pf!("<r>"));
{
self.indent += 1;
self.depth += 1;
let _ind = defer_decrement!(self.indent);
let _dep = defer_decrement!(self.depth);
let children = children.expect("child_len > 0 implies Some");
for i in 0..child_len {
if !self.single_line {
let _ = writer_.write_all(b"\n");
let _ = write_indent_n(self.indent, writer_);
}
let Ok(child) = children.get_index(self.global_this, i) else {
self.global_this.clear_exception_except_termination();
continue;
};
if !child.is_cell() {
continue;
}
let tag = Tag::get_advanced(child, self.global_this, self.tag_opts())?;
self.format::<C>(tag, writer_, child, self.global_this)?;
}
}
if !self.single_line {
let _ = writer_.write_all(b"\n");
let _ = write_indent_n(self.indent, writer_);
}
let _ = write!(
writer_,
"{}</{}>{}",
pf!("<green>"),
bstr::BStr::new(tag_bytes),
pf!("<r>"),
);
Ok(())
}

#[inline(never)]
fn print_object<const C: bool>(
&mut self,
Expand Down
Loading
Loading