Skip to content

fix: prevent stale prevLastEntry animation on market switch#468

Merged
behnam-deriv merged 1 commit intomasterfrom
fix-stale-prev-last-entry-from-old-series
Mar 12, 2026
Merged

fix: prevent stale prevLastEntry animation on market switch#468
behnam-deriv merged 1 commit intomasterfrom
fix-stale-prev-last-entry-from-old-series

Conversation

@behnam-deriv
Copy link
Collaborator

@behnam-deriv behnam-deriv commented Mar 11, 2026

fix: prevent stale prevLastEntry animation on market switch for OHLC charts

Problem

When switching markets on a candlestick (or any OHLC) chart, a one-frame glitch
appears on the last candle: a long vertical line shoots from the new market's
candle body down to the old market's price level.

Root cause chain:

  1. Switching markets calls setState(() { ticks = fetchedTicks; }), which
    triggers a rebuild and creates a new CandleSeries with the new market's data.
  2. DataSeries.didUpdate(oldSeries) is called on this new instance with the old
    market's series as oldData.
  3. Because the last candles differ (entries!.last != oldSeries.entries!.last),
    the else if branch fires unconditionally and sets:
    prevLastEntry = IndexedEntry(oldSeries.entries!.last, ...)
    — i.e. prevLastEntry now holds the old market's last candle (e.g. price ~996).
  4. In OhlcPainter.onPaintData, the animation logic uses prevLastEntry to
    compute yHigh:
    yHigh: lastCandle.high > prevLastCandle.entry.high
        ? quoteToY(prevLastCandle.entry.high)   // quoteToY(996) — old market price!
        : quoteToY(lastCandle.high),
    With the new market's Y-axis range (~1209), quoteToY(996) maps far off the
    bottom of the canvas. The wick line is drawn from that off-screen coordinate up
    to yLow, producing the visible artifact.

The same tick animation that was fixed for area/line charts in
fix: quote bound animation for disjoint price range does not cover this
case: even after completeCurrentTickAnimation() snaps currentTickPercent to
1.0, the yHigh/yLow clamping logic in OhlcPainter still reads the stale
prevLastEntry in that frame.

Solution

Add a _isSameSeries guard in DataSeries.didUpdate so that prevLastEntry is
only set when the incoming data is a continuation of the old series — not a
wholesale replacement.

} else if (oldSeries.entries != null && _isSameSeries(oldSeries)) {
    prevLastEntry = IndexedEntry<T>(...);
    updated = true;
} else {
    updated = true;   // data changed, but from a different source — no animation
}

_isSameSeries compares the first entries of both series by epoch and quote:

bool _isSameSeries(DataSeries<Tick> oldSeries) {
    final T newFirst = input.first;
    final Tick oldFirst = oldSeries.entries!.first;
    return newFirst.epoch == oldFirst.epoch && newFirst.quote == oldFirst.quote;
}

For an incremental update (new tick, updated candle) the start of the dataset is
unchanged, so the first entries match → animation runs as before.
For a market switch both datasets span the same time window but at completely
different prices → first quotes differ → prevLastEntry is never set → no glitch.

Because prevLastEntry is a nullable field initialised to null on every new
DataSeries instance, not setting it is sufficient — no explicit
resetLastEntryAnimation() call is needed.

The else { updated = true; } branch is preserved so that the method still
correctly returns true on a market switch (new data did arrive), which keeps
compound indicator sub-series recalculation and _playNewTickAnimation working
correctly.

Summary by Sourcery

Guard last-entry animations so that OHLC chart series only animate as continuations of the same data source, preventing visual glitches when switching markets.

Bug Fixes:

  • Prevent stale prevLastEntry-based wick animations from using the previous market’s last candle after a market or symbol switch.

Enhancements:

  • Introduce a series-identity check based on the first entry’s epoch and quote to distinguish incremental updates from wholesale dataset replacements.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Mar 11, 2026

Reviewer's Guide

Adds a guard in DataSeries.didUpdate to only carry over prevLastEntry when the new series is a continuation of the previous data, using a new _isSameSeries helper that compares first entries, and ensures updated remains true even when no animation state is transferred to avoid stale OHLC wick animations on market switches.

Sequence diagram for updated DataSeries.didUpdate behavior on market switch

