From 7c14d1e29f229cd056d11d1b66b6bf55112cf304 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Mon, 27 Apr 2026 08:06:18 +0200 Subject: [PATCH 1/2] forms: clamp value to min/max Per the WHATWG HTML "input type=range" value sanitization algorithm, an out-of-range value assigned via the JS `value` setter (or `Element.value`) must be clamped to `[min, max]`, with `min`/`max` defaulting to 0 and 100 when the attribute is missing or not a valid floating-point number; if the assigned value isn't a valid floating-point number, fall back to `min + (max - min) / 2`. The previous `.range` branch in `sanitizeValue` only validated the float grammar and returned `"50"` as a hardcoded default, so `; el.value = "999"` left `el.value === "999"` instead of `"100"`. Step matching is a separate sanitization rule and intentionally out of scope. Closes #2266 --- .../tests/element/html/input-attrs.html | 72 +++++++++++++++++++ src/browser/webapi/element/html/Input.zig | 44 +++++++++++- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/browser/tests/element/html/input-attrs.html b/src/browser/tests/element/html/input-attrs.html index 3e3bf606..51c629f5 100644 --- a/src/browser/tests/element/html/input-attrs.html +++ b/src/browser/tests/element/html/input-attrs.html @@ -85,3 +85,75 @@ testing.expectEqual('', i3.autocomplete); } + + diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index be82f254..7f6789a6 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -564,7 +564,7 @@ fn sanitizeValue(self: *Input, comptime dupe: bool, value: []const u8, frame: *F .time => return if (isValidTime(value)) if (comptime dupe) try frame.dupeString(value) else value else "", .@"datetime-local" => return try sanitizeDatetimeLocal(dupe, value, frame.arena), .number => return if (isValidFloatingPoint(value)) if (comptime dupe) try frame.dupeString(value) else value else "", - .range => return if (isValidFloatingPoint(value)) if (comptime dupe) try frame.dupeString(value) else value else "50", + .range => return try sanitizeRange(dupe, value, self.getMin(), self.getMax(), frame), .color => { if (value.len == 7 and value[0] == '#') { var needs_lower = false; @@ -786,6 +786,48 @@ fn sanitizeDatetimeLocal(comptime dupe: bool, value: []const u8, arena: std.mem. return result[0..total_len]; } +/// Sanitize value for `` per WHATWG HTML spec: +/// https://html.spec.whatwg.org/multipage/input.html#range-state-(type=range) +/// 1. If value is not a valid floating-point number, set it to +/// `min + (max - min) / 2`. +/// 2. If value < min, set it to min. +/// 3. If value > max, set it to max. +/// `min`/`max` default to 0 and 100 respectively when the attribute is missing +/// or fails to parse as a valid floating-point number. Step matching is not +/// applied here. +fn sanitizeRange( + comptime dupe: bool, + value: []const u8, + min_attr: []const u8, + max_attr: []const u8, + frame: *Frame, +) ![]const u8 { + const min: f64 = if (isValidFloatingPoint(min_attr)) + std.fmt.parseFloat(f64, min_attr) catch 0 + else + 0; + const max: f64 = if (isValidFloatingPoint(max_attr)) + std.fmt.parseFloat(f64, max_attr) catch 100 + else + 100; + + if (!isValidFloatingPoint(value)) { + return try formatFloat(frame.arena, min + (max - min) / 2); + } + + const v = std.fmt.parseFloat(f64, value) catch unreachable; // grammar already validated + if (v < min) return try formatFloat(frame.arena, min); + if (v > max) return try formatFloat(frame.arena, max); + return if (comptime dupe) try frame.dupeString(value) else value; +} + +/// Format an f64 to its shortest decimal representation, arena-allocated. +fn formatFloat(arena: std.mem.Allocator, value: f64) ![]const u8 { + var buf: [64]u8 = undefined; + const formatted = std.fmt.bufPrint(&buf, "{d}", .{value}) catch unreachable; + return arena.dupe(u8, formatted); +} + /// Parse a slice that must be ALL ASCII digits into a u32. Returns null if any non-digit or empty. fn parseAllDigits(s: []const u8) ?u32 { if (s.len == 0) return null; From fecab22ef0e0aa361da15b184ebed3d33596fa64 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Mon, 27 Apr 2026 22:10:00 +0200 Subject: [PATCH 2/2] forms: address review feedback on range clamp - Use std.fmt.allocPrint in formatFloat (per-review suggestion). - Drop the `r3.value = '1' -> '1'` fractional-bounds assertion, which conflicted with WHATWG step matching (default step=1, base=min=0.5, nearest valid is '1.5'). Step matching remains out of scope here. --- src/browser/tests/element/html/input-attrs.html | 6 ++++-- src/browser/webapi/element/html/Input.zig | 4 +--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/browser/tests/element/html/input-attrs.html b/src/browser/tests/element/html/input-attrs.html index 51c629f5..41f0e75a 100644 --- a/src/browser/tests/element/html/input-attrs.html +++ b/src/browser/tests/element/html/input-attrs.html @@ -129,8 +129,10 @@ testing.expectEqual('1.5', r3.value); r3.value = '0'; testing.expectEqual('0.5', r3.value); - r3.value = '1'; - testing.expectEqual('1', r3.value); + // Note: in-range pass-through under clamping alone is exercised by r1/r4. + // An assertion like `r3.value = '1' -> '1'` would conflict with WHATWG step + // matching (default step=1, base=min=0.5 -> nearest valid is '1.5'), which + // is intentionally out of scope for this PR; tracking separately. // Default min/max (0..100) when attributes absent const r4 = document.createElement('input'); diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 7f6789a6..d6175e8c 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -823,9 +823,7 @@ fn sanitizeRange( /// Format an f64 to its shortest decimal representation, arena-allocated. fn formatFloat(arena: std.mem.Allocator, value: f64) ![]const u8 { - var buf: [64]u8 = undefined; - const formatted = std.fmt.bufPrint(&buf, "{d}", .{value}) catch unreachable; - return arena.dupe(u8, formatted); + return std.fmt.allocPrint(arena, "{d}", .{value}); } /// Parse a slice that must be ALL ASCII digits into a u32. Returns null if any non-digit or empty.