From 29e20ae9700121acff379e63b1a040e82f82ca31 Mon Sep 17 00:00:00 2001 From: Charlie / Date: Sun, 19 Jul 2026 21:12:10 +0200 Subject: [PATCH] fix(js): implement Element scrollBy/scrollTo/scroll and functional scrollTop/scrollLeft (#431) Element.prototype.scrollBy, scrollTo and scroll were undefined; they only existed as no-ops on the window object. Scroll-driven infinite-scroll code therefore threw "scrollBy is not a function", and scrollTop/scrollLeft were no-op stubs that always read back 0. Track a per-element scroll offset so scrollTop/scrollLeft round-trip, add the three methods accepting either (x, y) or a ScrollToOptions object, and fire a scroll event so listeners that drive lazy loading can advance. There is no layout engine, so the offset is not clamped to a real overflow maximum. Register the new methods in the native-function list so their toString stays masked. Fixes #429. Co-authored-by: sycatle --- crates/obscura-js/js/bootstrap.js | 35 +++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 67d421f..2fac347 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -2094,8 +2094,13 @@ class Element extends Node { const t = this.tagName; return t === 'HTML' || t === 'BODY'; } - get scrollTop() { return 0; } set scrollTop(v) {} - get scrollLeft() { return 0; } set scrollLeft(v) {} + // No layout engine, so there is no real overflow to scroll. We still track a + // scroll offset so scrollTop/scrollLeft round-trip and the scroll methods + // below can report a position, which is what infinite-scroll code reads back. + get scrollTop() { return this._scrollTop || 0; } + set scrollTop(v) { v = +v; this._scrollTop = Number.isFinite(v) && v > 0 ? v : 0; } + get scrollLeft() { return this._scrollLeft || 0; } + set scrollLeft(v) { v = +v; this._scrollLeft = Number.isFinite(v) && v > 0 ? v : 0; } getBoundingClientRect() { globalThis.__obscura_click_target = this; // documentElement and body span the full viewport. Without this every @@ -2153,6 +2158,31 @@ class Element extends Node { get ariaSelected() { return this.getAttribute('aria-selected'); } set ariaSelected(v) { if (v == null) this.removeAttribute('aria-selected'); else this.setAttribute('aria-selected', String(v)); } scrollIntoView() { globalThis.__obscura_click_target = this; } + // scrollTo/scrollBy/scroll accept either (x, y) or a ScrollToOptions object. + // Without layout the offset cannot be clamped to a real max, but updating it + // and firing a scroll event lets scroll-driven lazy loaders advance instead + // of throwing "scrollBy is not a function". + scrollTo(x, y) { + let left, top; + if (x !== null && typeof x === 'object') { left = x.left; top = x.top; } + else { left = x; top = y; } + if (left !== undefined) this.scrollLeft = +left || 0; + if (top !== undefined) this.scrollTop = +top || 0; + this._fireScroll(); + } + scroll(x, y) { this.scrollTo(x, y); } + scrollBy(x, y) { + let dl, dt; + if (x !== null && typeof x === 'object') { dl = x.left; dt = x.top; } + else { dl = x; dt = y; } + this.scrollLeft = (this.scrollLeft || 0) + (+dl || 0); + this.scrollTop = (this.scrollTop || 0) + (+dt || 0); + this._fireScroll(); + } + _fireScroll() { + const self = this; + setTimeout(() => { try { self.dispatchEvent(new Event('scroll', { bubbles: false })); } catch (e) {} }, 0); + } animate(keyframes, options) { const duration = typeof options === 'number' ? options : (options?.duration || 0); return { @@ -5954,6 +5984,7 @@ _markNative(globalThis.Selection); Element.prototype.cloneNode, Element.prototype.attachShadow, Element.prototype.insertAdjacentHTML, Element.prototype.insertAdjacentText, Element.prototype.insertAdjacentElement, Element.prototype.scrollIntoView, + Element.prototype.scrollTo, Element.prototype.scrollBy, Element.prototype.scroll, Element.prototype.append, Element.prototype.prepend, Element.prototype.remove, Element.prototype.before, Element.prototype.after, Element.prototype.replaceWith, HTMLFormElement.prototype.reset,