Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ the native `RCT_EXTERN_METHOD` signatures are unchanged. Both platforms check th
current ref at call time and ignore commands after detachment. The native command
names, argument order and payloads are unchanged.

### Time-aware candle commands

`updateLastCandlestick` and `addCandlesticksAtTheEnd` compare bar timestamps with
the native array instead of trusting positions. A bar with the same time as the
last native bar replaces it, a newer bar is appended and an older bar is ignored.
A full `optionList` replacement and live commands can therefore arrive in either
order without dropping or overwriting a candle. On Android, option lists are parsed
in order on a per-chart worker and a result superseded by a newer option list is
discarded. Bars delivered by live commands while an option list is still being
parsed are merged back, by time, when that snapshot lands. iOS applies option lists
synchronously, so it has no such window. Missing indicator lists are reused only
for an update to the same bar, never for a newer bar that is appended. These paths are native-only and are not
covered by the JS command tests: validate them on a device (return to a retained
chart just before a bar boundary, repeat, and compare with a fresh reload).

Run `yarn test` (Node 22+) for command routing, payload, detachment and independent
chart checks. These tests mock the native bridge; they do **not** prove the
intermittent Android or iOS SIGSEGV is resolved. Validate a mobile build containing this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,26 +62,44 @@ public Map getExportedCustomDirectEventTypeConstants() {



@Override
public void onDropViewInstance(@Nonnull HTKLineContainerView containerView) {
super.onDropViewInstance(containerView);
containerView.optionListExecutor.shutdownNow();
}

@ReactProp(name = "optionList")
public void setOptionList(final HTKLineContainerView containerView, String optionList) {
if (optionList == null) {
return;
}

new Thread(new Runnable() {
// Parse in order on this chart's worker and drop results superseded by a
// newer option list: per-call threads could finish out of order and leave
// the chart on an older dataset.
final int generation = containerView.onOptionListScheduled();
containerView.optionListExecutor.execute(new Runnable() {
@Override
public void run() {
if (generation != containerView.optionListGeneration) {
return;
}
int disableDecimalFeature = JSON.DEFAULT_PARSER_FEATURE & ~Feature.UseBigDecimal.getMask();
Map optionMap = (Map)JSON.parse(optionList, disableDecimalFeature);
if (generation != containerView.optionListGeneration) {
return;
}
Comment on lines +89 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Late option replacement drops live candle

When updateLastCandlestick appends before pending optionList work finishes, the replacement overwrites that candle. optionListGeneration tracks only option calls, so the live candle disappears on both platforms.

Learn more

Option parsing runs asynchronously, while candle commands mutate the current native array immediately. The generation check rejects an option result only when another option call supersedes it. A candle command does not change the generation, so an older snapshot can still replace modelArray after that command. Android performs the replacement in reloadOptionList, and iOS has the same replacement behavior in reloadOptionList. Timestamp checks inside the commands only protect the opposite ordering, where the replacement lands first.

Example: The native array ends at 10:00. An option snapshot ending at 10:00 starts parsing, then updateLastCandlestick appends 10:01. The pending snapshot subsequently replaces the array, removing 10:01 even though no newer option call changed the generation.

Recommended fix: Parse off-thread, but serialize dataset application and candle commands on the UI thread. Before replacing modelArray, reconcile bars added or updated since the snapshot was scheduled by timestamp. Apply the equivalent ordering or reconciliation on iOS.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Valid for Android, fixed in bda3095. A bar delivered by a live command while an option list is still being parsed is now recorded per chart and merged back by time on the UI thread when that snapshot lands (onOptionListScheduled / onOptionListApplied / mergeBar), so a late replacement can no longer remove it. Only bars delivered after the latest option list was scheduled are replayed, so bars from a previous market or timeframe are never carried into a new dataset. Not applicable on iOS: optionList is applied synchronously in the prop setter there, so there is no pending window between a command and a replacement.

containerView.configManager.reloadOptionList(optionMap);
containerView.post(new Runnable() {
@Override
public void run() {
containerView.reloadConfigManager();
// Merge bars delivered by live commands while this snapshot
// was being parsed, then redraw.
containerView.onOptionListApplied(generation);
}
});
}
}).start();
});
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,54 @@ public class HTKLineContainerView extends RelativeLayout {
private ThemedReactContext reactContext;

public HTKLineConfigManager configManager = new HTKLineConfigManager();
// Option lists are parsed off the UI thread, in order, per chart.
public final java.util.concurrent.ExecutorService optionListExecutor =
java.util.concurrent.Executors.newSingleThreadExecutor();
// Written on the UI thread, read by the option-list worker.
public volatile int optionListGeneration = 0;
private int appliedOptionListGeneration = 0;
// Bars delivered by live commands while an option list is still being parsed.
// The parsed snapshot predates them, so they are merged back when it lands.
private final List<KLineEntity> barsSinceOptionList = new ArrayList<>();

/** UI thread: a new option list was handed to the parser. */
public int onOptionListScheduled() {
barsSinceOptionList.clear();
return ++optionListGeneration;
}

/** UI thread: the parsed option list has replaced the native dataset. */
public void onOptionListApplied(int generation) {
appliedOptionListGeneration = generation;
synchronized (configManager.modelArray) {
for (KLineEntity bar : barsSinceOptionList) {
mergeBar(bar);
}
}
barsSinceOptionList.clear();
reloadConfigManager();
}

private void rememberForPendingOptionList(KLineEntity bar) {
if (appliedOptionListGeneration != optionListGeneration) {
barsSinceOptionList.add(bar);
}
}

/** Same time replaces the last bar, a newer bar is appended, an older bar is ignored. */
private void mergeBar(KLineEntity bar) {
if (configManager.modelArray.isEmpty()) {
configManager.modelArray.add(bar);
return;
}
int lastIndex = configManager.modelArray.size() - 1;
long lastTime = configManager.modelArray.get(lastIndex).timestamp;
if (bar.timestamp <= 0 || lastTime <= 0 || bar.timestamp == lastTime) {
configManager.modelArray.set(lastIndex, bar);
} else if (bar.timestamp > lastTime) {
configManager.modelArray.add(bar);
}
}

public KLineChartView klineView;

Expand Down Expand Up @@ -334,19 +382,23 @@ public void updateLastCandlestick(Map<String, Object> candlestickData) {

// Only preserve indicator lists if the new data doesn't contain them
android.util.Log.d("HTKLineContainerView", "Using new indicator data from React Native");
if (newEntity.maList.isEmpty()) {
// Indicator values belong to one bar: reuse them only when this update is
// for that same bar, never for a newer bar that will be appended.
boolean sameBar = newEntity.timestamp <= 0 || existingEntity.timestamp <= 0
|| newEntity.timestamp == existingEntity.timestamp;
if (sameBar && newEntity.maList.isEmpty()) {
newEntity.maList = existingEntity.maList;
}
if (newEntity.maVolumeList.isEmpty()) {
if (sameBar && newEntity.maVolumeList.isEmpty()) {
newEntity.maVolumeList = existingEntity.maVolumeList;
}
if (newEntity.rsiList.isEmpty()) {
if (sameBar && newEntity.rsiList.isEmpty()) {
newEntity.rsiList = existingEntity.rsiList;
}
if (newEntity.wrList.isEmpty()) {
if (sameBar && newEntity.wrList.isEmpty()) {
newEntity.wrList = existingEntity.wrList;
}
if (newEntity.selectedItemList.isEmpty()) {
if (sameBar && newEntity.selectedItemList.isEmpty()) {
newEntity.selectedItemList = existingEntity.selectedItemList;
}

Expand All @@ -363,7 +415,22 @@ public void updateLastCandlestick(Map<String, Object> candlestickData) {
return;
}

configManager.modelArray.set(lastIndex, newEntity);
// Commands are time-aware: an option-list replacement can land before or
// after a live command, so the native array may be behind the JS data.
// Never overwrite an older bar with a newer one, and ignore stale bars.
KLineEntity currentLast = configManager.modelArray.get(configManager.modelArray.size() - 1);
long incomingTime = newEntity.timestamp;
long currentTime = currentLast != null ? currentLast.timestamp : 0;
if (incomingTime > 0 && currentTime > 0 && incomingTime < currentTime) {
android.util.Log.d("HTKLineContainerView", "Ignoring stale last-candlestick update");
return;
}
if (incomingTime > 0 && currentTime > 0 && incomingTime > currentTime) {
configManager.modelArray.add(newEntity);
Comment on lines +428 to +429

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 New candles inherit stale indicators

When newer updateLastCandlestick data omits indicators, the appended model retains the previous candle’s values. Both platforms render stale overlays for the new candle.

Learn more

The existing fallback copies empty indicator lists from the current last candle because this command previously replaced that same candle. The new timestamp branch changes the operation to an append after those copies have already happened. The iOS branch follows the same sequence in updateLastCandlestick. Values derived for one candle are therefore attached to a different timestamp.

Example: The 10:00 candle has MA5 100 and RSI 45. A 10:01 update contains OHLCV but no maList or rsiList. The new 10:01 candle is appended with MA5 100 and RSI 45 instead of values for 10:01 or empty indicators.

Recommended fix: Decide whether the command will replace or append before preserving indicators. Preserve missing lists only for equal timestamps. For a newer timestamp, leave missing lists empty or calculate them from the expanded dataset on both platforms.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Valid, fixed in bda3095 on both platforms. The indicator fallback now runs only when the update is for the same bar (sameBar); a newer bar that gets appended keeps its own lists, empty if the caller sent none.

} else {
configManager.modelArray.set(configManager.modelArray.size() - 1, newEntity);
}
rememberForPendingOptionList(newEntity);
android.util.Log.d("HTKLineContainerView", "Updated last candlestick at index: " + lastIndex);
}

Expand Down Expand Up @@ -447,8 +514,23 @@ public void addCandlesticksAtTheEnd(ReadableArray candlesticksArray) {

// Add new entities to the end of the array with synchronization
synchronized (configManager.modelArray) {
configManager.modelArray.addAll(newEntities);
android.util.Log.d("HTKLineContainerView", "Added " + newEntities.size() + " new candlesticks to the end");
// Skip bars the native array already has (same time replaces the last bar).
long lastTime = configManager.modelArray.isEmpty() ? 0
: configManager.modelArray.get(configManager.modelArray.size() - 1).timestamp;
List<KLineEntity> fresh = new ArrayList<>();
for (KLineEntity entity : newEntities) {
if (entity.timestamp <= 0 || lastTime <= 0 || entity.timestamp > lastTime) {
fresh.add(entity);
if (entity.timestamp > 0) lastTime = entity.timestamp;
} else if (entity.timestamp == lastTime && fresh.isEmpty()) {
configManager.modelArray.set(configManager.modelArray.size() - 1, entity);
}
}
configManager.modelArray.addAll(fresh);
for (KLineEntity entity : fresh) {
rememberForPendingOptionList(entity);
}
android.util.Log.d("HTKLineContainerView", "Added " + fresh.size() + " of " + newEntities.size() + " candlesticks to the end");
android.util.Log.d("HTKLineContainerView", "Total candlesticks now: " + configManager.modelArray.size());
android.util.Log.d("HTKLineContainerView", "Was at end before adding: " + wasAtEnd);
}
Expand Down
44 changes: 36 additions & 8 deletions ios/Classes/HTKLineContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -284,19 +284,21 @@ class HTKLineContainerView: UIView {

// Only preserve indicator lists if the new data doesn't contain them
print("HTKLineContainerView: Using new indicator data from React Native")
if updatedModel.maList.isEmpty {
// Indicator values belong to one bar: reuse them only for that same bar.
let sameBar = updatedModel.id <= 0 || existingModel.id <= 0 || updatedModel.id == existingModel.id
if sameBar, updatedModel.maList.isEmpty {
updatedModel.maList = existingModel.maList
}
if updatedModel.maVolumeList.isEmpty {
if sameBar, updatedModel.maVolumeList.isEmpty {
updatedModel.maVolumeList = existingModel.maVolumeList
}
if updatedModel.rsiList.isEmpty {
if sameBar, updatedModel.rsiList.isEmpty {
updatedModel.rsiList = existingModel.rsiList
}
if updatedModel.wrList.isEmpty {
if sameBar, updatedModel.wrList.isEmpty {
updatedModel.wrList = existingModel.wrList
}
if updatedModel.selectedItemList.isEmpty {
if sameBar, updatedModel.selectedItemList.isEmpty {
updatedModel.selectedItemList = existingModel.selectedItemList
}

Expand All @@ -305,15 +307,28 @@ class HTKLineContainerView: UIView {
print("HTKLineContainerView: Volume MA5: \(updatedModel.maVolumeList[0].value), MA10: \(updatedModel.maVolumeList[1].value)")
}

// Update the model array
configManager.modelArray[lastIndex] = updatedModel
// Commands are time-aware: never overwrite an older bar with a newer one,
// and ignore stale bars, so a dataset replacement cannot be corrupted by
// a live command that was issued against a different snapshot.
if updatedModel.id > 0, existingModel.id > 0, updatedModel.id < existingModel.id {
return
}
var appended = false
if updatedModel.id > 0, existingModel.id > 0, updatedModel.id > existingModel.id {
configManager.modelArray.append(updatedModel)
appended = true
} else {
configManager.modelArray[lastIndex] = updatedModel
}

print("HTKLineContainerView: Updated last candlestick at index \(lastIndex) with close: \(updatedModel.close)")
print("HTKLineContainerView: Preserved maVolumeList count: \(updatedModel.maVolumeList.count)")

// Force redraw without reloading the entire configuration
DispatchQueue.main.async { [weak self] in
print("HTKLineContainerView: Triggering redraw")
// An appended bar changes the scrollable width.
if appended { self?.klineView.reloadContentSize() }
self?.klineView.setNeedsDisplay()
}
} catch {
Expand All @@ -338,7 +353,20 @@ class HTKLineContainerView: UIView {
}

// Convert array of dictionaries to HTKLineModel array
let newModels = HTKLineModel.packModelArray(candlesticksArray)
var newModels = HTKLineModel.packModelArray(candlesticksArray)
// Skip bars the native array already has (same time replaces the last bar).
if let last = configManager.modelArray.last, last.id > 0 {
var replacedLast = false
if let same = newModels.first(where: { $0.id == last.id }) {
configManager.modelArray[configManager.modelArray.count - 1] = same
replacedLast = true
}
newModels = newModels.filter { $0.id <= 0 || $0.id > last.id }
if newModels.isEmpty, replacedLast {
DispatchQueue.main.async { [weak self] in self?.klineView.setNeedsDisplay() }
return
}
}

if newModels.isEmpty {
print("HTKLineContainerView: No valid models created from input data")
Expand Down
Loading