@onekeyfe/react-native-chart-webview 3.0.52 → 3.0.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,7 @@ Pod::Spec.new do |s|
11
11
  s.authors = package["author"]
12
12
 
13
13
  s.platforms = { :ios => min_ios_version_supported }
14
- s.source = { :git => "https://github.com/OneKeyHQ/app-views/react-native-chart-webview.git", :tag => "#{s.version}" }
14
+ s.source = { :git => "https://github.com/OneKeyHQ/native-views/react-native-chart-webview.git", :tag => "#{s.version}" }
15
15
 
16
16
  s.source_files = [
17
17
  "ios/**/*.{swift}",
package/README.md CHANGED
@@ -10,12 +10,25 @@ yarn add @onekeyfe/react-native-chart-webview
10
10
 
11
11
  ## Usage
12
12
 
13
- ```js
13
+ ```tsx
14
14
  import { ChartWebviewView } from '@onekeyfe/react-native-chart-webview';
15
15
 
16
16
  // ...
17
17
 
18
- <ChartWebviewView color="#ff0000" style={{ width: 100, height: 100 }} />
18
+ <ChartWebviewView
19
+ style={{ flex: 1 }}
20
+ uri="https://tradingview.onekey.so/?theme=dark&symbol=BTC"
21
+ reuseKey="market"
22
+ pooled={true}
23
+ active={true}
24
+ />
25
+
26
+ <ChartWebviewView
27
+ style={{ flex: 1 }}
28
+ localBundle="tradingview-assets"
29
+ entry="index.html"
30
+ paramsJson={JSON.stringify({ theme: 'dark', symbol: 'BTC' })}
31
+ />
19
32
  ```
20
33
 
21
34
  ## Contributing
@@ -36,7 +36,16 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
36
36
  // render-ready signal. Shared so render-ready (which arrives on whichever host
37
37
  // owns the WebView at that instant — not necessarily the awaiting one) reveals
38
38
  // the correct host regardless of the owner-handoff timing.
39
- private var pendingRevealHost: HybridChartWebview? = null
39
+ //
40
+ // WeakReference (iOS uses a `weak` static for the same reason): a strong static
41
+ // would leak the host — and its ReactContext/Activity — if the host unmounts
42
+ // mid-reveal. dispose() also clears it eagerly on detach.
43
+ private var pendingRevealHostRef: java.lang.ref.WeakReference<HybridChartWebview>? = null
44
+ private var pendingRevealHost: HybridChartWebview?
45
+ get() = pendingRevealHostRef?.get()
46
+ set(value) {
47
+ pendingRevealHostRef = value?.let { java.lang.ref.WeakReference(it) }
48
+ }
40
49
  }
41
50
 
42
51
  private val instanceId = instanceIds.incrementAndGet()
@@ -45,7 +54,11 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
45
54
  // we add at runtime (the WebView / placeholder). Without forcing a measure +
46
55
  // layout pass here, those children stay 0x0 and the slot renders blank. This
47
56
  // requestLayout override is the standard RN fix for custom ViewGroups.
48
- private inner class ChartContainer(ctx: android.content.Context) : FrameLayout(ctx) {
57
+ private inner class ChartContainer(ctx: android.content.Context) : FrameLayout(ctx), HostAware {
58
+ // Lets the view manager reach this host from the dropped View (onDropViewInstance)
59
+ // to run teardown — see TeardownChartWebviewManager.
60
+ override val chartHost: HybridChartWebview get() = this@HybridChartWebview
61
+
49
62
  private val relayout = Runnable {
50
63
  measure(
51
64
  MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
@@ -79,6 +92,9 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
79
92
  /** The WebView backing this host (a shared pool entry, or a private one). */
80
93
  private var backing: PooledChartWebView? = null
81
94
  private var attached = false
95
+ // The pooled key this host has refcounted via ChartWebviewPool.adopt (null when
96
+ // not pooled). Makes adopt/release idempotent per host across many reconciles.
97
+ private var adoptedPoolKey: String? = null
82
98
 
83
99
  // --- Source props ---
84
100
 
@@ -200,8 +216,16 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
200
216
  // displays the live WebView; inactive hosts display the cached frame snapshot
201
217
  // so their slot isn't blank (and the live WebView reparents on hand-off).
202
218
  private fun reconcilePooled() {
203
- val entry = ChartWebviewPool.acquireShared(effectiveKey(), context)
219
+ val key = effectiveKey()
220
+ val entry = ChartWebviewPool.acquireShared(key, context)
204
221
  backing = entry
222
+ // Refcount the pool entry once per host (reconcile runs many times). Balanced
223
+ // by releaseShared in dispose(). If the reuseKey changed, release the old one.
224
+ if (adoptedPoolKey != key) {
225
+ adoptedPoolKey?.let { ChartWebviewPool.releaseShared(it) }
226
+ ChartWebviewPool.adopt(key)
227
+ adoptedPoolKey = key
228
+ }
205
229
  if (wantsOwnership()) {
206
230
  entry.owner = this
207
231
  // Register the document-start bridge before the first load (the prop is set
@@ -328,4 +352,31 @@ class HybridChartWebview(val context: ThemedReactContext) : HybridChartWebviewSp
328
352
  placeholder?.let { container.removeView(it) }
329
353
  placeholder = null
330
354
  }
355
+
356
+ // Host teardown. Called by the view manager's onDropViewInstance (the only
357
+ // reliable "host is gone" signal — onViewDetachedFromWindow fires on every
358
+ // navigation, not just final teardown). Tears down the NON-pooled (private)
359
+ // WebView so it doesn't leak a Chromium renderer + JavascriptInterface per
360
+ // mount/unmount. Pooled (shared) backing is intentionally left alive in the
361
+ // warm pool — other hosts may still share it; its lifetime is the pool's.
362
+ fun dispose() {
363
+ stopOwnCapture()
364
+ container.removeCallbacks(reconcileRunnable)
365
+ container.removeCallbacks(revealFallbackRunnable)
366
+ // Don't leak this host (and its ReactContext/Activity) via the static
367
+ // pendingRevealHost if we unmount mid-reveal.
368
+ if (pendingRevealHost == this) pendingRevealHost = null
369
+ val entry = backing
370
+ if (entry != null && entry.owner == this) entry.owner = null
371
+ // Balance the pool adopt() if this host ever joined a pooled key. Kept warm by
372
+ // default (single-instance cache); the release path makes destroy() reachable.
373
+ adoptedPoolKey?.let { ChartWebviewPool.releaseShared(it) }
374
+ adoptedPoolKey = null
375
+ // A private (non-pooled) instance is owned solely by this host — tear it down
376
+ // so it doesn't leak a Chromium renderer + JavascriptInterface.
377
+ if (entry != null && !isPooled()) entry.destroy()
378
+ backing = null
379
+ removePlaceholder()
380
+ ownSnapshot = null
381
+ }
331
382
  }
@@ -1,5 +1,6 @@
1
1
  package com.margelo.nitro.chartwebview
2
2
 
3
+ import android.view.View
3
4
  import com.facebook.react.BaseReactPackage
4
5
  import com.facebook.react.bridge.NativeModule
5
6
  import com.facebook.react.bridge.ReactApplicationContext
@@ -8,6 +9,26 @@ import com.facebook.react.uimanager.ViewManager
8
9
 
9
10
  import com.margelo.nitro.chartwebview.views.HybridChartWebviewManager
10
11
 
12
+ /**
13
+ * View manager subclass that adds a teardown hook on top of the nitrogen-generated
14
+ * [HybridChartWebviewManager] (which is DO-NOT-MODIFY). The base manager removes
15
+ * the view from its private table on drop but never tears the WebView down, so the
16
+ * non-pooled (private) path would leak a Chromium renderer + JavascriptInterface
17
+ * per mount/unmount. On drop we reach the host via the container ([HostAware]) and
18
+ * call [HybridChartWebview.dispose].
19
+ */
20
+ class TeardownChartWebviewManager : HybridChartWebviewManager() {
21
+ override fun onDropViewInstance(view: View) {
22
+ (view as? HostAware)?.chartHost?.dispose()
23
+ super.onDropViewInstance(view)
24
+ }
25
+ }
26
+
27
+ /** Implemented by the host's container view so the manager can reach the host on drop. */
28
+ interface HostAware {
29
+ val chartHost: HybridChartWebview
30
+ }
31
+
11
32
  class ChartWebviewPackage : BaseReactPackage() {
12
33
  override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
13
34
  return null
@@ -18,7 +39,9 @@ class ChartWebviewPackage : BaseReactPackage() {
18
39
  }
19
40
 
20
41
  override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
21
- return listOf(HybridChartWebviewManager())
42
+ // Use the teardown-aware subclass so the non-pooled WebView is destroyed on
43
+ // host drop (the base manager never tears it down).
44
+ return listOf(TeardownChartWebviewManager())
22
45
  }
23
46
 
24
47
  companion object {
@@ -18,6 +18,7 @@ import com.facebook.react.uimanager.ThemedReactContext
18
18
  import android.webkit.WebResourceRequest
19
19
  import android.webkit.WebResourceResponse
20
20
  import android.webkit.WebView
21
+ import androidx.webkit.ScriptHandler
21
22
  import androidx.webkit.WebResourceErrorCompat
22
23
  import androidx.webkit.WebViewAssetLoader
23
24
  import androidx.webkit.WebViewClientCompat
@@ -66,18 +67,83 @@ class PooledChartWebView private constructor(
66
67
  // Shim (defines __chartNativePost) + the TS bridge that uses it. Set lazily via
67
68
  // setBridgeScript once the host has the prop value — registering it in init
68
69
  // would capture an empty script, since the first reconcile (window-attach) can
69
- // run before the bridgeScript prop is applied. Re-asserted on page start/finish.
70
+ // run before the bridgeScript prop is applied. The privileged bridge exposes
71
+ // window.$onekey.$private.request etc., so it must only ever be injected into
72
+ // the trusted chart origin(s) we load — NOT cross-origin subframes / arbitrary
73
+ // pages. The actual document-start registration is therefore deferred to
74
+ // registerBridgeForOrigins(), called once the load URL (hence origin) is known.
70
75
  private var outboundBridgeJs: String = ""
71
- private var bridgeRegistered = false
72
76
 
73
- // Called by the host before the first load. Registers the document-start bridge
74
- // once a non-empty script is available; subsequent calls are no-ops.
77
+ // Handler for the currently-registered document-start script, kept so it can be
78
+ // removed and re-registered if the bridge script or the trusted origin set
79
+ // changes (instead of silently latching the first one).
80
+ private var bridgeScriptHandler: ScriptHandler? = null
81
+ // The (script, origins) pair currently registered, so we can detect changes and
82
+ // avoid redundant re-registration.
83
+ private var registeredBridgeJs: String? = null
84
+ private var registeredOrigins: Set<String> = emptySet()
85
+
86
+ // True once we have a non-empty bridge script staged; gates loading (the page
87
+ // must not boot before the bridge is registered or its first requests are lost).
88
+ private val bridgeRegistered: Boolean
89
+ get() = outboundBridgeJs.isNotEmpty()
90
+
91
+ // Called by the host before the first load. Stages the document-start bridge
92
+ // (shim + the shared TS bridgeScript) once a non-empty script is available.
93
+ // The script is not injected into the page yet — that happens, scoped to the
94
+ // trusted origin(s), in registerBridgeForOrigins() when the load URL is known.
95
+ // If a second host sharing the reuseKey supplies a DIFFERENT script we update
96
+ // it (and re-register on next load) rather than silently dropping it; in the
97
+ // app's single-reuseKey + constant-bridge reality this branch never fires, but
98
+ // not latching keeps it correct if that ever changes.
75
99
  fun setBridgeScript(bridgeScript: String) {
76
- if (bridgeRegistered || bridgeScript.isEmpty()) return
100
+ if (bridgeScript.isEmpty()) return
77
101
  outboundBridgeJs = "$NATIVE_POST_SHIM_JS\n$bridgeScript"
78
- bridgeRegistered = true
79
- if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
80
- WebViewCompat.addDocumentStartJavaScript(webView, outboundBridgeJs, setOf("*"))
102
+ }
103
+
104
+ // Register (or re-register) the document-start bridge scoped to exactly the
105
+ // trusted origins we load. allowedOriginRules of setOf("*") would leak the
106
+ // privileged bridge into every cross-origin subframe; here we pass only the
107
+ // offline asset origin and (in online mode) the configured chart origin.
108
+ private fun registerBridgeForOrigins(origins: Set<String>) {
109
+ if (outboundBridgeJs.isEmpty() || origins.isEmpty()) return
110
+ if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return
111
+ // No-op if nothing changed (same script + same origin set already registered).
112
+ if (bridgeScriptHandler != null &&
113
+ registeredBridgeJs == outboundBridgeJs &&
114
+ registeredOrigins == origins
115
+ ) {
116
+ return
117
+ }
118
+ bridgeScriptHandler?.remove()
119
+ bridgeScriptHandler =
120
+ WebViewCompat.addDocumentStartJavaScript(webView, outboundBridgeJs, origins)
121
+ registeredBridgeJs = outboundBridgeJs
122
+ registeredOrigins = origins
123
+ }
124
+
125
+ // The trusted origin set for the current source: the offline asset origin
126
+ // (https://appassets.androidplatform.net) always, plus the online/fallback
127
+ // `uri` origin when running in online mode. computeTargetUrl serves offline
128
+ // content from ASSET_HOST and online content from `uri`, so these are exactly
129
+ // the origins the privileged bridge legitimately runs in.
130
+ private fun trustedOriginsFor(uri: String?): Set<String> {
131
+ val origins = linkedSetOf("https://$ASSET_HOST")
132
+ originOf(uri)?.let { origins.add(it) }
133
+ return origins
134
+ }
135
+
136
+ // scheme://host[:port] of a URL, or null if it can't be parsed / isn't http(s).
137
+ private fun originOf(url: String?): String? {
138
+ if (url.isNullOrEmpty()) return null
139
+ return try {
140
+ val u = java.net.URI(url)
141
+ val scheme = u.scheme?.lowercase() ?: return null
142
+ if (scheme != "https" && scheme != "http") return null
143
+ val host = u.host ?: return null
144
+ if (u.port != -1) "$scheme://$host:${u.port}" else "$scheme://$host"
145
+ } catch (e: Exception) {
146
+ null
81
147
  }
82
148
  }
83
149
 
@@ -93,6 +159,10 @@ class PooledChartWebView private constructor(
93
159
  // clearSnapshot() or the next capture replaces it.
94
160
  private var cachedSnapshot: Bitmap? = null
95
161
  private var overlay: ImageView? = null
162
+ // The bitmap currently shown by `overlay` (a snapshot reused directly, not a
163
+ // copy). Tracked so a concurrent capture doesn't recycle a bitmap still on
164
+ // screen — see capturePixelCopy.
165
+ private var overlaySnapshot: Bitmap? = null
96
166
 
97
167
  // How long the snapshot overlay stays up after a reparent, giving the WebView
98
168
  // time to draw its first frame in the new container (~5 frames).
@@ -119,14 +189,12 @@ class PooledChartWebView private constructor(
119
189
  request: WebResourceRequest,
120
190
  ): WebResourceResponse? = assetLoader?.shouldInterceptRequest(request.url)
121
191
 
122
- override fun onPageStarted(view: WebView, url: String?, favicon: android.graphics.Bitmap?) {
123
- super.onPageStarted(view, url, favicon)
124
- if (outboundBridgeJs.isNotEmpty()) view.evaluateJavascript(outboundBridgeJs, null)
125
- }
126
-
127
192
  override fun onPageFinished(view: WebView, url: String?) {
128
193
  super.onPageFinished(view, url)
129
- if (outboundBridgeJs.isNotEmpty()) view.evaluateJavascript(outboundBridgeJs, null)
194
+ // The bridge is delivered exclusively via the origin-scoped document-start
195
+ // script (see registerBridgeForOrigins). We deliberately do NOT re-inject
196
+ // it here via evaluateJavascript — that ran on every page event regardless
197
+ // of URL and would expose the privileged bridge to untrusted pages/frames.
130
198
  owner?.dispatchLoadEnd()
131
199
  // Prime the snapshot so the first move already has a frame to mask with.
132
200
  refreshSnapshotSoon()
@@ -207,6 +275,9 @@ class PooledChartWebView private constructor(
207
275
  fun clearSnapshot() {
208
276
  runOnUiThread {
209
277
  removeOverlay()
278
+ // The pool owns cachedSnapshot outright (hosts get copies via captureNow),
279
+ // so recycle it here instead of leaving a full-size ARGB_8888 bitmap to GC.
280
+ cachedSnapshot?.recycle()
210
281
  cachedSnapshot = null
211
282
  }
212
283
  }
@@ -220,9 +291,17 @@ class PooledChartWebView private constructor(
220
291
  }
221
292
 
222
293
  /// Capture now and deliver the fresh frame (e.g. so the host that is losing
223
- /// ownership can update its placeholder to its very last frame).
294
+ /// ownership can update its placeholder to its very last frame). The host gets
295
+ /// an independent COPY: the pool reuses/recycles its own `cachedSnapshot` on
296
+ /// every internal refresh, so handing out the live buffer would let it recycle
297
+ /// a bitmap still shown in a host placeholder. The copy is the host's to keep.
224
298
  fun captureNow(callback: (Bitmap?) -> Unit) {
225
- capturePixelCopy(callback)
299
+ capturePixelCopy { bmp ->
300
+ val copy = bmp?.takeIf { !it.isRecycled }?.let {
301
+ it.copy(it.config ?: Bitmap.Config.ARGB_8888, false)
302
+ }
303
+ callback(copy)
304
+ }
226
305
  }
227
306
 
228
307
  // Capture the WebView's REAL on-screen pixels (incl. GPU-rendered chart
@@ -247,7 +326,20 @@ class PooledChartWebView private constructor(
247
326
  src,
248
327
  bmp,
249
328
  { result ->
250
- if (result == PixelCopy.SUCCESS) cachedSnapshot = bmp
329
+ if (result == PixelCopy.SUCCESS) {
330
+ // Recycle the frame we're replacing to avoid piling up full-size
331
+ // ARGB_8888 bitmaps. Guard against recycling one still referenced by a
332
+ // live overlay/placeholder ImageView (showSnapshotOverlay reuses
333
+ // cachedSnapshot directly): only the unused old buffer is freed.
334
+ val old = cachedSnapshot
335
+ cachedSnapshot = bmp
336
+ if (old != null && old !== bmp && old !== overlaySnapshot && !old.isRecycled) {
337
+ old.recycle()
338
+ }
339
+ } else {
340
+ // Capture failed: the freshly allocated buffer is unused — free it.
341
+ if (!bmp.isRecycled) bmp.recycle()
342
+ }
251
343
  callback?.invoke(cachedSnapshot)
252
344
  },
253
345
  webView.handler ?: Handler(Looper.getMainLooper()),
@@ -279,12 +371,17 @@ class PooledChartWebView private constructor(
279
371
  }
280
372
  container.addView(iv) // added last => drawn on top of the WebView
281
373
  overlay = iv
374
+ overlaySnapshot = snap
282
375
  iv.postDelayed({ removeOverlay() }, overlayHideDelayMs)
283
376
  }
284
377
 
285
378
  private fun removeOverlay() {
286
- overlay?.let { (it.parent as? ViewGroup)?.removeView(it) }
379
+ overlay?.let {
380
+ (it.parent as? ViewGroup)?.removeView(it)
381
+ it.setImageDrawable(null)
382
+ }
287
383
  overlay = null
384
+ overlaySnapshot = null
288
385
  }
289
386
 
290
387
  /**
@@ -301,6 +398,11 @@ class PooledChartWebView private constructor(
301
398
  lastLocalBundle = localBundle
302
399
  rebuildAssetLoader(localBundle)
303
400
  }
401
+ // Register the document-start bridge scoped to exactly the origin(s) this load
402
+ // uses (offline asset origin, plus the online `uri` origin when present) before
403
+ // navigating, so the page boots with the bridge but cross-origin subframes /
404
+ // untrusted pages don't receive it.
405
+ registerBridgeForOrigins(trustedOriginsFor(uri))
304
406
  val target = computeTargetUrl(uri, localBundle, entry, paramsJson) ?: return
305
407
  if (target == lastLoadedUrl) return
306
408
  lastLoadedUrl = target
@@ -319,9 +421,19 @@ class PooledChartWebView private constructor(
319
421
  runOnUiThread { webView.reload() }
320
422
  }
321
423
 
322
- /** Permanently free the WebView (used for non-pooled / private instances). */
424
+ /**
425
+ * Permanently free the WebView and its bridge/snapshot resources. Used for the
426
+ * non-pooled (private) path on host teardown, and reachable for the pool (see
427
+ * ChartWebviewPool.release) so an evicted entry can be torn down rather than
428
+ * leaking a Chromium renderer + JavascriptInterface.
429
+ */
323
430
  fun destroy() {
324
431
  runOnUiThread {
432
+ bridgeScriptHandler?.remove()
433
+ bridgeScriptHandler = null
434
+ removeOverlay()
435
+ cachedSnapshot?.recycle()
436
+ cachedSnapshot = null
325
437
  (webView.parent as? ViewGroup)?.removeView(webView)
326
438
  webView.destroy()
327
439
  val n = liveCount.decrementAndGet()
@@ -405,8 +517,42 @@ class PooledChartWebView private constructor(
405
517
  */
406
518
  object ChartWebviewPool {
407
519
  private val shared = HashMap<String, PooledChartWebView>()
408
-
520
+ // Refcount per key: incremented when a host adopts the entry, decremented when a
521
+ // host releases it. Lets us know when an entry has no live hosts. With the app's
522
+ // single constant reuseKey this stays >= 0 and the entry is kept warm; the count
523
+ // exists so destroy() is *reachable* (see releaseShared) rather than dead code,
524
+ // and so a future multi-key/LRU policy can evict safely.
525
+ private val refCounts = HashMap<String, Int>()
526
+
527
+ /** Get (creating if absent) the entry for [key]. Does not change the refcount —
528
+ * call [adopt] exactly once per host to register a live reference. */
409
529
  @Synchronized
410
530
  fun acquireShared(key: String, context: Context): PooledChartWebView =
411
531
  shared.getOrPut(key) { PooledChartWebView.create(context, key) }
532
+
533
+ /** Register one live host reference to [key] (call once per host; idempotency is
534
+ * the caller's responsibility via adoptedPoolKey). */
535
+ @Synchronized
536
+ fun adopt(key: String) {
537
+ refCounts[key] = (refCounts[key] ?: 0) + 1
538
+ }
539
+
540
+ /**
541
+ * A host that previously adopted [key] is going away. Decrements the refcount.
542
+ * By default the entry is kept warm even at zero (intentional single-instance
543
+ * cache — see class doc); pass [destroyWhenIdle] = true to actually tear the
544
+ * WebView down when the last host leaves (used for an LRU/eviction policy).
545
+ */
546
+ @Synchronized
547
+ fun releaseShared(key: String, destroyWhenIdle: Boolean = false) {
548
+ val next = (refCounts[key] ?: 0) - 1
549
+ if (next <= 0) {
550
+ refCounts.remove(key)
551
+ if (destroyWhenIdle) {
552
+ shared.remove(key)?.destroy()
553
+ }
554
+ } else {
555
+ refCounts[key] = next
556
+ }
557
+ }
412
558
  }
@@ -63,6 +63,9 @@ class HybridChartWebview: HybridChartWebviewSpec {
63
63
  /// The WebView backing this host (a shared pool entry, or a private one).
64
64
  private var backing: PooledChartWebView?
65
65
  private var attached = false
66
+ // The pooled key this host has refcounted via the pool (nil when not pooled).
67
+ // Makes adopt/release idempotent per host across many reconciles.
68
+ private var adoptedPoolKey: String?
66
69
 
67
70
  override init() {
68
71
  super.init()
@@ -72,6 +75,26 @@ class HybridChartWebview: HybridChartWebviewSpec {
72
75
  }
73
76
  }
74
77
 
78
+ // Host teardown. ARC deallocates the host when the view manager drops it; this is
79
+ // the reliable "host is gone" signal. Tears down the NON-pooled (private) WebView
80
+ // so it doesn't leak, balances the pool refcount for the pooled path, and clears
81
+ // the static pendingRevealHost if we go away mid-reveal.
82
+ deinit {
83
+ revealFallbackWork?.cancel()
84
+ if HybridChartWebview.pendingRevealHost === self {
85
+ HybridChartWebview.pendingRevealHost = nil
86
+ }
87
+ let entry = backing
88
+ if let key = adoptedPoolKey {
89
+ // Pooled: balance the adopt(). Kept warm by default; release makes the
90
+ // pool's destroy() reachable.
91
+ ChartWebviewPool.shared.releaseShared(key: key)
92
+ } else {
93
+ // Private: this host solely owns the WebView — tear it down.
94
+ entry?.destroy()
95
+ }
96
+ }
97
+
75
98
  // MARK: - Props (source)
76
99
 
77
100
  var uri: String? { didSet { applySourceIfOwner() } }
@@ -168,8 +191,16 @@ class HybridChartWebview: HybridChartWebviewSpec {
168
191
  // displays the live WebView; inactive hosts display the cached frame snapshot
169
192
  // so their slot isn't blank (and the live WebView reparents on hand-off).
170
193
  private func reconcilePooled() {
171
- let pooled = ChartWebviewPool.shared.acquireShared(key: effectiveKey())
194
+ let key = effectiveKey()
195
+ let pooled = ChartWebviewPool.shared.acquireShared(key: key)
172
196
  backing = pooled
197
+ // Refcount the entry once per host (reconcile runs many times). Balanced by
198
+ // releaseShared in deinit. If the reuseKey changed, release the old one first.
199
+ if adoptedPoolKey != key {
200
+ if let old = adoptedPoolKey { ChartWebviewPool.shared.releaseShared(key: old) }
201
+ ChartWebviewPool.shared.adopt(key: key)
202
+ adoptedPoolKey = key
203
+ }
173
204
  if wantsOwnership() {
174
205
  pooled.owner = self
175
206
  // Register the document-start bridge before the first load (the prop is set
@@ -326,6 +357,9 @@ final class PooledChartWebView {
326
357
  // added lazily (see setBridgeScript) once the host has the prop value.
327
358
  private var userContent: WKUserContentController?
328
359
  private var bridgeRegistered = false
360
+ // The bridge script currently registered, so a differing script from a second
361
+ // host sharing a reuseKey is re-registered rather than silently dropped (fix #4).
362
+ private var registeredBridgeScript: String?
329
363
 
330
364
  private var webView: WKWebView!
331
365
  private var proxy: ChartWebViewProxy!
@@ -354,15 +388,27 @@ final class PooledChartWebView {
354
388
  // subsequent calls are no-ops. Done lazily because the first reconcile
355
389
  // (window-attach) can run before the bridgeScript prop is applied.
356
390
  func setBridgeScript(_ bridgeScript: String) {
357
- guard !bridgeRegistered, !bridgeScript.isEmpty, let userContent = userContent else { return }
391
+ guard !bridgeScript.isEmpty, let userContent = userContent else { return }
392
+ // Re-register if a second host sharing the reuseKey supplies a DIFFERENT script
393
+ // instead of silently dropping it (fix #4). In the app's single-reuseKey +
394
+ // constant-bridge reality this never fires; not latching keeps it correct.
395
+ if bridgeRegistered, registeredBridgeScript == bridgeScript { return }
396
+ if bridgeRegistered {
397
+ // WKUserScripts can't be removed individually; drop all and re-add ours.
398
+ userContent.removeAllUserScripts()
399
+ }
358
400
  bridgeRegistered = true
401
+ registeredBridgeScript = bridgeScript
359
402
  let handlerName = ChartWebviewConst.messageHandlerName
360
403
  let shim = "(function(){window.__chartNativePost=function(s){"
361
404
  + "window.webkit.messageHandlers.\(handlerName).postMessage(s);};})();"
362
405
  let userScript = WKUserScript(
363
406
  source: shim + "\n" + bridgeScript,
364
407
  injectionTime: .atDocumentStart,
365
- forMainFrameOnly: false
408
+ // Main frame only: the privileged bridge (window.$onekey.$private.request etc.)
409
+ // must not be exposed to cross-origin iframes. The chart page runs in the main
410
+ // frame, so it still gets the full bridge (fix #1).
411
+ forMainFrameOnly: true
366
412
  )
367
413
  userContent.addUserScript(userScript)
368
414
  }
@@ -430,6 +476,32 @@ final class PooledChartWebView {
430
476
  }
431
477
  }
432
478
 
479
+ /// Permanently tear down the WebView and its bridge. Used for the non-pooled
480
+ /// (private) path on host teardown, and reachable for the pool (see
481
+ /// ChartWebviewPool.releaseShared). Stops loading, detaches the view, and
482
+ /// removes the script message handler so the proxy retain is released.
483
+ func destroy() {
484
+ runOnMain { [weak self] in
485
+ guard let self = self else { return }
486
+ self.removeOverlay()
487
+ self.cachedSnapshot = nil
488
+ if let webView = self.webView {
489
+ webView.stopLoading()
490
+ webView.navigationDelegate = nil
491
+ webView.uiDelegate = nil
492
+ webView.removeFromSuperview()
493
+ }
494
+ // Drop the page -> native handler so the user-content controller stops
495
+ // retaining the proxy (and re-entrancy can't deliver to a torn-down owner).
496
+ self.userContent?.removeScriptMessageHandler(forName: ChartWebviewConst.messageHandlerName)
497
+ self.userContent?.removeAllUserScripts()
498
+ self.userContent = nil
499
+ self.webView = nil
500
+ PooledChartWebView.liveCount -= 1
501
+ NSLog("[ChartWebviewPool] WebView DESTROYED key=\(self.key) liveCount=\(PooledChartWebView.liveCount)")
502
+ }
503
+ }
504
+
433
505
  /// Drop the cached snapshot and remove any visible overlay.
434
506
  func clearSnapshot() {
435
507
  runOnMain { [weak self] in
@@ -741,11 +813,52 @@ extension ChartWebViewProxy: WKURLSchemeHandler {
741
813
  final class ChartWebviewPool {
742
814
  static let shared = ChartWebviewPool()
743
815
  private var entries: [String: PooledChartWebView] = [:]
744
-
816
+ // Refcount per key: incremented on adopt, decremented on release. Lets us know
817
+ // when an entry has no live hosts. With the app's single constant reuseKey this
818
+ // stays warm; the count exists so destroy() is reachable (see releaseShared) and
819
+ // so a future multi-key/LRU policy can evict safely.
820
+ private var refCounts: [String: Int] = [:]
821
+ // Serializes all dictionary access. Android guards the pool with @Synchronized;
822
+ // here concurrent acquire/release could otherwise corrupt the dict or create
823
+ // duplicate WebViews for one key (fix #3).
824
+ private let lock = NSLock()
825
+
826
+ /// Get (creating if absent) the entry for `key`. Does NOT change the refcount —
827
+ /// call `adopt` exactly once per host (idempotency via `adoptedPoolKey`).
745
828
  func acquireShared(key: String) -> PooledChartWebView {
829
+ lock.lock()
830
+ defer { lock.unlock() }
746
831
  if let existing = entries[key] { return existing }
747
832
  let created = PooledChartWebView(key: key)
748
833
  entries[key] = created
749
834
  return created
750
835
  }
836
+
837
+ /// Register one live host reference to `key` (call once per host).
838
+ func adopt(key: String) {
839
+ lock.lock()
840
+ defer { lock.unlock() }
841
+ refCounts[key, default: 0] += 1
842
+ }
843
+
844
+ /// A host that previously adopted `key` is going away. Decrements the refcount.
845
+ /// Kept warm by default (intentional single-instance cache); pass
846
+ /// `destroyWhenIdle: true` to tear the WebView down when the last host leaves.
847
+ func releaseShared(key: String, destroyWhenIdle: Bool = false) {
848
+ lock.lock()
849
+ let next = (refCounts[key] ?? 0) - 1
850
+ var toDestroy: PooledChartWebView?
851
+ if next <= 0 {
852
+ refCounts[key] = nil
853
+ if destroyWhenIdle {
854
+ toDestroy = entries.removeValue(forKey: key)
855
+ }
856
+ } else {
857
+ refCounts[key] = next
858
+ }
859
+ lock.unlock()
860
+ // destroy() hops to main and isn't part of the dict invariant — call outside
861
+ // the lock.
862
+ toDestroy?.destroy()
863
+ }
751
864
  }
@@ -1,19 +1,64 @@
1
1
  "use strict";
2
2
 
3
- import { createElement } from 'react';
4
- import { getHostComponent } from 'react-native-nitro-modules';
3
+ import { createElement, useCallback, useMemo, useRef } from 'react';
4
+ import { callback, getHostComponent } from 'react-native-nitro-modules';
5
5
  const ChartWebviewConfig = require('../nitrogen/generated/shared/json/ChartWebviewConfig.json');
6
6
  import { CHART_BRIDGE_JS } from "./bridge.js";
7
7
  export { CHART_BRIDGE_JS } from "./bridge.js";
8
8
  const NativeChartWebviewView = getHostComponent('ChartWebview', () => ChartWebviewConfig);
9
+ function unwrapCallback(wrapped) {
10
+ if (typeof wrapped === 'function') {
11
+ return wrapped;
12
+ }
13
+ return wrapped?.f;
14
+ }
9
15
 
10
16
  // Default the document-start bridge script so consumers never have to know about
11
17
  // it (and it's never absent — an optional string flipping to null is rejected by
12
18
  // the native binding). Callers can still override `bridgeScript` to customize.
13
- export function ChartWebviewView(props) {
19
+ export function ChartWebviewView({
20
+ bridgeScript,
21
+ hybridRef,
22
+ onMessage,
23
+ onLoadEnd,
24
+ onError,
25
+ ...props
26
+ }) {
27
+ const hybridRefRef = useRef(unwrapCallback(hybridRef));
28
+ hybridRefRef.current = unwrapCallback(hybridRef);
29
+ const onMessageRef = useRef(unwrapCallback(onMessage));
30
+ onMessageRef.current = unwrapCallback(onMessage);
31
+ const onLoadEndRef = useRef(unwrapCallback(onLoadEnd));
32
+ onLoadEndRef.current = unwrapCallback(onLoadEnd);
33
+ const onErrorRef = useRef(unwrapCallback(onError));
34
+ onErrorRef.current = unwrapCallback(onError);
35
+ const stableHybridRef = useCallback(ref => {
36
+ hybridRefRef.current?.(ref);
37
+ }, []);
38
+ const stableOnMessage = useCallback(message => {
39
+ onMessageRef.current?.(message);
40
+ }, []);
41
+ const stableOnLoadEnd = useCallback(() => {
42
+ onLoadEndRef.current?.();
43
+ }, []);
44
+ const stableOnError = useCallback(message => {
45
+ onErrorRef.current?.(message);
46
+ }, []);
47
+ const hasHybridRef = hybridRefRef.current != null;
48
+ const hasOnMessage = onMessageRef.current != null;
49
+ const hasOnLoadEnd = onLoadEndRef.current != null;
50
+ const hasOnError = onErrorRef.current != null;
51
+ const hybridRefProp = useMemo(() => hasHybridRef ? callback(stableHybridRef) : undefined, [hasHybridRef, stableHybridRef]);
52
+ const onMessageProp = useMemo(() => hasOnMessage ? callback(stableOnMessage) : undefined, [hasOnMessage, stableOnMessage]);
53
+ const onLoadEndProp = useMemo(() => hasOnLoadEnd ? callback(stableOnLoadEnd) : undefined, [hasOnLoadEnd, stableOnLoadEnd]);
54
+ const onErrorProp = useMemo(() => hasOnError ? callback(stableOnError) : undefined, [hasOnError, stableOnError]);
14
55
  return /*#__PURE__*/createElement(NativeChartWebviewView, {
15
- bridgeScript: CHART_BRIDGE_JS,
16
- ...props
56
+ ...props,
57
+ hybridRef: hybridRefProp,
58
+ onMessage: onMessageProp,
59
+ onLoadEnd: onLoadEndProp,
60
+ onError: onErrorProp,
61
+ bridgeScript: bridgeScript ?? CHART_BRIDGE_JS
17
62
  });
18
63
  }
19
64
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"names":["createElement","getHostComponent","ChartWebviewConfig","require","CHART_BRIDGE_JS","NativeChartWebviewView","ChartWebviewView","props","bridgeScript"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":";;AAAA,SAASA,aAAa,QAA6B,OAAO;AAC1D,SAASC,gBAAgB,QAAQ,4BAA4B;AAC7D,MAAMC,kBAAkB,GAAGC,OAAO,CAAC,2DAA2D,CAAC;AAE/F,SAASC,eAAe,QAAQ,aAAU;AAG1C,SAASA,eAAe,QAAQ,aAAU;AAE1C,MAAMC,sBAAsB,GAAGJ,gBAAgB,CAC7C,cAAc,EACd,MAAMC,kBACR,CAAC;;AAED;AACA;AACA;AACA,OAAO,SAASI,gBAAgBA,CAACC,KAAoD,EAAE;EACrF,oBAAOP,aAAa,CAACK,sBAAsB,EAAE;IAAEG,YAAY,EAAEJ,eAAe;IAAE,GAAGG;EAAM,CAAC,CAAC;AAC3F","ignoreList":[]}
1
+ {"version":3,"names":["createElement","useCallback","useMemo","useRef","callback","getHostComponent","ChartWebviewConfig","require","CHART_BRIDGE_JS","NativeChartWebviewView","unwrapCallback","wrapped","f","ChartWebviewView","bridgeScript","hybridRef","onMessage","onLoadEnd","onError","props","hybridRefRef","current","onMessageRef","onLoadEndRef","onErrorRef","stableHybridRef","ref","stableOnMessage","message","stableOnLoadEnd","stableOnError","hasHybridRef","hasOnMessage","hasOnLoadEnd","hasOnError","hybridRefProp","undefined","onMessageProp","onLoadEndProp","onErrorProp"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":"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","ignoreList":[]}
@@ -1,10 +1,17 @@
1
- import { type ComponentProps } from 'react';
1
+ import { type ViewProps } from 'react-native';
2
+ import { type NitroViewWrappedCallback } from 'react-native-nitro-modules';
2
3
  import type { ChartWebviewMethods, ChartWebviewProps } from './ChartWebview.nitro';
3
4
  export type { ChartWebviewMethods, ChartWebviewProps } from './ChartWebview.nitro';
4
5
  export { CHART_BRIDGE_JS } from './bridge';
5
- declare const NativeChartWebviewView: import("react-native-nitro-modules").ReactNativeView<ChartWebviewProps, ChartWebviewMethods>;
6
- export declare function ChartWebviewView(props: ComponentProps<typeof NativeChartWebviewView>): import("react").FunctionComponentElement<Omit<{
7
- hybridRef?: import("react-native-nitro-modules").NitroViewWrappedCallback<((ref: import("react-native-nitro-modules").HybridView<ChartWebviewProps, ChartWebviewMethods>) => void) | undefined> | undefined;
6
+ type MaybeWrappedCallback<T extends (...args: any[]) => void> = T | NitroViewWrappedCallback<T>;
7
+ type ChartWebviewViewProps = Omit<ChartWebviewProps, 'onMessage' | 'onLoadEnd' | 'onError'> & ViewProps & {
8
+ hybridRef?: MaybeWrappedCallback<(ref: any) => void>;
9
+ onMessage?: MaybeWrappedCallback<(message: string) => void>;
10
+ onLoadEnd?: MaybeWrappedCallback<() => void>;
11
+ onError?: MaybeWrappedCallback<(message: string) => void>;
12
+ };
13
+ export declare function ChartWebviewView({ bridgeScript, hybridRef, onMessage, onLoadEnd, onError, ...props }: ChartWebviewViewProps): import("react").FunctionComponentElement<Omit<{
14
+ hybridRef?: NitroViewWrappedCallback<((ref: import("react-native-nitro-modules").HybridView<ChartWebviewProps, ChartWebviewMethods>) => void) | undefined> | undefined;
8
15
  uri?: string | undefined;
9
16
  localBundle?: string | undefined;
10
17
  entry?: string | undefined;
@@ -13,9 +20,9 @@ export declare function ChartWebviewView(props: ComponentProps<typeof NativeChar
13
20
  reuseKey?: string | undefined;
14
21
  pooled?: boolean | undefined;
15
22
  active?: boolean | undefined;
16
- onMessage?: import("react-native-nitro-modules").NitroViewWrappedCallback<((message: string) => void) | undefined> | undefined;
17
- onLoadEnd?: import("react-native-nitro-modules").NitroViewWrappedCallback<(() => void) | undefined> | undefined;
18
- onError?: import("react-native-nitro-modules").NitroViewWrappedCallback<((message: string) => void) | undefined> | undefined;
23
+ onMessage?: NitroViewWrappedCallback<((message: string) => void) | undefined> | undefined;
24
+ onLoadEnd?: NitroViewWrappedCallback<(() => void) | undefined> | undefined;
25
+ onError?: NitroViewWrappedCallback<((message: string) => void) | undefined> | undefined;
19
26
  } & Readonly<Omit<Readonly<{
20
27
  onAccessibilityAction?: ((event: import("react-native").AccessibilityActionEvent) => unknown) | undefined;
21
28
  onAccessibilityTap?: (() => unknown) | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,cAAc,EAAE,MAAM,OAAO,CAAC;AAG3D,OAAO,KAAK,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAGnF,YAAY,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACnF,OAAO,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAE3C,QAAA,MAAM,sBAAsB,8FAG3B,CAAC;AAKF,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,cAAc,CAAC,OAAO,sBAAsB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAEpF"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.tsx"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,4BAA4B,CAAC;AAEpC,OAAO,KAAK,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAGnF,YAAY,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACnF,OAAO,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAO3C,KAAK,oBAAoB,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,IACxD,CAAC,GACD,wBAAwB,CAAC,CAAC,CAAC,CAAC;AAEhC,KAAK,qBAAqB,GAAG,IAAI,CAC/B,iBAAiB,EACjB,WAAW,GAAG,WAAW,GAAG,SAAS,CACtC,GACC,SAAS,GAAG;IACV,SAAS,CAAC,EAAE,oBAAoB,CAAC,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAC,CAAC;IACrD,SAAS,CAAC,EAAE,oBAAoB,CAAC,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC,CAAC;IAC5D,SAAS,CAAC,EAAE,oBAAoB,CAAC,MAAM,IAAI,CAAC,CAAC;IAC7C,OAAO,CAAC,EAAE,oBAAoB,CAAC,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC,CAAC;CAC3D,CAAC;AAcJ,wBAAgB,gBAAgB,CAAC,EAC/B,YAAY,EACZ,SAAS,EACT,SAAS,EACT,SAAS,EACT,OAAO,EACP,GAAG,KAAK,EACT,EAAE,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDvB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-chart-webview",
3
- "version": "3.0.52",
3
+ "version": "3.0.54",
4
4
  "description": "react-native-chart-webview",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
package/src/index.tsx CHANGED
@@ -1,5 +1,10 @@
1
- import { createElement, type ComponentProps } from 'react';
2
- import { getHostComponent } from 'react-native-nitro-modules';
1
+ import { createElement, useCallback, useMemo, useRef } from 'react';
2
+ import { type ViewProps } from 'react-native';
3
+ import {
4
+ callback,
5
+ getHostComponent,
6
+ type NitroViewWrappedCallback,
7
+ } from 'react-native-nitro-modules';
3
8
  const ChartWebviewConfig = require('../nitrogen/generated/shared/json/ChartWebviewConfig.json');
4
9
  import type { ChartWebviewMethods, ChartWebviewProps } from './ChartWebview.nitro';
5
10
  import { CHART_BRIDGE_JS } from './bridge';
@@ -12,9 +17,91 @@ const NativeChartWebviewView = getHostComponent<ChartWebviewProps, ChartWebviewM
12
17
  () => ChartWebviewConfig
13
18
  );
14
19
 
20
+ type MaybeWrappedCallback<T extends (...args: any[]) => void> =
21
+ | T
22
+ | NitroViewWrappedCallback<T>;
23
+
24
+ type ChartWebviewViewProps = Omit<
25
+ ChartWebviewProps,
26
+ 'onMessage' | 'onLoadEnd' | 'onError'
27
+ > &
28
+ ViewProps & {
29
+ hybridRef?: MaybeWrappedCallback<(ref: any) => void>;
30
+ onMessage?: MaybeWrappedCallback<(message: string) => void>;
31
+ onLoadEnd?: MaybeWrappedCallback<() => void>;
32
+ onError?: MaybeWrappedCallback<(message: string) => void>;
33
+ };
34
+
35
+ function unwrapCallback<T extends (...args: any[]) => void>(
36
+ wrapped: MaybeWrappedCallback<T> | undefined
37
+ ): T | undefined {
38
+ if (typeof wrapped === 'function') {
39
+ return wrapped;
40
+ }
41
+ return wrapped?.f;
42
+ }
43
+
15
44
  // Default the document-start bridge script so consumers never have to know about
16
45
  // it (and it's never absent — an optional string flipping to null is rejected by
17
46
  // the native binding). Callers can still override `bridgeScript` to customize.
18
- export function ChartWebviewView(props: ComponentProps<typeof NativeChartWebviewView>) {
19
- return createElement(NativeChartWebviewView, { bridgeScript: CHART_BRIDGE_JS, ...props });
47
+ export function ChartWebviewView({
48
+ bridgeScript,
49
+ hybridRef,
50
+ onMessage,
51
+ onLoadEnd,
52
+ onError,
53
+ ...props
54
+ }: ChartWebviewViewProps) {
55
+ const hybridRefRef = useRef(unwrapCallback(hybridRef));
56
+ hybridRefRef.current = unwrapCallback(hybridRef);
57
+ const onMessageRef = useRef(unwrapCallback(onMessage));
58
+ onMessageRef.current = unwrapCallback(onMessage);
59
+ const onLoadEndRef = useRef(unwrapCallback(onLoadEnd));
60
+ onLoadEndRef.current = unwrapCallback(onLoadEnd);
61
+ const onErrorRef = useRef(unwrapCallback(onError));
62
+ onErrorRef.current = unwrapCallback(onError);
63
+
64
+ const stableHybridRef = useCallback((ref: any) => {
65
+ hybridRefRef.current?.(ref);
66
+ }, []);
67
+ const stableOnMessage = useCallback((message: string) => {
68
+ onMessageRef.current?.(message);
69
+ }, []);
70
+ const stableOnLoadEnd = useCallback(() => {
71
+ onLoadEndRef.current?.();
72
+ }, []);
73
+ const stableOnError = useCallback((message: string) => {
74
+ onErrorRef.current?.(message);
75
+ }, []);
76
+
77
+ const hasHybridRef = hybridRefRef.current != null;
78
+ const hasOnMessage = onMessageRef.current != null;
79
+ const hasOnLoadEnd = onLoadEndRef.current != null;
80
+ const hasOnError = onErrorRef.current != null;
81
+
82
+ const hybridRefProp = useMemo(
83
+ () => (hasHybridRef ? callback(stableHybridRef) : undefined),
84
+ [hasHybridRef, stableHybridRef]
85
+ );
86
+ const onMessageProp = useMemo(
87
+ () => (hasOnMessage ? callback(stableOnMessage) : undefined),
88
+ [hasOnMessage, stableOnMessage]
89
+ );
90
+ const onLoadEndProp = useMemo(
91
+ () => (hasOnLoadEnd ? callback(stableOnLoadEnd) : undefined),
92
+ [hasOnLoadEnd, stableOnLoadEnd]
93
+ );
94
+ const onErrorProp = useMemo(
95
+ () => (hasOnError ? callback(stableOnError) : undefined),
96
+ [hasOnError, stableOnError]
97
+ );
98
+
99
+ return createElement(NativeChartWebviewView, {
100
+ ...props,
101
+ hybridRef: hybridRefProp,
102
+ onMessage: onMessageProp,
103
+ onLoadEnd: onLoadEndProp,
104
+ onError: onErrorProp,
105
+ bridgeScript: bridgeScript ?? CHART_BRIDGE_JS,
106
+ });
20
107
  }