From 82faf8274dfcfd9f6e58515c1048c837518e7b69 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 31 Oct 2026 18:09:21 -0700 Subject: [PATCH] fix(apple): normalize integral floating wire properties --- docs/api-migrations.md | 26 ++++++++++++ .../LUIAppleBackend/LUIWireProtocol.swift | 20 +++++---- .../LUIAppleBackendTests.swift | 42 +++++++++++++++++++ 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/docs/api-migrations.md b/docs/api-migrations.md index 49d34a80..5e5a9a73 100644 --- a/docs/api-migrations.md +++ b/docs/api-migrations.md @@ -13,6 +13,32 @@ inventory check is temporarily deferred until that inventory recognizes that renderer. The MyGo, Web, Apple, Android and shared ABI regressions remain covered separately. +## Unreleased: Apple normalizes all floating wire properties + +Apple now normalizes integral JSON numbers to its floating retained value for +every shared `FloatValue` property, not just the previously covered subset. +JSONDecoder can decode `2.0` or `2e0` as an integer; previously, for example, +`{"op":"set-prop","id":4,"property":"letter-spacing","value":2.0}` +rejected the entire batch even though the OCaml producer emitted a valid float. + +Existing normalization remains for `item-extent`, `grow`, `anchor-offset`, +`anchor-alignment-offset`, `source-x`, `source-y`, `source-width`, `source-height`, +`value`, `min`, `max`, and `step`. +Newly covered properties are `first-pane-width`, `resize-origin`, +`resize-handle-offset`, `opacity`, `label-opacity`, `letter-spacing`, `inset`, +`inset-top`, `inset-right`, `inset-bottom`, `inset-left`, `hover-opacity`, +`pressed-opacity`, `disabled-opacity`, `width-viewport`, `height-viewport`, +`min-width-viewport`, `max-width-viewport`, `min-height-viewport`, +`max-height-viewport`, `popup-x`, `popup-y`, `available-height`, and +`collision-padding`. + +No application migration or JSON rewriting is needed: continue emitting numeric +values, including integral floats such as `letter-spacing: 2.0` or `opacity: 1.0`. +Fractional numbers keep their existing behavior. Boolean and string values are +not converted; integer-only properties are unchanged. Property/kind admission, +finite-value checks, numeric bounds, and atomic batch rollback are unchanged. +The OCaml protocol and generic native ABI are unchanged. + ## Unreleased: Android accepts wrapping preserved text Android's retained text-flow validator now accepts `white-space: "pre-wrap"`, diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index d9b95a73..5c0cbf3a 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -1871,15 +1871,21 @@ struct LUIRetainedTree { extension LUIWireValue { func normalized(for property: LUIProperty) -> LUIWireValue { - if property == .itemExtent || property == .grow || property == .anchorOffset || property == .anchorAlignmentOffset || - property == .sourceX || property == .sourceY || - property == .sourceWidth || property == .sourceHeight || - property == .progressValue || property == .minValue || - property == .maxValue || property == .stepValue, - case let .int(value) = self { + guard case .int(let value) = self else { return self } + switch property { + case .itemExtent, .grow, .anchorOffset, .anchorAlignmentOffset, + .sourceX, .sourceY, .sourceWidth, .sourceHeight, .progressValue, + .minValue, .maxValue, .stepValue, .firstPaneWidth, + .resizeOrigin, .resizeHandleOffset, .opacity, .labelOpacity, + .letterSpacing, .inset, .insetTop, .insetRight, .insetBottom, .insetLeft, + .hoverOpacity, .pressedOpacity, .disabledOpacity, + .widthViewport, .heightViewport, .minWidthViewport, .maxWidthViewport, + .minHeightViewport, .maxHeightViewport, + .popupX, .popupY, .availableHeight, .collisionPadding: return .double(Double(value)) + default: + return self } - return self } var intValue: Int? { diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift index aee14eff..742a4849 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift @@ -31,6 +31,48 @@ private let captureAppleEvent: LUIAppleEventCallback = { kind, node, text in @MainActor @Suite("LUI SwiftUI backend", .serialized) struct LUISwiftUIBackendTests { + @Test("floating wire properties admit integral JSON numbers without weakening validation") + func floatingWireNumbers() throws { + let properties: [LUIProperty] = [ + .itemExtent, .grow, .anchorOffset, .anchorAlignmentOffset, + .sourceX, .sourceY, .sourceWidth, .sourceHeight, .progressValue, + .minValue, .maxValue, .stepValue, .firstPaneWidth, + .resizeOrigin, .resizeHandleOffset, .opacity, .labelOpacity, + .letterSpacing, .inset, .insetTop, .insetRight, .insetBottom, .insetLeft, + .hoverOpacity, .pressedOpacity, .disabledOpacity, + .widthViewport, .heightViewport, .minWidthViewport, .maxWidthViewport, + .minHeightViewport, .maxHeightViewport, + .popupX, .popupY, .availableHeight, .collisionPadding, + ] + for property in properties { + for (json, expected) in [("1", 1.0), ("1.0", 1.0), ("1e0", 1.0), ("0.5", 0.5)] { + let decoded = try JSONDecoder().decode(LUIWireValue.self, from: Data(json.utf8)) + let value = decoded.normalized(for: property) + #expect(value == .double(expected), "\(property.rawValue): \(json)") + #expect(value.matches(property, on: .box), "\(property.rawValue): \(json)") + } + for json in ["true", "\"1\""] { + let decoded = try JSONDecoder().decode(LUIWireValue.self, from: Data(json.utf8)) + #expect(!decoded.normalized(for: property).matches(property, on: .box)) + } + if property != .progressValue { + for number in [Double.nan, .infinity, -.infinity] { + #expect(!LUIWireValue.double(number).normalized(for: property).matches(property, on: .box)) + } + } + } + for (property, number): (LUIProperty, Int) in [ + (.grow, -1), (.firstPaneWidth, -1), (.stepValue, 0), (.itemExtent, 0), + (.opacity, 2), (.labelOpacity, -1), (.hoverOpacity, 2), + (.pressedOpacity, -1), (.disabledOpacity, 2), + (.widthViewport, 0), (.heightViewport, 2), (.minWidthViewport, 0), + (.maxWidthViewport, 2), (.minHeightViewport, 0), (.maxHeightViewport, 2), + (.availableHeight, -1), (.collisionPadding, -1), + ] { + #expect(!LUIWireValue.int(number).normalized(for: property).matches(property, on: .box)) + } + } + @Test("removed line-break nodes reject the batch without changing the tree") func removedLineBreakNode() throws { let backend = LUIAppleBackend()