Skip to content

Commit 13798f0

Browse files
authored
perf(core, ui): back .of() lookups with StreamStateScope instead of findAncestorStateOfType (#2726)
1 parent e155fca commit 13798f0

8 files changed

Lines changed: 363 additions & 46 deletions

File tree

packages/stream_chat_flutter/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
- `ScrollablePositionedList.padding` now accepts `EdgeInsetsGeometry` (resolved against
2020
`Directionality`), and `scrollTo` lands the target at the content-area edge by adjusting for
2121
leading padding.
22+
- `StreamChat.of` now resolves via `StreamStateScope` instead of `findAncestorStateOfType`, replacing
23+
an O(tree-depth) element walk with an O(1) inherited-widget lookup. Callers and behavior are
24+
unchanged.
2225

2326
## 9.24.0
2427

packages/stream_chat_flutter/lib/src/stream_chat.dart

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ class StreamChat extends StatefulWidget {
131131
/// See also:
132132
/// * [of], which throws if no [StreamChat] is found.
133133
static StreamChatState? maybeOf(BuildContext context) {
134-
return context.findAncestorStateOfType<StreamChatState>();
134+
return StreamStateScope.maybeOf<StreamChatState>(context);
135135
}
136136
}
137137

@@ -156,35 +156,38 @@ class StreamChatState extends State<StreamChat> {
156156
@override
157157
Widget build(BuildContext context) {
158158
final theme = _getTheme(context, widget.streamChatThemeData);
159-
return Portal(
160-
child: StreamChatConfiguration(
161-
data: streamChatConfigData,
162-
child: StreamChatTheme(
163-
data: theme,
164-
child: Builder(
165-
builder: (context) {
166-
final materialTheme = Theme.of(context);
167-
final streamTheme = StreamChatTheme.of(context);
168-
return Theme(
169-
data: materialTheme.copyWith(
170-
primaryIconTheme: streamTheme.primaryIconTheme,
171-
colorScheme: materialTheme.colorScheme.copyWith(
172-
secondary: streamTheme.colorTheme.accentPrimary,
159+
return StreamStateScope(
160+
state: this,
161+
child: Portal(
162+
child: StreamChatConfiguration(
163+
data: streamChatConfigData,
164+
child: StreamChatTheme(
165+
data: theme,
166+
child: Builder(
167+
builder: (context) {
168+
final materialTheme = Theme.of(context);
169+
final streamTheme = StreamChatTheme.of(context);
170+
return Theme(
171+
data: materialTheme.copyWith(
172+
primaryIconTheme: streamTheme.primaryIconTheme,
173+
colorScheme: materialTheme.colorScheme.copyWith(
174+
secondary: streamTheme.colorTheme.accentPrimary,
175+
),
173176
),
174-
),
175-
child: StreamChatCore(
176-
client: client,
177-
onBackgroundEventReceived: widget.onBackgroundEventReceived,
178-
backgroundKeepAlive: widget.backgroundKeepAlive,
179-
connectivityStream: widget.connectivityStream,
180-
child: Builder(
181-
builder: (context) {
182-
return widget.child ?? const Empty();
183-
},
177+
child: StreamChatCore(
178+
client: client,
179+
onBackgroundEventReceived: widget.onBackgroundEventReceived,
180+
backgroundKeepAlive: widget.backgroundKeepAlive,
181+
connectivityStream: widget.connectivityStream,
182+
child: Builder(
183+
builder: (context) {
184+
return widget.child ?? const Empty();
185+
},
186+
),
184187
),
185-
),
186-
);
187-
},
188+
);
189+
},
190+
),
188191
),
189192
),
190193
),

packages/stream_chat_flutter_core/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
- Added `StreamChannel.value` — exposes an already-initialized channel without running channel-page
66
positioning. Use it for sub-route and overlay wraps.
7+
- Added `StreamStateScope<T>` — a generic `InheritedWidget` that exposes a `State` to descendants for
8+
O(1) `.of(context)` lookup. Use it to back `static of(BuildContext)` accessors instead of
9+
`BuildContext.findAncestorStateOfType`.
710

811
🐞 Fixed
912

@@ -14,6 +17,12 @@
1417
- Fixed `StreamChannel.reloadChannel` merging the latest page on top of the previously loaded
1518
window instead of replacing it. The reload now matches a fresh open of the channel.
1619

20+
🚀 Performance
21+
22+
- `StreamChannel.of` and `StreamChatCore.of` now resolve via `StreamStateScope<T>` instead of
23+
`findAncestorStateOfType`, replacing an O(tree-depth) element walk with an O(1) inherited-widget
24+
lookup. Callers and behavior are unchanged.
25+
1726
## 9.24.0
1827

1928
✅ Added

packages/stream_chat_flutter_core/lib/src/stream_channel.dart

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:collection/collection.dart';
44
import 'package:flutter/material.dart';
55
import 'package:rxdart/rxdart.dart';
66
import 'package:stream_chat/stream_chat.dart';
7+
import 'package:stream_chat_flutter_core/src/stream_state_scope.dart';
78

89
/// Specifies query direction for pagination
910
enum QueryDirection {
@@ -191,7 +192,7 @@ class StreamChannel extends StatefulWidget {
191192
/// See also:
192193
/// * [of], which throws if no [StreamChannel] is found.
193194
static StreamChannelState? maybeOf(BuildContext context) {
194-
return context.findAncestorStateOfType<StreamChannelState>();
195+
return StreamStateScope.maybeOf<StreamChannelState>(context);
195196
}
196197

197198
@override
@@ -912,21 +913,24 @@ class StreamChannelState extends State<StreamChannel> {
912913

913914
@override
914915
Widget build(BuildContext context) {
915-
return FutureBuilder<void>(
916-
future: _channelInitFuture,
917-
builder: (context, snapshot) {
918-
if (snapshot.hasError) {
919-
final error = snapshot.error!;
920-
final stackTrace = snapshot.stackTrace;
921-
return widget.errorBuilder(context, error, stackTrace);
922-
}
923-
924-
if (snapshot.connectionState != ConnectionState.done) {
925-
if (widget.showLoading) return widget.loadingBuilder(context);
926-
}
927-
928-
return widget.child;
929-
},
916+
return StreamStateScope(
917+
state: this,
918+
child: FutureBuilder<void>(
919+
future: _channelInitFuture,
920+
builder: (context, snapshot) {
921+
if (snapshot.hasError) {
922+
final error = snapshot.error!;
923+
final stackTrace = snapshot.stackTrace;
924+
return widget.errorBuilder(context, error, stackTrace);
925+
}
926+
927+
if (snapshot.connectionState != ConnectionState.done) {
928+
if (widget.showLoading) return widget.loadingBuilder(context);
929+
}
930+
931+
return widget.child;
932+
},
933+
),
930934
);
931935
}
932936
}

packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
66
import 'package:package_info_plus/package_info_plus.dart';
77
import 'package:rxdart/rxdart.dart';
88
import 'package:stream_chat/stream_chat.dart';
9+
import 'package:stream_chat_flutter_core/src/stream_state_scope.dart';
910
import 'package:stream_chat_flutter_core/src/typedef.dart';
1011

1112
/// Widget used to provide information about the chat to the widget tree.
@@ -133,7 +134,7 @@ class StreamChatCore extends StatefulWidget {
133134
/// See also:
134135
/// * [of], which throws if no [StreamChatCore] is found.
135136
static StreamChatCoreState? maybeOf(BuildContext context) {
136-
return context.findAncestorStateOfType<StreamChatCoreState>();
137+
return StreamStateScope.maybeOf<StreamChatCoreState>(context);
137138
}
138139
}
139140

@@ -294,7 +295,9 @@ class StreamChatCoreState extends State<StreamChatCore>
294295
}
295296

296297
@override
297-
Widget build(BuildContext context) => widget.child;
298+
Widget build(BuildContext context) {
299+
return StreamStateScope(state: this, child: widget.child);
300+
}
298301
}
299302

300303
final class _ChatLifecycleManager {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import 'package:flutter/widgets.dart';
2+
3+
/// An [InheritedWidget] that exposes a [State] of type [T] to descendants for
4+
/// O(1) ancestor lookup.
5+
///
6+
/// Use this to back the `static of(BuildContext)` accessor of a
7+
/// [StatefulWidget] that wants to expose its [State] to descendants without
8+
/// the per-call cost of [BuildContext.findAncestorStateOfType], which walks
9+
/// the element tree on every call.
10+
///
11+
/// {@tool snippet}
12+
///
13+
/// Inside the widget's state, wrap the subtree in a [StreamStateScope] and
14+
/// define `of` / `maybeOf` that read it back:
15+
///
16+
/// ```dart
17+
/// class MyWidget extends StatefulWidget {
18+
/// // ...
19+
/// static MyWidgetState of(BuildContext context) =>
20+
/// StreamStateScope.of<MyWidgetState>(context);
21+
///
22+
/// static MyWidgetState? maybeOf(BuildContext context) =>
23+
/// StreamStateScope.maybeOf<MyWidgetState>(context);
24+
/// }
25+
///
26+
/// class MyWidgetState extends State<MyWidget> {
27+
/// @override
28+
/// Widget build(BuildContext context) {
29+
/// // [T] is inferred from `state: this`, no need to spell it out.
30+
/// return StreamStateScope(state: this, child: widget.child);
31+
/// }
32+
/// }
33+
/// ```
34+
/// {@end-tool}
35+
///
36+
/// [updateShouldNotify] returns `false`: lookups are intended as a dependency
37+
/// -free pointer to the enclosing [State], and the [State] identity is stable
38+
/// for the life of the element. Consumers that need to react to data exposed
39+
/// by the [State] should subscribe to its streams or notifiers explicitly.
40+
class StreamStateScope<T extends State> extends InheritedWidget {
41+
/// Creates a [StreamStateScope] exposing [state] to descendants.
42+
const StreamStateScope({
43+
super.key,
44+
required this.state,
45+
required super.child,
46+
});
47+
48+
/// The [State] exposed to descendants.
49+
final T state;
50+
51+
/// Returns the [State] of type [T] from the closest enclosing
52+
/// [StreamStateScope].
53+
///
54+
/// Throws a [FlutterError] if no matching [StreamStateScope] is found.
55+
static T of<T extends State>(BuildContext context) {
56+
final result = maybeOf<T>(context);
57+
if (result != null) return result;
58+
59+
throw FlutterError.fromParts(<DiagnosticsNode>[
60+
ErrorSummary(
61+
'StreamStateScope.of<$T>() called with a context that does not '
62+
'contain a StreamStateScope<$T>.',
63+
),
64+
ErrorDescription(
65+
'No StreamStateScope<$T> ancestor could be found starting from the '
66+
'context that was passed to StreamStateScope.of<$T>().',
67+
),
68+
context.describeElement('The context used was'),
69+
]);
70+
}
71+
72+
/// Returns the [State] of type [T] from the closest enclosing
73+
/// [StreamStateScope], or `null` if there isn't one.
74+
///
75+
/// This is a pure lookup — the calling element is **not** registered as a
76+
/// dependent, so the [State] identity must not change for the life of the
77+
/// scope (it doesn't, since [State.build] returns the same scope every
78+
/// frame). If consumers need to react to data exposed by the [State], they
79+
/// should subscribe to its streams or notifiers explicitly.
80+
static T? maybeOf<T extends State>(BuildContext context) {
81+
final element =
82+
context.getElementForInheritedWidgetOfExactType<StreamStateScope<T>>();
83+
final scope = element?.widget as StreamStateScope<T>?;
84+
return scope?.state;
85+
}
86+
87+
@override
88+
bool updateShouldNotify(StreamStateScope<T> oldWidget) => false;
89+
}

packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export 'src/stream_message_reminder_list_event_handler.dart';
2727
export 'src/stream_message_search_list_controller.dart';
2828
export 'src/stream_poll_controller.dart';
2929
export 'src/stream_poll_vote_list_controller.dart';
30+
export 'src/stream_state_scope.dart';
3031
export 'src/stream_thread_list_controller.dart';
3132
export 'src/stream_thread_list_event_handler.dart';
3233
export 'src/stream_user_list_controller.dart';

0 commit comments

Comments
 (0)