Skip to content

feat(talker_flutter): Performance optimizations and pause feature for TalkerView#488

Open
jan-siroky wants to merge 1 commit intoFrezyx:masterfrom
jan-siroky:feat/performance-and-pause
Open

feat(talker_flutter): Performance optimizations and pause feature for TalkerView#488
jan-siroky wants to merge 1 commit intoFrezyx:masterfrom
jan-siroky:feat/performance-and-pause

Conversation

@jan-siroky
Copy link
Copy Markdown

@jan-siroky jan-siroky commented Mar 30, 2026

Summary

This PR addresses significant performance issues in TalkerView / TalkerScreen that cause UI freezes when log history grows large, and adds a built-in pause/resume feature.

Performance Improvements

1. Throttled stream subscription replaces StreamBuilder (talker_builder.dart)

  • TalkerBuilder was a StatelessWidget wrapping a raw StreamBuilder, which rebuilds on every single log event — no throttling at all
  • Replaced with a StatefulWidget that uses a trailing-edge throttle (500ms window): during high-frequency logging bursts, at most one rebuild fires after the burst settles
  • This alone prevents hundreds of unnecessary rebuilds per second during heavy logging

2. Cached filtering with change detection (talker_view.dart)

  • Previously, _getFilteredLogs() iterated the entire log history and created a new filtered list on every single build
  • Now uses a hash-based cache that only recomputes when the source length or filter state actually changes
  • Eliminates redundant O(n) filter passes during scrolling or unrelated rebuilds

3. Configurable maxDisplayedLogs parameter (talker_view.dart, talker_screen.dart)

  • New optional parameter (default: 500) caps the number of log entries processed and rendered
  • Prevents UI freezes when history contains thousands of entries
  • Set to 0 to disable the cap (not recommended for large histories)
  • Capping happens before filtering, so at most N entries pass through the filter pipeline

4. ValueKey(index) replaces missing keys (talker_view.dart)

  • Log cards previously had no explicit key, forcing Flutter to diff by widget type/position with poor reuse
  • ValueKey(index) enables fast integer-based element matching within each filtered snapshot

5. RepaintBoundary per log card (talker_view.dart)

  • Each TalkerDataCard (and custom itemsBuilder output) is wrapped in RepaintBoundary
  • Isolates repaints so expanding/collapsing one card doesn't trigger repaint of siblings

6. Monitor button no longer spawns its own TalkerBuilder (talker_view_appbar.dart)

  • _MonitorButton previously created a second TalkerBuilder that scanned the full history on every event just to check for errors
  • Now receives the already-computed keys list from the parent and checks for error/exception keys directly — zero additional overhead

7. Debounced search (talker_view_controller.dart)

  • updateFilterSearchQuery() now debounces with a 300ms timer
  • Previously every keystroke triggered an immediate filter recomputation + full rebuild

New Feature: Pause / Resume

  • Pause button added to TalkerView app bar (left of the monitor icon)
  • When paused: freezes the current log snapshot, title shows "(Paused)", button turns yellow, TalkerBuilder stream events are skipped
  • When resumed: returns to live log stream with all filter state (search, key filters) preserved
  • Pause state is managed in TalkerViewController (isPaused, frozenLogs, togglePause()) so it can also be controlled externally

Performance Impact

Metric Before After
Rebuild on each log event Yes (raw StreamBuilder) Throttled (500ms trailing-edge)
Log entry cap None (all history rendered) 500 (configurable)
Filter computation Every build, full iteration Cached with change detection
Widget key strategy No explicit key ValueKey(index)
Card repaint isolation None RepaintBoundary per card
Search triggering Immediate on keystroke 300ms debounce
Monitor error check Separate TalkerBuilder scanning full history Reuses parent's keys list

Backward Compatibility

All changes are fully backward compatible:

  • maxDisplayedLogs defaults to 500 (same visual result for most apps, dramatically better performance)
  • TalkerBuilder API unchanged (constructor signature identical)
  • TalkerViewController new members (isPaused, frozenLogs, togglePause()) are additive
  • No existing parameter removed or renamed

