diff --git a/README.md b/README.md index a0325b0..6dbf594 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/android/src/main/java/com/github/fujianlian/klinechart/RNKLineView.java b/android/src/main/java/com/github/fujianlian/klinechart/RNKLineView.java index 8df761c..3901e17 100644 --- a/android/src/main/java/com/github/fujianlian/klinechart/RNKLineView.java +++ b/android/src/main/java/com/github/fujianlian/klinechart/RNKLineView.java @@ -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; + } 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 diff --git a/android/src/main/java/com/github/fujianlian/klinechart/container/HTKLineContainerView.java b/android/src/main/java/com/github/fujianlian/klinechart/container/HTKLineContainerView.java index f7028ae..1194a44 100644 --- a/android/src/main/java/com/github/fujianlian/klinechart/container/HTKLineContainerView.java +++ b/android/src/main/java/com/github/fujianlian/klinechart/container/HTKLineContainerView.java @@ -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 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; @@ -334,19 +382,23 @@ public void updateLastCandlestick(Map 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; } @@ -363,7 +415,22 @@ public void updateLastCandlestick(Map 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); + } else { + configManager.modelArray.set(configManager.modelArray.size() - 1, newEntity); + } + rememberForPendingOptionList(newEntity); android.util.Log.d("HTKLineContainerView", "Updated last candlestick at index: " + lastIndex); } @@ -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 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); } diff --git a/ios/Classes/HTKLineContainerView.swift b/ios/Classes/HTKLineContainerView.swift index 3b24075..0790f18 100644 --- a/ios/Classes/HTKLineContainerView.swift +++ b/ios/Classes/HTKLineContainerView.swift @@ -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 } @@ -305,8 +307,19 @@ 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)") @@ -314,6 +327,8 @@ class HTKLineContainerView: UIView { // 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 { @@ -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")