mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user