Files Changed

  • packages/talker_flutter/lib/src/ui/talker_builder.dart — StreamBuilder → throttled StatefulWidget
  • packages/talker_flutter/lib/src/ui/talker_view.dart — cached filtering, RepaintBoundary, ValueKey, maxDisplayedLogs, pause support
  • packages/talker_flutter/lib/src/ui/talker_screen.dart — pass through maxDisplayedLogs
  • packages/talker_flutter/lib/src/ui/widgets/talker_view_appbar.dart — pause button, optimized MonitorButton
  • packages/talker_flutter/lib/src/controller/talker_view_controller.dart — pause state, debounced search, dispose cleanup

Summary by Sourcery

Optimize TalkerView/TalkerScreen log rendering performance and add a pause/resume capability for the live log stream.

New Features:

  • Introduce a configurable maxDisplayedLogs parameter to cap the number of rendered log entries in TalkerView and TalkerScreen.
  • Add a pause/resume control and state management to freeze and resume the live log view without losing filter settings.

Enhancements:

  • Replace the StreamBuilder-based TalkerBuilder with a throttled, stateful listener to reduce rebuild frequency during high-volume logging.
  • Cache filtered log results with change detection in TalkerView to avoid redundant filtering on every rebuild and support efficient copy of filtered logs.
  • Wrap log list items in RepaintBoundary and add stable ValueKey-based identification to improve list diffing and repaint isolation.
  • Refactor TalkerViewAppBar monitor button to reuse existing log keys instead of maintaining its own TalkerBuilder subscription.
  • Debounce search query updates in TalkerViewController to limit expensive filter recomputations during rapid typing.

… TalkerView

Performance improvements:
- Replace StreamBuilder with throttled stream subscription (500ms trailing-edge)
  to prevent excessive widget rebuilds during high-frequency logging
- Add cached filtering with change detection — only recompute filtered logs
  when source data or filter state actually changes
- Add configurable maxDisplayedLogs parameter (default 500) to cap rendered
  entries and prevent UI freezes with large log histories
- Replace ObjectKey with ValueKey(index) for faster widget element diffing
- Wrap each log card in RepaintBoundary to isolate repaints
- Remove redundant TalkerBuilder from MonitorButton — reuse the keys list
  already computed by the parent instead of scanning full history again
- Add debounced search (300ms) in TalkerViewController to avoid rebuilds
  on every keystroke

New feature — Pause/Resume:
- Add pause button to TalkerView app bar (left of monitor icon)
- When paused, freezes current log snapshot and stops processing new events
- When resumed, returns to live log stream with filter state preserved
- Pause state managed in TalkerViewController for external control

All changes are backward compatible — new parameters are optional with
defaults matching current behavior.
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai bot commented Mar 30, 2026

Reviewer's Guide

Refactors TalkerView/TalkerScreen to significantly reduce rebuilds and filtering work via a throttled TalkerBuilder, cached filtering with a configurable maxDisplayedLogs cap, UI key/repaint optimizations, and introduces a pause/resume feature with debounced search and a cheaper monitor button implementation.

Sequence diagram for throttled TalkerBuilder updates

sequenceDiagram
  participant Talker
  participant TalkerBuilder
  participant _TalkerBuilderState as TalkerBuilderState
  participant FlutterFramework as Flutter

  TalkerBuilder->>TalkerBuilderState: initState()
  TalkerBuilderState->>Talker: stream.listen(callback)

  loop HighFrequencyLogging
    Talker-->>TalkerBuilderState: onLogEvent(TalkerData)
    TalkerBuilderState->>TalkerBuilderState: _throttleTimer.cancel()
    TalkerBuilderState->>TalkerBuilderState: _throttleTimer = Timer(500ms)
  end

  Note over TalkerBuilderState,Flutter: Only the last timer after the burst will fire

  TalkerBuilderState-->>Flutter: setState() after 500ms
  Flutter->>TalkerBuilderState: build(context)
  TalkerBuilderState->>TalkerBuilder: widget.builder(context, talker.history)
  TalkerBuilder-->>Flutter: Widget subtree rebuilt using latest history
Loading

Sequence diagram for pause and resume in TalkerView

