From 7b0cd521b1369e226a1bd69658fdf9ac05aeb23b Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Fri, 24 Jul 2026 14:27:19 -0300 Subject: [PATCH 01/11] feat(dom): add v0.17 ergonomics gaps Boolean attribute reflection (disabled/required/readOnly/multiple/ autofocus/selected), form.reset(), select.options/.selectedIndex/ .selectedOptions, CSS.escape()/CSS.supports(), and document.visibilityState. Gap list compiled by diffing against jsdom's own supported interfaces, scoped to what CMS embedded widget scripts actually reach for. Co-Authored-By: Claude Sonnet 5 --- cpp/quickjs/bindings/CSSOMBindings.cpp | 50 ++++++++++ cpp/quickjs/bindings/DocumentBindings.cpp | 1 + cpp/quickjs/bindings/ElementBindings.cpp | 62 +++++++++++++ cpp/quickjs/bindings/FormBindings.cpp | 68 ++++++++++++++ docs/overview.md | 32 +++++++ .../__harness__/JSDOM.attributes.harness.ts | 67 +++++++++++++ .../src/__harness__/JSDOM.cssom.harness.ts | 48 ++++++++++ .../src/__harness__/JSDOM.forms.harness.ts | 93 +++++++++++++++++++ .../__harness__/JSDOM.lifecycle.harness.ts | 11 +++ 9 files changed, 432 insertions(+) diff --git a/cpp/quickjs/bindings/CSSOMBindings.cpp b/cpp/quickjs/bindings/CSSOMBindings.cpp index e945857..2d0bea5 100644 --- a/cpp/quickjs/bindings/CSSOMBindings.cpp +++ b/cpp/quickjs/bindings/CSSOMBindings.cpp @@ -201,6 +201,56 @@ const char* kCSSOMBootstrapScript = R"JS( globalThis.CSSStyleRule = CSSStyleRule; globalThis.CSSStyleSheet = CSSStyleSheet; + function cssEscape(value) { + var string = String(value); + var length = string.length; + var index = -1; + var result = ''; + var firstCodeUnit = string.charCodeAt(0); + + if (length === 1 && firstCodeUnit === 0x002D) { + return '\\' + string; + } + + while (++index < length) { + var codeUnit = string.charCodeAt(index); + if (codeUnit === 0x0000) { + result += '\uFFFD'; + continue; + } + if ( + (codeUnit >= 0x0001 && codeUnit <= 0x001F) || codeUnit === 0x007F || + (index === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) || + (index === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit === 0x002D) + ) { + result += '\\' + codeUnit.toString(16) + ' '; + continue; + } + if ( + codeUnit >= 0x0080 || codeUnit === 0x002D || codeUnit === 0x005F || + (codeUnit >= 0x0030 && codeUnit <= 0x0039) || + (codeUnit >= 0x0041 && codeUnit <= 0x005A) || + (codeUnit >= 0x0061 && codeUnit <= 0x007A) + ) { + result += string.charAt(index); + continue; + } + result += '\\' + string.charAt(index); + } + return result; + } + + globalThis.CSS = { + escape: cssEscape, + supports: function(propertyOrCondition, value) { + if (arguments.length >= 2) { + return typeof propertyOrCondition === 'string' && propertyOrCondition.length > 0 && + value !== undefined && String(value).length > 0; + } + return typeof propertyOrCondition === 'string' && propertyOrCondition.trim().length > 0; + }, + }; + Object.defineProperty(Element.prototype, 'sheet', { get: function() { if (this.tagName !== 'STYLE') return null; diff --git a/cpp/quickjs/bindings/DocumentBindings.cpp b/cpp/quickjs/bindings/DocumentBindings.cpp index 5e54f19..92b8e84 100644 --- a/cpp/quickjs/bindings/DocumentBindings.cpp +++ b/cpp/quickjs/bindings/DocumentBindings.cpp @@ -360,6 +360,7 @@ void DocumentBindings::install(JSContext* ctx) { RuntimeContext* rctx = get_ctx(ctx); bool hidden = !(rctx && rctx->pretend_to_be_visual); JS_SetPropertyStr(ctx, doc, "hidden", JS_NewBool(ctx, hidden)); + JS_SetPropertyStr(ctx, doc, "visibilityState", JS_NewString(ctx, hidden ? "hidden" : "visible")); JS_SetPropertyStr(ctx, doc, "nodeType", JS_NewInt32(ctx, 9 /* DOCUMENT_NODE */)); JS_SetPropertyStr(ctx, doc, "nodeName", JS_NewString(ctx, "#document")); JS_SetPropertyStr(ctx, doc, "ownerDocument", JS_NULL); diff --git a/cpp/quickjs/bindings/ElementBindings.cpp b/cpp/quickjs/bindings/ElementBindings.cpp index 879f57b..4e0bdf3 100644 --- a/cpp/quickjs/bindings/ElementBindings.cpp +++ b/cpp/quickjs/bindings/ElementBindings.cpp @@ -303,6 +303,62 @@ JSValue js_el_set_checked(JSContext* ctx, JSValue this_val, JSValue val) { return JS_UNDEFINED; } +JSValue bool_attr_get(JSContext* ctx, JSValue this_val, const char* attr, size_t attr_len) { + auto* el = unwrap_element(ctx, this_val); + if (!el) return JS_FALSE; + bool has = lxb_dom_element_has_attribute(el, reinterpret_cast(attr), attr_len); + return JS_NewBool(ctx, has); +} + +JSValue bool_attr_set(JSContext* ctx, JSValue this_val, JSValue val, const char* attr, size_t attr_len) { + auto* el = unwrap_element(ctx, this_val); + if (!el) return JS_UNDEFINED; + bool on = JS_ToBool(ctx, val) > 0; + auto* attr_name = reinterpret_cast(attr); + bool has = lxb_dom_element_has_attribute(el, attr_name, attr_len); + + auto* rctx = get_ctx(ctx); + bool has_obs = rctx && rctx->mutation_observers && !rctx->mutation_observers->empty(); + std::optional old_val; + if (has_obs && has && rctx->mutation_observers->hasAttributeOldValueObserver()) { + size_t len = 0; + const lxb_char_t* v = lxb_dom_element_get_attribute(el, attr_name, attr_len, &len); + if (v) old_val = std::string(reinterpret_cast(v), len); + } + + if (on && !has) { + lxb_dom_element_set_attribute(el, attr_name, attr_len, reinterpret_cast(""), 0); + if (has_obs) { + rctx->mutation_observers->notifyAttribute(ctx, lxb_dom_interface_node(el), attr, std::nullopt); + } + } else if (!on && has) { + lxb_dom_element_remove_attribute(el, attr_name, attr_len); + if (has_obs) { + rctx->mutation_observers->notifyAttribute(ctx, lxb_dom_interface_node(el), attr, old_val); + } + } + + return JS_UNDEFINED; +} + +JSValue js_el_get_disabled(JSContext* ctx, JSValue this_val) { return bool_attr_get(ctx, this_val, "disabled", 8); } +JSValue js_el_set_disabled(JSContext* ctx, JSValue this_val, JSValue val) { return bool_attr_set(ctx, this_val, val, "disabled", 8); } + +JSValue js_el_get_required(JSContext* ctx, JSValue this_val) { return bool_attr_get(ctx, this_val, "required", 8); } +JSValue js_el_set_required(JSContext* ctx, JSValue this_val, JSValue val) { return bool_attr_set(ctx, this_val, val, "required", 8); } + +JSValue js_el_get_readOnly(JSContext* ctx, JSValue this_val) { return bool_attr_get(ctx, this_val, "readonly", 8); } +JSValue js_el_set_readOnly(JSContext* ctx, JSValue this_val, JSValue val) { return bool_attr_set(ctx, this_val, val, "readonly", 8); } + +JSValue js_el_get_multiple(JSContext* ctx, JSValue this_val) { return bool_attr_get(ctx, this_val, "multiple", 8); } +JSValue js_el_set_multiple(JSContext* ctx, JSValue this_val, JSValue val) { return bool_attr_set(ctx, this_val, val, "multiple", 8); } + +JSValue js_el_get_autofocus(JSContext* ctx, JSValue this_val) { return bool_attr_get(ctx, this_val, "autofocus", 9); } +JSValue js_el_set_autofocus(JSContext* ctx, JSValue this_val, JSValue val) { return bool_attr_set(ctx, this_val, val, "autofocus", 9); } + +JSValue js_el_get_selected(JSContext* ctx, JSValue this_val) { return bool_attr_get(ctx, this_val, "selected", 8); } +JSValue js_el_set_selected(JSContext* ctx, JSValue this_val, JSValue val) { return bool_attr_set(ctx, this_val, val, "selected", 8); } + JSValue js_el_get_textContent(JSContext* ctx, JSValue this_val) { lxb_dom_node_t* node = unwrap_node(ctx, this_val); if (!node) return JS_NewString(ctx, ""); @@ -1513,6 +1569,12 @@ void ElementBindings::install(JSContext* ctx) { define_prop(ctx, proto, "className", js_el_get_className, js_el_set_className); define_prop(ctx, proto, "value", js_el_get_value, js_el_set_value); define_prop(ctx, proto, "checked", js_el_get_checked, js_el_set_checked); + define_prop(ctx, proto, "disabled", js_el_get_disabled, js_el_set_disabled); + define_prop(ctx, proto, "required", js_el_get_required, js_el_set_required); + define_prop(ctx, proto, "readOnly", js_el_get_readOnly, js_el_set_readOnly); + define_prop(ctx, proto, "multiple", js_el_get_multiple, js_el_set_multiple); + define_prop(ctx, proto, "autofocus", js_el_get_autofocus, js_el_set_autofocus); + define_prop(ctx, proto, "selected", js_el_get_selected, js_el_set_selected); define_prop(ctx, proto, "innerHTML", js_el_get_innerHTML, js_el_set_innerHTML); define_prop(ctx, proto, "outerHTML", js_el_get_outerHTML, nullptr); define_prop(ctx, proto, "innerText", js_el_get_textContent, js_el_set_textContent); diff --git a/cpp/quickjs/bindings/FormBindings.cpp b/cpp/quickjs/bindings/FormBindings.cpp index e6e38aa..b19b9fc 100644 --- a/cpp/quickjs/bindings/FormBindings.cpp +++ b/cpp/quickjs/bindings/FormBindings.cpp @@ -125,6 +125,74 @@ const char* kFormBootstrapScript = R"JS( // Per spec, submit() bypasses the "submit" event and constraint validation. // There is no real navigation in this sandbox, so this is intentionally inert. }; + Element.prototype.reset = function() { + if (!isFormElement(this)) return; + this.dispatchEvent(new Event('reset', { bubbles: true, cancelable: true })); + }; + + // ── select.options / .selectedIndex / .selectedOptions ──────────────────── + function isSelectElement(el) { + return !!el && el.tagName === 'SELECT'; + } + + function selectOptionsArray(select) { + return Array.prototype.slice.call(select.querySelectorAll('option')); + } + + Object.defineProperty(Element.prototype, 'options', { + configurable: true, + get: function() { + if (!isSelectElement(this)) return undefined; + var select = this; + var opts = selectOptionsArray(select); + opts.item = function(index) { return this[index] !== undefined ? this[index] : null; }; + opts.namedItem = function(name) { + for (var i = 0; i < this.length; i++) { + if (this[i].id === name || this[i].getAttribute('name') === name) return this[i]; + } + return null; + }; + opts.add = function(option, before) { + var refNode = null; + if (typeof before === 'number') refNode = this[before] || null; + else if (before) refNode = before; + if (refNode) select.insertBefore(option, refNode); + else select.appendChild(option); + }; + opts.remove = function(index) { + var target = this[index]; + if (target) select.removeChild(target); + }; + return opts; + }, + }); + + Object.defineProperty(Element.prototype, 'selectedIndex', { + configurable: true, + get: function() { + if (!isSelectElement(this)) return -1; + var opts = selectOptionsArray(this); + for (var i = 0; i < opts.length; i++) { + if (opts[i].selected) return i; + } + return opts.length > 0 ? 0 : -1; + }, + set: function(index) { + if (!isSelectElement(this)) return; + var opts = selectOptionsArray(this); + for (var i = 0; i < opts.length; i++) { + opts[i].selected = (i === index); + } + }, + }); + + Object.defineProperty(Element.prototype, 'selectedOptions', { + configurable: true, + get: function() { + if (!isSelectElement(this)) return undefined; + return selectOptionsArray(this).filter(function(o) { return o.selected; }); + }, + }); // ── Constraint Validation API (ValidityState, checkValidity, ...) ───────── // Covers the subset real-world CMS forms actually hit: required, pattern, diff --git a/docs/overview.md b/docs/overview.md index b084a24..01bef12 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -497,6 +497,38 @@ dom.dispose() // ← always pair with create() - [x] `Element.prototype.getClientRects()`/`webkitMatchesSelector()`. - [x] `window.reportError()`. +### v0.17 — Boolean Attribute Reflection & Select Ergonomics +> A gap list compiled by diffing this project against jsdom's own supported +> interface set (`lib/jsdom/living/interfaces.js`), scoped down to what a +> real-world CMS embedded script (countdown timer, personalized greeting, +> discount badge) actually reaches for — dropping everything layout/rendering +> or full-page-navigation related, which stays out of scope for the reasons +> given throughout this roadmap. +- [x] `element.disabled` / `.required` / `.readOnly` / `.multiple` / + `.autofocus` / `.selected` as direct boolean properties (same + attribute-presence-as-truthiness convention `.checked` already used) — + previously only reachable via `getAttribute`/`setAttribute`, which is + not how real-world scripts disable a button or mark a field required. +- [x] `form.reset()` — dispatches the cancelable `reset` event (what widget + scripts actually listen for to run their own cleanup). Does not revert + field values: this sandbox has no separate default-value storage + (`element.value`/`.checked` read/write the live attribute directly), so + a script that already reassigned `.value` has overwritten its own + default with nothing left to revert to — the same simplification + `submit()` already made for the "submit" event. +- [x] `select.options` (`HTMLOptionsCollection`-like: `item()`/`namedItem()`/ + `add()`/`remove()`) / `.selectedIndex` / `.selectedOptions` — `select.value` + already worked; this rounds out the rest of the dropdown-widget surface. + Static array, not a live collection, same trade-off as `form.elements`/ + `element.labels`. +- [x] `CSS.escape()` — ports the CSSOM spec's own reference algorithm, so it's + exact rather than a best-effort stub; used by scripts building selectors + from dynamic IDs (`'#' + CSS.escape(dynamicId)`). `CSS.supports()` has + no real CSS engine to validate against, so it reports "supported" for + any syntactically-plausible property/value pair instead of parsing CSS. +- [x] `document.visibilityState` (`'visible'` / `'hidden'`) — the companion to + `document.hidden`, which already existed; scripts commonly check both. + --- ## Repository Structure diff --git a/example/src/__harness__/JSDOM.attributes.harness.ts b/example/src/__harness__/JSDOM.attributes.harness.ts index bdb2824..99892ff 100644 --- a/example/src/__harness__/JSDOM.attributes.harness.ts +++ b/example/src/__harness__/JSDOM.attributes.harness.ts @@ -111,6 +111,73 @@ describe('JSDOM attributes/dataset/style', () => { }); }); + it('disabled/required/readOnly/multiple/autofocus/selected reflect as boolean properties', async () => { + dom = JSDOM.create(` + + + + + + `); + const result = await dom.evaluate(` + const btn = document.getElementById('btn'); + const inp = document.getElementById('inp'); + const sel = document.getElementById('sel'); + const opt2 = document.getElementById('opt2'); + + const before = { + btnDisabled: btn.disabled, + inpRequired: inp.required, + inpReadOnly: inp.readOnly, + selMultiple: sel.multiple, + opt2Selected: opt2.selected, + }; + + btn.disabled = true; + inp.required = true; + inp.readOnly = true; + inp.autofocus = true; + opt2.selected = false; + + const after = { + btnDisabled: btn.disabled, + btnDisabledAttr: btn.getAttribute('disabled'), + inpRequired: inp.required, + inpReadOnly: inp.readOnly, + inpAutofocus: inp.autofocus, + opt2Selected: opt2.selected, + opt2SelectedAttr: opt2.hasAttribute('selected'), + }; + + btn.disabled = false; + const afterRemove = { btnDisabled: btn.disabled, btnDisabledAttr: btn.getAttribute('disabled') }; + + JSON.stringify({ before, after, afterRemove }); + `); + expect(JSON.parse(result)).toEqual({ + before: { + btnDisabled: false, + inpRequired: false, + inpReadOnly: false, + selMultiple: true, + opt2Selected: true, + }, + after: { + btnDisabled: true, + btnDisabledAttr: '', + inpRequired: true, + inpReadOnly: true, + inpAutofocus: true, + opt2Selected: false, + opt2SelectedAttr: false, + }, + afterRemove: { btnDisabled: false, btnDisabledAttr: null }, + }); + }); + it('getBoundingClientRect() returns a zeroed rect instead of throwing', async () => { dom = JSDOM.create('
'); const result = await dom.evaluate(` diff --git a/example/src/__harness__/JSDOM.cssom.harness.ts b/example/src/__harness__/JSDOM.cssom.harness.ts index 10e7452..1d54704 100644 --- a/example/src/__harness__/JSDOM.cssom.harness.ts +++ b/example/src/__harness__/JSDOM.cssom.harness.ts @@ -143,4 +143,52 @@ describe('JSDOM CSSOM (document.styleSheets / CSSStyleRule)', () => { `); expect(JSON.parse(result)).toEqual({ visibility: 'visible', opacity: '1', caught: 'TypeError' }); }); + + it('CSS.escape() escapes special characters and a leading digit/hyphen-digit per the CSSOM spec', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + JSON.stringify({ + idWithColon: CSS.escape('a:b'), + leadingDigit: CSS.escape('1a'), + leadingHyphenDigit: CSS.escape('-1a'), + lonelyHyphen: CSS.escape('-'), + plain: CSS.escape('plain-id_1'), + }); + `); + expect(JSON.parse(result)).toEqual({ + idWithColon: 'a\\:b', + leadingDigit: '\\31 a', + leadingHyphenDigit: '-\\31 a', + lonelyHyphen: '\\-', + plain: 'plain-id_1', + }); + }); + + it('CSS.escape() output round-trips through querySelector on a dynamic id', async () => { + dom = JSDOM.create('
'); + const result = await dom.evaluate(` + const dynamicId = 'weird:id.with.dots'; + const el = document.querySelector('#' + CSS.escape(dynamicId)); + String(el && el.id); + `); + expect(result).toBe('weird:id.with.dots'); + }); + + it('CSS.supports() reports true for a plausible property/value pair and false for empty input', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + JSON.stringify({ + withValue: CSS.supports('display', 'flex'), + emptyValue: CSS.supports('display', ''), + conditionText: CSS.supports('display: flex'), + emptyCondition: CSS.supports(''), + }); + `); + expect(JSON.parse(result)).toEqual({ + withValue: true, + emptyValue: false, + conditionText: true, + emptyCondition: false, + }); + }); }); diff --git a/example/src/__harness__/JSDOM.forms.harness.ts b/example/src/__harness__/JSDOM.forms.harness.ts index 5996da9..ded6b6b 100644 --- a/example/src/__harness__/JSDOM.forms.harness.ts +++ b/example/src/__harness__/JSDOM.forms.harness.ts @@ -415,4 +415,97 @@ describe('JSDOM form elements', () => { `); expect(JSON.parse(result)).toEqual({ id: 'a"b\\c', labels: ['Weird id label'] }); }); + + it('form.reset() dispatches a cancelable "reset" event and no-ops on non-form elements', async () => { + dom = JSDOM.create('
'); + const result = await dom.evaluate(` + const form = document.getElementById('f'); + const div = document.getElementById('d'); + let fired = 0; + let receivedCancelable; + form.addEventListener('reset', function(e) { + fired++; + receivedCancelable = e.cancelable; + }); + form.reset(); + div.reset(); + JSON.stringify({ fired, receivedCancelable }); + `); + expect(JSON.parse(result)).toEqual({ fired: 1, receivedCancelable: true }); + }); + + it('select.options exposes item()/namedItem()/add()/remove() over the option elements', async () => { + dom = JSDOM.create(` + + + + `); + const result = await dom.evaluate(` + const sel = document.getElementById('sel'); + const before = { + length: sel.options.length, + item0: sel.options.item(0).id, + byName: sel.options.namedItem('bee').id, + byId: sel.options.namedItem('a').id, + missing: sel.options.namedItem('nope'), + }; + + const created = document.createElement('option'); + created.id = 'c'; + created.value = 'c'; + sel.options.add(created); + const afterAdd = sel.options.length; + + sel.options.remove(0); + const afterRemove = { length: sel.options.length, ids: sel.options.item(0).id + ',' + sel.options.item(1).id }; + + JSON.stringify({ before, afterAdd, afterRemove }); + `); + expect(JSON.parse(result)).toEqual({ + before: { length: 2, item0: 'a', byName: 'b', byId: 'a', missing: null }, + afterAdd: 3, + afterRemove: { length: 2, ids: 'b,c' }, + }); + }); + + it('select.selectedIndex/.selectedOptions default to the first option and follow .selected writes', async () => { + dom = JSDOM.create(` + + + + + `); + const result = await dom.evaluate(` + const sel = document.getElementById('sel'); + const multi = document.getElementById('multi'); + + const defaultIndex = sel.selectedIndex; + sel.selectedIndex = 2; + const afterSet = { + index: sel.selectedIndex, + selectedOptions: sel.selectedOptions.map((o) => o.id), + cSelected: document.getElementById('c').selected, + aSelected: document.getElementById('a').selected, + }; + + const multiSelectedOptions = multi.selectedOptions.map((o) => o.id); + + JSON.stringify({ defaultIndex, afterSet, multiSelectedOptions }); + `); + expect(JSON.parse(result)).toEqual({ + defaultIndex: 0, + afterSet: { index: 2, selectedOptions: ['c'], cSelected: true, aSelected: false }, + multiSelectedOptions: ['x', 'y'], + }); + }); }); diff --git a/example/src/__harness__/JSDOM.lifecycle.harness.ts b/example/src/__harness__/JSDOM.lifecycle.harness.ts index f9327da..f9420e8 100644 --- a/example/src/__harness__/JSDOM.lifecycle.harness.ts +++ b/example/src/__harness__/JSDOM.lifecycle.harness.ts @@ -113,6 +113,17 @@ describe('JSDOM lifecycle', () => { expect(visibleResult).toBe('false'); }); + it('document.visibilityState mirrors document.hidden', async () => { + dom = JSDOM.create(''); + const defaultResult = await dom.evaluate('document.visibilityState'); + expect(defaultResult).toBe('hidden'); + dom.dispose(); + + dom = JSDOM.create('', { pretendToBeVisual: true }); + const visibleResult = await dom.evaluate('document.visibilityState'); + expect(visibleResult).toBe('visible'); + }); + it('evaluate() rejects with a clear error when called reentrantly from a callback', async () => { dom = JSDOM.create('', { onFetch: async () => { From bd9e8d328c7cca76ea2d5f6eae50c52a4da18a62 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Fri, 24 Jul 2026 14:27:26 -0300 Subject: [PATCH 02/11] feat(sandbox): make onConfirm awaitable onConfirm now returns Promise through the Nitro spec (same double-Promise pattern as setFetchCallback), so a real UI interaction (e.g. an Alert.alert button press) can be awaited instead of forcing a synchronous return. Regenerated via nitrogen. Co-Authored-By: Claude Sonnet 5 --- cpp/HybridHtmlSandbox.cpp | 6 +++--- cpp/HybridHtmlSandbox.hpp | 2 +- example/ios/Podfile.lock | 4 ++-- nitrogen/generated/shared/c++/HybridHtmlSandboxSpec.hpp | 2 +- src/classes/JSDOM/JSDOM.class.ts | 3 ++- src/classes/JSDOM/types/IJSDOMOptions.ts | 9 +++++---- src/specs/HtmlSandbox.nitro.ts | 2 +- 7 files changed, 15 insertions(+), 13 deletions(-) diff --git a/cpp/HybridHtmlSandbox.cpp b/cpp/HybridHtmlSandbox.cpp index b915e82..6fb0e46 100644 --- a/cpp/HybridHtmlSandbox.cpp +++ b/cpp/HybridHtmlSandbox.cpp @@ -111,7 +111,7 @@ void HybridHtmlSandbox::setConsoleCallback( void HybridHtmlSandbox::setDialogCallbacks( const std::optional>>& onAlert, - const std::optional>(const std::string&)>>>& onConfirm, + const std::optional>>>(const std::string&)>>>& onConfirm, const std::optional>>(const std::string&, const std::optional&)>>>& onPrompt) { if (!_runtime) return; @@ -129,10 +129,10 @@ void HybridHtmlSandbox::setDialogCallbacks( if (!onConfirm.has_value() || std::holds_alternative(onConfirm.value())) { _runtime->setConfirmCallback(nullptr); } else { - auto fn = std::get>(const std::string&)>>(onConfirm.value()); + auto fn = std::get>>>(const std::string&)>>(onConfirm.value()); _runtime->setConfirmCallback([fn](const std::string& message) -> bool { try { - return fn(message)->await().get(); + return fn(message)->await().get()->await().get(); } catch (...) { return false; } diff --git a/cpp/HybridHtmlSandbox.hpp b/cpp/HybridHtmlSandbox.hpp index f490538..32f207b 100644 --- a/cpp/HybridHtmlSandbox.hpp +++ b/cpp/HybridHtmlSandbox.hpp @@ -30,7 +30,7 @@ class HybridHtmlSandbox : public HybridHtmlSandboxSpec { void setDialogCallbacks( const std::optional>>& onAlert, - const std::optional>(const std::string& /* message */)>>>& onConfirm, + const std::optional>>>(const std::string& /* message */)>>>& onConfirm, const std::optional>>(const std::string& /* message */, const std::optional& /* defaultValue */)>>>& onPrompt ) override; diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index bc2f0f0..328306a 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -3,7 +3,7 @@ PODS: - hermes-engine (250829098.0.9): - hermes-engine/Pre-built (= 250829098.0.9) - hermes-engine/Pre-built (250829098.0.9) - - NitroJsdom (1.1.0): + - NitroJsdom (2.0.0): - hermes-engine - NitroModules - RCTRequired @@ -2140,7 +2140,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: e97c19a5a442429d1988f182a1940fb08df514da hermes-engine: 0e5b174599e75f7262336c832212c61e1d55ec46 - NitroJsdom: 5a37214eea24bc349cf0dec4b85a61d77218e85b + NitroJsdom: 90dea8eaa7d329fb7037d7e32c3fc9f739a06896 NitroModules: 5a0f735f3f834ac1d998c28fc65be992849c30f3 RCTDeprecation: af44b104091a34482596cd9bd7e8d90c4e9b4bd7 RCTRequired: bb77b070f75f53398ce43c0aaaa58337cebe2bf6 diff --git a/nitrogen/generated/shared/c++/HybridHtmlSandboxSpec.hpp b/nitrogen/generated/shared/c++/HybridHtmlSandboxSpec.hpp index fb7373b..258fc4f 100644 --- a/nitrogen/generated/shared/c++/HybridHtmlSandboxSpec.hpp +++ b/nitrogen/generated/shared/c++/HybridHtmlSandboxSpec.hpp @@ -58,7 +58,7 @@ namespace margelo::nitro::nitrojsdom { virtual std::shared_ptr> evaluate(const std::string& script) = 0; virtual std::string serialize() = 0; virtual void setConsoleCallback(const std::optional& /* args */)>>>& callback) = 0; - virtual void setDialogCallbacks(const std::optional>>& onAlert, const std::optional>(const std::string& /* message */)>>>& onConfirm, const std::optional>>(const std::string& /* message */, const std::optional& /* defaultValue */)>>>& onPrompt) = 0; + virtual void setDialogCallbacks(const std::optional>>& onAlert, const std::optional>>>(const std::string& /* message */)>>>& onConfirm, const std::optional>>(const std::string& /* message */, const std::optional& /* defaultValue */)>>>& onPrompt) = 0; virtual void setFetchCallback(const std::optional>>>(const std::string& /* url */, const std::string& /* method */, const std::string& /* headersJson */, const std::optional& /* body */)>>>& callback) = 0; protected: diff --git a/src/classes/JSDOM/JSDOM.class.ts b/src/classes/JSDOM/JSDOM.class.ts index 86e960b..5a81f56 100644 --- a/src/classes/JSDOM/JSDOM.class.ts +++ b/src/classes/JSDOM/JSDOM.class.ts @@ -55,9 +55,10 @@ export class JSDOM { } if (options?.onAlert || options?.onConfirm || options?.onPrompt) { + const onConfirm = options.onConfirm; sandbox.setDialogCallbacks( options.onAlert ?? null, - options.onConfirm ?? null, + onConfirm ? async (message) => await onConfirm(message) : null, options.onPrompt ?? null, ); } diff --git a/src/classes/JSDOM/types/IJSDOMOptions.ts b/src/classes/JSDOM/types/IJSDOMOptions.ts index 2dbfefc..20371d3 100644 --- a/src/classes/JSDOM/types/IJSDOMOptions.ts +++ b/src/classes/JSDOM/types/IJSDOMOptions.ts @@ -22,12 +22,13 @@ export interface IJSDOMOptions { onAlert?: (message: string) => void; /** * Callback invoked when `window.confirm(message)` is called inside the sandbox. - * Must return a boolean. If not provided, confirm() returns false (browser default). + * Return a boolean, or a `Promise`. If not provided, confirm() returns + * false (browser default). * - * WARNING: The callback is synchronous. Do NOT call back into the same sandbox - * from inside the callback — QuickJS is not re-entrant. + * WARNING: Do NOT call back into the same sandbox from inside the callback — + * QuickJS is not re-entrant. */ - onConfirm?: (message: string) => boolean; + onConfirm?: (message: string) => boolean | Promise; /** * Callback invoked when `window.prompt(message, defaultValue?)` is called inside * the sandbox. Return a string for the user input, or null for a dismissed prompt. diff --git a/src/specs/HtmlSandbox.nitro.ts b/src/specs/HtmlSandbox.nitro.ts index a18f98a..fe0dccf 100644 --- a/src/specs/HtmlSandbox.nitro.ts +++ b/src/specs/HtmlSandbox.nitro.ts @@ -17,7 +17,7 @@ export interface HtmlSandbox extends HybridObject<{ ios: 'c++'; android: 'c++' } // Pass null for any callback to use the browser default (no-op / false / null). setDialogCallbacks( onAlert: ((message: string) => void) | null, - onConfirm: ((message: string) => boolean) | null, + onConfirm: ((message: string) => Promise) | null, onPrompt: ((message: string, defaultValue?: string) => string | null) | null, ): void; From 272d88f08611e500508f7265cea3100109a42b7d Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Fri, 24 Jul 2026 14:42:11 -0300 Subject: [PATCH 03/11] feat(dom): reflect hidden/title/lang/dir on Element Same attribute-reflection convention as disabled/required/etc from the previous round. Co-Authored-By: Claude Sonnet 5 --- cpp/quickjs/bindings/ElementBindings.cpp | 49 +++++++++++++++++++ .../__harness__/JSDOM.attributes.harness.ts | 31 ++++++++++++ 2 files changed, 80 insertions(+) diff --git a/cpp/quickjs/bindings/ElementBindings.cpp b/cpp/quickjs/bindings/ElementBindings.cpp index 4e0bdf3..cd3bb75 100644 --- a/cpp/quickjs/bindings/ElementBindings.cpp +++ b/cpp/quickjs/bindings/ElementBindings.cpp @@ -359,6 +359,51 @@ JSValue js_el_set_autofocus(JSContext* ctx, JSValue this_val, JSValue val) { ret JSValue js_el_get_selected(JSContext* ctx, JSValue this_val) { return bool_attr_get(ctx, this_val, "selected", 8); } JSValue js_el_set_selected(JSContext* ctx, JSValue this_val, JSValue val) { return bool_attr_set(ctx, this_val, val, "selected", 8); } +JSValue js_el_get_hidden(JSContext* ctx, JSValue this_val) { return bool_attr_get(ctx, this_val, "hidden", 6); } +JSValue js_el_set_hidden(JSContext* ctx, JSValue this_val, JSValue val) { return bool_attr_set(ctx, this_val, val, "hidden", 6); } + +JSValue string_attr_get(JSContext* ctx, JSValue this_val, const char* attr, size_t attr_len) { + auto* el = unwrap_element(ctx, this_val); + if (!el) return JS_NewString(ctx, ""); + size_t len = 0; + const lxb_char_t* val = lxb_dom_element_get_attribute(el, reinterpret_cast(attr), attr_len, &len); + return val ? JS_NewStringLen(ctx, reinterpret_cast(val), len) : JS_NewString(ctx, ""); +} + +JSValue string_attr_set(JSContext* ctx, JSValue this_val, JSValue val, const char* attr, size_t attr_len) { + auto* el = unwrap_element(ctx, this_val); + if (!el) return JS_UNDEFINED; + const char* str = JS_ToCString(ctx, val); + if (!str) return JS_UNDEFINED; + auto* attr_name = reinterpret_cast(attr); + + auto* rctx = get_ctx(ctx); + bool has_obs = rctx && rctx->mutation_observers && !rctx->mutation_observers->empty(); + std::optional old_val; + if (has_obs && rctx->mutation_observers->hasAttributeOldValueObserver()) { + size_t len = 0; + const lxb_char_t* v = lxb_dom_element_get_attribute(el, attr_name, attr_len, &len); + if (v) old_val = std::string(reinterpret_cast(v), len); + } + + lxb_dom_element_set_attribute(el, attr_name, attr_len, reinterpret_cast(str), strlen(str)); + JS_FreeCString(ctx, str); + + if (has_obs) { + rctx->mutation_observers->notifyAttribute(ctx, lxb_dom_interface_node(el), attr, old_val); + } + return JS_UNDEFINED; +} + +JSValue js_el_get_title(JSContext* ctx, JSValue this_val) { return string_attr_get(ctx, this_val, "title", 5); } +JSValue js_el_set_title(JSContext* ctx, JSValue this_val, JSValue val) { return string_attr_set(ctx, this_val, val, "title", 5); } + +JSValue js_el_get_lang(JSContext* ctx, JSValue this_val) { return string_attr_get(ctx, this_val, "lang", 4); } +JSValue js_el_set_lang(JSContext* ctx, JSValue this_val, JSValue val) { return string_attr_set(ctx, this_val, val, "lang", 4); } + +JSValue js_el_get_dir(JSContext* ctx, JSValue this_val) { return string_attr_get(ctx, this_val, "dir", 3); } +JSValue js_el_set_dir(JSContext* ctx, JSValue this_val, JSValue val) { return string_attr_set(ctx, this_val, val, "dir", 3); } + JSValue js_el_get_textContent(JSContext* ctx, JSValue this_val) { lxb_dom_node_t* node = unwrap_node(ctx, this_val); if (!node) return JS_NewString(ctx, ""); @@ -1575,6 +1620,10 @@ void ElementBindings::install(JSContext* ctx) { define_prop(ctx, proto, "multiple", js_el_get_multiple, js_el_set_multiple); define_prop(ctx, proto, "autofocus", js_el_get_autofocus, js_el_set_autofocus); define_prop(ctx, proto, "selected", js_el_get_selected, js_el_set_selected); + define_prop(ctx, proto, "hidden", js_el_get_hidden, js_el_set_hidden); + define_prop(ctx, proto, "title", js_el_get_title, js_el_set_title); + define_prop(ctx, proto, "lang", js_el_get_lang, js_el_set_lang); + define_prop(ctx, proto, "dir", js_el_get_dir, js_el_set_dir); define_prop(ctx, proto, "innerHTML", js_el_get_innerHTML, js_el_set_innerHTML); define_prop(ctx, proto, "outerHTML", js_el_get_outerHTML, nullptr); define_prop(ctx, proto, "innerText", js_el_get_textContent, js_el_set_textContent); diff --git a/example/src/__harness__/JSDOM.attributes.harness.ts b/example/src/__harness__/JSDOM.attributes.harness.ts index 99892ff..e8427a1 100644 --- a/example/src/__harness__/JSDOM.attributes.harness.ts +++ b/example/src/__harness__/JSDOM.attributes.harness.ts @@ -178,6 +178,37 @@ describe('JSDOM attributes/dataset/style', () => { }); }); + it('hidden/title/lang/dir reflect as element properties', async () => { + dom = JSDOM.create('
'); + const result = await dom.evaluate(` + const div = document.getElementById('d'); + const before = { hidden: div.hidden, title: div.title, lang: div.lang, dir: div.dir }; + + div.hidden = true; + div.title = 'new tooltip'; + div.lang = 'en-US'; + div.dir = 'ltr'; + + const after = { + hidden: div.hidden, + hiddenAttr: div.getAttribute('hidden'), + title: div.title, + lang: div.lang, + dir: div.dir, + }; + + div.hidden = false; + const afterUnhide = { hidden: div.hidden, hiddenAttr: div.getAttribute('hidden') }; + + JSON.stringify({ before, after, afterUnhide }); + `); + expect(JSON.parse(result)).toEqual({ + before: { hidden: false, title: 'tooltip', lang: 'pt-BR', dir: 'rtl' }, + after: { hidden: true, hiddenAttr: '', title: 'new tooltip', lang: 'en-US', dir: 'ltr' }, + afterUnhide: { hidden: false, hiddenAttr: null }, + }); + }); + it('getBoundingClientRect() returns a zeroed rect instead of throwing', async () => { dom = JSDOM.create('
'); const result = await dom.evaluate(` From 4992fdfd172980644141564db95cec71070de098 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Fri, 24 Jul 2026 14:42:15 -0300 Subject: [PATCH 04/11] feat(dom): decompose / href into URL parts Adds .href (resolved, settable) and read-only .protocol/.hostname/ .pathname/.search/.hash/.host/.origin/etc, resolved against document.baseURI via the existing URL class. Co-Authored-By: Claude Sonnet 5 --- cpp/quickjs/bindings/UrlBindings.cpp | 34 +++++++++++++ example/src/__harness__/JSDOM.url.harness.ts | 52 ++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/cpp/quickjs/bindings/UrlBindings.cpp b/cpp/quickjs/bindings/UrlBindings.cpp index 7ead6e3..558dd7c 100644 --- a/cpp/quickjs/bindings/UrlBindings.cpp +++ b/cpp/quickjs/bindings/UrlBindings.cpp @@ -278,6 +278,40 @@ const char* kUrlBootstrapScript = R"JS( }; globalThis.URL = URL; + + function isAnchorElement(el) { + return !!el && (el.tagName === 'A' || el.tagName === 'AREA'); + } + + function anchorUrl(el) { + var raw = el.getAttribute('href'); + if (raw === null) return null; + try { return new URL(raw, document.baseURI); } catch (e) { return null; } + } + + Object.defineProperty(Element.prototype, 'href', { + configurable: true, + get: function() { + if (!isAnchorElement(this)) return undefined; + var u = anchorUrl(this); + return u ? u.href : ''; + }, + set: function(value) { + if (!isAnchorElement(this)) return; + this.setAttribute('href', String(value)); + }, + }); + + ['protocol', 'username', 'password', 'hostname', 'port', 'pathname', 'search', 'hash', 'host', 'origin'].forEach(function(part) { + Object.defineProperty(Element.prototype, part, { + configurable: true, + get: function() { + if (!isAnchorElement(this)) return undefined; + var u = anchorUrl(this); + return u ? u[part] : ''; + }, + }); + }); })(); )JS"; diff --git a/example/src/__harness__/JSDOM.url.harness.ts b/example/src/__harness__/JSDOM.url.harness.ts index 73f6c74..75efe42 100644 --- a/example/src/__harness__/JSDOM.url.harness.ts +++ b/example/src/__harness__/JSDOM.url.harness.ts @@ -83,4 +83,56 @@ describe('JSDOM URL/URLSearchParams', () => { `); expect(JSON.parse(result)).toEqual({ absolute: true, relativeWithBase: true, invalid: false }); }); + + it('/ href resolves relative URLs and decomposes into protocol/hostname/pathname/etc, other elements get undefined', async () => { + dom = JSDOM.create( + ` + 10% off + + + `, + { url: 'https://shop.example.com/dir/page.html' } + ); + const result = await dom.evaluate(` + const a = document.getElementById('rel'); + const area = document.getElementById('area'); + const div = document.getElementById('notLink'); + JSON.stringify({ + href: a.href, + protocol: a.protocol, + hostname: a.hostname, + pathname: a.pathname, + search: a.search, + hash: a.hash, + origin: a.origin, + areaHref: area.href, + divHref: div.href, + divProtocol: div.protocol, + }); + `); + expect(JSON.parse(result)).toEqual({ + href: 'https://shop.example.com/discount?code=SAVE10#top', + protocol: 'https:', + hostname: 'shop.example.com', + pathname: '/discount', + search: '?code=SAVE10', + hash: '#top', + origin: 'https://shop.example.com', + areaHref: 'https://shop.example.com/dir/page2.html', + divHref: undefined, + divProtocol: undefined, + }); + }); + + it('setting a.href writes the raw href attribute', async () => { + dom = JSDOM.create('old', { + url: 'https://example.com/', + }); + const result = await dom.evaluate(` + const a = document.getElementById('a'); + a.href = '/new'; + JSON.stringify({ attr: a.getAttribute('href'), resolved: a.href }); + `); + expect(JSON.parse(result)).toEqual({ attr: '/new', resolved: 'https://example.com/new' }); + }); }); From 5cfc356829e33580ddd005a5ade35e0dfa6e6c7f Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Fri, 24 Jul 2026 14:42:20 -0300 Subject: [PATCH 05/11] feat(dom): add document.currentScript Tracks the executing + +
+ +
+ + `); + const result = await dom.evaluate(` + JSON.stringify({ + widget1: window.__widget1Container, + widget2: window.__widget2Container, + afterExecution: document.currentScript, + }); + `); + expect(JSON.parse(result)).toEqual({ + widget1: 'widget-1', + widget2: 'widget-2', + afterExecution: null, + }); + }); }); From e5850252affe123f8659b14228a3656bc4e4aa8b Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Fri, 24 Jul 2026 14:42:25 -0300 Subject: [PATCH 06/11] feat(dom): add Intl.NumberFormat/DateTimeFormat polyfill QuickJS ships no Intl at all. Pure-JS implementation covering en/pt locale data (currency/percent/decimal formatting, date part ordering and month/weekday names), verified against real V8 Intl output. Also rewires Number/Date toLocaleString family to use it. Co-Authored-By: Claude Sonnet 5 --- cpp/quickjs/DOMBindings.cpp | 2 + cpp/quickjs/bindings/IntlBindings.cpp | 232 ++++++++++++++++++ cpp/quickjs/bindings/IntlBindings.hpp | 17 ++ example/src/__harness__/JSDOM.intl.harness.ts | 120 +++++++++ 4 files changed, 371 insertions(+) create mode 100644 cpp/quickjs/bindings/IntlBindings.cpp create mode 100644 cpp/quickjs/bindings/IntlBindings.hpp create mode 100644 example/src/__harness__/JSDOM.intl.harness.ts diff --git a/cpp/quickjs/DOMBindings.cpp b/cpp/quickjs/DOMBindings.cpp index f6ae46f..af1984e 100644 --- a/cpp/quickjs/DOMBindings.cpp +++ b/cpp/quickjs/DOMBindings.cpp @@ -16,6 +16,7 @@ #include "bindings/UrlBindings.hpp" #include "bindings/AbortBindings.hpp" #include "bindings/TextEncodingBindings.hpp" +#include "bindings/IntlBindings.hpp" #include "bindings/FormBindings.hpp" #include "bindings/BlobBindings.hpp" #include "bindings/CSSOMBindings.hpp" @@ -58,6 +59,7 @@ void DOMBindings::install(QuickJSRuntime* runtime, LexborDocument* document) { UrlBindings::install(ctx); AbortBindings::install(ctx); TextEncodingBindings::install(ctx); + IntlBindings::install(ctx); BlobBindings::install(ctx); // uses TextEncoder/TextDecoder + btoa, so must run after both FetchBindings::install(ctx); // XHR bootstrap uses `new Event(...)`, so must run after EventBindings FormBindings::install(ctx); // uses globalThis.Element + globalThis.Event, so must run after both diff --git a/cpp/quickjs/bindings/IntlBindings.cpp b/cpp/quickjs/bindings/IntlBindings.cpp new file mode 100644 index 0000000..b109b80 --- /dev/null +++ b/cpp/quickjs/bindings/IntlBindings.cpp @@ -0,0 +1,232 @@ +#include "IntlBindings.hpp" +#include + +namespace margelo::nitro::nitrojsdom { + +namespace { + +const char* kIntlBootstrapScript = R"JS( +(function() { + var LOCALE_DATA = { + en: { + months: { + long: ['January','February','March','April','May','June','July','August','September','October','November','December'], + short: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'], + narrow: ['J','F','M','A','M','J','J','A','S','O','N','D'], + }, + weekdays: { + long: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'], + short: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'], + narrow: ['S','M','T','W','T','F','S'], + }, + dayPeriod: ['AM', 'PM'], + decimal: '.', + group: ',', + dateOrder: 'MDY', + currencySpace: false, + }, + pt: { + months: { + long: ['janeiro','fevereiro','março','abril','maio','junho','julho','agosto','setembro','outubro','novembro','dezembro'], + short: ['jan','fev','mar','abr','mai','jun','jul','ago','set','out','nov','dez'], + narrow: ['J','F','M','A','M','J','J','A','S','O','N','D'], + }, + weekdays: { + long: ['domingo','segunda-feira','terça-feira','quarta-feira','quinta-feira','sexta-feira','sábado'], + short: ['dom','seg','ter','qua','qui','sex','sáb'], + narrow: ['D','S','T','Q','Q','S','S'], + }, + dayPeriod: ['AM', 'PM'], + decimal: ',', + group: '.', + dateOrder: 'DMY', + currencySpace: true, + }, + }; + + var CURRENCY_SYMBOLS = { + USD: '$', EUR: '€', GBP: '£', JPY: '¥', BRL: 'R$', CAD: 'CA$', AUD: 'A$', CHF: 'CHF', CNY: 'CN¥', INR: '₹', MXN: 'MX$', + }; + + function localeLanguage(locales) { + var tag = Array.isArray(locales) ? locales[0] : locales; + tag = String(tag || 'en'); + return tag.split(/[-_]/)[0].toLowerCase(); + } + + function resolveLocale(locales) { + var lang = localeLanguage(locales); + return LOCALE_DATA[lang] || LOCALE_DATA.en; + } + + function groupInteger(digits, groupSep) { + var out = ''; + var count = 0; + for (var i = digits.length - 1; i >= 0; i--) { + out = digits.charAt(i) + out; + count++; + if (count % 3 === 0 && i !== 0) out = groupSep + out; + } + return out; + } + + function formatFixed(value, minFrac, maxFrac, useGrouping, data) { + var negative = value < 0; + var abs = Math.abs(value); + var fixed = abs.toFixed(maxFrac); + var parts = fixed.split('.'); + var intPart = parts[0]; + var fracPart = parts[1] || ''; + + while (fracPart.length > minFrac && fracPart.charAt(fracPart.length - 1) === '0') { + fracPart = fracPart.slice(0, -1); + } + + var out = useGrouping ? groupInteger(intPart, data.group) : intPart; + if (fracPart.length > 0) out += data.decimal + fracPart; + return (negative ? '-' : '') + out; + } + + function NumberFormat(locales, options) { + this._data = resolveLocale(locales); + options = options || {}; + this._style = options.style || 'decimal'; + this._currency = options.currency || 'USD'; + var defaultMinFrac = this._style === 'currency' ? 2 : 0; + var defaultMaxFrac = this._style === 'currency' ? 2 : (this._style === 'percent' ? 0 : 3); + this._minFrac = options.minimumFractionDigits !== undefined ? options.minimumFractionDigits : defaultMinFrac; + this._maxFrac = options.maximumFractionDigits !== undefined ? options.maximumFractionDigits : Math.max(defaultMaxFrac, this._minFrac); + this._useGrouping = options.useGrouping !== undefined ? !!options.useGrouping : true; + } + + NumberFormat.prototype.format = function(value) { + value = Number(value); + if (this._style === 'percent') value = value * 100; + var formatted = formatFixed(value, this._minFrac, this._maxFrac, this._useGrouping, this._data); + if (this._style === 'percent') return formatted + '%'; + if (this._style === 'currency') { + var symbol = CURRENCY_SYMBOLS[this._currency] || this._currency; + return symbol + (this._data.currencySpace ? ' ' : '') + formatted; + } + return formatted; + }; + + NumberFormat.prototype.resolvedOptions = function() { + return { + style: this._style, + currency: this._style === 'currency' ? this._currency : undefined, + minimumFractionDigits: this._minFrac, + maximumFractionDigits: this._maxFrac, + useGrouping: this._useGrouping, + }; + }; + + function pad2(n) { return (n < 10 ? '0' : '') + n; } + + function DateTimeFormat(locales, options) { + this._data = resolveLocale(locales); + options = options || {}; + + if (options.dateStyle || options.timeStyle) { + var withDate = options.dateStyle ? { year: 'numeric', month: 'short', day: 'numeric' } : {}; + var withTime = options.timeStyle ? { hour: 'numeric', minute: 'numeric' } : {}; + options = Object.assign({}, withDate, withTime, options); + } else if (Object.keys(options).length === 0) { + options = { year: 'numeric', month: 'numeric', day: 'numeric' }; + } + + this._options = options; + } + + function datePartFor(date, o, data) { + var textual = o.month === 'long' || o.month === 'short' || o.month === 'narrow'; + var day = o.day ? (o.day === '2-digit' ? pad2(date.getDate()) : String(date.getDate())) : ''; + var year = o.year ? (o.year === '2-digit' ? pad2(date.getFullYear() % 100) : String(date.getFullYear())) : ''; + var month = o.month + ? (textual ? data.months[o.month][date.getMonth()] : (o.month === '2-digit' ? pad2(date.getMonth() + 1) : String(date.getMonth() + 1))) + : ''; + + if (!month && !day && !year) return ''; + + if (textual) { + if (data.dateOrder === 'MDY') { + var head = day ? month + ' ' + day : month; + return year ? head + ', ' + year : head; + } + return [day, month, year].filter(Boolean).join(' '); + } + + var ordered = data.dateOrder === 'MDY' ? [month, day, year] : [day, month, year]; + return ordered.filter(Boolean).join('/'); + } + + DateTimeFormat.prototype.format = function(date) { + date = date === undefined ? new Date() : (date instanceof Date ? date : new Date(date)); + var o = this._options; + var data = this._data; + var pieces = []; + + var datePart = datePartFor(date, o, data); + if (o.weekday) { + var w = date.getDay(); + var weekdayName = data.weekdays[o.weekday === 'narrow' ? 'narrow' : (o.weekday === 'long' ? 'long' : 'short')][w]; + pieces.push(datePart ? weekdayName + ', ' + datePart : weekdayName); + } else if (datePart) { + pieces.push(datePart); + } + + if (o.hour) { + var h = date.getHours(); + var hour12 = o.hour12 !== false; + var hourVal = hour12 ? (h % 12 === 0 ? 12 : h % 12) : h; + var timeSegs = [o.hour === '2-digit' ? pad2(hourVal) : String(hourVal)]; + if (o.minute) timeSegs.push(pad2(date.getMinutes())); + if (o.second) timeSegs.push(pad2(date.getSeconds())); + var timeStr = timeSegs.join(':'); + if (hour12) timeStr += ' ' + (h < 12 ? data.dayPeriod[0] : data.dayPeriod[1]); + pieces.push(timeStr); + } else if (o.minute) { + var timeSegs2 = [pad2(date.getMinutes())]; + if (o.second) timeSegs2.push(pad2(date.getSeconds())); + pieces.push(timeSegs2.join(':')); + } + + return pieces.join(', '); + }; + + DateTimeFormat.prototype.resolvedOptions = function() { + return Object.assign({}, this._options); + }; + + globalThis.Intl = globalThis.Intl || {}; + globalThis.Intl.NumberFormat = NumberFormat; + globalThis.Intl.DateTimeFormat = DateTimeFormat; + + Number.prototype.toLocaleString = function(locales, options) { + return new NumberFormat(locales, options).format(this); + }; + + Date.prototype.toLocaleString = function(locales, options) { + return new DateTimeFormat(locales, options || { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' }).format(this); + }; + Date.prototype.toLocaleDateString = function(locales, options) { + return new DateTimeFormat(locales, options || { year: 'numeric', month: 'numeric', day: 'numeric' }).format(this); + }; + Date.prototype.toLocaleTimeString = function(locales, options) { + return new DateTimeFormat(locales, options || { hour: 'numeric', minute: 'numeric', second: 'numeric' }).format(this); + }; +})(); +)JS"; + +} // namespace + +void IntlBindings::install(JSContext* ctx) { + JSValue result = JS_Eval(ctx, kIntlBootstrapScript, strlen(kIntlBootstrapScript), + "", JS_EVAL_TYPE_GLOBAL); + if (JS_IsException(result)) { + JS_FreeValue(ctx, JS_GetException(ctx)); + } + JS_FreeValue(ctx, result); +} + +} // namespace margelo::nitro::nitrojsdom diff --git a/cpp/quickjs/bindings/IntlBindings.hpp b/cpp/quickjs/bindings/IntlBindings.hpp new file mode 100644 index 0000000..52a6548 --- /dev/null +++ b/cpp/quickjs/bindings/IntlBindings.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include "quickjs.h" + +namespace margelo::nitro::nitrojsdom { + +// Registers a pure-JS globalThis.Intl.NumberFormat/DateTimeFormat (no ICU — +// QuickJS ships none). Locale data is a small hand-built table (en + pt, the +// two this project's users actually need), not real CLDR data. Also rewires +// Number.prototype.toLocaleString and Date.prototype.toLocaleString/ +// toLocaleDateString/toLocaleTimeString to go through these instead of +// QuickJS's own locale-blind built-ins. +struct IntlBindings { + static void install(JSContext* ctx); +}; + +} // namespace margelo::nitro::nitrojsdom diff --git a/example/src/__harness__/JSDOM.intl.harness.ts b/example/src/__harness__/JSDOM.intl.harness.ts new file mode 100644 index 0000000..d6c21d9 --- /dev/null +++ b/example/src/__harness__/JSDOM.intl.harness.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, afterEach } from 'react-native-harness'; +import { JSDOM } from '@salve-software/react-native-nitro-jsdom'; + +// Runs on a real device/simulator via react-native-harness, exercising the actual +// Nitro/QuickJS/Lexbor native module — not a JS mock. + +describe('JSDOM Intl.NumberFormat/DateTimeFormat', () => { + let dom: JSDOM | undefined; + + afterEach(() => { + dom?.dispose(); + dom = undefined; + }); + + it('Intl.NumberFormat formats currency for en-US and pt-BR with locale-correct grouping/decimal/symbol placement', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + JSON.stringify({ + usd: new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.5), + brl: new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(1234.5), + plainEn: new Intl.NumberFormat('en-US').format(1234567.891), + plainPt: new Intl.NumberFormat('pt-BR').format(1234567.891), + percent: new Intl.NumberFormat('en-US', { style: 'percent' }).format(0.4567), + }); + `); + expect(JSON.parse(result)).toEqual({ + usd: '$1,234.50', + brl: 'R$ 1.234,50', + plainEn: '1,234,567.891', + plainPt: '1.234.567,891', + percent: '46%', + }); + }); + + it('Intl.NumberFormat respects minimumFractionDigits/maximumFractionDigits/useGrouping', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + JSON.stringify({ + fixed2: new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(5), + noGrouping: new Intl.NumberFormat('en-US', { useGrouping: false }).format(1234567), + trimsTrailingZeros: new Intl.NumberFormat('en-US').format(5.1), + }); + `); + expect(JSON.parse(result)).toEqual({ + fixed2: '5.00', + noGrouping: '1234567', + trimsTrailingZeros: '5.1', + }); + }); + + it('Intl.DateTimeFormat orders date parts by locale (MDY for en, DMY for pt) and names months/weekdays', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + const d = new Date(2026, 6, 24, 9, 5, 3); + JSON.stringify({ + enLong: new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'long', day: 'numeric' }).format(d), + ptLong: new Intl.DateTimeFormat('pt-BR', { year: 'numeric', month: 'long', day: 'numeric' }).format(d), + enWeekday: new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'short', day: 'numeric', year: 'numeric' }).format(d), + enNumeric: new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'numeric', day: 'numeric' }).format(d), + ptNumeric: new Intl.DateTimeFormat('pt-BR', { year: 'numeric', month: 'numeric', day: 'numeric' }).format(d), + }); + `); + expect(JSON.parse(result)).toEqual({ + enLong: 'July 24, 2026', + ptLong: '24 julho 2026', + enWeekday: 'Friday, Jul 24, 2026', + enNumeric: '7/24/2026', + ptNumeric: '24/7/2026', + }); + }); + + it('Intl.DateTimeFormat formats time with hour12 AM/PM and 24h mode', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + const morning = new Date(2026, 6, 24, 9, 5, 3); + const afternoon = new Date(2026, 6, 24, 15, 5, 3); + JSON.stringify({ + amPm: new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: 'numeric' }).format(morning), + pm: new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: 'numeric' }).format(afternoon), + h24: new Intl.DateTimeFormat('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }).format(afternoon), + }); + `); + expect(JSON.parse(result)).toEqual({ + amPm: '9:05 AM', + pm: '3:05 PM', + h24: '15:05', + }); + }); + + it('Number.prototype.toLocaleString and Date.prototype.toLocaleDateString/toLocaleTimeString delegate to Intl', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + const d = new Date(2026, 6, 24, 9, 5, 3); + JSON.stringify({ + number: (1234.5).toLocaleString('en-US'), + dateOnly: d.toLocaleDateString('en-US'), + timeOnly: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), + }); + `); + expect(JSON.parse(result)).toEqual({ + number: '1,234.5', + dateOnly: '7/24/2026', + timeOnly: '09:05', + }); + }); + + it('falls back to English formatting for a locale with no built-in data', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + JSON.stringify({ + number: new Intl.NumberFormat('de-DE').format(1234.5), + date: new Intl.DateTimeFormat('de-DE', { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date(2026, 6, 24)), + }); + `); + expect(JSON.parse(result)).toEqual({ + number: '1,234.5', + date: 'July 24, 2026', + }); + }); +}); From b09eb4cf4429d4754f9c638e26805a8cc40ad149 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Fri, 24 Jul 2026 14:42:28 -0300 Subject: [PATCH 07/11] docs: add v0.18 roadmap entry Co-Authored-By: Claude Sonnet 5 --- docs/overview.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/overview.md b/docs/overview.md index 01bef12..a58646c 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -529,6 +529,53 @@ dom.dispose() // ← always pair with create() - [x] `document.visibilityState` (`'visible'` / `'hidden'`) — the companion to `document.hidden`, which already existed; scripts commonly check both. +### v0.18 — Link Ergonomics, currentScript & Intl +> A second pass on the same jsdom-interfaces gap list that produced v0.17, +> plus the one gap that isn't a missing DOM binding at all: QuickJS ships no +> `Intl` implementation, which blocks this project's own two headline +> examples (`docs/overview.md`'s "personalized greeting" needs date +> formatting, "discount badge" needs currency formatting). +- [x] `element.hidden` / `.title` / `.lang` / `.dir` as direct properties, + same attribute-reflection convention as v0.17's `.disabled` etc. +- [x] ``/`` `.href` (resolved absolute URL, settable) and read-only + `.protocol`/`.username`/`.password`/`.hostname`/`.port`/`.pathname`/ + `.search`/`.hash`/`.host`/`.origin`, resolved against `document.baseURI` + by delegating to the existing `URL` class (`UrlBindings.cpp`) rather + than re-implementing URL parsing. Other elements' `.href` and these + parts are `undefined`, matching real jsdom's behavior for non-hyperlink + elements. The component parts are read-only; only `.href` itself is + settable (writes the raw attribute) — real `HTMLHyperlinkElementUtils` + allows setting each part individually too, which this sandbox doesn't + attempt. +- [x] `document.currentScript` — the classic embedded-widget pattern (a + `