Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
109 changes: 109 additions & 0 deletions cpp/quickjs/bindings/ElementBindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1236,6 +1236,113 @@ JSValue js_el_insertAdjacentHTML(JSContext* ctx, JSValue this_val, int argc, JSV
return JS_UNDEFINED;
}

JSValue js_el_insertAdjacentElement(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
auto* el = unwrap_element(ctx, this_val);
if (!el || argc < 2) return JS_NULL;
const char* position = JS_ToCString(ctx, argv[0]);
if (!position) return JS_NULL;
std::string pos(position);
JS_FreeCString(ctx, position);

auto* inserted_el = unwrap_element(ctx, argv[1]);
if (!inserted_el) return JS_NULL;
lxb_dom_node_t* inserted = lxb_dom_interface_node(inserted_el);

lxb_dom_node_t* node = lxb_dom_interface_node(el);
lxb_dom_node_t* parent = nullptr;
lxb_dom_node_t* prev_sib = nullptr;
lxb_dom_node_t* next_sib = nullptr;

if (pos == "beforebegin") {
parent = node->parent;
if (!parent) return JS_NULL;
prev_sib = node->prev;
lxb_dom_node_insert_before(node, inserted);
next_sib = node;
} else if (pos == "afterbegin") {
parent = node;
lxb_dom_node_t* ref = node->first_child;
if (ref) lxb_dom_node_insert_before(ref, inserted); else lxb_dom_node_insert_child(node, inserted);
next_sib = ref;
} else if (pos == "beforeend") {
parent = node;
prev_sib = node->last_child;
lxb_dom_node_insert_child(node, inserted);
} else if (pos == "afterend") {
parent = node->parent;
if (!parent) return JS_NULL;
prev_sib = node;
lxb_dom_node_t* ref = node->next;
if (ref) lxb_dom_node_insert_before(ref, inserted); else lxb_dom_node_insert_child(parent, inserted);
next_sib = ref;
} else {
return throw_dom_exception(ctx, "SyntaxError", ("invalid insertAdjacentElement position '" + pos + "'").c_str());
}

auto* rctx = get_ctx(ctx);
if (rctx && rctx->mutation_observers && !rctx->mutation_observers->empty()) {
rctx->mutation_observers->notifyChildList(ctx, parent, {inserted}, {}, prev_sib, next_sib);
}
return JS_DupValue(ctx, argv[1]);
}

JSValue js_el_insertAdjacentText(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
auto* el = unwrap_element(ctx, this_val);
if (!el || argc < 2) return JS_UNDEFINED;
const char* position = JS_ToCString(ctx, argv[0]);
const char* text = JS_ToCString(ctx, argv[1]);
if (!position || !text) {
if (position) JS_FreeCString(ctx, position);
if (text) JS_FreeCString(ctx, text);
return JS_UNDEFINED;
}
std::string pos(position);
JS_FreeCString(ctx, position);

lxb_dom_node_t* node = lxb_dom_interface_node(el);
void* text_node_v = doc_for_node(ctx, node)->createTextNode(text);
JS_FreeCString(ctx, text);
if (!text_node_v) return JS_UNDEFINED;
auto* text_node = static_cast<lxb_dom_node_t*>(text_node_v);

lxb_dom_node_t* parent = nullptr;
lxb_dom_node_t* prev_sib = nullptr;
lxb_dom_node_t* next_sib = nullptr;

if (pos == "beforebegin") {
parent = node->parent;
if (!parent) { lxb_dom_node_destroy_deep(text_node); return JS_UNDEFINED; }
prev_sib = node->prev;
lxb_dom_node_insert_before(node, text_node);
next_sib = node;
} else if (pos == "afterbegin") {
parent = node;
lxb_dom_node_t* ref = node->first_child;
if (ref) lxb_dom_node_insert_before(ref, text_node); else lxb_dom_node_insert_child(node, text_node);
next_sib = ref;
} else if (pos == "beforeend") {
parent = node;
prev_sib = node->last_child;
lxb_dom_node_insert_child(node, text_node);
} else if (pos == "afterend") {
parent = node->parent;
if (!parent) { lxb_dom_node_destroy_deep(text_node); return JS_UNDEFINED; }
prev_sib = node;
lxb_dom_node_t* ref = node->next;
if (ref) lxb_dom_node_insert_before(ref, text_node); else lxb_dom_node_insert_child(parent, text_node);
next_sib = ref;
} else {
lxb_dom_node_destroy_deep(text_node);
return throw_dom_exception(ctx, "SyntaxError", ("invalid insertAdjacentText position '" + pos + "'").c_str());
}

auto* rctx = get_ctx(ctx);
if (rctx && rctx->mutation_observers && !rctx->mutation_observers->empty()) {
rctx->mutation_observers->notifyChildList(ctx, parent, {text_node}, {}, prev_sib, next_sib);
}
return JS_UNDEFINED;
}

JSValue js_el_matches(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
auto* el = unwrap_element(ctx, this_val);
if (!el || argc < 1) return JS_FALSE;
Expand Down Expand Up @@ -1603,6 +1710,8 @@ void ElementBindings::install(JSContext* ctx) {
JS_SetPropertyStr(ctx, proto, "getElementsByTagName", JS_NewCFunction(ctx, js_el_getElementsByTagName, "getElementsByTagName", 1));
JS_SetPropertyStr(ctx, proto, "closest", JS_NewCFunction(ctx, js_el_closest, "closest", 1));
JS_SetPropertyStr(ctx, proto, "insertAdjacentHTML", JS_NewCFunction(ctx, js_el_insertAdjacentHTML, "insertAdjacentHTML", 2));
JS_SetPropertyStr(ctx, proto, "insertAdjacentElement", JS_NewCFunction(ctx, js_el_insertAdjacentElement, "insertAdjacentElement", 2));
JS_SetPropertyStr(ctx, proto, "insertAdjacentText", JS_NewCFunction(ctx, js_el_insertAdjacentText, "insertAdjacentText", 2));
JS_SetPropertyStr(ctx, proto, "append", JS_NewCFunction(ctx, js_el_append, "append", 0));
JS_SetPropertyStr(ctx, proto, "prepend", JS_NewCFunction(ctx, js_el_prepend, "prepend", 0));
JS_SetPropertyStr(ctx, proto, "getBoundingClientRect", JS_NewCFunction(ctx, js_el_getBoundingClientRect, "getBoundingClientRect", 0));
Expand Down
12 changes: 8 additions & 4 deletions cpp/quickjs/bindings/EventBindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ JSValue js_doc_dispatchEvent(JSContext* ctx, JSValue, int argc, JSValue* argv) {
return dispatch_event_on_target(ctx, rctx, argv[0], node);
}

const char* kHandlerEventTypes[] = { "click", "load", "error", "unhandledrejection" };
const char* kHandlerEventTypes[] = { "click", "load", "error", "unhandledrejection", "change", "input", "submit", "reset" };

JSValue get_handler_prop_for_node(JSContext* ctx, RuntimeContext* rctx, void* node, const std::string& event_type) {
if (!rctx || !node) return JS_NULL;
Expand Down Expand Up @@ -684,9 +684,13 @@ void EventBindings::install(JSContext* ctx) {
JS_SetPropertyStr(ctx, node_proto, "removeEventListener", JS_NewCFunction(ctx, js_el_removeEventListener, "removeEventListener", 2));
JS_SetPropertyStr(ctx, node_proto, "dispatchEvent", JS_NewCFunction(ctx, js_el_dispatchEvent, "dispatchEvent", 1));

define_element_handler(ctx, node_proto, "onclick", 0);
define_element_handler(ctx, node_proto, "onload", 1);
define_element_handler(ctx, node_proto, "onerror", 2);
define_element_handler(ctx, node_proto, "onclick", 0);
define_element_handler(ctx, node_proto, "onload", 1);
define_element_handler(ctx, node_proto, "onerror", 2);
define_element_handler(ctx, node_proto, "onchange", 4);
define_element_handler(ctx, node_proto, "oninput", 5);
define_element_handler(ctx, node_proto, "onsubmit", 6);
define_element_handler(ctx, node_proto, "onreset", 7);
JS_FreeValue(ctx, node_proto);

JSValue element_proto = JS_GetClassProto(ctx, js_element_class_id);
Expand Down
9 changes: 9 additions & 0 deletions cpp/quickjs/bindings/WindowBindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,15 @@ const char* kLocationBootstrapScript = R"JS(
globalThis.Location = Location;
globalThis.location = new Location(globalThis.__initialHref);
delete globalThis.__initialHref;

if (typeof document !== 'undefined') {
Object.defineProperty(document, 'location', {
get: function() { return location; },
set: function(v) { location.href = v; },
enumerable: true,
configurable: true,
});
}
})();
)JS";

Expand Down
24 changes: 24 additions & 0 deletions example/src/__harness__/JSDOM.events.harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,30 @@ describe('JSDOM events', () => {
expect(JSON.parse(result)).toEqual({ log: [], onclick: null });
});

it('el.onchange/oninput/onsubmit/onreset fire like el.onclick', async () => {
dom = JSDOM.create(`
<html><body>
<input id="i" />
<form id="f"><input id="submit-btn" type="submit" /></form>
</body></html>
`);
const result = await dom.evaluate(`
const input = document.getElementById('i');
const form = document.getElementById('f');
const log = [];
input.onchange = () => log.push('change');
input.oninput = () => log.push('input');
form.onsubmit = (e) => { e.preventDefault(); log.push('submit'); };
form.onreset = () => log.push('reset');
input.dispatchEvent(new Event('change'));
input.dispatchEvent(new Event('input'));
form.dispatchEvent(new Event('submit', { cancelable: true }));
form.dispatchEvent(new Event('reset'));
JSON.stringify(log);
`);
expect(JSON.parse(result)).toEqual(['change', 'input', 'submit', 'reset']);
});

it('window.onload and document.onload share one handler slot', async () => {
dom = JSDOM.create('<html><body></body></html>');
const result = await dom.evaluate(`
Expand Down
50 changes: 50 additions & 0 deletions example/src/__harness__/JSDOM.mutation.harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,56 @@ describe('JSDOM DOM mutation', () => {
);
});

it('insertAdjacentElement() inserts an existing element at the four standard positions and returns it', async () => {
dom = JSDOM.create('<html><body><div id="d"><span>mid</span></div></body></html>');
const result = await dom.evaluate(`
const div = document.getElementById('d');
function make(id) { const e = document.createElement('i'); e.id = id; return e; }
const ab = div.insertAdjacentElement('afterbegin', make('ab'));
const be = div.insertAdjacentElement('beforeend', make('be'));
const bb = div.insertAdjacentElement('beforebegin', make('bb'));
const ae = div.insertAdjacentElement('afterend', make('ae'));
JSON.stringify({
html: document.body.innerHTML,
returnedSameElement: ab.id === 'ab' && be.id === 'be' && bb.id === 'bb' && ae.id === 'ae',
});
`);
expect(JSON.parse(result)).toEqual({
html: '<i id="bb"></i><div id="d"><i id="ab"></i><span>mid</span><i id="be"></i></div><i id="ae"></i>',
returnedSameElement: true,
});
});

it('insertAdjacentElement() with an invalid position throws a SyntaxError DOMException and returns null on detached nodes', async () => {
dom = JSDOM.create('<html><body><div id="d"></div></body></html>');
const result = await dom.evaluate(`
const div = document.getElementById('d');
const detached = document.createElement('span');
let caught;
try { div.insertAdjacentElement('nowhere', document.createElement('i')); }
catch (e) { caught = { name: e.name, isDOMException: e instanceof DOMException }; }
const returnedForDetachedBeforebegin = detached.insertAdjacentElement('beforebegin', document.createElement('i'));
JSON.stringify({ caught, returnedForDetachedBeforebegin });
`);
expect(JSON.parse(result)).toEqual({
caught: { name: 'SyntaxError', isDOMException: true },
returnedForDetachedBeforebegin: null,
});
});

it('insertAdjacentText() inserts a text node at the four standard positions', async () => {
dom = JSDOM.create('<html><body><div id="d"><span>mid</span></div></body></html>');
const result = await dom.evaluate(`
const div = document.getElementById('d');
div.insertAdjacentText('afterbegin', 'ab');
div.insertAdjacentText('beforeend', 'be');
div.insertAdjacentText('beforebegin', 'bb');
div.insertAdjacentText('afterend', 'ae');
document.body.innerHTML;
`);
expect(result).toBe('bb<div id="d">ab<span>mid</span>be</div>ae');
});

it('document.createComment()/createDocumentFragment() create nodes usable with appendChild', async () => {
dom = JSDOM.create('<html><body><div id="d"></div></body></html>');
const result = await dom.evaluate(`
Expand Down
30 changes: 30 additions & 0 deletions example/src/__harness__/JSDOM.navigator.harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,36 @@ describe('JSDOM navigator/matchMedia', () => {
expect(JSON.parse(result)).toEqual({ media: '(min-width: 600px)', matches: false, changeFired: false });
});

it('document.location mirrors window.location as the same instance', async () => {
dom = JSDOM.create('<html><body></body></html>', { url: 'https://example.com/widget?a=1' });
const result = await dom.evaluate(`
JSON.stringify({
sameInstance: document.location === location,
href: document.location.href,
search: document.location.search,
pathname: document.location.pathname,
});
`);
expect(JSON.parse(result)).toEqual({
sameInstance: true,
href: 'https://example.com/widget?a=1',
search: '?a=1',
pathname: '/widget',
});
});

it('setting document.location navigates like window.location.href', async () => {
dom = JSDOM.create('<html><body></body></html>', { url: 'https://example.com/' });
const result = await dom.evaluate(`
document.location = '/next-page?x=1';
JSON.stringify({ href: location.href, docHref: document.location.href });
`);
expect(JSON.parse(result)).toEqual({
href: 'https://example.com/next-page?x=1',
docHref: 'https://example.com/next-page?x=1',
});
});

it('history.pushState/replaceState track state and length without firing popstate', async () => {
dom = JSDOM.create('<html><body></body></html>', { url: 'https://example.com/' });
const result = await dom.evaluate(`
Expand Down
Loading
Loading