sequenceDiagram
  actor User
  participant AppBar as TalkerViewAppBar
  participant Controller as TalkerViewController
  participant View as TalkerViewState
  participant Builder as TalkerBuilder

  User->>AppBar: tap pause button
  AppBar->>Controller: onPauseTap() -> togglePause()

  alt currently running
    Controller->>Controller: _frozenLogs = List.from(_talker.history)
    Controller->>Controller: _isPaused = true
  else currently paused
    Controller->>Controller: _frozenLogs = []
    Controller->>Controller: _isPaused = false
  end

  Controller-->>View: notifyListeners()
  View->>View: AnimatedBuilder rebuild

  alt isPaused == true
    View->>View: _buildLogList(frozenLogs, theme)
    View-->>Builder: TalkerBuilder not built
  else isPaused == false
    View->>Builder: build with talker.history via TalkerBuilder
  end

  View->>AppBar: rebuild with
  AppBar->>AppBar: update icon(color, play/pause)
  AppBar->>AppBar: title includes (Paused) when isPaused == true
Loading

Class diagram for updated TalkerView and TalkerScreen structure

classDiagram
  class Talker {
    List~TalkerData~ history
    Stream~TalkerData~ stream
  }

  class TalkerData {
    String? key
    Color getFlutterColor(TalkerScreenTheme theme)
  }

  class TalkerFilter {
    List~String~ enabledKeys
    String searchQuery
    TalkerFilter copyWith(List~String~ enabledKeys, String searchQuery)
    bool filter(TalkerData data)
  }

  class TalkerScreenTheme {
  }

  class TalkerViewController {
    -Talker _talker
    -TalkerFilter _uiFilter
    -bool _expandedLogs
    -bool _isLogOrderReversed
    -bool _isPaused
    -List~TalkerData~ _frozenLogs
    -Timer? _searchDebounceTimer
    +static Duration _searchDebounceDuration

    +TalkerFilter get filter()
    +set filter(TalkerFilter val)
    +bool get expandedLogs()
    +set expandedLogs(bool val)
    +bool get isLogOrderReversed()
    +set isLogOrderReversed(bool val)

    +bool get isPaused()
    +List~TalkerData~ get frozenLogs()

    +void togglePause()
    +void updateFilterSearchQuery(String query)
    +void addFilterKey(String key)
    +void removeFilterKey(String key)
    +void setFilterKeys(List~String~ keys)
    +void update()
    +void dispose()
  }

  class TalkerBuilder {
    +Talker talker
    +TalkerWidgetBuilder builder
    +createState() _TalkerBuilderState
  }

  class _TalkerBuilderState {
    -StreamSubscription~TalkerData~? _subscription
    -Timer? _throttleTimer
    +static Duration _throttleDuration

    +void initState()
    +void didUpdateWidget(TalkerBuilder oldWidget)
    +void dispose()
    -void _subscribe()
    -void _unsubscribe()
    +Widget build(BuildContext context)
  }

  class TalkerView {
    +Talker talker
    +TalkerScreenTheme theme
    +ScrollController? scrollController
    +List~Widget~ customActions
    +List~Widget~ customSettings
    +bool isLogsExpanded
    +bool isLogOrderReversed
    +int maxDisplayedLogs
    +createState() _TalkerViewState
  }

  class _TalkerViewState {
    -TalkerViewController _controller
    -List~TalkerData~ _cachedFiltered
    -int _lastSourceLength
    -int _lastFilterHash
    +int get _filterHash
    -List~TalkerData~ _getFilteredLogs(List~TalkerData~ source)
    -Widget _buildLogList(List~TalkerData~ data, TalkerScreenTheme talkerTheme)
    +Widget build(BuildContext context)
    -void _onToggleKey(String key, bool selected)
    -void _openSettings(BuildContext context, TalkerScreenTheme theme)
    -void _cleanHistory()
    -void _copyFilteredLogs(BuildContext context)
  }

  class TalkerScreen {
    +Talker talker
    +TalkerScreenTheme theme
    +List~Widget~ customActions
    +List~Widget~ customSettings
    +bool isLogsExpanded
    +bool isLogOrderReversed
    +int maxDisplayedLogs
    +Widget build(BuildContext context)
  }

  class TalkerViewAppBar {
    +String? title
    +Widget? leading
    +Talker talker
    +TalkerScreenTheme talkerTheme
    +TalkerViewController controller
    +List~String?~ keys
    +List~String?~ uniqKeys
    +VoidCallback onMonitorTap
    +VoidCallback onSettingsTap
    +VoidCallback onActionsTap
    +VoidCallback onPauseTap
    +bool isPaused
  }

  class _MonitorButton {
    +Talker talker
    +TalkerScreenTheme talkerTheme
    +VoidCallback onPressed
    +List~String?~ keys
    +Widget build(BuildContext context)
  }

  TalkerBuilder --> Talker : uses
  _TalkerBuilderState --> TalkerBuilder : stateOf
  _TalkerBuilderState --> Talker : subscribesTo

  TalkerView --> TalkerViewController : creates
  _TalkerViewState --> TalkerViewController : uses
  _TalkerViewState --> TalkerBuilder : buildsWith
  _TalkerViewState --> TalkerViewAppBar : builds

  TalkerScreen --> TalkerView : composes

  TalkerViewAppBar --> TalkerViewController : controls
  TalkerViewAppBar --> _MonitorButton : has
  _MonitorButton --> Talker : readsHistoryViaKeys

  TalkerViewController --> TalkerFilter : owns
  TalkerViewController --> Talker : readsHistory
