From 5e9cf739598a98b6eaf07553735e7eef0475e14f Mon Sep 17 00:00:00 2001 From: Alex Sikora Date: Tue, 6 Oct 2026 16:37:59 -0500 Subject: [PATCH] Fix Fabric custom view height updates in SwiftUI --- .../Providers/BridgeViewProvider.swift | 54 ++++++++++++++----- .../Providers/FabricContentHeight.swift | 24 +++++++++ AgentforceSDK-ReactNative-Bridge/package.json | 2 +- ios/Tests/FabricContentHeightTests.swift | 30 +++++++++++ ios/project.yml | 1 + package-lock.json | 2 +- 6 files changed, 99 insertions(+), 14 deletions(-) create mode 100644 AgentforceSDK-ReactNative-Bridge/ios/Agentforce/Providers/FabricContentHeight.swift create mode 100644 ios/Tests/FabricContentHeightTests.swift diff --git a/AgentforceSDK-ReactNative-Bridge/ios/Agentforce/Providers/BridgeViewProvider.swift b/AgentforceSDK-ReactNative-Bridge/ios/Agentforce/Providers/BridgeViewProvider.swift index 317a5b6..4cb1887 100644 --- a/AgentforceSDK-ReactNative-Bridge/ios/Agentforce/Providers/BridgeViewProvider.swift +++ b/AgentforceSDK-ReactNative-Bridge/ios/Agentforce/Providers/BridgeViewProvider.swift @@ -88,6 +88,7 @@ private struct ReactNativeViewWrapper: View { bridge: bridge, moduleName: moduleName, initialProperties: initialProperties, + measuredHeight: fabricHeight, onFabricHeightChange: { fabricHeight = $0 } ) .frame(height: fabricHeight) @@ -98,6 +99,7 @@ private struct ReactNativeRootView: UIViewRepresentable { let bridge: RCTBridge? let moduleName: String let initialProperties: [String: Any] + let measuredHeight: CGFloat? let onFabricHeightChange: (CGFloat) -> Void func makeCoordinator() -> Coordinator { @@ -122,7 +124,9 @@ private struct ReactNativeRootView: UIViewRepresentable { } func updateUIView(_ uiView: UIView, context: Context) { - // React Native updates the hosted view through its runtime. + // React Native updates the hosted view through its runtime. Refresh the + // callback when SwiftUI recreates this value around an existing UIView. + context.coordinator.onHeightChange = onFabricHeightChange } func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIView, context: Context) -> CGSize? { @@ -130,6 +134,10 @@ private struct ReactNativeRootView: UIViewRepresentable { let width = proposedWidth.isFinite && proposedWidth > 0 ? proposedWidth : UIScreen.main.bounds.width + if uiView is RCTSurfaceHostingProxyRootView, + let measuredHeight { + return CGSize(width: width, height: measuredHeight) + } let maxHeight: CGFloat = uiView is RCTSurfaceHostingProxyRootView ? 10_000 : CGFloat.greatestFiniteMagnitude @@ -147,7 +155,7 @@ private struct ReactNativeRootView: UIViewRepresentable { private weak var rootView: RCTSurfaceHostingProxyRootView? private var timer: Timer? private var lastHeight: CGFloat = 0 - private let onHeightChange: (CGFloat) -> Void + var onHeightChange: (CGFloat) -> Void init(onHeightChange: @escaping (CGFloat) -> Void) { self.onHeightChange = onHeightChange @@ -156,9 +164,14 @@ private struct ReactNativeRootView: UIViewRepresentable { func observe(_ rootView: RCTSurfaceHostingProxyRootView) { self.rootView = rootView rootView.delegate = self - timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in + // Fabric can change a mounted root component's frame without a + // corresponding intrinsic-size notification. Keep observing while + // this view is mounted, including while its parent is scrolling. + let timer = Timer(timeInterval: 0.2, repeats: true) { [weak self] _ in self?.measure() } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer } func stopObserving() { @@ -168,20 +181,37 @@ private struct ReactNativeRootView: UIViewRepresentable { } func rootViewDidChangeIntrinsicSize(_ rootView: RCTRootView) { - measure() + DispatchQueue.main.async { [weak self] in self?.measure() } } private func measure() { guard let rootView, rootView.window != nil, rootView.bounds.width > 0 else { return } - let size = rootView.sizeThatFits(CGSize(width: rootView.bounds.width, height: 10_000)) - guard size.height > 0, abs(size.height - lastHeight) > 0.5 else { return } - lastHeight = size.height - DispatchQueue.main.async { [onHeightChange] in - onHeightChange(size.height) + // Prefer the Fabric component's rendered frame. In the bridgeless + // path it can already be 160pt while the proxy and surface still + // report the 1pt fallback supplied by sizeThatFits below. + let contentHeight = FabricContentHeight.height(in: rootView.view) + let intrinsicHeight = rootView.intrinsicContentSize.height + let height: CGFloat + if let contentHeight, contentHeight > 1 || (lastHeight > 0 && contentHeight >= 0) { + height = contentHeight + } else if intrinsicHeight.isFinite, intrinsicHeight > 1 { + height = intrinsicHeight + } else { + let fittingHeight = rootView.sizeThatFits( + CGSize(width: rootView.bounds.width, height: 10_000) + ).height + if fittingHeight.isFinite, fittingHeight > 1 { + height = fittingHeight + } else { + return // Never publish the temporary 1pt host as content. + } + } + guard abs(height - lastHeight) > 0.5 else { return } + lastHeight = height + DispatchQueue.main.async { [weak self] in + guard let self, self.rootView != nil else { return } + self.onHeightChange(height) } - // Subsequent content size changes arrive through RCTRootViewDelegate. - timer?.invalidate() - timer = nil } } } diff --git a/AgentforceSDK-ReactNative-Bridge/ios/Agentforce/Providers/FabricContentHeight.swift b/AgentforceSDK-ReactNative-Bridge/ios/Agentforce/Providers/FabricContentHeight.swift new file mode 100644 index 0000000..3068c08 --- /dev/null +++ b/AgentforceSDK-ReactNative-Bridge/ios/Agentforce/Providers/FabricContentHeight.swift @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024-present, salesforce.com, inc. All rights reserved. + * + * Reads the mounted Fabric root's content height. The surface hosting view can + * remain at its temporary 1-point size after its child has laid out, so its + * own sizeThatFits and intrinsicContentSize are not always useful to SwiftUI. + */ + +import UIKit + +enum FabricContentHeight { + static func height(in view: UIView) -> CGFloat? { + if NSStringFromClass(type(of: view)).hasSuffix("RCTRootComponentView") { + let height = view.bounds.height + return height.isFinite ? height : nil + } + for child in view.subviews { + if let height = height(in: child) { + return height + } + } + return nil + } +} diff --git a/AgentforceSDK-ReactNative-Bridge/package.json b/AgentforceSDK-ReactNative-Bridge/package.json index 990b4a1..5e10806 100644 --- a/AgentforceSDK-ReactNative-Bridge/package.json +++ b/AgentforceSDK-ReactNative-Bridge/package.json @@ -1,6 +1,6 @@ { "name": "@salesforce/react-native-agentforce", - "version": "0.7.3-patch", + "version": "0.7.3-patch2", "description": "Agentforce React Native bridge: JS API and native module for Service Agent and Employee Agent (auth bridge included; host app adds Mobile SDK).", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/ios/Tests/FabricContentHeightTests.swift b/ios/Tests/FabricContentHeightTests.swift new file mode 100644 index 0000000..e928abb --- /dev/null +++ b/ios/Tests/FabricContentHeightTests.swift @@ -0,0 +1,30 @@ +import UIKit +import XCTest + +private final class TestRCTRootComponentView: UIView {} + +final class FabricContentHeightTests: XCTestCase { + func testFindsMountedComponentHeightBeyondOnePointHost() { + let surface = UIView(frame: CGRect(x: 0, y: 0, width: 334, height: 1)) + let root = UIView(frame: surface.bounds) + let component = TestRCTRootComponentView(frame: CGRect(x: 0, y: 0, width: 334, height: 160)) + surface.addSubview(root) + root.addSubview(component) + + XCTAssertEqual(FabricContentHeight.height(in: surface), 160) + + component.frame.size.height = 240 + XCTAssertEqual(FabricContentHeight.height(in: surface), 240) + + component.frame.size.height = 1 + XCTAssertEqual(FabricContentHeight.height(in: surface), 1) + + component.frame.size.height = 0 + XCTAssertEqual(FabricContentHeight.height(in: surface), 0) + } + + func testReturnsNilBeforeFabricComponentMounts() { + let surface = UIView(frame: CGRect(x: 0, y: 0, width: 334, height: 1)) + XCTAssertNil(FabricContentHeight.height(in: surface)) + } +} diff --git a/ios/project.yml b/ios/project.yml index 4ad6dfd..a0d8f39 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -178,3 +178,4 @@ targets: - path: Tests type: group - path: ../AgentforceSDK-ReactNative-Bridge/ios/Agentforce/Models/AgentMode.swift + - path: ../AgentforceSDK-ReactNative-Bridge/ios/Agentforce/Providers/FabricContentHeight.swift diff --git a/package-lock.json b/package-lock.json index 010a802..695e6b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,7 @@ }, "AgentforceSDK-ReactNative-Bridge": { "name": "@salesforce/react-native-agentforce", - "version": "0.7.3-patch", + "version": "0.7.3-patch2", "license": "Apache-2.0", "devDependencies": { "@types/node": "^20.11.0",