sequenceDiagram
  actor User
  participant MarketSwitcher
  participant ChartWidget
  participant DataSeries
  participant OhlcPainter

  User->>MarketSwitcher: selectNewMarket()
  MarketSwitcher->>ChartWidget: setState(newTicks)
  ChartWidget->>DataSeries: create new DataSeries with input
  ChartWidget->>DataSeries: didUpdate(oldSeries)
  alt oldSeries.entries not null and same last entry
    DataSeries->>DataSeries: prevLastEntry = oldSeries.prevLastEntry
  else oldSeries.entries not null and _isSameSeries(oldSeries) is true
    DataSeries->>DataSeries: prevLastEntry = IndexedEntry(oldSeries.entries.last)
    DataSeries->>ChartWidget: didUpdate returns updated = true
  else different series (e.g. market switch)
    DataSeries->>DataSeries: prevLastEntry remains null
    DataSeries->>ChartWidget: didUpdate returns updated = true
  end

  ChartWidget->>OhlcPainter: paint with lastCandle and prevLastEntry
  alt prevLastEntry is not null
    OhlcPainter->>OhlcPainter: animate wick using prevLastEntry and lastCandle
  else prevLastEntry is null (market switch)
    OhlcPainter->>OhlcPainter: render lastCandle without cross-series animation
  end
Loading

Class diagram for DataSeries with new _isSameSeries guard

classDiagram
  class Tick {
    double quote
    int epoch
  }

  class IndexedEntry_T_ {
    T entry
    int index
  }

  class Series {
  }

  class DataSeries_T_ {
    T extends Tick
    List~T~? entries
    List~T~ input
    IndexedEntry_T_? prevLastEntry

    +didUpdate(DataSeries_T_ oldSeries) bool
    +isOldDataAvailable(DataSeries_Tick_ oldSeries) bool
    -_isSameSeries(DataSeries_Tick_ oldSeries) bool
    +shouldRepaint(ChartData oldDelegate) bool
  }

  class DataSeries_Tick_ {
  }

  class ChartData {
  }

  DataSeries_T_ ..|> Series
  DataSeries_T_ --> Tick : uses
  DataSeries_T_ --> IndexedEntry_T_ : wraps
  DataSeries_T_ --> DataSeries_Tick_ : parameter in _isSameSeries and isOldDataAvailable
  DataSeries_T_ --> ChartData : parameter in shouldRepaint

  %% Method detail for _isSameSeries
  class DataSeries_T_ {
    -_isSameSeries(oldSeries DataSeries_Tick_) bool
  }
Loading

File-Level Changes

Change Details Files
Guard transfer of prevLastEntry to prevent animating between unrelated OHLC series (e.g., on market switch).
  • Modify didUpdate to only set prevLastEntry when oldSeries has entries and _isSameSeries(oldSeries) is true, otherwise just mark data as updated without carrying over animation state.
  • Introduce a private _isSameSeries helper that returns true when both series are non-empty and their first entries share the same epoch and quote, treating those as evidence of a continuous data source.
  • Document the intent of _isSameSeries as distinguishing incremental updates from entirely new datasets, ensuring prevLastEntry is not reused across market/symbol switches.
lib/src/deriv_chart/chart/data_visualization/chart_series/data_series.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

@claude
Copy link

claude bot commented Mar 11, 2026

Claude finished @behnam-deriv's task —— View job


Code Review Complete ✅

I've reviewed this PR and the fix looks solid. The implementation correctly addresses the root cause of the stale prevLastEntry animation glitch when switching markets.


Summary

Verdict: APPROVED

The fix is well-designed and implements a clean guard condition to prevent animation artifacts when switching between different market datasets. The solution is minimal, focused, and follows good engineering practices.


High Priority Items

None identified. The implementation is correct and safe.


Medium Priority Items

1. Missing Test Coverage

Severity: Medium
Location: lib/src/deriv_chart/chart/data_visualization/chart_series/data_series.dart:301-308

There are no tests covering the _isSameSeries logic or the market-switch scenario. Consider adding tests for:

  • Same series detection: Verify that incremental updates (new tick on same market) return true
  • Different series detection: Verify that market switches (same time window, different prices) return false
  • Edge cases: Empty lists, single-entry lists, identical epochs with different quotes

Example test structure:

test('_isSameSeries returns true for incremental update', () {
  final oldSeries = CandleSeries([
    Candle(epoch: 1000, quote: 100, ...),
    Candle(epoch: 2000, quote: 101, ...),
  ]);
  
  final newSeries = CandleSeries([
    Candle(epoch: 1000, quote: 100, ...),  // Same first entry
    Candle(epoch: 2000, quote: 101, ...),
    Candle(epoch: 3000, quote: 102, ...),  // New tick
  ]);
  
  expect(newSeries._isSameSeries(oldSeries), isTrue);
});

test('_isSameSeries returns false for market switch', () {
  final oldSeries = CandleSeries([
    Candle(epoch: 1000, quote: 996, ...),  // Old market price
    Candle(epoch: 2000, quote: 997, ...),
  ]);
  
  final newSeries = CandleSeries([
    Candle(epoch: 1000, quote: 1209, ...),  // New market price
    Candle(epoch: 2000, quote: 1210, ...),
  ]);
  
  expect(newSeries._isSameSeries(oldSeries), isFalse);
});

2. Documentation Could Mention the Painter Context

Severity: Low
Location: lib/src/deriv_chart/chart/data_visualization/chart_series/data_series.dart:292-300

The documentation is excellent but could briefly mention that this guard prevents visual artifacts in OhlcPainter (lines 89-98 in ohlc_painter.dart) where prevLastEntry prices are used with the new series' Y-axis range. This would help future maintainers understand the full context.

Optional addition:

/// Returns true when [oldSeries] and this series share the same data source,
/// i.e. this series is an incremental update of [oldSeries] rather than an
/// entirely new dataset (e.g. a market/symbol switch).
///
/// This guard prevents visual artifacts in painters (e.g. OhlcPainter) that
/// use [prevLastEntry] for animation: when markets switch, the old market's
/// prices would be rendered with the new market's Y-axis scale, causing
/// off-screen wick lines.
///
/// Compares the first entries by epoch and quote: for an incremental update
/// (new tick, new candle) the beginning of the data is unchanged, so the
/// first entries are identical. For a market switch both datasets cover the
/// same time window, but the prices differ, so the first entries will not
/// match and [prevLastEntry] must not carry over.

Low Priority Items

None identified.


Detailed Analysis

Correctness ✅

The _isSameSeries implementation is correct:

  1. Null safety: Properly handles empty input or oldSeries.entries (line 302-304)
  2. Comparison logic: Comparing first entry's epoch and quote is the right approach (line 307)
    • For incremental updates: First entries match → returns true → animation proceeds
    • For market switches: Same time window but different prices → returns false → no stale animation
  3. Integration: The guard at line 256 prevents setting prevLastEntry for different series while preserving the updated = true behavior (line 263) needed for compound indicators

Edge Cases ✅

The implementation handles edge cases well:

  • Empty lists: Returns false (correct - can't determine series identity)
  • Single entry: Works correctly - compares the only entry
  • Same epoch, different quote: Returns false (correct - this is the market-switch scenario)
  • Type safety: Uses generic T for newFirst but Tick for oldFirst, leveraging the type hierarchy correctly

Performance ✅

  • O(1) operation using first element access
  • No unnecessary iterations or allocations
  • Efficient and appropriate for hot path code

Potential Regressions ✅

No regressions identified. The change is conservative:

  • Only adds a guard condition to existing logic
  • Preserves all existing behavior for same-series updates
  • The new else branch (line 262-263) maintains the updated = true return that was implicit before
  • prevLastEntry starts as null on new instances, so not setting it is safe

Alternative Approaches Considered

The PR description mentions this approach is superior to:

  1. Calling completeCurrentTickAnimation() - wouldn't prevent the single-frame glitch
  2. Explicit resetLastEntryAnimation() calls - not needed since prevLastEntry defaults to null

The chosen solution is the most elegant - prevent the problem at the source rather than patching symptoms.


Conclusion

This is a high-quality fix that addresses a real visual bug with a minimal, well-documented change. The implementation is correct, handles edge cases properly, and follows the codebase's architecture patterns.

Recommendations:

  1. Add unit tests for the _isSameSeries logic (medium priority)
  2. Consider the documentation enhancement (low priority, optional)

The PR is ready to merge. Great work on the detailed problem analysis and clean solution! 🎯


@github-actions
Copy link

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

OpenSSF Scorecard

PackageVersionScoreDetails

Scanned Manifest Files

Copy link
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 1 issue, and left some high level feedback:

  • In _isSameSeries, consider aligning the parameter type with the generic on DataSeries (e.g. DataSeries<T> or DataSeries<T extends Tick>) instead of DataSeries<Tick> to avoid unnecessary type narrowing and potential subtype issues.
  • _isSameSeries currently compares input.first against oldSeries.entries!.first; if entries is the canonical, transformed representation used for painting, you may want to compare entries on both sides for consistency or document why input vs entries is intentional.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `_isSameSeries`, consider aligning the parameter type with the generic on `DataSeries` (e.g. `DataSeries<T>` or `DataSeries<T extends Tick>`) instead of `DataSeries<Tick>` to avoid unnecessary type narrowing and potential subtype issues.
- `_isSameSeries` currently compares `input.first` against `oldSeries.entries!.first`; if `entries` is the canonical, transformed representation used for painting, you may want to compare `entries` on both sides for consistency or document why `input` vs `entries` is intentional.

## Individual Comments

### Comment 1
<location path="lib/src/deriv_chart/chart/data_visualization/chart_series/data_series.dart" line_range="301-308" />
<code_context>
+    if (input.isEmpty || (oldSeries.entries?.isEmpty ?? true)) {
+      return false;
+    }
+    final T newFirst = input.first;
+    final Tick oldFirst = oldSeries.entries!.first;
+    return newFirst.epoch == oldFirst.epoch && newFirst.quote == oldFirst.quote;
+  }
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Using direct equality on quote for floating-point-like data may be fragile.

This comparison hinges on `newFirst.quote == oldFirst.quote`. If `quote` is a floating-point value or can differ slightly between sources, strict equality may cause logically identical updates to be treated as different series. If that’s possible here, compare within a tolerance or use a more stable key (e.g., instrument ID plus epoch) instead.

```suggestion
  bool _isSameSeries(DataSeries<Tick> oldSeries) {
    if (input.isEmpty || (oldSeries.entries?.isEmpty ?? true)) {
      return false;
    }
    final T newFirst = input.first;
    final Tick oldFirst = oldSeries.entries!.first;

    // Epoch must match exactly; quote may differ slightly, so compare with a tolerance.
    if (newFirst.epoch != oldFirst.epoch) {
      return false;
    }

    const double quoteTolerance = 1e-8;
    final double quoteDiff = (newFirst.quote - oldFirst.quote).abs();
    return quoteDiff <= quoteTolerance;
  }
```
</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.

Comment on lines +301 to +308
bool _isSameSeries(DataSeries<Tick> oldSeries) {
if (input.isEmpty || (oldSeries.entries?.isEmpty ?? true)) {
return false;
}
final T newFirst = input.first;
final Tick oldFirst = oldSeries.entries!.first;
return newFirst.epoch == oldFirst.epoch && newFirst.quote == oldFirst.quote;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (bug_risk): Using direct equality on quote for floating-point-like data may be fragile.

This comparison hinges on newFirst.quote == oldFirst.quote. If quote is a floating-point value or can differ slightly between sources, strict equality may cause logically identical updates to be treated as different series. If that’s possible here, compare within a tolerance or use a more stable key (e.g., instrument ID plus epoch) instead.

Suggested change
bool _isSameSeries(DataSeries<Tick> oldSeries) {
if (input.isEmpty || (oldSeries.entries?.isEmpty ?? true)) {
return false;
}
final T newFirst = input.first;
final Tick oldFirst = oldSeries.entries!.first;
return newFirst.epoch == oldFirst.epoch && newFirst.quote == oldFirst.quote;
}
bool _isSameSeries(DataSeries<Tick> oldSeries) {
if (input.isEmpty || (oldSeries.entries?.isEmpty ?? true)) {
return false;
}
final T newFirst = input.first;
final Tick oldFirst = oldSeries.entries!.first;
// Epoch must match exactly; quote may differ slightly, so compare with a tolerance.
if (newFirst.epoch != oldFirst.epoch) {
return false;
}
const double quoteTolerance = 1e-8;
final double quoteDiff = (newFirst.quote - oldFirst.quote).abs();
return quoteDiff <= quoteTolerance;
}

@behnam-deriv behnam-deriv merged commit 8efb1dc into master Mar 12, 2026
7 checks passed
@behnam-deriv behnam-deriv deleted the fix-stale-prev-last-entry-from-old-series branch March 12, 2026 04:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant