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) } }