Fix iOS channel crash from unbounded scroll depth (#8407)

### Summary

Opening a channel on iOS 27.0.1 could terminate Buzz when a reversed,
lazily loaded timeline reported an unbounded scroll extent. Flutter
forwarded `Infinity` to the native navigation bar, and UIKit threw
`CALayerInvalidGeometry` while setting the scroll view offset.

Use the finite viewport depth for that unbounded case, preserving the
scrolled-under appearance. Reject non-finite offsets at the native
platform boundary as a final safety check.

### Validation

- Added tests: a Flutter widget regression for an unbounded reversed
timeline, and an iOS native regression for a non-finite platform-channel
offset.
- Reproduced the original crash by tapping `#general` on an iPhone SE
running iOS 27.0.1. With this change, the channel rendered and survived
repeated back-and-reopen cycles. Native logging confirmed the channel
now sends finite offsets where it previously sent `Infinity`.
- The native regression passed on iOS 26.5 and 27.0 simulators with
Xcode 27.0. Flutter widget tests and `flutter analyze` passed.
- A full channel-flow simulator run was not available: the isolated
simulator Profile build could not resolve `BuzzPushKit`. The iOS 27.0.1
physical run exercised the full channel flow.
- The full Flutter suite had eight notification and push test failures,
all reproduced on the unchanged 0.24.0 source.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-16 16:25:41 -07:00
committed by GitHub
parent 97e1f6a082
commit bf8b356f3b
4 changed files with 62 additions and 2 deletions

View File

@@ -530,6 +530,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
}
private func setScrollOffset(_ value: CGFloat) {
guard value.isFinite else { return }
offset = max(0, value)
material.alpha = alwaysFrosted ? 1 : min(1, offset / 12)
applyScroll()

View File

@@ -520,6 +520,29 @@ class RunnerTests: XCTestCase {
}
}
@MainActor
func testNavigationIgnoresNonFiniteScrollOffsets() throws {
let parent = UIViewController()
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = parent
window.makeKeyAndVisible()
defer { window.isHidden = true }
let messenger = NavigationTestMessenger()
let bar = IosNavigationBarFactory(messenger: messenger, parent: parent).create(
withFrame: CGRect(x: 0, y: 0, width: window.bounds.width, height: 120),
viewIdentifier: 999990,
arguments: ["title": "general", "alwaysFrosted": true])
parent.view.addSubview(bar.view())
bar.view().layoutIfNeeded()
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
let scroll = try XCTUnwrap(navigation.topViewController?.view as? UIScrollView)
messenger.scroll(to: 52)
let finiteOffset = scroll.contentOffset.y
XCTAssertTrue(finiteOffset.isFinite)
messenger.scroll(to: .infinity)
XCTAssertEqual(scroll.contentOffset.y, finiteOffset, accuracy: 0.5)
}
@MainActor
func testSubtitlelessThreadKeepsFullFallbackBehindControls() throws {
let parent = UIViewController()

View File

@@ -175,8 +175,14 @@ class FrostedScaffold extends HookWidget {
if (!nativePinned && depth == 0 && metrics.axis == Axis.vertical) {
// Keep real depth so a later UIKit metrics update (Dynamic Type or
// rotation) can apply its new collapse range without another scroll.
final next = topContentDepth.clamp(0.0, double.infinity);
if ((scrollOffset.value - next).abs() > 0.1) {
// Lazy reversed lists can report an unbounded extentAfter. The
// visible viewport is enough depth for UIKit's collapsed state,
// while infinity is not a valid UIScrollView content offset.
final finiteDepth = topContentDepth.isFinite
? topContentDepth
: metrics.viewportDimension;
final next = finiteDepth.clamp(0.0, double.infinity);
if (next.isFinite && (scrollOffset.value - next).abs() > 0.1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) scrollOffset.value = next;
});

View File

@@ -1,6 +1,7 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/frosted_scaffold.dart';
import 'package:buzz/shared/widgets/ios_navigation_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -12,6 +13,35 @@ Border? _appBarBorder(WidgetTester tester) {
}
void main() {
testWidgets('unbounded reversed scroll depth stays finite for native bar', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(
home: FrostedScaffold(
appBar: FrostedAppBar(title: Text('Conversation')),
body: SizedBox(key: ValueKey('timeline')),
),
),
);
final source = tester.element(find.byKey(const ValueKey('timeline')));
final offset = IosNavigationScrollScope.maybeOf(source)!;
ScrollUpdateNotification(
metrics: FixedScrollMetrics(
minScrollExtent: 0,
maxScrollExtent: double.infinity,
pixels: 0,
viewportDimension: 600,
axisDirection: AxisDirection.up,
devicePixelRatio: 1,
),
context: source,
scrollDelta: 0,
).dispatch(source);
await tester.pump();
expect(offset.value, 600);
});
testWidgets('foreground paints above header and leaves body interactive', (
tester,
) async {