Give the document a height and clamp the viewport scroll to it

html and body reported a fixed 100M px height and window.scrollTo never
clamped. The document height is now the tallest of the synthetic node
positions, body's stacked children and the viewport, and the window
can't scroll past its bottom.
This commit is contained in:
Adrià Arrufat committed 2026-09-24 13:40:01 +02:00
1 parent 20b0943a21
commit f5d9f3e737
6 files changed
+92 -21

No files matched your search

+4
View File
@@ -154,6 +154,10 @@ _queued_events: *std.ArrayList(QueuedEvent) = undefined,
_focus_fixup_pending: bool = false,
_style_manager: StyleManager,
// Element.documentHeight, valid while the style version and viewport height
// it was computed for hold.
_document_height: ?struct { version: usize, viewport: u32, value: f64 } = null,
_script_manager: ScriptManager,
_http_owner: HttpClient.Owner,
+10 -5
View File
@@ -337,17 +337,17 @@
testing.expectEqual(0, panel.clientHeight);
testing.expectTrue(panel.scrollHeight > 0);
// The root containers keep their synthetic size rather than summing
// children. The root element's clientHeight is the viewport (CSSOM View):
// jstracker tiles clientWidth/10 cells over clientHeight and calls
// elementFromPoint per cell, which the 100M px box turned into ~5M walks.
// The root containers span the document rather than summing children.
// The root element's clientHeight is the viewport (CSSOM View): jstracker
// tiles clientWidth/10 cells over clientHeight and calls elementFromPoint
// per cell, which a document-sized box turned into ~5M walks.
testing.expectEqual(document.body.clientHeight, document.body.scrollHeight);
testing.expectEqual(window.innerHeight, document.documentElement.clientHeight);
testing.expectEqual(
document.documentElement.offsetHeight,
document.documentElement.scrollHeight,
);
testing.expectTrue(document.documentElement.scrollHeight > document.documentElement.clientHeight);
testing.expectTrue(document.documentElement.scrollHeight >= document.documentElement.clientHeight);
}
</script>
@@ -578,6 +578,11 @@
deep.id = 'deep';
document.body.appendChild(deep);
// Room below it, or the viewport couldn't bring its top up
const room = document.createElement('div');
room.style.height = '10000px';
document.body.appendChild(room);
const targetY = deep.getBoundingClientRect().y;
testing.expectTrue(targetY > 0);
+2
View File
@@ -70,5 +70,7 @@
document.getElementById('rad').addEventListener('click', function() { window.radClicked = true; });
document.getElementById('rad').addEventListener('change', function() { window.radChanged = true; });
</script>
<!-- Room to scroll the window: the document can't scroll past its bottom -->
<div style="height: 10000px"></div>
</body>
</html>
+25
View File
@@ -1,6 +1,9 @@
<!DOCTYPE html>
<script src="testing.js"></script>
<!-- Room to scroll: the document can't scroll past its bottom -->
<div style="height: 10000px"></div>
<script id=scrollBy_exists>
testing.expectEqual('function', typeof window.scrollBy);
</script>
@@ -45,6 +48,28 @@
testing.expectEqual(0, window.scrollY);
</script>
<script id=scrollTo_clamps_to_document>
const max = document.documentElement.scrollHeight - window.innerHeight;
testing.expectTrue(max > 0);
window.scrollTo(0, 99999999);
testing.expectEqual(max, window.scrollY);
window.scrollBy(0, 100);
testing.expectEqual(max, window.scrollY);
// The document has no honest width, so x isn't bounded
window.scrollTo(99999, 0);
testing.expectEqual(99999, window.scrollX);
// Content added at the bottom extends the limit
const more = document.createElement('div');
more.style.height = '5000px';
document.body.appendChild(more);
window.scrollTo(0, 99999999);
testing.expectEqual(max + 5000, window.scrollY);
more.remove();
window.scrollTo(0, 0);
</script>
<script id=element_scroll_events_coalesce_and_preserve_reentrancy type=module>
const state = await testing.async();
const element = document.createElement('div');
+45 -15
View File
@@ -1500,12 +1500,12 @@ pub fn getElementAxis(self: *Element, frame: *Frame, comptime axis: Axis) Axis.S
}
}
// Root containers get large default size to contain descendant positions.
// With calculateDocumentPosition using linear depth scaling (100px per level),
// even very deep trees (100 levels) stay within 10,000px.
// 100M pixels is plausible for very long documents.
// Root containers span the document, see documentHeight.
if (root) {
return .{ .value = if (axis == .width) 1920.0 else 100_000_000.0 };
return .{ .value = switch (axis) {
.width => 1920.0,
.height => if (self.ownerFrame(frame)) |owner| documentHeight(owner) else 0.0,
} };
}
// Presentational attributes lose to CSS sizes.
@@ -1679,8 +1679,7 @@ pub fn getScrollHeight(self: *Element, frame: *Frame) f64 {
const height = self.getElementAxis(frame, .height).value;
const tag = self.getTag();
// As in getScrollWidth: the root containers carry artificial giant
// defaults, and page-level overflow checks read them.
// The root containers already span the document.
if (tag == .html or tag == .body) {
return height;
}
@@ -1696,9 +1695,9 @@ pub fn getScrollWidth(self: *Element, frame: *Frame) f64 {
const width = self.getElementAxis(frame, .width).value;
const tag = self.getTag();
// The root containers carry artificial giant defaults (1920 and
// 100_000_000, see getElementAxis). Stacking their children on
// top would inflate a value sites read to detect page overflow.
// The root containers span the document (see getElementAxis), and
// their children don't lie side by side on one row: stacking them
// would inflate a value sites read to detect page overflow.
if (tag == .html or tag == .body) {
return width;
}
@@ -1708,9 +1707,9 @@ pub fn getScrollWidth(self: *Element, frame: *Frame) f64 {
/// Null where we can't prove a limit, which leaves the offset unbounded:
/// without an explicit size the client and content measurements collapse onto
/// the same sum, and html and body carry giant defaults that would fabricate
/// an extent against the real viewport. Refusing a scroll we can't prove
/// impossible is worse than allowing one too many.
/// the same sum. html and body scroll the viewport, which Window.scrollTo
/// clamps. Refusing a scroll we can't prove impossible is worse than allowing
/// one too many.
fn scrollExtent(self: *Element, frame: *Frame, comptime axis: Axis) ?f64 {
if (self.scrollsViewport() or !self.getElementAxis(frame, axis).explicit) {
return null;
@@ -1781,8 +1780,8 @@ fn contentAxis(self: *Element, frame: *Frame, comptime axis: Axis) f64 {
return total;
}
// Unlike clientHeight, the root's offsetHeight is its box (the document
// extent), so it stays on the synthetic root default.
// Unlike clientHeight, the root's offsetHeight is its box: the document
// height.
pub fn getOffsetHeight(self: *Element, frame: *Frame) f64 {
if (!self.isVisible(frame)) {
return 0.0;
@@ -1913,6 +1912,37 @@ fn calculateDocumentPosition(node: *Node) f64 {
return position * 5.0; // 5px per node
}
/// The document's height, which html and body span and the viewport scrolls
/// over. Tall enough for every synthetic position (calculateDocumentPosition's
/// 5px per node), for body's children stacked on top of each other, and never
/// shorter than the viewport, like the root's scrollHeight.
pub fn documentHeight(frame: *Frame) f64 {
const version = frame.page.style_version;
const viewport = frame.page.getViewport().height;
if (frame._document_height) |cached| {
if (cached.version == version and cached.viewport == viewport) {
return cached.value;
}
}
const doc = frame.document;
var height = @max(@as(f64, @floatFromInt(viewport)), countSubtreeNodes(doc.asNode()) * 5.0);
if (doc.getDocumentElement()) |root| {
var child = root.asNode().firstChild();
while (child) |node| : (child = node.nextSibling()) {
if (node.is(Html.Body)) |body| {
height = @max(height, body.asElement().contentAxis(frame, .height));
break;
}
}
}
// Whole pixels, like the scroll offsets clamped against it
height = @ceil(height);
frame._document_height = .{ .version = version, .viewport = viewport, .value = height };
return height;
}
// Counts total nodes in a subtree (node + all descendants)
fn countSubtreeNodes(node: *Node) f64 {
var count: f64 = 1.0; // Count this node
+6 -1
View File
@@ -952,7 +952,12 @@ fn getDevicePixelRatio(_: *const Window, frame: *Frame) f32 {
pub fn scrollTo(self: *Window, opts: Element.ScrollToOpts, y: ?i32, frame: *Frame) !void {
const o = opts.offsets(y);
const new_x: u32 = if (o.left) |left| @intCast(@max(0, left)) else self._scroll_pos.x;
const new_y: u32 = if (o.top) |top| @intCast(@max(0, top)) else self._scroll_pos.y;
var new_y: u32 = if (o.top) |top| @intCast(@max(0, top)) else self._scroll_pos.y;
// The document can't scroll past its bottom. It has no honest width
// (children don't lie side by side), so x stays unbounded.
const max_y = Element.documentHeight(self._frame) - @as(f64, @floatFromInt(self.getInnerHeight(self._frame)));
new_y = @min(new_y, @as(u32, @intFromFloat(@max(0, @min(max_y, std.math.maxInt(u32))))));
if (new_x == self._scroll_pos.x and new_y == self._scroll_pos.y) {
return;