From 243466da040e0fb59632c15c51ee04fbbbb2a1f8 Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 28 Oct 2026 19:25:18 +0800 Subject: [PATCH] fix(ios): keep transcript rows in sync with environment changes on iOS 27 On iOS 27, SwiftUI re-runs a representable's update only when an environment value read during it changes; even @Environment(\.self) is tracked per key. AnchoredTranscriptList copied context.environment without reading keys, and compared metrics later in a drain that is often deferred. A theme switch or a system text size change therefore never reached the hosted rows: text kept its old size and colors. updateUIViewController now reads every runtime-changing value the rows follow (Dynamic Type, bold text, typography, locale, direction, color scheme, theme, link action, enabled state). Rows still receive the complete environment snapshot. The tests drive Dynamic Type through UIKit traits, as the system does. On iOS 27 a SwiftUI dynamicTypeSize override does not reach a UIKit representable's environment, and the app never sets one above the list. A new test checks that theme-only and text-size-only changes reach visible rows without reconfiguring cells; it fails without the fix. Co-Authored-By: Claude Opus 5.5 --- docs/native-chat-scrolling.md | 8 ++++- ios/HapiTests/TranscriptRefreshTests.swift | 36 +++++++++++++++++-- .../HapiUI/Chat/AnchoredTranscriptList.swift | 13 +++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/docs/native-chat-scrolling.md b/docs/native-chat-scrolling.md index 6148a008..c5bf24eb 100644 --- a/docs/native-chat-scrolling.md +++ b/docs/native-chat-scrolling.md @@ -83,6 +83,10 @@ hosting roots read a shared observable renderer and the latest representable environment; SwiftUI diffs their content without reinstalling every visible hosting configuration. This preserves theme, Dynamic Type, locale, direction, services, and action/value captures without an incomplete environment whitelist. +SwiftUI re-runs the representable update only for environment keys read during +it (strictly on iOS 27), so the update reads every runtime-changing value rows +follow (Dynamic Type, bold text, typography, locale, direction, color scheme, +theme, link action, enabled state); the snapshot handed to rows stays complete. The row builder executes inside a SwiftUI body so row-owned observable reads belong to the row. Dynamic heights still use UIKit self-sizing; no frozen-height or measurement-result cache is introduced. @@ -197,7 +201,9 @@ stable recomputes. UIKit tests also verify that regrouping preserves both groups and the existing reading anchor, plus duplicate-delivery handling. `TranscriptRefreshTests` verifies that offscreen streaming does not reconfigure unchanged visible cells, while visible edits resize, recycled rows see the -latest data, and equal items receive updated environments and action captures. +latest data, and equal items receive updated environments and action captures, +including environment-only theme and Dynamic Type changes. Tests change Dynamic +Type through UIKit traits, as the system does. Debug counters also check the zero-configuration budget for ordinary context updates; UIKit may request cells itself when layout-direction traits change. Replay-gap tests cover same-epoch retention, epoch/reset replacement, and diff --git a/ios/HapiTests/TranscriptRefreshTests.swift b/ios/HapiTests/TranscriptRefreshTests.swift index 7c7713a0..d759375c 100644 --- a/ios/HapiTests/TranscriptRefreshTests.swift +++ b/ios/HapiTests/TranscriptRefreshTests.swift @@ -25,7 +25,6 @@ final class TranscriptRefreshTests: XCTestCase { var version = 0 var basePath = "/before" var dark = false - var size = DynamicTypeSize.large var locale = Locale(identifier: "en") var direction = LayoutDirection.leftToRight var actionToken = 0 @@ -41,7 +40,6 @@ final class TranscriptRefreshTests: XCTestCase { let token = driver.actionToken ListView(driver: driver) .hapiTheme(driver.dark ? .dark : .light) - .environment(\.dynamicTypeSize, driver.size) .environment(\.locale, driver.locale) .environment(\.layoutDirection, driver.direction) .environment(\.hapiOpenURL, HapiOpenURLAction { _ in @@ -95,6 +93,7 @@ final class TranscriptRefreshTests: XCTestCase { private func host(_ driver: Driver) throws -> (UIWindow, TranscriptCollectionController, UICollectionView) { let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) let window = UIWindow(windowScene: scene) + window.setDynamicTypeSize(.large) let host = UIHostingController(rootView: Harness(driver: driver)) window.rootViewController = host window.makeKeyAndVisible() @@ -153,6 +152,28 @@ final class TranscriptRefreshTests: XCTestCase { XCTAssertEqual(tail.frame.height, 180, accuracy: 1, "Recycled cells must use the latest offscreen value") } + func testEnvironmentOnlyChangesReachVisibleRows() async throws { + let driver = Driver() + let (window, controller, view) = try host(driver) + defer { window.isHidden = true } + await settle { driver.layouts.contains(0) } + await browse(view, index: 25) + #if DEBUG + let configurations = controller.cellConfigurationCount + #endif + // No item or closure capture changes: only the environment does. + driver.dark = true + await settle { driver.samples["25"]?.dark == true } + XCTAssertEqual(driver.samples["25"]?.dark, true, "A theme change must reach hosted rows") + #if DEBUG + XCTAssertEqual(controller.cellConfigurationCount, configurations) + #endif + window.setDynamicTypeSize(.accessibility2) + await settle { driver.samples["25"]?.size == .accessibility2 } + let cell = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0))) + XCTAssertEqual(cell.frame.height, 180, accuracy: 1, "A system text size change must re-measure rows") + } + func testEqualItemsReceiveLatestEnvironmentValueCapturesAndActions() async throws { let driver = Driver() let (window, controller, view) = try host(driver) @@ -177,7 +198,7 @@ final class TranscriptRefreshTests: XCTestCase { // change; only ordinary data/context updates have a zero-config budget. driver.locale = Locale(identifier: "ar") driver.direction = .rightToLeft - driver.size = .accessibility2 + window.setDynamicTypeSize(.accessibility2) let recycled = Sample(basePath: "/after", dark: true, size: .accessibility2, locale: "ar", direction: .rightToLeft) await settle { driver.samples["25"] == recycled } let cell = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0))) @@ -186,3 +207,12 @@ final class TranscriptRefreshTests: XCTestCase { XCTAssertEqual(driver.samples["60"], recycled) } } + +private extension UIWindow { + /// Change text size as the system does. On iOS 27 a SwiftUI + /// `dynamicTypeSize` override does not reach a UIKit representable's + /// `context.environment`, and the app never overrides it above the list. + func setDynamicTypeSize(_ size: DynamicTypeSize) { + traitOverrides.preferredContentSizeCategory = UIContentSizeCategory(size) + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift b/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift index 935b13d0..dea5f1dc 100644 --- a/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift +++ b/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift @@ -50,6 +50,19 @@ public struct AnchoredTranscriptList: UIViewCont public func updateUIViewController(_ controller: TranscriptCollectionController, context: Context) { var next = self next.environment = context.environment + // SwiftUI re-runs this update only when an environment value read here + // changes (strictly so on iOS 27; even `@Environment(\.self)` is tracked + // per key). Hosted rows render from the copied snapshot in their own + // hosting roots, so read every value they follow at runtime. + _ = context.environment.dynamicTypeSize + _ = context.environment.legibilityWeight + _ = context.environment.hapiTypography + _ = context.environment.locale + _ = context.environment.layoutDirection + _ = context.environment.colorScheme + _ = context.environment.hapiTheme + _ = context.environment.hapiOpenURL + _ = context.environment.isEnabled controller.update(next) } }