Loading

File-Level Changes

Change Details Files
Replace raw StreamBuilder-based TalkerBuilder with a throttled, stateful listener to reduce rebuild frequency during high-volume logging.
  • Convert TalkerBuilder from StatelessWidget using StreamBuilder to StatefulWidget managing a StreamSubscription and Timer
  • Implement a 500ms trailing-edge throttle on Talker.stream events that batches multiple events into a single setState
  • Ensure subscription is recreated when the Talker instance changes and disposed cleanly to avoid leaks
  • Expose the same builder API and keep using talker.history as the data source to preserve public behavior
packages/talker_flutter/lib/src/ui/talker_builder.dart
Optimize TalkerView list rendering with cached filtering, capped log count, repaint isolation, and better keys, while centralizing list-building logic.
  • Add maxDisplayedLogs parameter (default 500) to TalkerView and cap the source list before filtering to avoid processing large histories
  • Introduce a hash-based cache for filtered logs keyed by source length and filter state, invalidated on history/filters changes and cleanHistory
  • Replace inline build logic with a dedicated _buildLogList that handles both live and paused views, including reversed ordering
  • Wrap each rendered item (both default TalkerDataCard and custom itemsBuilder) in RepaintBoundary and assign ValueKey(index) to improve list diffing and repaint performance
  • Use cached filtered list for copyFilteredLogs to avoid recomputation and adjust key computation so the Monitor button can reuse it
packages/talker_flutter/lib/src/ui/talker_view.dart
Propagate configurable maxDisplayedLogs through TalkerScreen to TalkerView for safer defaults at the screen level.
  • Add maxDisplayedLogs field with documentation to TalkerScreen and default it to 500
  • Pass maxDisplayedLogs down into TalkerView from TalkerScreen’s build method
packages/talker_flutter/lib/src/ui/talker_screen.dart
Add a pause/resume control in the TalkerView app bar and simplify the Monitor button to reuse parent-computed keys.
  • Extend TalkerViewAppBar constructor to accept onPauseTap and isPaused and render a pause/play IconButton that changes color and icon based on paused state
  • Adjust title to reflect paused state and wire pause button to TalkerViewController.togglePause
  • Refactor _MonitorButton to take the precomputed keys list instead of creating its own TalkerBuilder, and determine error presence by checking for TalkerKey.error/exception
  • Keep existing monitor icon behavior but remove redundant history scans and stream listeners
packages/talker_flutter/lib/src/ui/widgets/talker_view_appbar.dart
packages/talker_flutter/lib/src/ui/talker_view.dart
Extend TalkerViewController with pause/resume state management and debounced search to control TalkerView behavior more efficiently.
  • Store a private Talker reference in TalkerViewController so it can snapshot history when pausing
  • Add isPaused, frozenLogs, and togglePause() to manage a frozen snapshot of logs and notify listeners on state changes
  • Introduce a debounced updateFilterSearchQuery using a 300ms Timer to avoid excessive filter recomputation during typing and cancel the timer on dispose
  • Override dispose to cancel the debounce timer and update controller initialization to include the Talker reference
packages/talker_flutter/lib/src/controller/talker_view_controller.dart

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • Consider making the _throttleDuration in TalkerBuilder configurable via the constructor so callers can tune responsiveness vs. rebuild frequency for different use cases.
  • The _filterHash computation in TalkerViewState currently relies on enabledKeys.hashCode, which is identity-based for lists; using a content-based hash (e.g., Object.hashAll over enabledKeys) would make the cache invalidation more robust when the enabled key set changes.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider making the `_throttleDuration` in `TalkerBuilder` configurable via the constructor so callers can tune responsiveness vs. rebuild frequency for different use cases.
- The `_filterHash` computation in `TalkerViewState` currently relies on `enabledKeys.hashCode`, which is identity-based for lists; using a content-based hash (e.g., `Object.hashAll` over `enabledKeys`) would make the cache invalidation more robust when the enabled key set changes.

## Individual Comments

### Comment 1
<location path="packages/talker_flutter/lib/src/ui/talker_view.dart" line_range="319" />
<code_context>
     Clipboard.setData(ClipboardData(
-        text: _getFilteredLogs(widget.talker.history)
-            .text(timeFormat: widget.talker.settings.timeFormat)));
+        text: _cachedFiltered.text(
+            timeFormat: widget.talker.settings.timeFormat)));
     _showSnackBar(context, 'All filtered logs copied in buffer');
</code_context>
<issue_to_address>
**question (bug_risk):** Copying filtered logs now respects the display cap, which may unintentionally truncate exported logs.

By switching from `_getFilteredLogs(widget.talker.history)` to `_cachedFiltered`, the export now uses the capped list (`maxDisplayedLogs`) instead of the full history. This means copied logs may be limited to the last N entries. If the goal is to export everything matching the filter, consider using an uncapped filter for export or providing a separate “copy visible” vs “copy all” option.
</issue_to_address>

### Comment 2
<location path="packages/talker_flutter/lib/src/ui/widgets/talker_view_appbar.dart" line_range="306-307" />
<code_context>
-                  color: talkerTheme.textColor,
-                ),
-              ),
+    final haveErrors = keys.any(
+      (k) => k == TalkerKey.error || k == TalkerKey.exception,
+    );
+    return Stack(
</code_context>
<issue_to_address>
**question (bug_risk):** Monitor error indicator now depends on keys of only the capped subset and on key naming, which may change semantics.

Previously `_MonitorButton` scanned the full `data` list for `TalkerError` / `TalkerException`. Now it infers `haveErrors` from `keys` built from the capped list in `_buildLogList`. As a result: (1) errors older than `maxDisplayedLogs` no longer affect the indicator, and (2) custom error/exception types without `TalkerKey.error` / `TalkerKey.exception` won’t trigger it. If this change isn’t deliberate, consider checking errors on the full data set or relaxing the condition used for the indicator.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Clipboard.setData(ClipboardData(
text: _getFilteredLogs(widget.talker.history)
.text(timeFormat: widget.talker.settings.timeFormat)));
text: _cachedFiltered.text(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (bug_risk): Copying filtered logs now respects the display cap, which may unintentionally truncate exported logs.

By switching from _getFilteredLogs(widget.talker.history) to _cachedFiltered, the export now uses the capped list (maxDisplayedLogs) instead of the full history. This means copied logs may be limited to the last N entries. If the goal is to export everything matching the filter, consider using an uncapped filter for export or providing a separate “copy visible” vs “copy all” option.

Comment on lines +306 to +307
final haveErrors = keys.any(
(k) => k == TalkerKey.error || k == TalkerKey.exception,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (bug_risk): Monitor error indicator now depends on keys of only the capped subset and on key naming, which may change semantics.

Previously _MonitorButton scanned the full data list for TalkerError / TalkerException. Now it infers haveErrors from keys built from the capped list in _buildLogList. As a result: (1) errors older than maxDisplayedLogs no longer affect the indicator, and (2) custom error/exception types without TalkerKey.error / TalkerKey.exception won’t trigger it. If this change isn’t deliberate, consider checking errors on the full data set or relaxing the condition used for the indicator.

@Frezyx Frezyx added enhancement New feature or request awaiting On the list for consideration or merge labels Apr 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting On the list for consideration or merge enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants