From 27f664cb3a3bd73ebf4c299db966509f3fe3886c Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sat, 25 Jul 2026 16:34:04 -0300 Subject: [PATCH 1/7] feat(intl): add Intl.RelativeTimeFormat and Intl.PluralRules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two Intl gaps left unmodeled in v0.18 (RelativeTimeFormat, PluralRules) — directly relevant to the project's own countdown-timer and pluralized-greeting examples. Co-Authored-By: Claude Sonnet 5 --- cpp/quickjs/bindings/IntlBindings.cpp | 93 ++++++++++++++ cpp/quickjs/bindings/IntlBindings.hpp | 21 +++- docs/overview.md | 25 ++++ example/src/__harness__/JSDOM.intl.harness.ts | 116 +++++++++++++++++- 4 files changed, 248 insertions(+), 7 deletions(-) diff --git a/cpp/quickjs/bindings/IntlBindings.cpp b/cpp/quickjs/bindings/IntlBindings.cpp index f6ab129..d2ff593 100644 --- a/cpp/quickjs/bindings/IntlBindings.cpp +++ b/cpp/quickjs/bindings/IntlBindings.cpp @@ -224,9 +224,102 @@ const char* kIntlBootstrapScript = R"JS( return Object.assign({}, this._options); }; + function pluralCategoryFor(n, lang) { + n = Math.abs(Number(n)); + if (lang === 'pt') return (n === 0 || n === 1) ? 'one' : 'other'; + return n === 1 ? 'one' : 'other'; + } + + function PluralRules(locales, options) { + this._lang = localeLanguage(locales); + options = options || {}; + this._type = options.type || 'cardinal'; + } + + PluralRules.prototype.select = function(n) { + return pluralCategoryFor(n, this._lang); + }; + + PluralRules.prototype.resolvedOptions = function() { + return { locale: this._lang, type: this._type, pluralCategories: ['one', 'other'] }; + }; + + var RTF_UNITS = { + en: { + year: ['year', 'years'], quarter: ['quarter', 'quarters'], month: ['month', 'months'], + week: ['week', 'weeks'], day: ['day', 'days'], hour: ['hour', 'hours'], + minute: ['minute', 'minutes'], second: ['second', 'seconds'], + }, + pt: { + year: ['ano', 'anos'], quarter: ['trimestre', 'trimestres'], month: ['mês', 'meses'], + week: ['semana', 'semanas'], day: ['dia', 'dias'], hour: ['hora', 'horas'], + minute: ['minuto', 'minutos'], second: ['segundo', 'segundos'], + }, + }; + + var RTF_PATTERNS = { + en: { past: '{0} {1} ago', future: 'in {0} {1}' }, + pt: { past: 'há {0} {1}', future: 'em {0} {1}' }, + }; + + var RTF_AUTO = { + en: { + day: { '-1': 'yesterday', '0': 'today', '1': 'tomorrow' }, + week: { '-1': 'last week', '0': 'this week', '1': 'next week' }, + month: { '-1': 'last month', '0': 'this month', '1': 'next month' }, + year: { '-1': 'last year', '0': 'this year', '1': 'next year' }, + second: { '0': 'now' }, + }, + pt: { + day: { '-1': 'ontem', '0': 'hoje', '1': 'amanhã' }, + week: { '-1': 'semana passada', '0': 'esta semana', '1': 'semana que vem' }, + month: { '-1': 'mês passado', '0': 'este mês', '1': 'próximo mês' }, + year: { '-1': 'ano passado', '0': 'este ano', '1': 'próximo ano' }, + second: { '0': 'agora' }, + }, + }; + + var RTF_UNIT_ALIASES = { + years: 'year', quarters: 'quarter', months: 'month', weeks: 'week', + days: 'day', hours: 'hour', minutes: 'minute', seconds: 'second', + }; + + function RelativeTimeFormat(locales, options) { + this._lang = RTF_UNITS[localeLanguage(locales)] ? localeLanguage(locales) : 'en'; + options = options || {}; + this._numeric = options.numeric || 'always'; + this._style = options.style || 'long'; + } + + RelativeTimeFormat.prototype.format = function(value, unit) { + value = Number(value); + unit = RTF_UNIT_ALIASES[unit] || unit; + var units = RTF_UNITS[this._lang][unit]; + if (!units) throw new RangeError('Invalid unit argument for RelativeTimeFormat.format()'); + + if (this._numeric === 'auto') { + var autoData = RTF_AUTO[this._lang][unit]; + var rounded = Math.round(value); + if (autoData && autoData[String(rounded)] !== undefined) { + return autoData[String(rounded)]; + } + } + + var n = Math.abs(value); + var unitName = units[pluralCategoryFor(n, this._lang) === 'one' ? 0 : 1]; + var pattern = value < 0 ? RTF_PATTERNS[this._lang].past : RTF_PATTERNS[this._lang].future; + return pattern.replace('{0}', String(n)).replace('{1}', unitName); + }; + + RelativeTimeFormat.prototype.resolvedOptions = function() { + return { locale: this._lang, style: this._style, numeric: this._numeric }; + }; + globalThis.Intl = globalThis.Intl || {}; globalThis.Intl.NumberFormat = NumberFormat; globalThis.Intl.DateTimeFormat = DateTimeFormat; + globalThis.Intl.PluralRules = PluralRules; + globalThis.Intl.RelativeTimeFormat = RelativeTimeFormat; function hasOwnKeys(o) { return !!o && Object.keys(o).length > 0; } diff --git a/cpp/quickjs/bindings/IntlBindings.hpp b/cpp/quickjs/bindings/IntlBindings.hpp index 52a6548..024be54 100644 --- a/cpp/quickjs/bindings/IntlBindings.hpp +++ b/cpp/quickjs/bindings/IntlBindings.hpp @@ -4,12 +4,21 @@ 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. +// Registers a pure-JS globalThis.Intl.NumberFormat/DateTimeFormat/ +// PluralRules/RelativeTimeFormat (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. +// +// RelativeTimeFormat's `style` option ('long'/'short'/'narrow') is accepted +// but 'short'/'narrow' render identically to 'long' — there's no abbreviated +// unit-name table, same trade-off DateTimeFormat already made collapsing +// timeStyle 'long'/'full'. `numeric: 'auto'` only has special phrasing +// ("yesterday", "next week", ...) for day/week/month/year — hour/minute/ +// quarter always render numerically, matching real Intl's own behavior for +// units without a natural auto phrase. struct IntlBindings { static void install(JSContext* ctx); }; diff --git a/docs/overview.md b/docs/overview.md index 4302f27..72088bb 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -583,6 +583,31 @@ dom.dispose() // ← always pair with create() Gregorian, `Intl.PluralRules`/`Intl.RelativeTimeFormat`/ `Intl.ListFormat`, and `Intl.Locale`. +### v0.19 — Relative Time & Pluralization +> Closes the two `Intl` gaps v0.18 explicitly flagged as unmodeled, since +> both are directly relevant to this project's own headline examples: a +> countdown timer needs `RelativeTimeFormat` ("in 3 days"), a discount badge +> or personalized greeting needs `PluralRules` to pick "item" vs "items" +> correctly across `en`/`pt`. +- [x] `Intl.PluralRules` — `select(n)` returns `'one'`/`'other'` per the same + `en`/`pt` cardinal rule `pt`'s CLDR data defines (`pt` treats both `0` + and `1` as singular, e.g. "0 item"/"1 item"/"2 itens"; `en` only `1`). + `type: 'ordinal'` and categories beyond `one`/`other` (`few`, `many`, + `two`, `zero`) are not modeled — neither `en` nor `pt` cardinal rules + need them. +- [x] `Intl.RelativeTimeFormat` — `format(value, unit)` for `year`/`quarter`/ + `month`/`week`/`day`/`hour`/`minute`/`second` (plural unit spellings + like `'days'` accepted too, per spec). `numeric: 'auto'` produces + special phrasing ("yesterday", "next week", "agora") for day/week/ + month/year/second=0; `numeric: 'always'` (the default) always renders + numerically ("in 3 days" / "há 3 dias"). `style: 'short'`/`'narrow'` + are accepted but render identically to `'long'` — no abbreviated + unit-name table, the same trade-off `DateTimeFormat` already made + collapsing `timeStyle` `'long'`/`'full'`. `formatToParts()` is not + implemented (`DateTimeFormat`/`NumberFormat` don't have it either, for + the same reason: no consumer in this project's scope needs part-level + output over the plain formatted string). + --- ## Repository Structure diff --git a/example/src/__harness__/JSDOM.intl.harness.ts b/example/src/__harness__/JSDOM.intl.harness.ts index 0922cab..2ba1caf 100644 --- a/example/src/__harness__/JSDOM.intl.harness.ts +++ b/example/src/__harness__/JSDOM.intl.harness.ts @@ -4,7 +4,7 @@ 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', () => { +describe('JSDOM Intl.NumberFormat/DateTimeFormat/PluralRules/RelativeTimeFormat', () => { let dom: JSDOM | undefined; afterEach(() => { @@ -166,4 +166,118 @@ describe('JSDOM Intl.NumberFormat/DateTimeFormat', () => { date: 'July 24, 2026', }); }); + + it('Intl.PluralRules selects "one"/"other" per locale cardinal rule (pt treats 0 and 1 as singular)', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + const en = new Intl.PluralRules('en-US'); + const pt = new Intl.PluralRules('pt-BR'); + JSON.stringify({ + en: [0, 1, 2, 5].map((n) => en.select(n)), + pt: [0, 1, 2, 5].map((n) => pt.select(n)), + }); + `); + expect(JSON.parse(result)).toEqual({ + en: ['other', 'one', 'other', 'other'], + pt: ['one', 'one', 'other', 'other'], + }); + }); + + it('Intl.PluralRules.resolvedOptions() reports locale, type and categories', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + JSON.stringify(new Intl.PluralRules('en-US').resolvedOptions()); + `); + expect(JSON.parse(result)).toEqual({ + locale: 'en', + type: 'cardinal', + pluralCategories: ['one', 'other'], + }); + }); + + it('Intl.RelativeTimeFormat formats past/future with numeric:"always" (the default) in en and pt', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + const en = new Intl.RelativeTimeFormat('en-US'); + const pt = new Intl.RelativeTimeFormat('pt-BR'); + JSON.stringify({ + enPast: en.format(-3, 'day'), + enFuture: en.format(3, 'day'), + enSingular: en.format(1, 'day'), + ptPast: pt.format(-3, 'day'), + ptFuture: pt.format(3, 'day'), + ptSingular: pt.format(1, 'day'), + ptZeroSingular: pt.format(0, 'hour'), + }); + `); + expect(JSON.parse(result)).toEqual({ + enPast: '3 days ago', + enFuture: 'in 3 days', + enSingular: 'in 1 day', + ptPast: 'há 3 dias', + ptFuture: 'em 3 dias', + ptSingular: 'em 1 dia', + ptZeroSingular: 'em 0 hora', + }); + }); + + it('Intl.RelativeTimeFormat with numeric:"auto" uses special phrasing for day/week/month/year, and plain numeric otherwise', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + const en = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' }); + const pt = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' }); + JSON.stringify({ + enYesterday: en.format(-1, 'day'), + enToday: en.format(0, 'day'), + enTomorrow: en.format(1, 'day'), + enNextWeek: en.format(1, 'week'), + enFallsBackToNumeric: en.format(3, 'day'), + enHourHasNoAutoPhrase: en.format(-1, 'hour'), + ptOntem: pt.format(-1, 'day'), + ptHoje: pt.format(0, 'day'), + ptAmanha: pt.format(1, 'day'), + }); + `); + expect(JSON.parse(result)).toEqual({ + enYesterday: 'yesterday', + enToday: 'today', + enTomorrow: 'tomorrow', + enNextWeek: 'next week', + enFallsBackToNumeric: 'in 3 days', + enHourHasNoAutoPhrase: '1 hour ago', + ptOntem: 'ontem', + ptHoje: 'hoje', + ptAmanha: 'amanhã', + }); + }); + + it('Intl.RelativeTimeFormat accepts plural unit spellings and throws RangeError for an invalid unit', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + const rtf = new Intl.RelativeTimeFormat('en-US'); + let threw; + try { rtf.format(1, 'fortnight'); threw = null; } + catch (e) { threw = { name: e.name, isRangeError: e instanceof RangeError }; } + JSON.stringify({ + pluralUnit: rtf.format(2, 'days'), + threw, + }); + `); + expect(JSON.parse(result)).toEqual({ + pluralUnit: 'in 2 days', + threw: { name: 'RangeError', isRangeError: true }, + }); + }); + + it('Intl.RelativeTimeFormat.resolvedOptions() reports locale, style and numeric', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + JSON.stringify(new Intl.RelativeTimeFormat('en-US', { numeric: 'auto', style: 'short' }).resolvedOptions()); + `); + expect(JSON.parse(result)).toEqual({ + locale: 'en', + style: 'short', + numeric: 'auto', + }); + }); }); From 11c2848d9fa61f6e7ca0ec38360ea8e4185150ca Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sat, 25 Jul 2026 16:34:32 -0300 Subject: [PATCH 2/7] feat(dom): add named access on the window object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elements with an id, or a name attribute on embed/form/img/object/ iframe/frame, become reachable as bare globals while connected to the primary document — a common pattern in real-world CMS-embedded widget scripts that predate/skip getElementById/querySelector. Co-Authored-By: Claude Sonnet 5 --- android/CMakeLists.txt | 1 + cpp/quickjs/DOMBindings.cpp | 2 + .../WindowNamedPropertiesBindings.cpp | 202 ++++++++++++++++++ .../WindowNamedPropertiesBindings.hpp | 69 ++++++ docs/overview.md | 40 ++++ .../__harness__/JSDOM.namedaccess.harness.ts | 180 ++++++++++++++++ 6 files changed, 494 insertions(+) create mode 100644 cpp/quickjs/bindings/WindowNamedPropertiesBindings.cpp create mode 100644 cpp/quickjs/bindings/WindowNamedPropertiesBindings.hpp create mode 100644 example/src/__harness__/JSDOM.namedaccess.harness.ts diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index c67b815..d647a33 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -74,6 +74,7 @@ add_library(${PACKAGE_NAME} SHARED ../cpp/quickjs/bindings/LayoutStubBindings.cpp ../cpp/quickjs/bindings/TreeWalkerBindings.cpp ../cpp/quickjs/bindings/EventTargetBindings.cpp + ../cpp/quickjs/bindings/WindowNamedPropertiesBindings.cpp ${QUICKJS_SOURCES} ) diff --git a/cpp/quickjs/DOMBindings.cpp b/cpp/quickjs/DOMBindings.cpp index af1984e..dcf9beb 100644 --- a/cpp/quickjs/DOMBindings.cpp +++ b/cpp/quickjs/DOMBindings.cpp @@ -29,6 +29,7 @@ #include "bindings/LayoutStubBindings.hpp" #include "bindings/TreeWalkerBindings.hpp" #include "bindings/EventTargetBindings.hpp" +#include "bindings/WindowNamedPropertiesBindings.hpp" #include #include @@ -68,6 +69,7 @@ void DOMBindings::install(QuickJSRuntime* runtime, LexborDocument* document) { LayoutStubBindings::install(ctx); // needs Element's proto + globalThis.document to exist TreeWalkerBindings::install(ctx); // needs Element's proto (Node traversal props) + globalThis.document to exist EventTargetBindings::install(ctx); // no dependencies + WindowNamedPropertiesBindings::install(ctx); // wraps appendChild/removeChild/innerHTML/insertAdjacentHTML/setAttribute, so runs after ElementBindings/CustomElementsBindings // ── localStorage / sessionStorage ────────────────────────────────────────── { diff --git a/cpp/quickjs/bindings/WindowNamedPropertiesBindings.cpp b/cpp/quickjs/bindings/WindowNamedPropertiesBindings.cpp new file mode 100644 index 0000000..19397ad --- /dev/null +++ b/cpp/quickjs/bindings/WindowNamedPropertiesBindings.cpp @@ -0,0 +1,202 @@ +#include "WindowNamedPropertiesBindings.hpp" +#include + +namespace margelo::nitro::nitrojsdom { + +namespace { + +const char* kWindowNamedPropertiesBootstrapScript = R"JS( +(function() { + var NAME_ATTR_TAGS = { embed: 1, form: 1, img: 1, object: 1, iframe: 1, frame: 1 }; + + // registry: key -> element[] currently claiming it. owned: key -> true once + // this module has written globalThis[key] (so it knows it's safe to delete + // or reassign later without clobbering something else's global). + var registry = Object.create(null); + var owned = Object.create(null); + + function isConnected(el) { + var docEl = document.documentElement; + var n = el; + while (n) { + if (n === docEl) return true; + n = n.parentNode; + } + return false; + } + + function refreshKey(key) { + var els = registry[key]; + if (!els || els.length === 0) { + if (owned[key]) { delete globalThis[key]; delete owned[key]; } + return; + } + if (!owned[key] && (key in globalThis)) return; // don't clobber a real global + globalThis[key] = els.length === 1 ? els[0] : els.slice(); + owned[key] = true; + } + + function addToRegistry(key, el) { + if (!key) return; + var els = registry[key] || (registry[key] = []); + if (els.indexOf(el) === -1) els.push(el); + refreshKey(key); + } + + function removeFromRegistry(key, el) { + if (!key) return; + var els = registry[key]; + if (!els) return; + var idx = els.indexOf(el); + if (idx !== -1) els.splice(idx, 1); + refreshKey(key); + } + + function keysFor(el) { + var keys = []; + var id = el.getAttribute ? el.getAttribute('id') : null; + if (id) keys.push(id); + var tag = el.tagName ? el.tagName.toLowerCase() : ''; + if (NAME_ATTR_TAGS[tag]) { + var name = el.getAttribute('name'); + if (name) keys.push(name); + } + return keys; + } + + function registerIfConnected(el) { + if (!el || el.nodeType !== 1 || !isConnected(el)) return; + keysFor(el).forEach(function(k) { addToRegistry(k, el); }); + } + + function unregister(el) { + if (!el || el.nodeType !== 1) return; + keysFor(el).forEach(function(k) { removeFromRegistry(k, el); }); + } + + function registerSubtree(root) { + if (!root) return; + if (root.nodeType === 1) registerIfConnected(root); + if (typeof root.querySelectorAll !== 'function') return; + var found = root.querySelectorAll('*'); + for (var i = 0; i < found.length; i++) registerIfConnected(found[i]); + } + + function unregisterSubtree(root) { + if (!root) return; + if (root.nodeType === 1) unregister(root); + if (typeof root.querySelectorAll !== 'function') return; + var found = root.querySelectorAll('*'); + for (var i = 0; i < found.length; i++) unregister(found[i]); + } + + // ── Initial population: elements already present in the parsed document ── + if (document.documentElement) registerSubtree(document.documentElement); + + // ── appendChild / removeChild ───────────────────────────────────────── + var DOCUMENT_FRAGMENT_NODE = 11; + + var origAppendChild = Node.prototype.appendChild; + Node.prototype.appendChild = function(child) { + var moved = (child && child.nodeType === DOCUMENT_FRAGMENT_NODE) + ? Array.prototype.slice.call(child.childNodes) + : [child]; + var result = origAppendChild.call(this, child); + moved.forEach(registerSubtree); + return result; + }; + + var origRemoveChild = Node.prototype.removeChild; + Node.prototype.removeChild = function(child) { + unregisterSubtree(child); + return origRemoveChild.call(this, child); + }; + + var origRemove = Element.prototype.remove; + Element.prototype.remove = function() { + unregisterSubtree(this); + return origRemove.call(this); + }; + + // ── innerHTML ──────────────────────────────────────────────────────── + var innerHTMLDesc = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML'); + if (innerHTMLDesc && innerHTMLDesc.set) { + var origInnerHTMLSet = innerHTMLDesc.set; + Object.defineProperty(Element.prototype, 'innerHTML', { + get: innerHTMLDesc.get, + set: function(html) { + unregisterSubtree(this); // walk the OLD content before it's replaced + origInnerHTMLSet.call(this, html); + registerSubtree(this); // register the NEW content + }, + enumerable: innerHTMLDesc.enumerable, + configurable: true, + }); + } + + // ── insertAdjacentHTML ────────────────────────────────────────────── + var origInsertAdjacentHTML = Element.prototype.insertAdjacentHTML; + Element.prototype.insertAdjacentHTML = function(position, html) { + origInsertAdjacentHTML.call(this, position, html); + var pos = String(position).toLowerCase(); + registerSubtree((pos === 'beforebegin' || pos === 'afterend') ? (this.parentNode || this) : this); + }; + + // ── element.id (native accessor property — bypasses setAttribute) ── + var idDesc = Object.getOwnPropertyDescriptor(Element.prototype, 'id'); + if (idDesc && idDesc.set) { + var origIdSet = idDesc.set; + Object.defineProperty(Element.prototype, 'id', { + get: idDesc.get, + set: function(value) { + var oldValue = this.getAttribute('id'); + origIdSet.call(this, value); + if (oldValue) removeFromRegistry(oldValue, this); + registerIfConnected(this); + }, + enumerable: idDesc.enumerable, + configurable: true, + }); + } + + // ── setAttribute / removeAttribute (id/name changes) ─────────────── + function isTrackedAttr(el, name) { + var lower = String(name).toLowerCase(); + if (lower === 'id') return true; + var tag = el.tagName ? el.tagName.toLowerCase() : ''; + return lower === 'name' && !!NAME_ATTR_TAGS[tag]; + } + + var origSetAttribute = Element.prototype.setAttribute; + Element.prototype.setAttribute = function(name, value) { + var tracked = isTrackedAttr(this, name); + var oldValue = tracked ? this.getAttribute(name) : null; + origSetAttribute.call(this, name, value); + if (tracked) { + if (oldValue) removeFromRegistry(oldValue, this); + registerIfConnected(this); + } + }; + + var origRemoveAttribute = Element.prototype.removeAttribute; + Element.prototype.removeAttribute = function(name) { + var tracked = isTrackedAttr(this, name); + var oldValue = tracked ? this.getAttribute(name) : null; + origRemoveAttribute.call(this, name); + if (tracked && oldValue) removeFromRegistry(oldValue, this); + }; +})(); +)JS"; + +} // namespace + +void WindowNamedPropertiesBindings::install(JSContext* ctx) { + JSValue result = JS_Eval(ctx, kWindowNamedPropertiesBootstrapScript, strlen(kWindowNamedPropertiesBootstrapScript), + "", 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/WindowNamedPropertiesBindings.hpp b/cpp/quickjs/bindings/WindowNamedPropertiesBindings.hpp new file mode 100644 index 0000000..7e0a087 --- /dev/null +++ b/cpp/quickjs/bindings/WindowNamedPropertiesBindings.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include "quickjs.h" + +namespace margelo::nitro::nitrojsdom { + +// Implements "named access on the Window object" +// (https://html.spec.whatwg.org/#named-access-on-the-window-object): an +// element with an `id`, or a `name` attribute on one of `embed`/`form`/ +// `img`/`object`/`iframe`/`frame`, becomes reachable as a bare global while +// connected to the primary document — e.g. `
` lets a +// script write `countdown.textContent = ...` without `getElementById`. This +// is a very common pattern in real-world CMS-embedded widget scripts (this +// project's core use case), which predate/skip `querySelector`. +// +// Pure JS, built entirely on top of already-exposed primitives (appendChild, +// removeChild, remove, innerHTML, insertAdjacentHTML, setAttribute, +// removeAttribute, the `id` accessor, querySelectorAll) via monkey-patching — +// no new native bindings, no engine-level Proxy on the global object +// (QuickJS's actual global object, the one unqualified identifiers resolve +// against, can't be swapped for a Proxy — so exposure is push-based: a real +// data property is set/deleted on `globalThis` as elements +// connect/disconnect/rename). +// +// removeChild()/remove() unregister BEFORE calling through to the native +// implementation, not after: `js_el_removeChild` invalidates the removed +// node's wrapper-cache entry (nulls its opaque pointer, since the detached +// node's underlying memory can be reused), so `getAttribute('id')` on it +// afterward silently returns nothing. innerHTML's old-content walk has the +// same requirement and already ran before-replacement for the same reason. +// +// Scope/limitations (documented rather than silently wrong): +// - Connectivity is tracked via appendChild/removeChild/remove()/innerHTML +// assignment/insertAdjacentHTML/setAttribute("id"|"name")/removeAttribute, +// plus a one-time walk of the initially-parsed document at install time. +// `element.id = ...` is also hooked separately from setAttribute: it's a +// native accessor property (ElementBindings' js_el_get_id/js_el_set_id) +// that writes the attribute directly in C++, bypassing the JS-level +// setAttribute monkey-patch entirely — `name` has no equivalent accessor +// (only reachable via get/setAttribute), so it needed no separate hook. +// Other insertion paths (insertBefore, before/after/replaceWith/append/ +// prepend, insertAdjacentElement) are not hooked — the same scope +// CustomElementsBindings' connectivity tracking already accepted, for the +// same reason (JS-level monkey-patching can't reach every native +// mutation entry point without becoming a second full copy of +// ElementBindings' insertion logic). +// - Shadow DOM content is correctly never registered: connectivity is +// derived by walking `parentNode` up to `document.documentElement`, which +// a shadow tree's root never reaches (per spec, named window access does +// not cross shadow boundaries). +// - Duplicate ids/names resolve to a plain (non-live) array of the matching +// elements, not a live `HTMLCollection` — the same static-array trade-off +// already made for `form.elements`/`element.labels`. +// - A key already present on `globalThis` before this module claims it +// (any real builtin/global) is never overwritten — named access only +// fills in previously-unclaimed identifiers, and only this module's own +// previously-claimed keys are ever deleted/reassigned later. +// +// Must run after ElementBindings, DocumentBindings, and ShadowRootBindings +// (wraps their prototypes) — CustomElementsBindings is not a hard +// dependency, but installing after it means this module's hooks wrap +// CustomElements' own wrapped appendChild/removeChild/innerHTML/ +// insertAdjacentHTML/setAttribute/removeAttribute rather than the other way +// around, which is arbitrary but keeps install order consistent. +struct WindowNamedPropertiesBindings { + static void install(JSContext* ctx); +}; + +} // namespace margelo::nitro::nitrojsdom diff --git a/docs/overview.md b/docs/overview.md index 72088bb..0ab92a6 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -608,6 +608,46 @@ dom.dispose() // ← always pair with create() the same reason: no consumer in this project's scope needs part-level output over the plain formatted string). +### v0.20 — Named Access on the Window Object +> Found by diffing this project's globals against jsdom's own +> `lib/jsdom/living/window-properties.js`, which implements the HTML spec's +> ["named access on the Window +> object"](https://html.spec.whatwg.org/#named-access-on-the-window-object). +> Directly relevant to this project's core use case: real-world CMS-embedded +> widget scripts (this project's stated target) very commonly reference an +> element's `id` as a bare global (`countdown.textContent = ...`) instead of +> `getElementById`/`querySelector`, especially in older or copy-pasted +> snippets. +- [x] An element with an `id`, or a `name` attribute on `embed`/`form`/`img`/ + `object`/`iframe`/`frame`, becomes reachable as a `globalThis` property + while connected to the primary document. Implemented as a push-based + registry (QuickJS's actual global object — the one unqualified + identifiers resolve against — can't be swapped for a `Proxy`, so there's + no lazy/on-demand fallback available; every connect/disconnect/rename + eagerly sets or deletes a real data property instead), populated at + install time from the initially-parsed document and kept in sync via + `appendChild`/`removeChild`/`remove()`/`innerHTML`/`insertAdjacentHTML`/ + `setAttribute`/`removeAttribute`/`element.id` (the last one hooked + separately — it's a native accessor that bypasses `setAttribute` + entirely) monkey-patches — the same JS-level + wrapping approach and the same insertion-path scope + `CustomElementsBindings`' connectivity tracking already settled on + (`insertBefore`/`before`/`after`/`replaceWith`/`append`/`prepend`/ + `insertAdjacentElement` are not hooked). Never overwrites a key that + already exists on `globalThis` before this module claims it. Duplicate + ids/names resolve to a plain (non-live) array, the same static-array + trade-off already made for `form.elements`/`element.labels`. Shadow DOM + content is correctly excluded (connectivity is derived by walking + `parentNode` to `document.documentElement`, which a shadow tree never + reaches). `removeChild()`/`remove()` unregister *before* calling through + to the native implementation rather than after: `js_el_removeChild` + nulls the removed node's wrapper-cache opaque pointer (the detached + node's memory can be reused), so `getAttribute('id')` on it afterward + silently returns nothing — this cost a real debugging pass (a `removeChild()` + test kept seeing the global stay defined) before landing on the + before/after ordering `innerHTML`'s old-content walk already used for + the same reason. + --- ## Repository Structure diff --git a/example/src/__harness__/JSDOM.namedaccess.harness.ts b/example/src/__harness__/JSDOM.namedaccess.harness.ts new file mode 100644 index 0000000..7b7e33d --- /dev/null +++ b/example/src/__harness__/JSDOM.namedaccess.harness.ts @@ -0,0 +1,180 @@ +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 named access on window', () => { + let dom: JSDOM | undefined; + + afterEach(() => { + dom?.dispose(); + dom = undefined; + }); + + it('an element with an id already in the parsed document is reachable as a bare global', async () => { + dom = JSDOM.create('
3 days left
'); + const result = await dom.evaluate(` + JSON.stringify({ + bare: countdown.textContent, + viaWindow: window.countdown.textContent, + sameNode: countdown === document.getElementById('countdown'), + }); + `); + expect(JSON.parse(result)).toEqual({ + bare: '3 days left', + viaWindow: '3 days left', + sameNode: true, + }); + }); + + it('a "name" attribute is exposed only for embed/form/img/object/iframe/frame, not arbitrary elements', async () => { + dom = JSDOM.create(` + +
+ + + + `); + const result = await dom.evaluate(` + JSON.stringify({ + form: typeof myForm !== 'undefined' && myForm.tagName, + img: typeof myImg !== 'undefined' && myImg.tagName, + span: typeof myShouldNotAppear, + }); + `); + expect(JSON.parse(result)).toEqual({ + form: 'FORM', + img: 'IMG', + span: 'undefined', + }); + }); + + it('a dynamically created element is not reachable before being connected', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + const a = document.createElement('div'); + a.id = 'widgetA'; + typeof widgetA; + `); + expect(result).toBe('undefined'); + }); + + it('appendChild() connects a dynamically created element, and it resolves to the same object', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + const a = document.createElement('div'); + a.id = 'widgetA'; + document.body.appendChild(a); + JSON.stringify({ typeofWidgetA: typeof widgetA, sameObject: typeof widgetA !== 'undefined' && widgetA === a }); + `); + expect(JSON.parse(result)).toEqual({ typeofWidgetA: 'object', sameObject: true }); + }); + + it('removeChild() disconnects a dynamically created element', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + const a = document.createElement('div'); + a.id = 'widgetA'; + document.body.appendChild(a); + document.body.removeChild(a); + typeof widgetA; + `); + expect(result).toBe('undefined'); + }); + + it('element.remove() disconnects a dynamically created element', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + const b = document.createElement('div'); + b.id = 'widgetB'; + document.body.appendChild(b); + const beforeRemove = typeof widgetB; + b.remove(); + JSON.stringify({ beforeRemove, afterRemove: typeof widgetB }); + `); + expect(JSON.parse(result)).toEqual({ beforeRemove: 'object', afterRemove: 'undefined' }); + }); + + it('innerHTML assignment disconnects the old subtree and connects the new one', async () => { + dom = JSDOM.create('
old
'); + const result = await dom.evaluate(` + const before = typeof old; + container.innerHTML = 'fresh'; + JSON.stringify({ before, afterOld: typeof old, fresh: typeof fresh !== 'undefined' && fresh.textContent }); + `); + expect(JSON.parse(result)).toEqual({ before: 'object', afterOld: 'undefined', fresh: 'fresh' }); + }); + + it('insertAdjacentHTML connects newly inserted elements', async () => { + dom = JSDOM.create('
'); + const result = await dom.evaluate(` + container.insertAdjacentHTML('beforeend', '-20%'); + JSON.stringify(typeof badge !== 'undefined' && badge.textContent); + `); + expect(JSON.parse(result)).toBe('-20%'); + }); + + it("changing an element's id via setAttribute()/removeAttribute() updates which global resolves", async () => { + dom = JSDOM.create('
hi
'); + const result = await dom.evaluate(` + const log = []; + log.push(typeof oldName); + oldName.setAttribute('id', 'newName'); + log.push(typeof oldName, typeof newName, newName.textContent); + newName.removeAttribute('id'); + log.push(typeof newName); + JSON.stringify(log); + `); + expect(JSON.parse(result)).toEqual(['object', 'undefined', 'object', 'hi', 'undefined']); + }); + + it('duplicate ids resolve to a plain array of the matching elements', async () => { + dom = JSDOM.create(` + +
a
+
b
+ + `); + const result = await dom.evaluate(` + JSON.stringify({ + isArray: Array.isArray(dup), + length: dup.length, + classes: dup.map((el) => el.className), + }); + `); + expect(JSON.parse(result)).toEqual({ isArray: true, length: 2, classes: ['first', 'second'] }); + }); + + it('never overwrites a key that already exists as a real global', async () => { + dom = JSDOM.create('
not the real document
'); + const result = await dom.evaluate(` + JSON.stringify({ + stillRealDocument: document.nodeType === 9, + foundViaQuery: document.getElementById('document').textContent, + }); + `); + expect(JSON.parse(result)).toEqual({ stillRealDocument: true, foundViaQuery: 'not the real document' }); + }); + + it('elements inside a shadow root are not exposed as named globals', async () => { + dom = JSDOM.create('
'); + const result = await dom.evaluate(` + const root = host.attachShadow({ mode: 'open' }); + root.innerHTML = 'hi'; + JSON.stringify(typeof insideShadow); + `); + expect(JSON.parse(result)).toBe('undefined'); + }); + + it('documented limitation: insertBefore() does not register the inserted element (only appendChild/removeChild/remove/innerHTML/insertAdjacentHTML/setAttribute are hooked)', async () => { + dom = JSDOM.create('
ref
'); + const result = await dom.evaluate(` + const el = document.createElement('div'); + el.id = 'notHooked'; + container.insertBefore(el, ref); + JSON.stringify(typeof notHooked); + `); + expect(JSON.parse(result)).toBe('undefined'); + }); +}); From 41599647b9d071ed50760bb3a3b1cb3882da24b7 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sat, 25 Jul 2026 16:35:07 -0300 Subject: [PATCH 3/7] docs(changelog): remove erroneous breaking-change note from v2.0.0 Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a267ce..17ba41e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,5 @@ ## [2.0.0](https://github.com/Salve-Software/react-native-nitro-jsdom/compare/v1.1.0...v2.0.0) (2026-07-24) -### ⚠ BREAKING CHANGES - -* **release:** commits were mapped to a minor release, same as a -plain feat, so no commit could ever trigger a major bump. Regular -feat/fix commits are unaffected — only commits marked with ! or a -* **release:** footer are impacted. - -Co-Authored-By: Claude Sonnet 5 - ### ✨ Features * **blob:** add File constructor ([6a52021](https://github.com/Salve-Software/react-native-nitro-jsdom/commit/6a5202123fd60262bc7120ff4eca2d754a09c941)) From b1ed1fa73f94941dd248e4b97be09bbc4d418ad8 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sat, 25 Jul 2026 16:35:11 -0300 Subject: [PATCH 4/7] build(ios): bump CocoaPods lockfile version to 1.16.2 Co-Authored-By: Claude Sonnet 5 --- example/ios/Podfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 328306a..b99558b 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2217,4 +2217,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 300091531b06c93ba8f4fee738e78f886957690a -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 From 20f58d07752f7b414a0c216598373115f542df9a Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sat, 25 Jul 2026 19:20:15 -0300 Subject: [PATCH 5/7] fix(intl): don't round values for RelativeTimeFormat numeric:auto format(1.2, 'day') was rounding to 1 before checking the idiomatic- phrase map, incorrectly returning "tomorrow" instead of falling through to numeric output. Address CodeRabbit review on PR #50. Co-Authored-By: Claude Sonnet 5 --- cpp/quickjs/bindings/IntlBindings.cpp | 5 ++--- cpp/quickjs/bindings/IntlBindings.hpp | 9 ++++++--- example/src/__harness__/JSDOM.intl.harness.ts | 2 ++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/cpp/quickjs/bindings/IntlBindings.cpp b/cpp/quickjs/bindings/IntlBindings.cpp index d2ff593..a03ad5a 100644 --- a/cpp/quickjs/bindings/IntlBindings.cpp +++ b/cpp/quickjs/bindings/IntlBindings.cpp @@ -299,9 +299,8 @@ const char* kIntlBootstrapScript = R"JS( if (this._numeric === 'auto') { var autoData = RTF_AUTO[this._lang][unit]; - var rounded = Math.round(value); - if (autoData && autoData[String(rounded)] !== undefined) { - return autoData[String(rounded)]; + if (autoData && autoData[String(value)] !== undefined) { + return autoData[String(value)]; } } diff --git a/cpp/quickjs/bindings/IntlBindings.hpp b/cpp/quickjs/bindings/IntlBindings.hpp index 024be54..732f848 100644 --- a/cpp/quickjs/bindings/IntlBindings.hpp +++ b/cpp/quickjs/bindings/IntlBindings.hpp @@ -16,9 +16,12 @@ namespace margelo::nitro::nitrojsdom { // but 'short'/'narrow' render identically to 'long' — there's no abbreviated // unit-name table, same trade-off DateTimeFormat already made collapsing // timeStyle 'long'/'full'. `numeric: 'auto'` only has special phrasing -// ("yesterday", "next week", ...) for day/week/month/year — hour/minute/ -// quarter always render numerically, matching real Intl's own behavior for -// units without a natural auto phrase. +// ("yesterday", "next week", ..., plus "now"/"agora" for second: 0) for +// day/week/month/year/second — hour/minute/quarter always render +// numerically, matching real Intl's own behavior for units without a +// natural auto phrase. Auto phrasing only ever applies to exact supported +// integer values (e.g. -1/0/1) — a fractional value like 1.2 always falls +// through to numeric formatting rather than rounding into a phrase. struct IntlBindings { static void install(JSContext* ctx); }; diff --git a/example/src/__harness__/JSDOM.intl.harness.ts b/example/src/__harness__/JSDOM.intl.harness.ts index 2ba1caf..571d41c 100644 --- a/example/src/__harness__/JSDOM.intl.harness.ts +++ b/example/src/__harness__/JSDOM.intl.harness.ts @@ -232,6 +232,7 @@ describe('JSDOM Intl.NumberFormat/DateTimeFormat/PluralRules/RelativeTimeFormat' enTomorrow: en.format(1, 'day'), enNextWeek: en.format(1, 'week'), enFallsBackToNumeric: en.format(3, 'day'), + enFractionalFallsBackToNumeric: en.format(1.2, 'day'), enHourHasNoAutoPhrase: en.format(-1, 'hour'), ptOntem: pt.format(-1, 'day'), ptHoje: pt.format(0, 'day'), @@ -244,6 +245,7 @@ describe('JSDOM Intl.NumberFormat/DateTimeFormat/PluralRules/RelativeTimeFormat' enTomorrow: 'tomorrow', enNextWeek: 'next week', enFallsBackToNumeric: 'in 3 days', + enFractionalFallsBackToNumeric: 'in 1.2 days', enHourHasNoAutoPhrase: '1 hour ago', ptOntem: 'ontem', ptHoje: 'hoje', From 90b2e8c53ee57869ec9ecff67b4772c5a3bac325 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sat, 25 Jul 2026 19:20:24 -0300 Subject: [PATCH 6/7] fix(dom): harden named-access registry mutations - Hook element.textContent: the native setter destroys old children without going through removeChild, so ids/names on those children were left stale in the registry. - removeChild()/remove() now check parentNode before committing the unregistration, instead of unregistering unconditionally: both silently no-op on the wrong parent/no parent, so a still-connected element no longer disappears from named access. - Install WindowNamedPropertiesBindings after localStorage/ sessionStorage are set up, so it can never claim (and later delete) those globals. Address CodeRabbit review on PR #50. Co-Authored-By: Claude Sonnet 5 --- cpp/quickjs/DOMBindings.cpp | 3 +- .../WindowNamedPropertiesBindings.cpp | 47 +++++++++++-- .../WindowNamedPropertiesBindings.hpp | 67 ++++--------------- .../__harness__/JSDOM.namedaccess.harness.ts | 31 +++++++++ 4 files changed, 90 insertions(+), 58 deletions(-) diff --git a/cpp/quickjs/DOMBindings.cpp b/cpp/quickjs/DOMBindings.cpp index dcf9beb..d5bac0c 100644 --- a/cpp/quickjs/DOMBindings.cpp +++ b/cpp/quickjs/DOMBindings.cpp @@ -69,7 +69,6 @@ void DOMBindings::install(QuickJSRuntime* runtime, LexborDocument* document) { LayoutStubBindings::install(ctx); // needs Element's proto + globalThis.document to exist TreeWalkerBindings::install(ctx); // needs Element's proto (Node traversal props) + globalThis.document to exist EventTargetBindings::install(ctx); // no dependencies - WindowNamedPropertiesBindings::install(ctx); // wraps appendChild/removeChild/innerHTML/insertAdjacentHTML/setAttribute, so runs after ElementBindings/CustomElementsBindings // ── localStorage / sessionStorage ────────────────────────────────────────── { @@ -92,6 +91,8 @@ void DOMBindings::install(QuickJSRuntime* runtime, LexborDocument* document) { rctx->mutation_observers->install(ctx, doc_node); } } + + WindowNamedPropertiesBindings::install(ctx); // must run last, after localStorage/sessionStorage exist } } // namespace margelo::nitro::nitrojsdom diff --git a/cpp/quickjs/bindings/WindowNamedPropertiesBindings.cpp b/cpp/quickjs/bindings/WindowNamedPropertiesBindings.cpp index 19397ad..fea5a3c 100644 --- a/cpp/quickjs/bindings/WindowNamedPropertiesBindings.cpp +++ b/cpp/quickjs/bindings/WindowNamedPropertiesBindings.cpp @@ -90,6 +90,25 @@ const char* kWindowNamedPropertiesBootstrapScript = R"JS( for (var i = 0; i < found.length; i++) unregister(found[i]); } + function collectSubtreeKeys(root) { + var pending = []; + function collect(el) { + if (!el || el.nodeType !== 1) return; + keysFor(el).forEach(function(k) { pending.push({ key: k, el: el }); }); + } + if (!root) return pending; + collect(root); + if (typeof root.querySelectorAll === 'function') { + var found = root.querySelectorAll('*'); + for (var i = 0; i < found.length; i++) collect(found[i]); + } + return pending; + } + + function commitUnregister(pending) { + pending.forEach(function(entry) { removeFromRegistry(entry.key, entry.el); }); + } + // ── Initial population: elements already present in the parsed document ── if (document.documentElement) registerSubtree(document.documentElement); @@ -108,14 +127,20 @@ const char* kWindowNamedPropertiesBootstrapScript = R"JS( var origRemoveChild = Node.prototype.removeChild; Node.prototype.removeChild = function(child) { - unregisterSubtree(child); - return origRemoveChild.call(this, child); + var pending = collectSubtreeKeys(child); + var willRemove = child && child.parentNode === this; + var result = origRemoveChild.call(this, child); + if (willRemove) commitUnregister(pending); + return result; }; var origRemove = Element.prototype.remove; Element.prototype.remove = function() { - unregisterSubtree(this); - return origRemove.call(this); + var pending = collectSubtreeKeys(this); + var hadParent = this.parentNode !== null; + var result = origRemove.call(this); + if (hadParent) commitUnregister(pending); + return result; }; // ── innerHTML ──────────────────────────────────────────────────────── @@ -134,6 +159,20 @@ const char* kWindowNamedPropertiesBootstrapScript = R"JS( }); } + var textContentDesc = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent'); + if (textContentDesc && textContentDesc.set) { + var origTextContentSet = textContentDesc.set; + Object.defineProperty(Node.prototype, 'textContent', { + get: textContentDesc.get, + set: function(value) { + unregisterSubtree(this); + origTextContentSet.call(this, value); + }, + enumerable: textContentDesc.enumerable, + configurable: true, + }); + } + // ── insertAdjacentHTML ────────────────────────────────────────────── var origInsertAdjacentHTML = Element.prototype.insertAdjacentHTML; Element.prototype.insertAdjacentHTML = function(position, html) { diff --git a/cpp/quickjs/bindings/WindowNamedPropertiesBindings.hpp b/cpp/quickjs/bindings/WindowNamedPropertiesBindings.hpp index 7e0a087..b6ff8d7 100644 --- a/cpp/quickjs/bindings/WindowNamedPropertiesBindings.hpp +++ b/cpp/quickjs/bindings/WindowNamedPropertiesBindings.hpp @@ -6,62 +6,23 @@ namespace margelo::nitro::nitrojsdom { // Implements "named access on the Window object" // (https://html.spec.whatwg.org/#named-access-on-the-window-object): an -// element with an `id`, or a `name` attribute on one of `embed`/`form`/ -// `img`/`object`/`iframe`/`frame`, becomes reachable as a bare global while -// connected to the primary document — e.g. `
` lets a -// script write `countdown.textContent = ...` without `getElementById`. This -// is a very common pattern in real-world CMS-embedded widget scripts (this -// project's core use case), which predate/skip `querySelector`. +// element with an `id`, or a `name` attribute on `embed`/`form`/`img`/ +// `object`/`iframe`/`frame`, becomes reachable as a bare global while +// connected to the primary document. // -// Pure JS, built entirely on top of already-exposed primitives (appendChild, -// removeChild, remove, innerHTML, insertAdjacentHTML, setAttribute, -// removeAttribute, the `id` accessor, querySelectorAll) via monkey-patching — -// no new native bindings, no engine-level Proxy on the global object -// (QuickJS's actual global object, the one unqualified identifiers resolve -// against, can't be swapped for a Proxy — so exposure is push-based: a real -// data property is set/deleted on `globalThis` as elements -// connect/disconnect/rename). +// Pure JS, monkey-patching appendChild/removeChild/remove/innerHTML/ +// textContent/insertAdjacentHTML/setAttribute/removeAttribute/the `id` +// accessor — no new native bindings, no engine-level Proxy (QuickJS's real +// global object can't be swapped for one), so exposure is push-based. // -// removeChild()/remove() unregister BEFORE calling through to the native -// implementation, not after: `js_el_removeChild` invalidates the removed -// node's wrapper-cache entry (nulls its opaque pointer, since the detached -// node's underlying memory can be reused), so `getAttribute('id')` on it -// afterward silently returns nothing. innerHTML's old-content walk has the -// same requirement and already ran before-replacement for the same reason. +// Duplicate ids/names resolve to a plain array, not a live `HTMLCollection` +// (same trade-off as `form.elements`/`element.labels`). insertBefore/before/ +// after/replaceWith/append/prepend/insertAdjacentElement are not hooked +// (same scope CustomElementsBindings' connectivity tracking accepted). +// Never overwrites a key already present on `globalThis`. // -// Scope/limitations (documented rather than silently wrong): -// - Connectivity is tracked via appendChild/removeChild/remove()/innerHTML -// assignment/insertAdjacentHTML/setAttribute("id"|"name")/removeAttribute, -// plus a one-time walk of the initially-parsed document at install time. -// `element.id = ...` is also hooked separately from setAttribute: it's a -// native accessor property (ElementBindings' js_el_get_id/js_el_set_id) -// that writes the attribute directly in C++, bypassing the JS-level -// setAttribute monkey-patch entirely — `name` has no equivalent accessor -// (only reachable via get/setAttribute), so it needed no separate hook. -// Other insertion paths (insertBefore, before/after/replaceWith/append/ -// prepend, insertAdjacentElement) are not hooked — the same scope -// CustomElementsBindings' connectivity tracking already accepted, for the -// same reason (JS-level monkey-patching can't reach every native -// mutation entry point without becoming a second full copy of -// ElementBindings' insertion logic). -// - Shadow DOM content is correctly never registered: connectivity is -// derived by walking `parentNode` up to `document.documentElement`, which -// a shadow tree's root never reaches (per spec, named window access does -// not cross shadow boundaries). -// - Duplicate ids/names resolve to a plain (non-live) array of the matching -// elements, not a live `HTMLCollection` — the same static-array trade-off -// already made for `form.elements`/`element.labels`. -// - A key already present on `globalThis` before this module claims it -// (any real builtin/global) is never overwritten — named access only -// fills in previously-unclaimed identifiers, and only this module's own -// previously-claimed keys are ever deleted/reassigned later. -// -// Must run after ElementBindings, DocumentBindings, and ShadowRootBindings -// (wraps their prototypes) — CustomElementsBindings is not a hard -// dependency, but installing after it means this module's hooks wrap -// CustomElements' own wrapped appendChild/removeChild/innerHTML/ -// insertAdjacentHTML/setAttribute/removeAttribute rather than the other way -// around, which is arbitrary but keeps install order consistent. +// Must run last in DOMBindings::install (after localStorage/sessionStorage +// exist, so it never claims those keys). struct WindowNamedPropertiesBindings { static void install(JSContext* ctx); }; diff --git a/example/src/__harness__/JSDOM.namedaccess.harness.ts b/example/src/__harness__/JSDOM.namedaccess.harness.ts index 7b7e33d..4f102a1 100644 --- a/example/src/__harness__/JSDOM.namedaccess.harness.ts +++ b/example/src/__harness__/JSDOM.namedaccess.harness.ts @@ -33,6 +33,10 @@ describe('JSDOM named access on window', () => {
+ + + + `); @@ -40,12 +44,20 @@ describe('JSDOM named access on window', () => { JSON.stringify({ form: typeof myForm !== 'undefined' && myForm.tagName, img: typeof myImg !== 'undefined' && myImg.tagName, + embed: typeof myEmbed !== 'undefined' && myEmbed.tagName, + object: typeof myObject !== 'undefined' && myObject.tagName, + iframe: typeof myIframe !== 'undefined' && myIframe.tagName, + frame: typeof myFrame !== 'undefined' && myFrame.tagName, span: typeof myShouldNotAppear, }); `); expect(JSON.parse(result)).toEqual({ form: 'FORM', img: 'IMG', + embed: 'EMBED', + object: 'OBJECT', + iframe: 'IFRAME', + frame: 'FRAME', span: 'undefined', }); }); @@ -106,6 +118,25 @@ describe('JSDOM named access on window', () => { expect(JSON.parse(result)).toEqual({ before: 'object', afterOld: 'undefined', fresh: 'fresh' }); }); + it('textContent assignment disconnects the old subtree', async () => { + dom = JSDOM.create('
old
'); + const result = await dom.evaluate(` + const before = typeof old2; + container.textContent = 'plain text'; + JSON.stringify({ before, after: typeof old2 }); + `); + expect(JSON.parse(result)).toEqual({ before: 'object', after: 'undefined' }); + }); + + it('removeChild() with the wrong parent is a no-op and leaves the registry untouched', async () => { + dom = JSDOM.create('
x
'); + const result = await dom.evaluate(` + b.removeChild(child1); + JSON.stringify({ stillReachable: typeof child1, stillUnderA: child1.parentNode === a }); + `); + expect(JSON.parse(result)).toEqual({ stillReachable: 'object', stillUnderA: true }); + }); + it('insertAdjacentHTML connects newly inserted elements', async () => { dom = JSDOM.create('
'); const result = await dom.evaluate(` From bbb842302743570b25fcd19ea86e322e7dae1ee5 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Sat, 25 Jul 2026 19:22:02 -0300 Subject: [PATCH 7/7] fix(test): test frame's name attribute via createElement inside is dropped by HTML parsing (only valid inside ), so the parsed-HTML fixture never actually created the element. Test it via createElement + appendChild instead. Co-Authored-By: Claude Sonnet 5 --- .../src/__harness__/JSDOM.namedaccess.harness.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/example/src/__harness__/JSDOM.namedaccess.harness.ts b/example/src/__harness__/JSDOM.namedaccess.harness.ts index 4f102a1..6e93dcf 100644 --- a/example/src/__harness__/JSDOM.namedaccess.harness.ts +++ b/example/src/__harness__/JSDOM.namedaccess.harness.ts @@ -28,7 +28,7 @@ describe('JSDOM named access on window', () => { }); }); - it('a "name" attribute is exposed only for embed/form/img/object/iframe/frame, not arbitrary elements', async () => { + it('a "name" attribute is exposed only for embed/form/img/object/iframe, not arbitrary elements', async () => { dom = JSDOM.create(`
@@ -36,7 +36,6 @@ describe('JSDOM named access on window', () => { - `); @@ -47,7 +46,6 @@ describe('JSDOM named access on window', () => { embed: typeof myEmbed !== 'undefined' && myEmbed.tagName, object: typeof myObject !== 'undefined' && myObject.tagName, iframe: typeof myIframe !== 'undefined' && myIframe.tagName, - frame: typeof myFrame !== 'undefined' && myFrame.tagName, span: typeof myShouldNotAppear, }); `); @@ -57,11 +55,21 @@ describe('JSDOM named access on window', () => { embed: 'EMBED', object: 'OBJECT', iframe: 'IFRAME', - frame: 'FRAME', span: 'undefined', }); }); + it('a "name" attribute is exposed for a dynamically created frame element', async () => { + dom = JSDOM.create(''); + const result = await dom.evaluate(` + const el = document.createElement('frame'); + el.setAttribute('name', 'myFrame'); + document.body.appendChild(el); + JSON.stringify(typeof myFrame !== 'undefined' && myFrame.tagName); + `); + expect(JSON.parse(result)).toBe('FRAME'); + }); + it('a dynamically created element is not reachable before being connected', async () => { dom = JSDOM.create(''); const result = await dom.evaluate(`